12 Commits

55 changed files with 10744 additions and 2121 deletions
+1
View File
@@ -3,4 +3,5 @@ __pycache__/
*.py[cod]
logs/
model_settings.json
link_settings.json
runtime/
+22 -112
View File
@@ -1,124 +1,34 @@
# SUB Control
A deliberately small, low-bandwidth MAVLink controller for bench-testing an
ArduSub vehicle over a USB serial connection. It sends no parameter-list
request and no telemetry-stream request.
A lightweight Windows controller for ArduSub ROVs over USB/MAVLink.
![SUB Control interface](docs/sub-control-interface.png)
![SUB Control V2 interface in Demo mode](docs/sub-control-v2.jpg)
The desktop window uses a local HTML/CSS/JavaScript interface rendered by the
Windows WebView. Python remains the only launcher and owns the MAVLink worker,
arming, logging, output limiting, and parameter writes. It does not run a web
server, open a browser tab, or require an internet connection.
## Quick start
The WebView is frameless and uses the compact title bar drawn by the interface.
The vehicle rail includes a local STL viewer: choose **Load STL** to start with
an orthographic top-view silhouette, then drag to orbit or use the mouse wheel
to zoom. A cached STL copy is stored locally under the ignored `runtime/`
directory so the model can be restored on the next launch.
STEP/STP CAD solids must first be tessellated/exported as STL because the
embedded renderer intentionally does not include a full CAD kernel.
Binary and ASCII STL faces are preserved in full; large assemblies are not
rendered by skipping triangles. Imports are limited to 30 MB to keep startup
and interactive orbiting responsive.
Install Python 3.11+ with the Python launcher, then run:
## Safety first
```powershell
./run.ps1
```
- Remove propellers for initial tests and restrain the vehicle.
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range,
leak/failsafe, and emergency-stop arrangements before arming.
- The application starts disarmed and limits pilot demand to 20% by default.
Once armed, control movement is sent directly with no additional gate.
- Closing or disconnecting sends neutral controls and a disarm request, but a
physical power cutoff is still required.
The script creates a virtual environment and installs dependencies automatically.
On first launch, it also adds a **SUB Control** desktop shortcut.
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
Connection settings are under **•••**.
Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details.
The controls are pilot-demand axes, not direct PWM values. ArduSub performs
the frame mixing and generates the configured ESC outputs. For ArduSub,
`MANUAL_CONTROL.z = 500` is neutral vertical thrust.
## Controls
## Run on Windows
- Joystick: forward/back and turning. Vertical slider: rise/dive.
- Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive.
- Physical controller: select **Joystick** while disarmed. Left stick drives/turns; right stick controls rise/dive. **Setup** changes axes, inversion, and dead zone while disarmed.
- Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
- Movement controls ease back to neutral when released.
- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle.
1. Install Python 3.11 or newer. A virtual environment installs packages, but
it does not install Python itself.
2. Open PowerShell in this folder and run `./run.ps1` (or right-click it and
choose **Run with PowerShell**).
3. Select the autopilot's COM port, then click **Connect**. Baud and control-rate
settings are under the small `•••` link button (normally 57600 baud / 20 Hz).
4. Confirm `ArduSub` and a live heartbeat are shown.
5. Click **Set MANUAL**, then **ARM** only when the bench is safe.
6. Move the controls. Once armed, no additional enable button is needed.
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
`run.ps1` automatically creates a private `.venv` on the first run, installs
the versions declared in `requirements.txt`, and starts SUB Control. Later runs
reuse the same environment and update missing dependencies when necessary.
The `.venv` directory is intentionally ignored by Git: virtual environments
contain machine-specific executables and can be recreated from
`requirements.txt`.
## Safety
## Repository layout
The Gitea repository is named `argo`. `SUB Control` is a normal directory inside
that repository containing this application. A Gitea **Project** is a planning
board for issues and tasks; source code is not attached to a Project board.
Git tracks the folder automatically because its files are committed on the
repository's `main` branch.
Keyboard controls for SimpleROV-3: W/S forward/back, A/D yaw left/right,
Q/E alternative yaw control, and R/F rise/dive.
Releasing movement keys eases their controls back to neutral.
## Three-thruster limitation
The common ArduSub `SimpleROV-3` arrangement controls forward, yaw, and depth.
It cannot independently control lateral, roll, or pitch. Accordingly, the main
pad is mapped to forward/yaw, while the vertical slider controls depth thrust.
## Link behavior
- MAVLink GCS heartbeat: 1 Hz
- `MANUAL_CONTROL`: selectable 10 or 20 Hz (default 20 Hz). Lower rates are
intentionally excluded because they make short return ramps become abrupt
steps at the flight controller.
- No full parameter download
- On connection, inherited bulk telemetry streams are stopped. Only
`SYS_STATUS` at 1 Hz and `SERVO_OUTPUT_RAW` / `RC_CHANNELS` at 2 Hz are
requested, in addition to the normal heartbeat and event-driven status text.
- Arm/disarm uses `MAV_CMD_COMPONENT_ARM_DISARM` and displays `COMMAND_ACK`
- Link health is based on received autopilot heartbeats
- Battery voltage is read from `SYS_STATUS` at 1 Hz and shown as pack voltage,
estimated per-cell voltage, and a voltage-only percentage. Cell count,
capacity, C rating, full/empty cell voltages, and warning percentage are
configurable and saved locally in `battery_settings.json`. Capacity consumed
cannot be measured without a current sensor.
- Servo configuration scans `SERVO1_FUNCTION` through `SERVO16_FUNCTION` and
ArduSub's `MOT_1_DIRECTION` through `MOT_12_DIRECTION` one at a time, waiting
for each response and retrying missed replies. A selected output can be
assigned to Motor 1–12 or set unassigned. Motor direction is toggled with the
corresponding `MOT_n_DIRECTION` value while disarmed; `SERVOx_REVERSED` is
intentionally not used for Motor functions. It never downloads the full
parameter list.
The diagnostic scan also reads `SERVO1`–`SERVO3` MIN/TRIM/MAX and ArduSub's
joystick gain values into the local session log; these values remain
read-only in the application.
- Every session creates a local text log under `logs/`, including operator
actions, outgoing manual-control values and commands, acknowledgements,
status text, and all received MAVLink messages. This does not use ArduPilot's
onboard logger or add any link traffic.
The car-style virtual joystick maps only to forward/reverse and continuous
differential turning; it never sends lateral motion. The joystick and vertical
slider ease back to center over 400 ms after mouse/key release, so neither can
be left commanding motion.
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are
intentionally not eased.
In addition, transmitted pilot demands have a small 4% input deadband and a
0.8-second full-range slew limit. This makes both acceleration and deceleration
arrive at the flight controller as a sequence of stable intermediate values.
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
limit is applied, preventing their mixer contributions from adding beyond the
selected horizontal limit.
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
MAVLink input. Those parameters must already be correctly configured.
Verify motor direction, ESC settings, and vehicle failsafes before use. Remove propellers for initial bench tests and keep a physical power cutoff available. A lost connection cannot guarantee the motors stop.
+1
View File
@@ -3,4 +3,5 @@ __pycache__/
*.py[cod]
logs/
model_settings.json
link_settings.json
runtime/
+22 -112
View File
@@ -1,124 +1,34 @@
# SUB Control
A deliberately small, low-bandwidth MAVLink controller for bench-testing an
ArduSub vehicle over a USB serial connection. It sends no parameter-list
request and no telemetry-stream request.
A lightweight Windows controller for ArduSub ROVs over USB/MAVLink.
![SUB Control interface](docs/sub-control-interface.png)
![SUB Control V2 interface in Demo mode](docs/sub-control-v2.jpg)
The desktop window uses a local HTML/CSS/JavaScript interface rendered by the
Windows WebView. Python remains the only launcher and owns the MAVLink worker,
arming, logging, output limiting, and parameter writes. It does not run a web
server, open a browser tab, or require an internet connection.
## Quick start
The WebView is frameless and uses the compact title bar drawn by the interface.
The vehicle rail includes a local STL viewer: choose **Load STL** to start with
an orthographic top-view silhouette, then drag to orbit or use the mouse wheel
to zoom. A cached STL copy is stored locally under the ignored `runtime/`
directory so the model can be restored on the next launch.
STEP/STP CAD solids must first be tessellated/exported as STL because the
embedded renderer intentionally does not include a full CAD kernel.
Binary and ASCII STL faces are preserved in full; large assemblies are not
rendered by skipping triangles. Imports are limited to 30 MB to keep startup
and interactive orbiting responsive.
Install Python 3.11+ with the Python launcher, then run:
## Safety first
```powershell
./run.ps1
```
- Remove propellers for initial tests and restrain the vehicle.
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range,
leak/failsafe, and emergency-stop arrangements before arming.
- The application starts disarmed and limits pilot demand to 20% by default.
Once armed, control movement is sent directly with no additional gate.
- Closing or disconnecting sends neutral controls and a disarm request, but a
physical power cutoff is still required.
The script creates a virtual environment and installs dependencies automatically.
On first launch, it also adds a **SUB Control** desktop shortcut.
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
Connection settings are under **•••**.
Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details.
The controls are pilot-demand axes, not direct PWM values. ArduSub performs
the frame mixing and generates the configured ESC outputs. For ArduSub,
`MANUAL_CONTROL.z = 500` is neutral vertical thrust.
## Controls
## Run on Windows
- Joystick: forward/back and turning. Vertical slider: rise/dive.
- Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive.
- Physical controller: select **Joystick** while disarmed. Left stick drives/turns; right stick controls rise/dive. **Setup** changes axes, inversion, and dead zone while disarmed.
- Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
- Movement controls ease back to neutral when released.
- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle.
1. Install Python 3.11 or newer. A virtual environment installs packages, but
it does not install Python itself.
2. Open PowerShell in this folder and run `./run.ps1` (or right-click it and
choose **Run with PowerShell**).
3. Select the autopilot's COM port, then click **Connect**. Baud and control-rate
settings are under the small `•••` link button (normally 57600 baud / 20 Hz).
4. Confirm `ArduSub` and a live heartbeat are shown.
5. Click **Set MANUAL**, then **ARM** only when the bench is safe.
6. Move the controls. Once armed, no additional enable button is needed.
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
`run.ps1` automatically creates a private `.venv` on the first run, installs
the versions declared in `requirements.txt`, and starts SUB Control. Later runs
reuse the same environment and update missing dependencies when necessary.
The `.venv` directory is intentionally ignored by Git: virtual environments
contain machine-specific executables and can be recreated from
`requirements.txt`.
## Safety
## Repository layout
The Gitea repository is named `argo`. `SUB Control` is a normal directory inside
that repository containing this application. A Gitea **Project** is a planning
board for issues and tasks; source code is not attached to a Project board.
Git tracks the folder automatically because its files are committed on the
repository's `main` branch.
Keyboard controls for SimpleROV-3: W/S forward/back, A/D yaw left/right,
Q/E alternative yaw control, and R/F rise/dive.
Releasing movement keys eases their controls back to neutral.
## Three-thruster limitation
The common ArduSub `SimpleROV-3` arrangement controls forward, yaw, and depth.
It cannot independently control lateral, roll, or pitch. Accordingly, the main
pad is mapped to forward/yaw, while the vertical slider controls depth thrust.
## Link behavior
- MAVLink GCS heartbeat: 1 Hz
- `MANUAL_CONTROL`: selectable 10 or 20 Hz (default 20 Hz). Lower rates are
intentionally excluded because they make short return ramps become abrupt
steps at the flight controller.
- No full parameter download
- On connection, inherited bulk telemetry streams are stopped. Only
`SYS_STATUS` at 1 Hz and `SERVO_OUTPUT_RAW` / `RC_CHANNELS` at 2 Hz are
requested, in addition to the normal heartbeat and event-driven status text.
- Arm/disarm uses `MAV_CMD_COMPONENT_ARM_DISARM` and displays `COMMAND_ACK`
- Link health is based on received autopilot heartbeats
- Battery voltage is read from `SYS_STATUS` at 1 Hz and shown as pack voltage,
estimated per-cell voltage, and a voltage-only percentage. Cell count,
capacity, C rating, full/empty cell voltages, and warning percentage are
configurable and saved locally in `battery_settings.json`. Capacity consumed
cannot be measured without a current sensor.
- Servo configuration scans `SERVO1_FUNCTION` through `SERVO16_FUNCTION` and
ArduSub's `MOT_1_DIRECTION` through `MOT_12_DIRECTION` one at a time, waiting
for each response and retrying missed replies. A selected output can be
assigned to Motor 1–12 or set unassigned. Motor direction is toggled with the
corresponding `MOT_n_DIRECTION` value while disarmed; `SERVOx_REVERSED` is
intentionally not used for Motor functions. It never downloads the full
parameter list.
The diagnostic scan also reads `SERVO1`–`SERVO3` MIN/TRIM/MAX and ArduSub's
joystick gain values into the local session log; these values remain
read-only in the application.
- Every session creates a local text log under `logs/`, including operator
actions, outgoing manual-control values and commands, acknowledgements,
status text, and all received MAVLink messages. This does not use ArduPilot's
onboard logger or add any link traffic.
The car-style virtual joystick maps only to forward/reverse and continuous
differential turning; it never sends lateral motion. The joystick and vertical
slider ease back to center over 400 ms after mouse/key release, so neither can
be left commanding motion.
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are
intentionally not eased.
In addition, transmitted pilot demands have a small 4% input deadband and a
0.8-second full-range slew limit. This makes both acceleration and deceleration
arrive at the flight controller as a sequence of stable intermediate values.
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
limit is applied, preventing their mixer contributions from adding beyond the
selected horizontal limit.
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
MAVLink input. Those parameters must already be correctly configured.
Verify motor direction, ESC settings, and vehicle failsafes before use. Remove propellers for initial bench tests and keep a physical power cutoff available. A lost connection cannot guarantee the motors stop.
+525 -41
View File
@@ -13,6 +13,9 @@ from datetime import datetime
from pathlib import Path
from tkinter import messagebox, ttk
# ESC_TELEMETRY_1_TO_4 uses a MAVLink 2 message ID. Select the v2 dialect
# before importing pymavlink so received ESC packets can be decoded/logged.
os.environ["MAVLINK20"] = "1"
from pymavlink import mavutil
from serial.tools import list_ports
@@ -42,16 +45,44 @@ class Axes:
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:
def __init__(self, events: queue.Queue):
self.events = events
self.connection = None
self.stop_event = threading.Event()
self.lock = threading.Lock()
# pymavlink writes are not thread-safe. The web/API thread can issue
# commands while this worker sends heartbeats and controls, so serialize
# every packet before it reaches a low-bandwidth serial link.
self.tx_lock = threading.Lock()
self.thread = None
self.axes = Axes()
self.output_axes = Axes()
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.
# At 20 Hz this produces five small steps from neutral to full command.
self.ramp_seconds = 0.25
@@ -63,6 +94,34 @@ class MavlinkWorker:
self.last_rx = 0.0
self.armed = False
self.config_paused = False
# Keep configuration traffic off the link during an arm/disarm
# transition, including the wait for the vehicle's confirmation.
self.command_quiet_until = 0.0
self.disarm_requested_until = 0.0
self.pending_arm_state = None
self.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0
self.arm_confirmation = 0
self.stream_config = deque()
self.next_stream_config_at = 0.0
# Low-rate TIMESYNC echoes measure PC↔FC round trips with minimal traffic.
self.ping_rate_hz = 1
self.heavy_quality_test = None
self.heavy_pending_pings = {}
self.heavy_quality_latest = None
self.telemetry_rates_hz = {
"SYS_STATUS": 1, "SERVO_OUTPUT_RAW": 2,
"RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1,
}
self.ping_sequence = 0
self.next_ping_at = 0.0
self.pending_pings = {}
self.ping_results = deque(maxlen=300)
self.bad_frame_times = deque()
self.bad_frame_bytes = deque()
self.rx_sequence_by_source = {}
self.rx_delivery_results = deque(maxlen=1024)
self.next_quality_publish_at = 0.0
self.param_scan = deque()
self.param_waiting = None
self.param_deadline = 0.0
@@ -72,47 +131,92 @@ class MavlinkWorker:
self.log_lock = threading.Lock()
self.log_file = None
self.log_path = None
self.console_lines = deque(maxlen=500)
self.console_rx_lines = deque(maxlen=300)
self.console_tx_lines = deque(maxlen=300)
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():
print(f"[MavlinkWorker] Thread already alive, ignoring connect()")
return
self.stop_event.clear()
log_dir = Path(__file__).resolve().parent / "logs"
log_dir.mkdir(exist_ok=True)
self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log"
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
self.log("SESSION", f"start port={port} baud={baud} control_hz={self.rate_hz}")
normalized = normalize_endpoint(port)
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} "
f"control_hz={self.rate_hz} rx=MAVLink2 tx=MAVLink2")
self.events.put(("log_path", str(self.log_path)))
self.last_heartbeat = 0.0
self.last_rx = 0.0
self.next_quality_publish_at = 0.0
self.thread = threading.Thread(
target=self._run, args=(port, baud), daemon=True
target=self._run, args=(normalized, baud), daemon=True
)
self.thread.start()
print(f"[MavlinkWorker] worker thread started")
def _run(self, port: str, baud: int):
print(f"[MavlinkWorker._run] ENTERED: port={port!r}, baud={baud}")
try:
print(f"[MavlinkWorker._run] Creating mavlink_connection...")
conn = mavutil.mavlink_connection(
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.events.put(("status", f"Port open; waiting for heartbeat at {baud} baud…"))
next_gcs_hb = 0.0
if port.startswith("tcp:"):
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")
self._send_mav(conn, "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
while not self.stop_event.is_set():
now = time.monotonic()
if now >= next_gcs_hb:
self.log("TX_HEARTBEAT", "GCS")
conn.mav.heartbeat_send(
self._send_mav(conn, "heartbeat_send",
mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
)
next_gcs_hb = now + 1.0
if self.target_system and now >= next_control:
# ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a
# 20 Hz neutral stream alive here only competes with ARM and
# its acknowledgement on constrained links.
if self.target_system and self.armed and now >= next_control:
self._send_control()
next_control = now + (1.0 / max(1, self.rate_hz))
# The first disarm packet is preceded by a neutral command.
# Until disarm is confirmed, keep sending neutral at a
# lower rate instead of consuming 20 Hz on the weak link.
control_hz = min(self.rate_hz, 5) if now < self.disarm_requested_until else self.rate_hz
next_control = now + (1.0 / max(1, control_hz))
elif not self.armed:
next_control = now
if self.target_system:
self._tick_arm_retry(now)
self._tick_link_probe(now)
self._tick_stream_config(now)
self._tick_param_scan(now)
if now >= self.next_quality_publish_at:
self._publish_link_quality()
self.next_quality_publish_at = now + 1.0
msg = conn.recv_match(blocking=False)
if msg:
@@ -120,8 +224,11 @@ class MavlinkWorker:
else:
time.sleep(0.01)
except Exception as exc:
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
self.events.put(("error", f"Connection error: {exc}"))
finally:
if self.heavy_quality_test is not None:
self.cancel_heavy_quality_test()
self._safe_stop()
if self.connection:
try:
@@ -132,6 +239,19 @@ class MavlinkWorker:
self.target_system = 0
self.armed = False
self.config_paused = False
self.command_quiet_until = 0.0
self.disarm_requested_until = 0.0
self.pending_arm_state = None
self.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0
self.arm_confirmation = 0
self.stream_config.clear()
self.pending_pings.clear()
self.ping_results.clear()
self.bad_frame_times.clear()
self.bad_frame_bytes.clear()
self.rx_sequence_by_source.clear()
self.rx_delivery_results.clear()
self.log("SESSION", "disconnected")
with self.log_lock:
if self.log_file:
@@ -141,22 +261,75 @@ class MavlinkWorker:
def log(self, category: str, message: str):
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
line = f"{timestamp} [{category}] {message}"
with self.log_lock:
self.console_lines.append(line[:2000])
if category.startswith("RX") or category == "ONBOARD_LOG_ERROR":
self.console_rx_lines.append(line[:2000])
elif category.startswith("TX") or category == "OPERATOR":
self.console_tx_lines.append(line[:2000])
if self.log_file:
self.log_file.write(f"{timestamp} [{category}] {message}\n")
self.log_file.write(line + "\n")
def read_console(self, limit=150):
with self.log_lock:
limit = max(1, min(300, int(limit)))
return {
"rx": list(self.console_rx_lines)[-limit:],
"tx": list(self.console_tx_lines)[-limit:],
}
def _send_mav(self, connection, method: str, *args):
"""Serialize MAVLink 2 writes and protect the start of each burst."""
with self.tx_lock:
# This full-duplex module pair loses the opening bytes of some
# PC-to-FC bursts after the line has been idle. Put a valid,
# expendable GCS heartbeat first so the following packet remains
# intact and the FC also sees a healthy GCS heartbeat stream.
if method != "heartbeat_send" and hasattr(connection.mav, "heartbeat_send"):
connection.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
)
getattr(connection.mav, method)(*args)
def _handle_message(self, msg):
msg_type = msg.get_type()
if msg_type == "BAD_DATA":
now = time.monotonic()
self.bad_frame_times.append(now)
self.bad_frame_bytes.append((now, len(getattr(msg, "data", b""))))
self._trim_bad_frames(now)
self.log("RX_BAD_DATA", repr(getattr(msg, "data", b"")))
return
self.last_rx = time.monotonic()
self._track_rx_delivery(msg)
try:
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
except Exception:
payload = str(msg)
self.log("RX", payload)
if msg_type == "HEARTBEAT" and msg.get_srcComponent() == 1:
if msg_type == "TIMESYNC" and int(getattr(msg, "tc1", 0)) != 0:
token = int(getattr(msg, "ts1", -1))
heavy_probe = self.heavy_pending_pings.pop(token, None)
if heavy_probe is not None:
stage_index, sent_at = heavy_probe
self._record_heavy_probe(stage_index, (time.monotonic() - sent_at) * 1000.0)
self._publish_heavy_quality()
sent_at = None
else:
sent_at = self.pending_pings.pop(token, None)
if sent_at is not None:
rtt_ms = (time.monotonic() - sent_at) * 1000.0
self.ping_results.append((time.monotonic(), rtt_ms))
self.log("RX_LINK_PROBE", f"timesync={token} rtt_ms={rtt_ms:.1f}")
self._publish_link_quality()
elif 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
self.target_system = msg.get_srcSystem()
self.target_component = msg.get_srcComponent()
@@ -164,26 +337,42 @@ class MavlinkWorker:
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
self.armed = armed
self.config_paused = armed
if self.pending_arm_state is not None and armed == self.pending_arm_state:
self.pending_arm_state = None
if not armed:
self.disarm_requested_until = 0.0
try:
mode = mavutil.mode_string_v10(msg)
except Exception:
mode = str(msg.custom_mode)
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
ap_name = autopilot.name if autopilot else str(msg.autopilot)
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed))
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
if first:
self.next_ping_at = time.monotonic()
self.events.put(("status", "Heartbeat received; controls are neutral."))
self._configure_low_bandwidth_streams()
self._start_servo_scan()
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_name = result.name if result else str(msg.result)
self.events.put(("ack", int(msg.command), result_name))
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
self.log("RX_STREAM_CONFIG_ACK", result_name)
return
if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")):
self.pending_arm_state = None
self.events.put(("ack", int(msg.command), result_name, self.last_rx))
elif msg_type == "STATUSTEXT":
text = msg.text
if isinstance(text, bytes):
text = text.decode(errors="replace")
self.events.put(("statustext", text.rstrip("\x00")))
text = text.rstrip("\x00")
if "/APM/LOGS" in text and "Failed" in text:
self.log("ONBOARD_LOG_ERROR", text)
self.events.put(("statustext", text))
elif msg_type == "SYS_STATUS":
voltage_mv = int(msg.voltage_battery)
if 0 < voltage_mv < 65535:
@@ -210,6 +399,238 @@ class MavlinkWorker:
self.events.put(("motor_direction", int(parts[1]),
int(round(msg.param_value))))
def _trim_bad_frames(self, now):
while self.bad_frame_times and now - self.bad_frame_times[0] > 20.0:
self.bad_frame_times.popleft()
def _track_rx_delivery(self, msg):
"""Estimate downlink loss from MAVLink's per-source packet sequence."""
try:
source = (int(msg.get_srcSystem()), int(msg.get_srcComponent()))
sequence = int(msg.get_seq())
except (AttributeError, TypeError, ValueError):
return
if self.target_system and source != (self.target_system, self.target_component):
return
previous = self.rx_sequence_by_source.get(source)
self.rx_sequence_by_source[source] = sequence
if previous is None:
return
missing = (sequence - previous - 1) & 0xFF
# A large backwards jump is a reset/reordered packet, not 200 losses.
if missing > 127:
return
timestamp = time.monotonic()
self.rx_delivery_results.extend((timestamp, False) for _ in range(missing))
self.rx_delivery_results.append((timestamp, True))
def _publish_link_quality(self):
now = time.monotonic()
self._trim_bad_frames(now)
while self.ping_results and now - self.ping_results[0][0] > 30.0:
self.ping_results.popleft()
while self.rx_delivery_results and now - self.rx_delivery_results[0][0] > 30.0:
self.rx_delivery_results.popleft()
while self.bad_frame_bytes and now - self.bad_frame_bytes[0][0] > 20.0:
self.bad_frame_bytes.popleft()
samples = [sample for _, sample in self.ping_results]
received = [sample for sample in samples if sample is not None]
total = len(samples)
loss_percent = 100.0 * (total - len(received)) / total if total else None
ordered_rtt = sorted(received)
latency_ms = (sum(ordered_rtt) / len(ordered_rtt)) if ordered_rtt else None
median_rtt_ms = ordered_rtt[len(ordered_rtt) // 2] if ordered_rtt else None
p95_rtt_ms = ordered_rtt[min(len(ordered_rtt) - 1, (len(ordered_rtt) * 95 + 99) // 100 - 1)] if ordered_rtt else None
rx_received = sum(1 for _, delivered in self.rx_delivery_results if delivered)
rx_missing = sum(1 for _, delivered in self.rx_delivery_results if not delivered)
rx_loss_percent = 100.0 * rx_missing / (rx_received + rx_missing) if rx_received else None
rx_corrupt_bytes = sum(byte_count for _, byte_count in self.bad_frame_bytes)
heartbeat_age = (now - self.last_heartbeat) if self.last_heartbeat else None
if total < 10 or rx_received < 10 or heartbeat_age is None:
grade = "testing"
elif (heartbeat_age > 3 or loss_percent > 15
or (rx_loss_percent is not None and rx_loss_percent > 5)
or len(self.bad_frame_bytes) >= 5):
grade = "poor"
elif (loss_percent > 3 or (rx_loss_percent is not None and rx_loss_percent > 1)
or (p95_rtt_ms is not None and p95_rtt_ms > 500) or self.bad_frame_bytes):
grade = "fair"
elif (loss_percent > 0.5 or (rx_loss_percent is not None and rx_loss_percent > 0.2)
or (p95_rtt_ms is not None and p95_rtt_ms > 200)):
grade = "good"
else:
grade = "excellent"
self.events.put(("link_quality", {
"grade": grade,
"latency_ms": round(latency_ms, 1) if latency_ms is not None else None,
"rtt_median_ms": round(median_rtt_ms, 1) if median_rtt_ms is not None else None,
"rtt_p95_ms": round(p95_rtt_ms, 1) if p95_rtt_ms is not None else None,
"loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
"probe_loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
"probe_samples": total,
"probe_replies": len(received),
"rx_packets_30s": rx_received,
"rx_sequence_loss_percent": round(rx_loss_percent, 1) if rx_loss_percent is not None else None,
"rx_corrupt_frames_20s": len(self.bad_frame_bytes),
"rx_corrupt_bytes_20s": rx_corrupt_bytes,
"samples": total,
"corrupt_frames_20s": len(self.bad_frame_times),
"heartbeat_age_s": round(heartbeat_age, 1) if heartbeat_age is not None else None,
"probe_supported": total > 0,
}))
def _tick_link_probe(self, now):
if self.heavy_quality_test is not None:
self._tick_heavy_quality(now)
return
expired = [sequence for sequence, sent_at in self.pending_pings.items()
if now - sent_at >= 2.5]
for sequence in expired:
self.pending_pings.pop(sequence, None)
self.ping_results.append((now, None))
self.log("LINK_PROBE_TIMEOUT", f"timesync={sequence}")
if expired:
self._publish_link_quality()
if (now < self.next_ping_at or now < self.command_quiet_until
or not self.connection or not self.target_system):
return
token = time.monotonic_ns()
self.pending_pings[token] = now
self.log("TX_LINK_PROBE", f"timesync={token}")
# ArduPilot implements TIMESYNC even on builds that omit PING. A
# request has tc1=0; the response mirrors our unique ts1 token.
self._send_mav(self.connection, "timesync_send", 0, token)
self.next_ping_at = now + (1.0 / max(1, self.ping_rate_hz))
def start_heavy_quality_test(self):
if not self.connection or not self.target_system:
raise RuntimeError("Connect to the flight controller before testing the link")
if self.armed:
raise RuntimeError("The heavy link test is available while the vehicle is disarmed")
if self.pending_arm_state is not None or time.monotonic() < self.command_quiet_until:
raise RuntimeError("Wait for the current vehicle command to finish")
if self.heavy_quality_test is not None:
raise RuntimeError("A heavy link test is already running")
self.pending_pings.clear()
self.heavy_pending_pings.clear()
self.heavy_quality_latest = {
"running": True, "stage": 1, "stages": 3, "rate_hz": 1,
"status": "Running", "results": [],
}
self.heavy_quality_test = {
"rates": (1, 5, 10), "duration_s": 10.0, "stage_index": 0,
"stage_started": time.monotonic(), "next_probe_at": time.monotonic(),
"stages": [{"rate_hz": rate, "sent": 0, "replies": 0,
"timeouts": 0, "rtts": []} for rate in (1, 5, 10)],
}
self.log("TX_HEAVY_TEST", "started rates=1,5,10Hz stage_seconds=10")
self._publish_heavy_quality()
return self.heavy_quality_latest
def cancel_heavy_quality_test(self):
if self.heavy_quality_test is None:
return self.heavy_quality_latest
self.heavy_pending_pings.clear()
test = self.heavy_quality_test
self.heavy_quality_test = None
self.heavy_quality_latest = {
"running": False, "stage": 0, "stages": len(test["rates"]),
"rate_hz": None, "status": "Cancelled", "results": self._heavy_results(test),
}
self.next_ping_at = time.monotonic()
self.log("TX_HEAVY_TEST", "cancelled")
self.events.put(("heavy_quality", self.heavy_quality_latest))
return self.heavy_quality_latest
@staticmethod
def _heavy_results(test):
results = []
for stage in test["stages"]:
rtts = sorted(stage["rtts"])
replies = stage["replies"]
resolved = replies + stage["timeouts"]
results.append({
"rate_hz": stage["rate_hz"], "sent": stage["sent"], "replies": replies,
"loss_percent": round(100.0 * stage["timeouts"] / resolved, 1) if resolved else None,
"median_rtt_ms": round(rtts[len(rtts) // 2], 1) if rtts else None,
"p95_rtt_ms": round(rtts[min(len(rtts) - 1, (len(rtts) * 95 + 99) // 100 - 1)], 1) if rtts else None,
})
return results
def _record_heavy_probe(self, stage_index, rtt_ms=None):
test = self.heavy_quality_test
if test is None or not 0 <= stage_index < len(test["stages"]):
return
stage = test["stages"][stage_index]
if rtt_ms is None:
stage["timeouts"] += 1
else:
stage["replies"] += 1
stage["rtts"].append(rtt_ms)
def _publish_heavy_quality(self):
test = self.heavy_quality_test
if test is None:
return
index = test["stage_index"]
stage = test["stages"][index]
self.heavy_quality_latest = {
"running": True, "stage": index + 1, "stages": len(test["rates"]),
"rate_hz": stage["rate_hz"], "sent": stage["sent"],
"replies": stage["replies"], "timeouts": stage["timeouts"],
"status": f"Stage {index + 1} of {len(test['rates'])}: {stage['rate_hz']} Hz",
"results": self._heavy_results(test),
}
self.events.put(("heavy_quality", self.heavy_quality_latest))
def _tick_heavy_quality(self, now):
test = self.heavy_quality_test
if test is None:
return
for token, (stage_index, sent_at) in list(self.heavy_pending_pings.items()):
if now - sent_at >= 2.5:
self.heavy_pending_pings.pop(token, None)
self._record_heavy_probe(stage_index)
self.log("HEAVY_PROBE_TIMEOUT", f"rate={test['rates'][stage_index]}Hz timesync={token}")
index = test["stage_index"]
stage_pending = any(stage_index == index for stage_index, _ in self.heavy_pending_pings.values())
if now - test["stage_started"] >= test["duration_s"] and not stage_pending:
index += 1
if index >= len(test["rates"]):
results = self._heavy_results(test)
self.heavy_quality_test = None
self.heavy_quality_latest = {
"running": False, "stage": 3, "stages": 3, "rate_hz": None,
"status": "Complete", "results": results,
}
self.next_ping_at = now
self.log("TX_HEAVY_TEST", "complete")
self.events.put(("heavy_quality", self.heavy_quality_latest))
return
test["stage_index"] = index
test["stage_started"] = now
test["next_probe_at"] = now
self._publish_heavy_quality()
index = test["stage_index"]
stage = test["stages"][index]
if now >= test["next_probe_at"] and now - test["stage_started"] < test["duration_s"]:
token = time.monotonic_ns()
self.heavy_pending_pings[token] = (index, now)
stage["sent"] += 1
self.log("TX_HEAVY_PROBE", f"rate={stage['rate_hz']}Hz timesync={token}")
self._send_mav(self.connection, "timesync_send", 0, token)
test["next_probe_at"] = now + 1.0 / stage["rate_hz"]
if now >= self.next_quality_publish_at:
self._publish_heavy_quality()
self.next_quality_publish_at = now + 1.0
def set_link_rates(self, ping_hz, telemetry_rates):
self.ping_rate_hz = max(1, min(10, int(ping_hz)))
for name in self.telemetry_rates_hz:
if name in telemetry_rates:
self.telemetry_rates_hz[name] = max(0, min(10, int(telemetry_rates[name])))
if self.connection and self.target_system:
self._configure_low_bandwidth_streams()
def _start_servo_scan(self):
names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)]
# ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal,
@@ -230,31 +651,52 @@ class MavlinkWorker:
self.param_retries = 0
def _configure_low_bandwidth_streams(self):
"""Stop inherited high-rate streams and request only diagnostic essentials."""
"""Pace optional telemetry changes; never burst them at arm time."""
if not self.connection:
return
self.log("TX_STREAM_CONFIG", "stop MAV_DATA_STREAM_ALL")
self.connection.mav.request_data_stream_send(
self._send_mav(self.connection, "request_data_stream_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0,
)
# Heartbeat and STATUSTEXT do not need to be requested. Keep only
# output/input evidence needed to diagnose controls, at 2 Hz each.
for message_id, interval_us, name in (
(mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS, 1_000_000, "SYS_STATUS"),
(mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW, 500_000, "SERVO_OUTPUT_RAW"),
(mavutil.mavlink.MAVLINK_MSG_ID_RC_CHANNELS, 500_000, "RC_CHANNELS"),
):
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
self.connection.mav.command_long_send(
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
0, message_id, interval_us, 0, 0, 0, 0, 0,
)
self.events.put(("status", "Low-bandwidth telemetry configured; reading servo setup…"))
# ATTITUDE and VFR_HUD interval commands were accepted by this FC.
# AHRS2 is also unneeded, but an acknowledgement has not been seen.
# NAMED_VALUE_FLOAT explicitly returned DENIED, so do not retry it.
# A single interval command is sometimes lost, so send two paced
# passes. This does not change persistent parameters or onboard logs.
unwanted = (
"ATTITUDE", "AHRS2", "VFR_HUD",
)
self.stream_config = deque(
(name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), -1)
for _ in range(2) for name in unwanted
)
for name, rate_hz in self.telemetry_rates_hz.items():
interval_us = -1 if rate_hz == 0 else round(1_000_000 / rate_hz)
self.stream_config.append((
name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), interval_us,
))
self.next_stream_config_at = time.monotonic()
self.events.put(("status", "Applying low-bandwidth telemetry rates…"))
def _tick_stream_config(self, now):
if (not self.connection or not self.target_system or self.armed
or now < self.command_quiet_until or now < self.next_stream_config_at
or not self.stream_config):
return
name, message_id, interval_us = self.stream_config.popleft()
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
self._send_mav(self.connection, "command_long_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
0, message_id, interval_us, 0, 0, 0, 0, 0,
)
self.next_stream_config_at = now + 0.5
def _tick_param_scan(self, now):
if not self.connection or self.config_paused or now < self.param_deadline:
if (not self.connection or self.config_paused
or self.stream_config or now < self.next_stream_config_at
or now < self.command_quiet_until or now < self.param_deadline):
return
if self.param_waiting is not None:
if self.param_retries < 2:
@@ -275,7 +717,7 @@ class MavlinkWorker:
def _request_param(self, name: str):
self.log("TX_PARAM_REQUEST", name)
self.connection.mav.param_request_read_send(
self._send_mav(self.connection, "param_request_read_send",
self.target_system, self.target_component, name.encode("ascii"), -1
)
@@ -289,7 +731,7 @@ class MavlinkWorker:
raise RuntimeError(f"{name} has not been read from the autopilot")
self.pending_sets[name] = int(value)
self.log("TX_PARAM_SET", f"{name}={value} type={param_type}")
self.connection.mav.param_set_send(
self._send_mav(self.connection, "param_set_send",
self.target_system, self.target_component, name.encode("ascii"),
float(value), param_type,
)
@@ -300,10 +742,17 @@ class MavlinkWorker:
if not conn or not self.target_system:
return
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()
gain = self.gain
now = time.monotonic()
if force_neutral:
self.output_axes = Axes()
else:
@@ -335,7 +784,7 @@ class MavlinkWorker:
f"x={x} y={y} z={z} r={r} active={active} "
f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})",
)
conn.mav.manual_control_send(self.target_system, x, y, z, r, 0)
self._send_mav(conn, "manual_control_send", self.target_system, x, y, z, r, 0)
@staticmethod
def _approach(current: float, target: float, max_change: float) -> float:
@@ -348,18 +797,45 @@ class MavlinkWorker:
conn = self.connection
if not conn or not self.target_system:
raise RuntimeError("No autopilot heartbeat")
if arm and self.heavy_quality_test is not None:
self.cancel_heavy_quality_test()
now = time.monotonic()
self.command_quiet_until = now + 6.0
if not arm:
self.disarm_requested_until = now + 6.0
self._send_control(force_neutral=True)
else:
# Give the ARM request priority over the low-rate parameter scan.
self.config_paused = True
self.log("TX_COMMAND", "ARM" if arm else "DISARM")
conn.mav.command_long_send(
self.pending_arm_state = bool(arm)
self.next_arm_retry_at = now + 0.25
self.arm_retry_deadline = now + 5.0
self.arm_confirmation = 0
self._send_arm_packet(arm)
def _send_arm_packet(self, arm: bool):
conn = self.connection
if not conn or not self.target_system:
return
self._send_mav(conn, "command_long_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
self.arm_confirmation, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
)
def _tick_arm_retry(self, now):
if self.pending_arm_state is None or now < self.next_arm_retry_at:
return
if now >= self.arm_retry_deadline:
self.log("TX_COMMAND_TIMEOUT", "ARM" if self.pending_arm_state else "DISARM")
self.pending_arm_state = None
return
self.log("TX_COMMAND_RETRY", "ARM" if self.pending_arm_state else "DISARM")
self.arm_confirmation = min(255, self.arm_confirmation + 1)
self._send_arm_packet(self.pending_arm_state)
self.next_arm_retry_at = now + 0.25
def set_manual_mode(self):
conn = self.connection
if not conn or not self.target_system:
@@ -369,13 +845,13 @@ class MavlinkWorker:
if manual is None:
raise RuntimeError("MANUAL mode is not advertised by this firmware")
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
conn.mav.set_mode_send(
self._send_mav(conn, "set_mode_send",
self.target_system,
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
manual,
)
def set_axes(self, axes: Axes):
def set_axes(self, axes: Axes, sequence=None):
def conditioned(value):
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
if abs(value) < 0.04:
@@ -383,6 +859,11 @@ class MavlinkWorker:
return round(max(-1.0, min(1.0, value)), 2)
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(
conditioned(axes.forward), conditioned(axes.lateral),
conditioned(axes.vertical), conditioned(axes.yaw),
@@ -401,7 +882,7 @@ class MavlinkWorker:
if self.connection and self.target_system:
try:
self._send_control(force_neutral=True)
self.connection.mav.command_long_send(
self._send_mav(self.connection, "command_long_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 0, 0, 0, 0, 0, 0, 0,
@@ -887,6 +1368,9 @@ class App(tk.Tk):
def refresh_ports(self):
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)
if ports and self.port.get() not in ports:
self.port.set(ports[0])
@@ -1139,7 +1623,7 @@ class App(tk.Tk):
event = self.events.get_nowait()
kind = event[0]
if kind == "heartbeat":
_, system, autopilot, mode, armed = event
_, system, autopilot, mode, armed = event[:5]
self.vehicle_label.configure(
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+14
View File
@@ -0,0 +1,14 @@
"""Package the original PNG in a Windows ICO without altering its pixels."""
from pathlib import Path
import struct
root = Path(__file__).resolve().parent
png = (root / "assets" / "sub-control-icon.png").read_bytes()
if png[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError("Icon source must be PNG")
width, height = struct.unpack(">II", png[16:24])
if not (1 <= width <= 256 and 1 <= height <= 256):
raise ValueError("Windows icon source must be at most 256 x 256")
header = struct.pack("<HHH", 0, 1, 1)
entry = struct.pack("<BBBBHHII", width % 256, height % 256, 0, 0, 1, 32, len(png), 22)
(root / "assets" / "sub-control.ico").write_bytes(header + entry + png)
+43
View File
@@ -0,0 +1,43 @@
param([switch]$UpdateIcon)
$ErrorActionPreference = 'Stop'
$shortcutProject = $PSScriptRoot
$shortcutMarker = Join-Path $shortcutProject 'runtime\desktop-shortcut-created.txt'
if ((Test-Path -LiteralPath $shortcutMarker) -and -not $UpdateIcon) { return }
try {
# Resolves the real Desktop, including a redirected/OneDrive Desktop.
$shortcutDesktop = [Environment]::GetFolderPath('DesktopDirectory')
if (-not $shortcutDesktop) { throw 'Windows Desktop folder is unavailable.' }
$shortcutPath = Join-Path $shortcutDesktop 'SUB Control.lnk'
# Never replace a shortcut the user already has.
if (-not (Test-Path -LiteralPath $shortcutPath)) {
$shortcutShell = New-Object -ComObject WScript.Shell
$shortcutLink = $shortcutShell.CreateShortcut($shortcutPath)
$shortcutLink.TargetPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$shortcutLink.Arguments = '-NoProfile -WindowStyle Hidden -File "' + (Join-Path $shortcutProject 'run.ps1') + '"'
$shortcutLink.WorkingDirectory = $shortcutProject
$shortcutLink.Description = 'Launch SUB Control'
$shortcutLink.WindowStyle = 7
$shortcutIcon = Join-Path $shortcutProject 'assets\sub-control.ico'
if (Test-Path -LiteralPath $shortcutIcon) { $shortcutLink.IconLocation = "$shortcutIcon,0" }
$shortcutLink.Save()
} elseif ($UpdateIcon) {
$shortcutShell = New-Object -ComObject WScript.Shell
$shortcutLink = $shortcutShell.CreateShortcut($shortcutPath)
$expectedScript = '"' + (Join-Path $shortcutProject 'run.ps1') + '"'
if ($shortcutLink.Arguments.Contains($expectedScript)) {
$shortcutLink.IconLocation = (Join-Path $shortcutProject 'assets\sub-control.ico') + ',0'
$shortcutLink.Save()
} else {
throw 'Existing shortcut belongs to another installation; left unchanged.'
}
}
New-Item -ItemType Directory -Path (Split-Path -Parent $shortcutMarker) -Force | Out-Null
Set-Content -LiteralPath $shortcutMarker -Value $shortcutPath
} catch {
# Desktop permissions must not prevent the controller from starting.
Write-Warning "Could not create the SUB Control desktop shortcut: $_"
}
+21
View File
@@ -0,0 +1,21 @@
"""Best-effort Windows first-run shortcut, including direct Python launches."""
import os
from pathlib import Path
import subprocess
def ensure_desktop_shortcut():
if os.name != "nt":
return
root = Path(__file__).resolve().parent
if (root / "runtime" / "desktop-shortcut-created.txt").exists():
return
powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe"
try:
subprocess.run(
[str(powershell), "-NoProfile", "-NonInteractive", "-File", str(root / "create_shortcut.ps1")],
cwd=root, timeout=10, check=True, capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW,
)
except (OSError, subprocess.SubprocessError) as error:
print(f"Desktop shortcut setup skipped: {error}")
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+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()
+2
View File
@@ -3,6 +3,8 @@ $ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location -LiteralPath $projectRoot
& (Join-Path $projectRoot 'create_shortcut.ps1')
if (-not (Get-Command py -ErrorAction SilentlyContinue)) {
throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again."
}
+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
}
+300
View File
@@ -0,0 +1,300 @@
// 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('model viewport fills the available space above battery status',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.rov-stage \{ flex:1; height:auto; min-height:0; width:100%/);
assert.match(css,/\.telemetry-zone \.model-card \{ flex: 1; min-height: 0;/);
});
test('white wash stays behind controls; right panel is gray and edges reach 50 percent',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.workspace-light \{[^}]*z-index:-1/);
assert.match(css,/\.controls-workspace \{[^}]*isolation:isolate/);
assert.match(css,/\.controls-workspace \{[^}]*background:#ededed/);
assert.match(css,/\.sidebar-deck \{[^}]*background:linear-gradient\(160deg,#333,#242424\)/);
assert.match(css,/\.cursor-light::after \{[^}]*rgba\(255,255,255,\.50\)/);
});
test('keyboard easing publishes intermediate demands to the real Python bridge',async()=>{
const h=harness();h.state();h.run('bridgeReady=true');
keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');
for(let i=0;i<10;i++){h.advance(50);await Promise.resolve();await Promise.resolve();await Promise.resolve();}
const sent=h.calls.filter(call=>call[0]==='axes');
assert.ok(sent.some(call=>call[1]>0&&call[1]<1),'surge ramp is transmitted, not just drawn');
assert.ok(sent.some(call=>call[3]>0&&call[3]<1),'vertical ramp is transmitted, not just drawn');
assert.ok(sent.every(call=>call[1]>=0&&call[1]<=1&&call[3]>=0&&call[3]<=1));
});
test('cursor lighting stays decorative and arrows use centered geometry',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.doesNotMatch(js,/set_axes|setJoyControl|setHeave|requestControl/);
assert.match(js,/workspace\.append\(wash\)/);
assert.match(js,/pointerleave',clear/);
assert.match(css,/\.cursor-light \{[^}]*pointer-events:none/);
assert.match(css,/range-pill button \{[^}]*place-items:center/);
assert.match(css,/#engage\.danger \.engage-label \{ color:#777/);
});
test('edge lighting uses angle; only visible pills get blue and sidebar is excluded',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.match(js,/Math\.atan2/);
assert.match(js,/!host\.closest\('\.sidebar-deck,#engage'\)/);
assert.match(js,/host\.matches\(pillSelector\)/);
assert.match(css,/\.cursor-light\.pill-light::before \{ background:radial-gradient/);
assert.match(css,/\.cursor-light::after \{[^}]*linear-gradient\(var\(--light-angle/);
assert.doesNotMatch(css,/\.cursor-light::after \{[^}]*radial-gradient/);
assert.match(css,/\.rov-stage \{[^}]*border-radius:31px; overflow:hidden/);
});
test('all interface typography uses Helvetica with inherited control fonts',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const families=[...css.matchAll(/font-family\s*:\s*([^;}]+)/g)].map(match=>match[1].trim());
assert.ok(families.includes('"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.ok(families.every(family=>family==='inherit'||family==='"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.match(css,/button, select, input \{ font: inherit; \}/);
});
function keyEvent(h,type,key,repeat=false){h.fire(type,{key,repeat,target:{tagName:'BODY'},preventDefault(){}})}
test('keyboard ramps joystick and vertical together, then eases back on release',()=>{
const h=harness();h.state();
keyEvent(h,'keydown','w');keyEvent(h,'keydown','d');keyEvent(h,'keydown','r');
assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
h.advance(180);
for(const axis of ['forward','turn','vertical'])assert.ok(h.run(`control.${axis}`)>0&&h.run(`control.${axis}`)<1);
keyEvent(h,'keydown','w',true);h.advance(240);
assert.ok(Math.abs(h.run('control.forward')-Math.SQRT1_2)<1e-6);
assert.ok(Math.abs(h.run('control.turn')-Math.SQRT1_2)<1e-6);
assert.equal(h.run('control.vertical'),1);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','d');keyEvent(h,'keyup','r');
h.advance(180);assert.ok(h.run('control.forward')>0);assert.ok(h.run('control.vertical')>0);
h.advance(250);assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
});
test('keyboard reversal is continuous and focus loss cancels its ramp',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(420);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','r');keyEvent(h,'keydown','s');keyEvent(h,'keydown','f');
assert.equal(h.run('control.forward'),1);assert.equal(h.run('control.vertical'),1);
h.advance(200);assert.ok(Math.abs(h.run('control.forward'))<.3);assert.ok(Math.abs(h.run('control.vertical'))<.3);
h.advance(220);assert.equal(h.run('control.forward'),-1);assert.equal(h.run('control.vertical'),-1);
keyEvent(h,'keyup','s');keyEvent(h,'keyup','f');h.fire('blur');h.advance(500);
assert.equal(h.run('control.forward+control.vertical'),0);
});
test('touch takes ownership from a keyboard ramp without later keyboard overwrite',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(100);
h.el('#joystick-canvas').emit('pointerdown',{clientX:300,clientY:160});
h.el('#heave').emit('pointerdown',{pointerId:2,clientY:300});
keyEvent(h,'keydown','a');h.advance(450);
assert.equal(h.run('control.turn'),1);assert.equal(Math.abs(h.run('control.forward')),0);assert.equal(h.run('control.vertical'),-1);
});
test('hold fill and pending engagement share the same green and glow',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.engage-progress-label \{ color:var\(--engage-green\).*filter:var\(--engage-glow\)/);
assert.match(css,/#engage\.pending \.engage-label \{ color:var\(--engage-green\); filter:var\(--engage-glow\)/);
});
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…');
});
+31
View File
@@ -0,0 +1,31 @@
/* Pure conditioning and log parsing tests: no hardware or browser needed. */
const assert=require('node:assert/strict');
const {deadZone,defaults,read,Motion}=require('../web/controller-input.js');
const {parse}=require('../web/console-view.js');
const pad={connected:true,axes:[0,0,0,0]};
assert.equal(deadZone(.06),0);assert.equal(deadZone(-1),-1);assert.equal(deadZone(1),1);
assert.equal(defaults({axes:[0,0]}).vertical,-1);
assert.equal(read({...pad,connected:false},defaults(pad)),null);
assert.equal(read(pad,{...defaults(pad),vertical:10}),null);
const diagonal=read({...pad,axes:[1,-1,0,-1]},defaults(pad));
assert(Math.abs(Math.hypot(diagonal.turn,diagonal.forward)-1)<1e-10);
assert.equal(diagonal.vertical,1);
assert.equal(read({...pad,axes:[-.5,0,0,0]},{...defaults(pad),invertTurn:true}).turn,deadZone(.5));
const motion=new Motion(),target={turn:.6,forward:.8,vertical:1};
assert.deepEqual(motion.step(target,0),{turn:0,forward:0,vertical:0});
const partial=motion.step(target,16);assert(partial.vertical>0&&partial.vertical<.3);
let current;for(let time=32;time<=1000;time+=16){current=motion.step(target,time);assert(Math.hypot(current.turn,current.forward)<=1+1e-10)}
assert(current.vertical>.99);
const neutral={turn:0,forward:0,vertical:0};
assert(motion.step(neutral,1024).vertical>.99);
assert(motion.step(neutral,1200).vertical>0);
assert.deepEqual(motion.step(neutral,1400),neutral);
motion.reset();assert.deepEqual(motion.step(target,1500),neutral);
assert.equal(parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"HEARTBEAT","base_mode":128}').quiet,true);
const ack=parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0}');
assert.equal(ack.time,'10:11:12.123');assert.equal(ack.type,'COMMAND_ACK');assert.equal(ack.tone,'command');
assert.equal(parse('2026-10-01T10:11:12 [TX_LINK_PROBE_TIMEOUT] retry').quiet,false);
assert.equal(parse('2026-10-01T10:11:12 [RX] {"mavpackettype":"STATUSTEXT","severity":3,"text":"warning"}').tone,'warning');
assert.equal(parse('2026-10-01T10:11:12 [RX] null').type,'RX');
assert.equal(parse('unstructured text').payload,'unstructured text');
console.log('PASS: dead zone, mapping/inversion, diagonal cap, ramp, finite center return, resets, console parsing and warning visibility.');
+333
View File
@@ -0,0 +1,333 @@
"""No serial port is opened by these tests."""
import queue
import threading
import time
import unittest
from types import SimpleNamespace
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.control_values(),
(1, 0, 0, NEUTRAL_Z, 0, 0))
def control_values(self):
return self.worker.connection.mav.manual_control_send.call_args.args
def test_manual_control_uses_native_v2_without_forced_long_payload(self):
self.worker._send_mav(self.worker.connection, 'manual_control_send',
1, 50, 0, 525, -20, 0)
self.assertEqual(self.control_values(), (1, 50, 0, 525, -20, 0))
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.kwargs, {})
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_intermediate_demands_reach_mavlink_with_output_limit(self):
with patch("app.time.monotonic", return_value=10):
self.worker.last_control_at = 9.9
self.worker.set_axes(Axes(forward=.25, vertical=.25), 1)
self.worker._send_control()
# 25% input at the default 20% limit: 5% command, not 100%.
self.assertEqual(self.control_values(),
(1, 50, 0, 525, 0, 0))
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())
def test_mavlink_transmissions_are_serialized(self):
first_entered = threading.Event()
release_first = threading.Event()
second_entered = threading.Event()
class Mav:
def first_send(self, **_):
first_entered.set()
release_first.wait(1)
def second_send(self, **_):
second_entered.set()
connection = SimpleNamespace(mav=Mav())
first = threading.Thread(target=self.worker._send_mav,
args=(connection, "first_send"))
second = threading.Thread(target=self.worker._send_mav,
args=(connection, "second_send"))
first.start()
self.assertTrue(first_entered.wait(.5))
second.start()
time.sleep(.05)
self.assertFalse(second_entered.is_set())
release_first.set()
first.join(1)
second.join(1)
self.assertTrue(second_entered.is_set())
def test_live_console_is_read_only_bounded_and_keeps_newest_lines(self):
for index in range(310):
self.worker.log("RX_TEST", f"vehicle {index}")
self.worker.log("TX_TEST", f"computer {index}")
sides = self.worker.read_console(2)
self.assertEqual(set(sides), {"rx", "tx"})
self.assertTrue(sides["rx"][0].endswith("[RX_TEST] vehicle 308"))
self.assertTrue(sides["rx"][1].endswith("[RX_TEST] vehicle 309"))
self.assertTrue(sides["tx"][1].endswith("[TX_TEST] computer 309"))
self.assertEqual(len(self.worker.console_rx_lines), 300)
self.assertEqual(len(self.worker.console_tx_lines), 300)
self.assertEqual(len(self.worker.console_lines), 500)
def test_arm_request_pauses_parameter_traffic(self):
self.worker.armed = False
self.worker.param_scan.append("SERVO1_FUNCTION")
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(True)
self.worker.config_paused = False # A disarmed heartbeat arrived.
self.worker._tick_param_scan(10.1)
self.assertEqual(self.worker.command_quiet_until, 16)
self.worker.connection.mav.param_request_read_send.assert_not_called()
self.worker.connection.mav.command_long_send.assert_called_once()
def test_arm_request_retries_until_state_confirmation(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(True)
self.worker._tick_arm_retry(10.24)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_arm_retry(10.25)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
self.worker.pending_arm_state = None
self.worker._tick_arm_retry(10.50)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
def test_outbound_command_has_valid_heartbeat_lead_in(self):
self.worker._send_mav(self.worker.connection, "timesync_send", 0, 123)
names = [call[0] for call in self.worker.connection.mav.method_calls]
self.assertEqual(names[-2:], ["heartbeat_send", "timesync_send"])
def test_disarm_sends_neutral_and_pauses_parameter_traffic(self):
self.worker.param_scan.append("SERVO1_FUNCTION")
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(False)
self.worker.config_paused = False
self.worker._tick_param_scan(10.1)
self.assertEqual(self.worker.disarm_requested_until, 16)
self.assertEqual(self.control_values(),
(1, 0, 0, NEUTRAL_Z, 0, 0))
self.worker.connection.mav.param_request_read_send.assert_not_called()
def test_telemetry_config_is_paced_and_includes_low_rate_esc(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker._configure_low_bandwidth_streams()
self.assertEqual(len(self.worker.stream_config), 10)
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
self.worker.connection.mav.command_long_send.assert_not_called()
self.worker._tick_stream_config(10)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_stream_config(10.1)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker.command_quiet_until = 16
self.worker._tick_stream_config(11)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_stream_config(16)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
def test_link_probe_uses_configured_rate_and_measures_exact_echo(self):
self.worker.armed = False
self.worker.ping_rate_hz = 5
with patch("app.time.monotonic", return_value=10), \
patch("app.time.monotonic_ns", return_value=10_000_000_000):
self.worker._tick_link_probe(10)
self.worker.connection.mav.timesync_send.assert_called_once_with(
0, 10_000_000_000)
self.assertEqual(self.worker.pending_pings, {10_000_000_000: 10})
self.assertEqual(self.worker.next_ping_at, 10.2)
message = SimpleNamespace(
tc1=1, ts1=10_000_000_000, get_type=lambda: "TIMESYNC",
to_dict=lambda: {"tc1": 1, "ts1": 10_000_000_000})
with patch("app.time.monotonic", return_value=10.04):
self.worker._handle_message(message)
quality = self.worker.events.get_nowait()
self.assertEqual(quality[0], "link_quality")
self.assertEqual(quality[1]["latency_ms"], 40.0)
self.assertEqual(quality[1]["loss_percent"], 0.0)
def test_link_rates_rebuild_requested_telemetry_intervals(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker.set_link_rates(10, {
"SYS_STATUS": 5, "SERVO_OUTPUT_RAW": 10,
"RC_CHANNELS": 0, "ESC_TELEMETRY_1_TO_4": 2,
})
self.assertEqual(self.worker.ping_rate_hz, 10)
configured = {name: interval for name, _, interval in self.worker.stream_config}
self.assertEqual(configured["SYS_STATUS"], 200_000)
self.assertEqual(configured["SERVO_OUTPUT_RAW"], 100_000)
self.assertEqual(configured["RC_CHANNELS"], -1)
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 500_000)
def test_link_probe_counts_timeout_and_pauses_for_commands(self):
self.worker.pending_pings[4] = 5
self.worker.rx_delivery_results.extend(((10, True), (10, False)))
self.worker.next_ping_at = 0
self.worker.command_quiet_until = 20
with patch("app.time.monotonic", return_value=10):
self.worker._tick_link_probe(10)
self.worker.connection.mav.timesync_send.assert_not_called()
self.assertEqual(list(self.worker.ping_results), [(10, None)])
quality = self.worker.events.get_nowait()[1]
self.assertEqual(quality["loss_percent"], 100.0)
self.assertEqual(quality["grade"], "testing")
def test_clean_downlink_cannot_hide_failed_round_trips(self):
self.worker.ping_results.extend([(10, None)] * 20)
self.worker.rx_delivery_results.extend([(10, True)] * 200)
with patch("app.time.monotonic", return_value=10):
self.worker._publish_link_quality()
quality = self.worker.events.get_nowait()[1]
self.assertEqual(quality["loss_percent"], 100.0)
self.assertEqual(quality["grade"], "poor")
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()
+270
View File
@@ -0,0 +1,270 @@
/* Browser regression check. Run: node tests/ui_windows.cjs [path-to-playwright]
Uses a simulated Python bridge; never opens a COM port or sends MAVLink. */
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const http=require('node:http');
const {chromium}=require(process.argv[2]||'playwright');
const root=path.resolve(__dirname,'..'),output=path.join(root,'runtime','ui-check');
fs.mkdirSync(output,{recursive:true});
const server=http.createServer((req,res)=>{
const url=decodeURIComponent(new URL(req.url,'http://localhost').pathname);
let file=path.resolve(root,'.'+url);
if(url==='/model.stl'||url==='/model')file=path.join(root,'runtime','vehicle_model.stl');
if(!file.startsWith(root+path.sep)||!fs.existsSync(file)||!fs.statSync(file).isFile()){
res.writeHead(404);res.end();return;
}
const types={'.html':'text/html','.js':'text/javascript','.css':'text/css','.stl':'model/stl'};
res.setHeader('Content-Type',types[path.extname(file)]||'application/octet-stream');
fs.createReadStream(file).pipe(res);
});
async function main(){
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage({viewport:{width:1080,height:720}});
const errors=[];page.on('pageerror',error=>errors.push(error.message));
const setArmed=value=>page.evaluate(value=>{
Object.assign(testBridge.state,{connected:true,armed:value,armed_known:true,app_state:value?'armed':'connected_disarmed'});render(testBridge.state);
},value);
await page.addInitScript(()=>{
const clone=value=>JSON.parse(JSON.stringify(value));
const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null};
const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null};
const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20};
const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1};
const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[],axes:[],axisVisual:[],stops:0};
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false};
window.testPads=[];Object.defineProperty(navigator,'getGamepads',{value:()=>window.testPads});
window.pywebview={api:{
initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}),
refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state),
get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>{calls.stops++;return true},set_axes:async(...axes)=>{calls.axes.push(axes);calls.axisVisual.push({joy:joystick3D.getState(),vertical:verticalControl.getState().value});return true},
save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)},
save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)},
connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)},
disconnect:async()=>{Object.assign(state,{connected:false,armed_known:false,app_state:'disconnected'});return true},
reverse_motor:async servo=>{calls.reverse++;if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>state.servos[servo].direction*=-1,650);return true},
set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true},
start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)},
cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)},
read_console:async()=>{calls.console++;return {rx:['2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0,"sample":1}', '2026-10-01T10:11:12.124 [RX_BAD_DATA] corrupt_frame='+calls.console, '2026-10-01T10:11:12.125 [RX] {"mavpackettype":"HEARTBEAT"}'],tx:['2026-10-01T10:11:12.124 [TX_COMMAND] ARM '+calls.console]}},
minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true
}};
});
await page.goto('http://127.0.0.1:'+server.address().port+'/web/index.html');
await page.waitForFunction(()=>bridgeReady);
const openLink=()=>page.locator('[data-open="link-modal"]').click();
const close=async id=>{await page.locator('#'+id+' [data-close]').first().click();await page.waitForFunction(id=>!document.getElementById(id).open,id)};
await openLink();
await page.locator('#baud').selectOption('115200');
await page.locator('#link-modal').getByRole('button',{name:'Cancel',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('link-modal').open);
await openLink();assert.equal(await page.locator('#baud').inputValue(),'57600');
await page.locator('#custom-port-input').fill('COM42');
await page.locator('#custom-port-btn').click();
assert(await page.locator('#link-modal').evaluate(d=>d.open));
assert.equal(await page.locator('#port').inputValue(),'COM42');
await page.locator('#baud').selectOption('115200');
assert(await page.locator('#link-form [type="submit"]').evaluate(el=>{el.click();return el.disabled}));
await page.waitForFunction(()=>!document.getElementById('link-modal').open);
await page.locator('#refresh').click();assert.equal(await page.locator('#port').inputValue(),'COM42');
await page.locator('#connect').click();
await page.waitForFunction(()=>testBridge.calls.connect.length===1);
assert.equal(await page.evaluate(()=>testBridge.calls.connect[0].baud),115200);
await page.locator('[data-open="battery-modal"]').click();
await page.locator('#battery-form [name="cells"]').fill('5');
await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.getElementById('battery-modal').open);
await page.locator('[data-open="battery-modal"]').click();
assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'4');
await page.evaluate(()=>testBridge.failBattery=true);
await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click();
await page.locator('#battery-form .form-feedback').waitFor({state:'visible'});
assert(await page.locator('#battery-modal').evaluate(d=>d.open));
assert.equal(await page.locator('#battery-form .form-feedback').textContent(),'Battery save failed');
await page.evaluate(()=>testBridge.failBattery=false);
await page.locator('#battery-form [name="cells"]').fill('5');
await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click();
await page.waitForFunction(()=>!document.getElementById('battery-modal').open);
await page.locator('[data-open="battery-modal"]').click();
assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'5');
await page.screenshot({path:path.join(output,'battery.png')});await close('battery-modal');
await page.locator('[data-open="motors-modal"]').click();
assert.equal(await page.locator('#motor-list > .motor-row').count(),3);
await page.locator('#motor-list > .motor-row').first().getByRole('button').click();
await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Confirming…');
assert(await page.locator('#motor-list > .motor-row').first().getByRole('button').isDisabled());
await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Reversed');
await page.evaluate(()=>testBridge.failMotor=true);
await page.locator('#motor-list > .motor-row').first().locator('select').selectOption('2');
await page.locator('#motor-feedback').waitFor({state:'visible'});
assert.equal(await page.locator('#motor-list > .motor-row').first().locator('select').inputValue(),'1');
await page.screenshot({path:path.join(output,'motors.png')});await close('motors-modal');
await page.locator('#link-quality').click();
assert.equal(await page.locator('#quality-detail-rtt').textContent(),'24 ms');
await page.locator('#heavy-quality-run').click();
assert(await page.locator('#heavy-quality-run').isDisabled());
await page.locator('#heavy-quality-cancel').waitFor({state:'visible'});
assert.equal(await page.evaluate(()=>testBridge.calls.heavy),1);
await page.locator('#heavy-quality-cancel').click();
await page.waitForFunction(()=>!testBridge.state.heavy_quality.running);
await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal');
await page.locator('#console').click();
await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('COMMAND_ACK'));
await page.locator('#console-pause').click();
const paused=await page.locator('#console-rx-output').textContent();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused);
await page.locator('#console-pause').click();
await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused);
await page.screenshot({path:path.join(output,'console.png')});await page.locator('#console-close').click();
for(const viewport of [{width:900,height:560},{width:480,height:720}]){
await page.setViewportSize(viewport);
for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','controller-modal']){
await page.evaluate(id=>openModal(id),id);
const box=await page.locator('#'+id).boundingBox();
assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport');
assert.equal(await page.evaluate(()=>canDrive()),false);
const closeGeometry=await page.locator('#'+id+' .modal-close').evaluate(button=>{
const b=button.getBoundingClientRect(),s=button.querySelector('svg').getBoundingClientRect();
return {dx:Math.abs(b.x+b.width/2-s.x-s.width/2),dy:Math.abs(b.y+b.height/2-s.y-s.height/2),bg:getComputedStyle(button).backgroundColor};
});
assert(closeGeometry.dx<.5&&closeGeometry.dy<.5,id+' close icon must be centered');
assert.equal(closeGeometry.bg,'rgba(0, 0, 0, 0)');
await close(id);
}
}
await page.setViewportSize({width:1080,height:720});await openLink();
await page.screenshot({path:path.join(output,'link.png')});await close('link-modal');
await page.setViewportSize({width:1080,height:720});
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:true,armed:false,armed_known:true,app_state:'connected_disarmed',battery:{voltage:15.8,percent:72,cells:4,low:false}});focusChanged(true);render(testBridge.state)});
await page.locator('#engage').focus();
assert.equal(await page.locator('#engage').evaluate(el=>getComputedStyle(el).outlineStyle),'none');
await page.evaluate(()=>startEngageHold());
assert.equal(await page.locator('#input-joystick').isDisabled(),true);
await page.evaluate(()=>cancelEngageHold(false));
assert.equal(await page.locator('#input-joystick').isDisabled(),false);
await page.locator('#input-joystick').click();
for(const pending of ['arm','disarm']){
await page.evaluate(pending=>{testBridge.state.pending_command=pending;render(testBridge.state)},pending);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
assert.equal(await page.evaluate(()=>{setInputMode('keyboard');return inputMode}),'joystick');
}
await page.evaluate(()=>{delete testBridge.state.pending_command;testBridge.state.armed_known=false;render(testBridge.state)});
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await setArmed(true);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await page.waitForFunction(()=>document.getElementById('controller-status').textContent.includes('Waiting'));
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[1,-1,0,-1]}]);
await page.waitForFunction(()=>document.getElementById('controller-status').textContent==='Center sticks to resume');
assert.deepEqual(await page.evaluate(()=>({...control})),{turn:0,forward:0,vertical:0});
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
await page.waitForFunction(()=>!controllerNeedCenter);
await page.keyboard.down('w');await page.waitForTimeout(420);await page.keyboard.up('w');
assert.equal(await page.evaluate(()=>control.forward),0);
await page.evaluate(()=>testPads[0].axes=[1,-1,0,-1]);
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.8);
await page.waitForFunction(()=>control.vertical>.99);
const matching=await page.evaluate(()=>{
const joy=joystick3D.getState(),sent=testBridge.calls.axes.at(-1);
return {length:Math.hypot(control.turn,control.forward),joy,vertical:control.vertical,sent,visual:testBridge.calls.axisVisual.at(-1),control:{...control}};
});
assert(matching.length<=1.00001);
assert.equal(matching.joy.x,matching.control.turn);assert.equal(matching.joy.y,-matching.control.forward);
assert.equal(matching.sent[0],-matching.visual.joy.y);assert.equal(matching.sent[1],matching.visual.joy.x);assert.equal(matching.sent[2],matching.visual.vertical);
// Dispatch even though CSS excludes pointers, to exercise the handler gate.
await page.locator('#joystick-canvas').dispatchEvent('pointerdown',{pointerId:91,clientX:20,clientY:20,button:0});
await page.locator('#heave').dispatchEvent('pointerdown',{pointerId:92,clientX:20,clientY:20,button:0});
assert.equal(await page.evaluate(()=>joystick3D.getState().active||verticalControl.getState().active),false);
await page.locator('#console').click();
await page.waitForFunction(()=>!document.getElementById('console-dock').hidden);
await page.waitForFunction(()=>document.querySelector('.rx-dot').classList.contains('lit')&&document.querySelector('.tx-dot').classList.contains('lit'));
await page.locator('#console-pause').click();
await page.waitForTimeout(500);
assert.equal(await page.locator('.rx-dot').evaluate(el=>el.classList.contains('lit')),false);
assert.equal(await page.locator('.tx-dot').evaluate(el=>el.classList.contains('lit')),false);
await page.locator('#console-pause').click();
assert.equal(await page.evaluate(()=>canDrive()),true);
assert.equal(await page.locator('#console-rx-output .console-row').count(),2);
await page.locator('#console-filter').selectOption('all');
assert.equal(await page.locator('#console-rx-output .console-row').count(),3);
await page.locator('#console-rx-output details').first().locator('summary').click();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output details').first().evaluate(el=>el.open),true);
await page.screenshot({path:path.join(output,'controller-console.png')});await page.locator('#console-close').click();
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.95);
await page.waitForFunction(()=>control.vertical===0&&control.turn===0&&control.forward===0);
await setArmed(false);
assert.equal(await page.locator('#input-keyboard').isDisabled(),false);
await page.locator('#controller-settings').click();
await page.screenshot({path:path.join(output,'controller-setup.png')});
await page.locator('#controller-form [name="invertTurn"]').check();
await page.locator('#controller-form').getByRole('button',{name:'Cancel',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
await page.locator('#controller-settings').click();
assert.equal(await page.locator('#controller-form [name="invertTurn"]').isChecked(),false);
await page.locator('#controller-form [name="invertTurn"]').check();
await page.locator('#controller-form').getByRole('button',{name:'Save mapping',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
await setArmed(true);
await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);
await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>testPads=[]);
await page.waitForFunction(()=>control.turn===0);
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[.5,0,0,0]}]);
await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>focusChanged(false));assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>focusChanged(true));await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>testPads[0].id='Different controller');
await page.waitForFunction(()=>control.turn===0);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await setArmed(false);
await page.locator('#input-keyboard').click();
await setArmed(true);
await page.keyboard.down('w');await page.waitForFunction(()=>control.forward>.9);await page.keyboard.up('w');
await page.waitForFunction(()=>control.forward===0);
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:false,armed_known:false});render(testBridge.state)});
assert.equal(await page.evaluate(()=>canDrive()),false);
await setArmed(false);
const deckLayout=await page.evaluate(()=>{
const battery=document.querySelector('.battery-card'),sidebar=document.querySelector('.sidebar-deck'),actions=document.querySelector('.activity-actions'),heading=document.querySelector('.activity-heading');
return {last:battery===document.querySelector('.telemetry-zone').lastElementChild,title:heading.textContent,
actionHeight:actions.querySelector('button').getBoundingClientRect().height,
batteryHeight:battery.getBoundingClientRect().height,connectHeight:document.querySelector('#connect').getBoundingClientRect().height,
bottomGap:sidebar.getBoundingClientRect().bottom-battery.getBoundingClientRect().bottom,
overlay:getComputedStyle(document.querySelector('.battery-copy')).zIndex,
actionRightGap:actions.getBoundingClientRect().right-actions.lastElementChild.getBoundingClientRect().right,
sidebarPadding:getComputedStyle(sidebar).paddingTop,
workspacePadding:getComputedStyle(document.querySelector('.controls-workspace')).paddingTop};
});
assert.equal(deckLayout.last,true);assert.equal(deckLayout.title,'Activity:');
assert.equal(deckLayout.actionHeight,32);assert.equal(deckLayout.batteryHeight,62);assert.equal(deckLayout.connectHeight,62);
assert(deckLayout.bottomGap>=10&&deckLayout.bottomGap<=24);assert.equal(deckLayout.overlay,'1');
assert.equal(deckLayout.actionRightGap,0);assert.equal(deckLayout.sidebarPadding,deckLayout.workspacePadding);
const batteryReadout=await page.locator('.battery-card').evaluate(el=>{
const percent=el.querySelector('#battery-percent'),voltage=el.querySelector('#battery-title');
return {percent:percent.textContent,voltage:voltage.textContent,border:getComputedStyle(el).borderTopWidth,
mainSize:parseFloat(getComputedStyle(percent).fontSize),voltageSize:parseFloat(getComputedStyle(voltage).fontSize)};
});
assert.match(batteryReadout.percent,/^\d+%$/);assert.match(batteryReadout.voltage,/^\d+\.\d{2} V$/);
assert.equal(batteryReadout.border,'0px');assert(batteryReadout.mainSize>batteryReadout.voltageSize);
await page.screenshot({path:path.join(output,'ui-cleanup.png')});
assert.deepEqual(errors,[]);
console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, five dialogs at two window sizes; controller conditioning/read-only graphics, actual bridge values, mapping, focus/unplug/identity safety and dock filtering; mode locking during hold/armed/pending/unknown states, centered SVG close icons and bottom battery layout.');
}catch(error){
const page=browser.contexts()[0]?.pages()[0];
if(page){
await page.screenshot({path:path.join(output,'failure.png')});
console.error(await page.evaluate(()=>[...document.querySelectorAll('input')].filter(el=>!el.checkValidity()).map(el=>({name:el.name,value:el.value,error:el.validationMessage}))));
}
throw error;
}finally{await browser.close();await new Promise(resolve=>server.close(resolve))}
}
main().catch(error=>{console.error(error);server.close();process.exitCode=1});
+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}
+752 -82
View File
@@ -1,49 +1,297 @@
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 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={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
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){const changed=row.textContent!==(activity[i]||'');row.textContent=activity[i]||'';row.hidden=!activity[i];if(changed&&!row.hidden&&!matchMedia('(prefers-reduced-motion:reduce)').matches)row.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'})}
});
}
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&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive;
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
const controllerMotion=new ControllerInput.Motion();
const controllerId=pad=>pad.index+':'+pad.id;
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
function loadControllerConfig(pad){
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
}
function selectController(pad){
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
}
function setInputMode(mode){
if(mode===inputMode||inputConfigurationLocked())return false;
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
$('#controller-settings').hidden=mode!=='joystick';
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys');
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
return true;
}
function inputConfigurationLocked(){
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
}
function syncInputModeControls(){
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
$('.input-mode-switch').dataset.locked=String(locked);
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
}
$('#controller-settings').disabled=locked||!pads.length;
$('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup';
if($('#controller-modal').open){
const form=$('#controller-form');
for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad;
$('#controller-device').disabled=locked||!pads.length;
const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.';
if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback);
}
}
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
$('#input-joystick').onclick=()=>setInputMode('joystick');
function fillControllerForm(){
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
if(!pads.length)device.add(new Option('No controller detected',''));
device.value=controllerKey||'';
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
const config=controllerConfig||ControllerInput.defaults(pad);
for(const name of ['turn','forward','vertical']){
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
const invert='invert'+name[0].toUpperCase()+name.slice(1);
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
}
form.elements.deadZone.value=Math.round(config.deadZone*100);
device.disabled=inputConfigurationLocked()||!pads.length;
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
}
$('#controller-device').onchange=e=>{
if(inputConfigurationLocked())return;
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
if(pad){selectController(pad);fillControllerForm()}
};
$('#controller-form').onsubmit=e=>{
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
const config={deadZone:Number(form.elements.deadZone.value)/100};
for(const name of ['turn','forward','vertical']){
config[name]=Number(form.elements[name].value);
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
}
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
}
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
resetInputs();closeModal($('#controller-modal'));
};
function pollController(now){
try{
if(inputMode==='joystick'){
const pads=getControllers();
if(controllerKey===null&&pads.length)selectController(pads[0]);
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id;
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
if(!target||!canDrive()){
if(controllerWasDriving)resetInputs();
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
}else{
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
if(!controllerNeedCenter){
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
control.turn=demand.turn;control.forward=demand.forward;
window.joystick3D?.setVisual(demand.turn,-demand.forward);
setHeave(demand.vertical,false);updateReadouts();sendAxes();
}
}
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
if($('#controller-modal').open){
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes;
if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm();
}
}else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'}
}catch(error){
if(controllerWasDriving)resetInputs();
$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard';
}
requestAnimationFrame(pollController);
}
document.body.dataset.inputMode=inputMode;
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},link_quality:null,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)}
window.showToast=toast;
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
const manualPorts=new Map();
function populatePorts(ports){
const p=$('#port');
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);
manualPorts.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(!window.canTouchControl())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 heaveDrag=false,heaveAnim=0;
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes()}
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))}
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 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 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(!window.canTouchControl()||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(target=0){cancelAnimationFrame(heaveAnim);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+(target-sv)*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()}
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('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave()});
function applyKeys(){if(inputMode!=='keyboard'||!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)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(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(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;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();});
function resetInputs(){
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
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 setFeedback(el,message=''){
if(!el)return;
el.textContent=message;el.hidden=!message;
}
function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id;
if(!el)return;
if(!el||el.open)return;
if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return}
resetInputs();
el._returnFocus=document.activeElement;
if(el.id==='battery-modal')fillBatteryForm();
if(el.id==='controller-modal')fillControllerForm();
if(el.id==='link-modal'){
fillLinkForm();
$('#custom-port-status').textContent='';
$('#custom-port-input').value='';
}
el.querySelectorAll('.form-feedback').forEach(feedback=>setFeedback(feedback));
el.classList.remove('closing');
if(!el.open)el.showModal();
el.showModal();
}
function closeModal(dialog){
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
if(!dialog||!dialog.open||dialog.classList.contains('closing')||dialog.querySelector('[data-busy="true"]'))return;
dialog.classList.add('closing');
let done=false;
const finish=()=>{
if(done)return;
done=true;
dialog.removeEventListener('animationend',finish);
dialog.removeEventListener('animationend',onEnd);
dialog.classList.remove('closing');
dialog.close();
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
};
dialog.addEventListener('animationend',finish,{once:true});
setTimeout(finish,300);
const onEnd=e=>{if(e.target===dialog)finish()};
dialog.addEventListener('animationend',onEnd);
setTimeout(finish,180);
}
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
$('#link-quality').onclick=()=>openModal('link-quality-modal');
let heavyActionBusy=false;
async function requestHeavyTest(action){
if(heavyActionBusy)return;
heavyActionBusy=true;
setFeedback($('#heavy-quality-error'));
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
try{
const test=await api(action);
vehicleSnapshot.heavy_quality=test;
renderHeavyQuality(test,vehicleSnapshot.connected,vehicleSnapshot.armed);
}catch(e){setFeedback($('#heavy-quality-error'),e.message||'Link test unavailable. Try again.')}
finally{
heavyActionBusy=false;
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
}
}
$('#heavy-quality-run').onclick=()=>requestHeavyTest('start_heavy_quality_test');
$('#heavy-quality-cancel').onclick=()=>requestHeavyTest('cancel_heavy_quality_test');
$$('dialog').forEach(d=>{
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
d.addEventListener('click',e=>{
@@ -51,7 +299,7 @@ $$('dialog').forEach(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');
async function handleStlFile(file){
@@ -119,68 +367,472 @@ if(rovStage){
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)}};
$('#manual').onclick=async()=>{try{await api('command','manual');toast('Manual mode command sent')}catch(e){toast(e.message,true)}};
const armButton=$('#arm');
let armHoldTimer=null, armStart=0, armCharged=false;
const ARM_HOLD_MS=1000;
$('#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:'';
function updateArmProgress(){
if(!armStart)return;
const elapsed=performance.now()-armStart;
const progress=Math.min(1,elapsed/ARM_HOLD_MS);
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));
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;
}
armHoldTimer=requestAnimationFrame(updateArmProgress);
}
function startArmHold(e){
if(armStart||armCharged)return;
if(e&&e.button!==undefined&&e.button!==0)return;
armStart=performance.now();
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');
if(appState==='connecting'){
label.textContent='Canceling…';
try{ await api('disconnect'); }catch(_){}
return;
}
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,+linkConfig.baud||57600,+linkConfig.control_hz||20);
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){$('#custom-port-status').textContent='Enter a port first.';customPortInput.focus();return;}
if(vehicleSnapshot.connected)return;
manualPorts.set(val,{device:val,label:`Custom (${val})`});
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;
}
$('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
};
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%');
syncInputModeControls();
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');
syncInputModeControls();
}
armButton.addEventListener('pointerdown',startArmHold);
armButton.addEventListener('pointerup',()=>stopArmHold(true));
armButton.addEventListener('pointerleave',()=>stopArmHold(true));
armButton.addEventListener('pointercancel',()=>stopArmHold(true));
armButton.addEventListener('contextmenu',e=>e.preventDefault());
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');
};
}
$('#disarm').onclick=async()=>{try{await api('command','disarm');toast('Disarm command sent')}catch(e){toast(e.message,true)}};
$('#logs').onclick=()=>api('open_logs');
const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0;
const consolePulseTimers={};
function pulseConsole(side){
const dot=$('.'+side+'-dot');
clearTimeout(consolePulseTimers[side]);dot.classList.add('lit');
consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260);
}
function renderConsole(){
const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache);
if(signature===consoleSignature)return;
consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter);
}
function setConsoleOpen(open){
consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open);
if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')}
else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180);
$('#console').setAttribute('aria-expanded',String(open));
if(open)refreshConsole();else $('#console').focus({preventScroll:true});
}
$('#console').setAttribute('aria-controls','console-dock');
$('#console').setAttribute('aria-expanded','false');
$('#console').onclick=()=>setConsoleOpen(!consoleOpen);
$('#console-close').onclick=()=>setConsoleOpen(false);
$('#console-filter').onchange=renderConsole;
$('#console-pause').onclick=()=>{
consolePaused=!consolePaused;
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
$('#console-state').textContent=consolePaused?'Paused':'';
$('#console-state').hidden=!consolePaused;
if(!consolePaused)refreshConsole();
};
async function refreshConsole(){
if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
consoleRefreshBusy=true;
try{
const sides=await withTimeout(api('read_console',150),1500);
if(consolePaused||!consoleOpen)return;
const filter=$('#console-filter').value;
for(const side of ['rx','tx']){
const previous=new Set(consoleCache[side]||[]);
if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side);
}
consoleCache=sides;renderConsole();
$('#console-state').textContent='';$('#console-state').hidden=true;
}catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}}
finally{consoleRefreshBusy=false}
}
setInterval(refreshConsole,750);
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);
$('#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)}};
// 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))}});
async function saveSettings(form,method,commit,message){
if(form.dataset.busy==='true'||!form.reportValidity())return;
const data=Object.fromEntries(new FormData(form));
const button=form.querySelector('[type="submit"]'),label=button.textContent;
const disabled=new Map([...form.querySelectorAll('input,select,button')].map(el=>[el,el.disabled]));
form.dataset.busy='true';disabled.forEach((_,el)=>el.disabled=true);button.textContent='Saving…';
setFeedback(form.querySelector('.form-feedback'));
let saved=false;
try{commit(await api(method,data));saved=true;toast(message)}
catch(error){setFeedback(form.querySelector('.form-feedback'),error.message||'Could not save settings. Try again.')}
finally{
delete form.dataset.busy;disabled.forEach((value,el)=>el.disabled=value);button.textContent=label;
if(saved)closeModal(form.closest('dialog'));
}
}
$('#battery-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_battery',value=>batteryConfig=value,'Battery settings saved')};
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 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)}
$('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
const motorEdits=new Map();
async function requestMotorEdit(servo,kind,value){
if(motorEdits.has(servo))return;
const s=vehicleSnapshot.servos?.[String(servo)]||{};
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
setFeedback($('#motor-feedback'));
if(demoActive){
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
else s.direction=expected;
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
}
motorEdits.set(servo,{kind,expected,started:performance.now()});
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
try{
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
}catch(error){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
}
}
function renderMotors(servos){
for(const [servo,edit] of motorEdits){
const s=servos[String(servo)]||{};
if((edit.kind==='function'?s.function:s.direction)===edit.expected){
motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`);
}else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`);
}
}
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
const assigned=Object.values(servos).filter(s=>s.motor).length;
$('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…';
const signature=JSON.stringify([servos,editable,[...motorEdits]]);
if(signature===servoSignature)return;
servoSignature=signature;
const list=$('#motor-list'),expanded=list.querySelector('details')?.open;
list.replaceChildren();
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded;
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary);
for(let i=1;i<=16;i++){
const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row';
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
const select=row.querySelector('select');
select.add(new Option(s.function==null?'Reading…':'Unassigned','0'));
for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor)));
select.value=String(s.motor||0);
select.disabled=!editable||!!pending||s.function==null;
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal';
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null;
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`);
reverse.onclick=()=>requestMotorEdit(i,'direction');
(s.motor?list:other).append(row);
}
if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)}
if(assigned<16)list.append(other);
}
function renderHeavyQuality(test,connected,armed){
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
if(!status||!results||!run||!stop)return;
const quality=demoActive?null:vehicleSnapshot.link_quality;
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
const running=!!test?.running;
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
stop.hidden=!running;
stop.disabled=heavyActionBusy;
results.replaceChildren();
for(const row of test?.results||[]){
const line=document.createElement('div');line.className='heavy-result-row';
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
const stats=document.createElement('span');
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
line.append(rate,stats);results.append(line);
}
}
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});
syncInputModeControls();
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
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;
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
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)}%`;
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
$('.battery-card').title=`Estimated charge from voltage · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
$('#battery-fill').style.width=`${b.percent}%`;
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
$('.battery-card').dataset.low=String(!!b.low);
}else{
$('#battery-title').textContent='No voltage';
$('#battery-percent').textContent='—';
$('.battery-card').dataset.stale='false';
$('.battery-card').title='Waiting for battery voltage · Battery settings';
$('#battery-fill').style.width='0';
$('.battery-card').dataset.low='false';
}
const quality=s.link_quality,qualityCard=$('#link-quality');
if(qualityCard){
if(demoActive){
qualityCard.dataset.grade='offline';
$('#link-quality-grade').textContent='Demo';
$('#link-quality-roundtrip').textContent='no hardware link test';
$('#link-quality-downlink').textContent='';
renderMotors(s.servos||{});
return;
}
const grade=quality?.grade||(s.connected?'testing':'offline');
qualityCard.dataset.grade=grade;
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
if(quality&&quality.probe_samples){
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
const downLoss=percent(quality.rx_sequence_loss_percent);
const badFrames=quality.rx_corrupt_frames_20s??0;
const badBytes=quality.rx_corrupt_bytes_20s??0;
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
if(quality.probe_samples<10||quality.rx_packets_30s<10){
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
}
}else{
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
$('#link-quality-downlink').textContent='';
}
}
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;
@@ -254,36 +906,54 @@ async function loadDefaultModel(){
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(){
if(booted||!window.pywebview?.api)return;
booted=true;
bridgeReady=true;
loadDefaultModel();
if(booted||booting||!hasDesktopApi())return;
booting=true;clearTimeout(bootRetry);
try{
const init=await api('initial_state');
batteryConfig=init.battery_config;
// 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||{};
linkConfig=init.link_config||{};
populatePorts(init.ports);
fillBatteryForm();
render(init.state);
fillLinkForm();
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state);
poll();
setInterval(async()=>{
if(!connected){
try{populatePorts(await api('refresh_ports'))}catch(_){}
// 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(window.pywebview?.api)boot();
else setTimeout(waitForBridge,50);
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();
}
requestAnimationFrame(pollController);
+70
View File
@@ -0,0 +1,70 @@
/* Read-only presentation of the existing local RX/TX logs. */
((root)=>{
const quiet=/^(HEARTBEAT|TIMESYNC|LINK_PROBE|HEAVY_PROBE|GCS)/;
function parse(line){
const match=String(line).match(/^(\S+) \[([^\]]+)\] ([\s\S]*)$/);
if(!match)return {time:'',type:'Message',payload:String(line),raw:String(line),tone:'normal',quiet:false};
const [,timestamp,category,message]=match;
let payload=message,type=category.replace(/^(RX|TX)_?/,'')||category;
try{payload=JSON.parse(message);type=payload.mavpackettype||type}catch(_){}
const tone=/BAD_DATA|ERROR|TIMEOUT|FAIL/.test(category)||type==='STATUSTEXT'&&Number(payload.severity)<=4?'warning':/COMMAND|ACK|PARAM_SET|OPERATOR/.test(category+' '+type)?'command':'normal';
const time=timestamp.match(/T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)?.[1]||timestamp;
const pairs=typeof payload==='string'?[...payload.matchAll(/(?:^|\s)([a-zA-Z_]\w*)=([^\s]+)/g)]:[];
const fields=pairs.length?Object.fromEntries(pairs.map(([,key,value])=>[key,value])):payload;
return {time,type,payload,fields,raw:String(line),tone,quiet:tone!=='warning'&&(quiet.test(type)||/LINK_PROBE|HEAVY_PROBE|HEARTBEAT/.test(category))};
}
function valueText(value){
if(typeof value==='number')return Number.isInteger(value)?String(value):Number(value.toFixed(3)).toString();
if(Array.isArray(value))return value.slice(0,6).map(valueText).join(', ')+(value.length>6?'…':'');
return typeof value==='object'?JSON.stringify(value):String(value);
}
function makeRow(row,previous){
if(previous&&previous.dataset.key===row.raw&&previous.dataset.tone===row.tone)return previous;
const details=previous||document.createElement('details');
details.className='console-row';details.dataset.key=row.raw;details.dataset.tone=row.tone;
let summary=details.querySelector('summary'),time,body,type,fields,raw;
if(!summary){
details.replaceChildren();summary=document.createElement('summary');time=document.createElement('time');
body=document.createElement('span');body.className='console-message';type=document.createElement('strong');
fields=document.createElement('span');fields.className='console-fields';body.append(type,fields);summary.append(time,body);
raw=document.createElement('pre');details.append(summary,raw);
}else{
time=summary.querySelector('time');body=summary.querySelector('.console-message');
type=body.querySelector('strong');fields=body.querySelector('.console-fields');raw=details.querySelector('pre');
}
time.textContent=row.time;type.textContent=row.type;fields.replaceChildren();
const values=row.fields??row.payload;
if(typeof values==='object'&&values!==null){
for(const [key,value] of Object.entries(values).filter(([key])=>key!=='mavpackettype').slice(0,6)){
const item=document.createElement('span'),label=document.createElement('i'),text=document.createElement('b');
label.textContent=key+' ';text.textContent=valueText(value);item.append(label,text);fields.append(item);
}
}else fields.textContent=row.payload;
raw.textContent=typeof row.payload==='object'?JSON.stringify(row.payload,null,2):row.raw;
return details;
}
function render(host,lines,filter){
const atBottom=host.scrollHeight-host.scrollTop-host.clientHeight<36,scroll=host.scrollTop;
const rows=lines.map(parse).filter(row=>filter==='all'||!row.quiet);
const existing=new Map();
for(const node of host.querySelectorAll('details.console-row')){
const key=node.dataset.key,list=existing.get(key)||[];list.push(node);existing.set(key,list);
}
if(!rows.length){
if(!host.querySelector('.console-empty')){
const empty=document.createElement('p');empty.className='console-empty';
empty.textContent='No messages';
empty.title=filter==='all'?'Waiting for session messages.':'Routine heartbeat and probe traffic is hidden. Choose All traffic to see it.';
host.replaceChildren(empty);
}
return;
}
const wanted=rows.map(row=>makeRow(row,existing.get(row.raw)?.shift()||null));
let cursor=host.firstElementChild;
for(const node of wanted){if(node!==cursor)host.insertBefore(node,cursor);cursor=node.nextElementSibling}
while(cursor){const next=cursor.nextElementSibling;cursor.remove();cursor=next}
if(atBottom&&!host.querySelector('details[open]'))host.scrollTop=host.scrollHeight;else host.scrollTop=scroll;
}
root.ConsoleView={parse,render};
if(typeof module!=='undefined')module.exports=root.ConsoleView;
})(typeof window!=='undefined'?window:globalThis);
+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);
+53
View File
@@ -0,0 +1,53 @@
/* Controller conditioning is independent of drawing and MAVLink transport. */
((root)=>{
const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0));
function deadZone(value,zone=.08){
value=clamp(value);zone=Math.max(0,Math.min(.4,zone));
return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone);
}
function defaults(pad){
return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1,
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
}
function read(pad,config){
if(!pad?.connected||!pad.axes)return null;
const axis=(index,invert)=>{
if(index===-1)return 0;
if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null;
return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1);
};
let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical);
if(turn===null||forward===null||vertical===null)return null;
const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length}
return {turn,forward,vertical};
}
class Smoother{
constructor(){this.reset()}
reset(){this.value=0;this.last=null;this.release=null}
step(target,now){
target=clamp(target);
const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now;
if(target===0){
if(!this.release)this.release={at:now,start:this.value};
const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t);
this.value=this.release.start*(1-ease);
}else{
this.release=null;
this.value+=(target-this.value)*(1-Math.exp(-dt/.1));
if(Math.abs(target-this.value)<.001)this.value=target;
}
return this.value;
}
}
class Motion{
constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}}
reset(){Object.values(this.axes).forEach(axis=>axis.reset())}
step(target,now){
const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)]));
const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length}
return demand;
}
}
root.ControllerInput={deadZone,defaults,read,Motion};
if(typeof module!=='undefined')module.exports=root.ControllerInput;
})(typeof window!=='undefined'?window:globalThis);
+42
View File
@@ -0,0 +1,42 @@
/* Decorative only: no control demands or vehicle state are accessed here. */
(() => {
const workspace=document.querySelector('.controls-workspace');
if(!workspace)return;
const wash=document.createElement('span');
wash.className='workspace-light';wash.setAttribute('aria-hidden','true');workspace.append(wash);
// Explicit visual surfaces, not every semantic button. The entire sidebar
// and text-only Engage control are excluded from local lighting.
const pillSelector='.config-btn,.modal .button,.range-pill,.number-pill';
const shapeSelector=`${pillSelector},.heave-track,.heave-knob,.joystick,.modal`;
const layers=[...document.querySelectorAll(shapeSelector)].filter(host=>!host.closest('.sidebar-deck,#engage')).map(host=>{
host.classList.add('light-surface');
const layer=document.createElement('span');layer.className='cursor-light';
layer.setAttribute('aria-hidden','true');
layer.classList.toggle('pill-light',host.matches(pillSelector));
host.append(layer);return {host,layer};
});
let frame=0,point=null;
const clear=()=>{point=null;cancelAnimationFrame(frame);frame=0;wash.classList.remove('lit');layers.forEach(({layer})=>layer.classList.remove('lit','near'))};
function paint(){
frame=0;if(!point)return;
const {x,y}=point,r=workspace.getBoundingClientRect();
wash.style.setProperty('--light-x',`${x-r.left}px`);wash.style.setProperty('--light-y',`${y-r.top}px`);
wash.classList.toggle('lit',x>=r.left&&x<=r.right&&y>=r.top&&y<=r.bottom);
for(const {host,layer} of layers){
const b=host.getBoundingClientRect();
layer.style.setProperty('--light-x',`${x-b.left}px`);layer.style.setProperty('--light-y',`${y-b.top}px`);
const inside=x>=b.left&&x<=b.right&&y>=b.top&&y<=b.bottom;
// CSS gradient angles start at up; atan2 is measured from the x axis.
const angle=Math.atan2(y-(b.top+b.height/2),x-(b.left+b.width/2))*180/Math.PI+90;
layer.style.setProperty('--light-angle',`${angle}deg`);
layer.classList.toggle('lit',inside);layer.classList.add('near');
}
}
document.addEventListener('pointermove',event=>{
if(event.pointerType==='touch'){clear();return}
point={x:event.clientX,y:event.clientY};if(!frame)frame=requestAnimationFrame(paint);
},{passive:true});
document.documentElement.addEventListener('pointerleave',clear);
document.addEventListener('pointercancel',clear);window.addEventListener('blur',clear);
document.addEventListener('visibilitychange',()=>{if(document.hidden)clear()});
})();
+198 -60
View File
@@ -4,93 +4,231 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="styles.css?v=20261002-spacing-motion-v2">
</head>
<body>
<main class="app-shell">
<aside class="vehicle-rail">
<header class="brand pywebview-drag-region">
<span>Sub Control</span>
</header>
<div class="eyebrow">live vehicle</div>
<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>
<div id="model-status" class="model-status">Initializing…</div>
<div class="model-tools">
<button id="load-model">Load STL</button>
<input id="model-input" type="file" accept=".stl,.STL" hidden>
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
<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">
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<div id="model-status" class="model-status">Initializing…</div>
<div class="model-tools">
<button id="load-model">Load STL</button>
<input id="model-input" type="file" accept=".stl,.STL" hidden>
</div>
</div>
</div>
<div class="vehicle-name"><small>vehicle</small><strong id="vehicle">no heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
<div class="rail-bottom">
<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>
<b><em id="battery-fill"></em></b>
</button>
<div class="link-state"><span id="link-dot"></span><strong id="link">disconnected</strong></div>
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
<span class="link-quality-dot" aria-hidden="true"></span>
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
<i><span id="link-quality-roundtrip">waiting for connection</span><span id="link-quality-downlink"></span></i>
</button>
<div class="deck-status-bar">
<header class="activity-heading">Activity:</header>
<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>
<div class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></div>
</div>
<button class="battery-card ghost" data-open="battery-modal" aria-label="Battery status and settings">
<span class="battery-meter" aria-hidden="true"><em id="battery-fill"></em></span>
<span class="battery-copy"><span class="battery-summary"><strong id="battery-percent">—</strong><small id="battery-title">No voltage</small></span></span>
</button>
</section>
</aside>
<section class="workspace">
<header class="topbar">
<div class="connection">
<select id="port" aria-label="Serial port"><option value="">Select COM port…</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>
<button id="connect" class="button connect-button">Connect</button>
</div>
<div class="topbar-spacer pywebview-drag-region"></div>
<!-- ── PRIMARY CONTROL ZONE ── -->
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
<!-- Frameless Drag & Window Controls Header -->
<div class="workspace-topbar">
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
<div class="workspace-drag-area pywebview-drag-region"></div>
<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-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>
</header>
</div>
<section class="drive-grid">
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
<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><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>
<header>
<div title="Steer and throttle · W A S D"></div>
</header>
<div class="joystick-pad">
<div id="joystick" class="joystick" touch-action="none">
<canvas id="joystick-canvas" title="Steer and throttle · W A S D" 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">0</b></span><span class="steer-readout-right"><b id="yaw-readout">0</b> turn</span></footer>
<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>
<!-- Vertical Thruster Slider -->
<article class="control-card vertical-card">
<header><div><small>vertical</small><strong>center thruster</strong></div><span class="key-hint">r / f</span></header>
<div class="vertical-control">
<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>
<span>dive</span>
</div>
<footer><span><b id="heave-readout">0</b> vertical</span></footer>
</article>
</section>
<header>
<div title="Vertical thrust · R / F"></div>
<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>
</header>
<div class="heave-pad">
<div class="vertical-control">
<span>rise</span>
<div id="heave" title="Vertical thrust · R / F" 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>
</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>
<button class="button subtle" data-open="motors-modal">Motor Setup</button>
</section>
<footer>
<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"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></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"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
</div>
</label>
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
</div>
</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>
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
<header class="console-dock-header"><div><h2 id="console-title" title="Local session messages. Select a message to expand its details.">Console</h2></div><button id="console-close" type="button" class="console-close" aria-label="Hide console" title="Hide console"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
<div class="console-toolbar"><label><span class="visually-hidden">Console filter</span><select id="console-filter"><option value="useful">Useful traffic</option><option value="all">All traffic</option></select></label><button id="console-pause" type="button" aria-pressed="false">Pause</button></div>
<p id="console-state" class="console-state" role="status" title="Pausing the viewer does not stop session logging." hidden></p>
<div class="console-streams"><section class="console-side"><header><i class="rx-dot" aria-hidden="true" title="Vehicle to PC · new displayed message"></i><span>RX</span></header><div id="console-rx-output" class="console-output" tabindex="0" aria-label="Received messages"></div></section><section class="console-side"><header><i class="tx-dot" aria-hidden="true" title="PC to vehicle · new displayed message"></i><span>TX</span></header><div id="console-tx-output" class="console-output" tabindex="0" aria-label="Sent messages"></div></section></div>
</aside>
</main>
<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>
<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>
<!-- Modals -->
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="link-title">Link settings</h2>
<form id="link-form" title="Baud changes apply on the next connection.">
<section class="settings-section"><h3>Serial connection</h3>
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
<script src="model-viewer.js"></script>
<script src="joystick-3d.js"></script>
<script src="app.js"></script>
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
<p id="custom-port-status" class="note" role="status"></p>
</section>
<section class="settings-section"><h3 title="Lower rates leave more bandwidth for controls. Rates reset after a reboot.">Telemetry rates</h3>
<div class="form-grid two">
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
</div>
</section>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
</form>
</dialog>
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="quality-title">Link quality</h2>
<div class="quality-summary" title="Round-trip replies, incoming packet loss, and heartbeat freshness."><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
<section class="heavy-test-panel"><h3 title="Tests 1, 5 and 10 Hz for about 35 seconds while disarmed. The live check resumes afterward.">Extended test</h3>
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
<div id="heavy-quality-results" class="heavy-quality-results"></div>
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button id="heavy-quality-run" type="button" class="button modal-primary">Run extended test</button><button id="heavy-quality-cancel" type="button" class="button subtle" hidden>Stop test</button></div>
</section>
</dialog>
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery settings</h2>
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save battery</button></div>
</form>
</dialog>
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
<form id="controller-form" title="Center sticks after changing input mode or reconnecting before movement resumes.">
<label><span>Controller</span><select id="controller-device"></select></label>
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
<div class="controller-mapping-row"><span>Turn</span><select name="turn" aria-label="Turn axis"></select><input name="invertTurn" type="checkbox" aria-label="Invert Turn"></div>
<div class="controller-mapping-row"><span>Forward / back</span><select name="forward" aria-label="Forward / back axis"></select><input name="invertForward" type="checkbox" aria-label="Invert Forward / back"></div>
<div class="controller-mapping-row"><span>Rise / dive</span><select name="vertical" aria-label="Rise / dive axis"></select><input name="invertVertical" type="checkbox" aria-label="Invert Rise / dive"></div>
<label class="controller-deadzone"><span>Dead zone · %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save mapping</button></div>
</form>
</dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20261002-spacing-motion-v2"></script>
<script src="control-state.js?v=20260827-state-v1"></script>
<script src="controller-input.js?v=20261001-controller-v1"></script>
<script src="console-view.js?v=20261002-console-v2"></script>
<script src="app.js?v=20261002-spacing-motion-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body>
</html>
+40 -306
View File
@@ -1,317 +1,51 @@
/* joystick-3d.js – Clean Canvas joystick with tactile press animation,
expanded soft red boundary glow, clean lowercase curved labels, and 20% larger knob. */
/* 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;
const host = document.getElementById('joystick');
if (!host) return;
let canvas = document.getElementById('joystick-canvas');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'joystick-canvas';
host.appendChild(canvas);
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);
canvas.style.cssText =
'position:absolute;inset:0;width:100%;height:100%;z-index:5;touch-action:none;cursor:grab;';
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();
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;
let nx = 0, ny = 0; // normalised knob position: -1 to +1
let dragging = false;
let animId = 0;
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();
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819);
handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939');
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;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();
}
window.addEventListener('resize', resize);
const TAU = Math.PI * 2;
function drawCurvedText(text, cx, cy, radius, centerAngle, flip = false) {
ctx.save();
ctx.font = '300 11.5px Inter, "SF Pro Display", "Helvetica Neue", Arial, sans-serif';
ctx.fillStyle = '#656f6c';
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 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 easeTo(nextX,nextY){
cancelAnimationFrame(returnFrame);
const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}
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+(nextX-startX)*smooth,startY+(nextY-startY)*smooth);if(p<1)returnFrame=requestAnimationFrame(tick)};
returnFrame=requestAnimationFrame(tick);
}
function returnToCenter(){easeTo(0,0)}
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()}
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(); }
};
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canTouchControl?.()||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);new ResizeObserver(resize).observe(host);resize();
window.joystick3D={setVisual(nextX,nextY){setPosition(nextX,nextY,false)},setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(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}}};
})();
+1050 -645
View File
File diff suppressed because it is too large Load Diff
+264 -35
View File
@@ -16,6 +16,7 @@ import webview
from serial.tools import list_ports
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
from vehicle_state import VehicleState
class ControlApi:
@@ -24,20 +25,30 @@ class ControlApi:
def __init__(self):
self.events: queue.Queue = queue.Queue()
self.worker = MavlinkWorker(self.events)
self.worker.require_input_lease = True
self.vehicle_state = VehicleState()
self.battery_received_at = None
self.demo_mode = False
self.lock = threading.Lock()
self._poll_lock = threading.Lock()
self.state = {
"connected": False,
"link": "DISCONNECTED",
"vehicle": "No heartbeat",
"armed": False,
"mode": None,
"app_state": "disconnected",
"status": "Ready. Select a USB port to begin.",
"battery": None,
"link_quality": None,
"heavy_quality": None,
"log_path": None,
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
}
self.servo_functions: dict[int, int] = {}
self.motor_directions: dict[int, int] = {}
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.link_path = Path(__file__).resolve().parent / "link_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
@@ -54,17 +65,40 @@ class ControlApi:
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
self.link_config = {
"baud": 57600, "control_hz": 20,
"ping_hz": 1, "system_hz": 1, "servo_hz": 2,
"rc_hz": 2, "esc_hz": 1,
}
try:
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
self._apply_link_config()
def initial_state(self):
return {
"ports": [p.device for p in list_ports.comports()],
"ports": self.refresh_ports(),
"battery_config": self.battery_config,
"link_config": self.link_config,
"state": self._snapshot(),
"model_info": self.get_saved_model_info(),
}
def refresh_ports(self):
return [p.device for p in list_ports.comports()]
ports = []
# Add physical and virtual serial COM ports
for p in list_ports.comports():
desc = p.description or ""
if desc and desc != "n/a" and desc != p.device:
label = f"{p.device} ({desc})"
else:
label = p.device
ports.append({"device": p.device, "label": label, "type": "serial"})
# Built-in UI testing: no external process, port, or MAVLink socket.
ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"})
return ports
def minimize_window(self):
if self._window:
@@ -202,28 +236,97 @@ class ControlApi:
return None
def connect(self, port, baud=57600, rate=20):
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
if not port:
raise ValueError("Select a USB serial port")
if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
self.demo_mode = True
self.servo_functions = {1: 33, 2: 34, 3: 35}
self.motor_directions = {1: 1, 2: 1, 3: 1}
with self.lock:
self.state.update(
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
link_quality={"grade": "excellent", "latency_ms": 12.0,
"loss_percent": 0.0, "samples": 20,
"corrupt_frames_20s": 0, "heartbeat_age_s": 0.1},
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
"cells": 4, "low": False},
)
for servo in range(1, 17):
function = self.servo_functions.get(servo, 0)
motor = motor_number_for_function(function)
self.state["servos"][str(servo)] = {
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
}
return self._snapshot()
self.worker.rate_hz = int(rate)
self.worker.connect(str(port), int(baud))
with self.lock:
self.vehicle_state.disconnect()
self.state.update(
connected=True, link="CONNECTING",
status=f"Opening {port} at {baud} baud…",
)
return True
self.worker.connect(str(port), int(baud))
print(f"[web_ui] connect() dispatched to worker thread OK")
# Return only once the worker has either seen a heartbeat or reported a
# connection failure. This makes the button independent of the regular
# UI polling loop and prevents an indefinite visual "Connecting" state.
deadline = time.monotonic() + 8.0
while time.monotonic() < deadline:
snapshot = self.poll()
if snapshot["app_state"] != "connecting":
return snapshot
time.sleep(0.05)
self.worker.disconnect()
time.sleep(0.1)
self.poll()
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self):
if self.worker.heavy_quality_test is not None:
self.worker.cancel_heavy_quality_test()
if self.demo_mode:
self.demo_mode = False
with self.lock:
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, mode=None, battery=None, link_quality=None,
status="Demo mode disconnected")
return True
self.worker.disconnect()
return True
def set_axes(self, forward, turn, vertical):
self.worker.set_axes(Axes(
max(-1.0, min(1.0, float(forward))),
0.0,
max(-1.0, min(1.0, float(vertical))),
max(-1.0, min(1.0, float(turn))),
))
def start_heavy_quality_test(self):
if self.demo_mode:
raise RuntimeError("The heavy link test requires a real flight-controller connection")
return self.worker.start_heavy_quality_test()
def cancel_heavy_quality_test(self):
return self.worker.cancel_heavy_quality_test()
def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode:
with self.lock:
self.state["status"] = (
f"Demo controls • surge {float(forward):+.2f} • "
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
)
return True
with self.lock:
allowed = self.vehicle_state.snapshot()["controls_allowed"]
self.worker.set_axes(Axes(
max(-1.0, min(1.0, float(forward))), 0.0,
max(-1.0, min(1.0, float(vertical))),
max(-1.0, min(1.0, float(turn))),
) if allowed else Axes(), sequence)
return True
def stop_controls(self, sequence=None):
"""Invalidate queued input without claiming that the vehicle disarmed."""
with self.lock:
self.worker.set_axes(Axes(), sequence)
self.worker._send_control(force_neutral=True)
return True
def set_gain(self, value):
@@ -231,7 +334,16 @@ class ControlApi:
self.worker.gain = value / 100.0
return value
def command(self, name):
def command(self, name, sequence=None, expires_at=None):
if self.demo_mode:
if name not in ("manual", "arm", "disarm"):
raise ValueError(f"Unknown command: {name}")
armed = name == "arm" if name != "manual" else self.state.get("armed", False)
with self.lock:
self.state.update(armed=armed, mode="MANUAL",
vehicle=f"DEMO • {'ARMED' if armed else 'DISARMED'}",
status=f"Demo: {'Manual mode' if name == 'manual' else name.capitalize()} confirmed")
return True
if not self.worker.connection or not self.worker.target_system:
if name == "manual":
raise RuntimeError("Connect to vehicle to enable Manual mode")
@@ -240,15 +352,25 @@ class ControlApi:
elif name == "disarm":
raise RuntimeError("Connect to vehicle before disarming")
raise RuntimeError("No autopilot heartbeat")
if name in ("arm", "disarm"):
self.poll()
with self.lock:
if name == "arm":
if expires_at is not None and time.time() * 1000 > expires_at:
raise RuntimeError("Engage request expired before it could be sent")
if sequence is not None and sequence <= self.worker.last_input_sequence:
raise RuntimeError("Engage canceled by a newer input reset")
self.vehicle_state.request(name)
self.worker.set_axes(Axes(), sequence)
try:
self.worker.command_arm(name == "arm")
except Exception as exc:
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
raise
return self._snapshot()
if name == "manual":
self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode()
elif name == "arm":
self.worker.log("OPERATOR", "clicked ARM")
self.worker.command_arm(True)
elif name == "disarm":
self.worker.log("OPERATOR", "clicked DISARM")
self.worker.command_arm(False)
else:
raise ValueError(f"Unknown command: {name}")
return True
@@ -258,6 +380,10 @@ class ControlApi:
if not 1 <= servo <= 16 or not 0 <= motor <= 12:
raise ValueError("Invalid servo or motor")
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
if self.demo_mode:
self.servo_functions[servo] = function
self._update_servo(servo)
return True
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
return True
@@ -269,6 +395,10 @@ class ControlApi:
current = self.motor_directions.get(motor)
if current is None:
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
if self.demo_mode:
self.motor_directions[motor] = -1 if current == 1 else 1
self._update_servo(servo)
return True
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
return True
@@ -287,8 +417,33 @@ class ControlApi:
raise ValueError("Full voltage per cell must be greater than empty voltage")
if not 0 <= clean["warning_percent"] <= 100:
raise ValueError("Warning percentage must be between 0 and 100")
self.battery_config = clean
self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.battery_config = clean
return clean
def _apply_link_config(self):
self.worker.rate_hz = self.link_config["control_hz"]
self.worker.set_link_rates(self.link_config["ping_hz"], {
"SYS_STATUS": self.link_config["system_hz"],
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
"RC_CHANNELS": self.link_config["rc_hz"],
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
})
def save_link_settings(self, config):
allowed = {0, 1, 2, 5, 10}
clean = {key: int(config.get(key, value)) for key, value in self.link_config.items()}
if clean["baud"] not in {9600, 19200, 38400, 57600, 115200}:
raise ValueError("Choose a supported baud rate")
if clean["control_hz"] not in {10, 20}:
raise ValueError("Control rate must be 10 or 20 Hz")
if clean["ping_hz"] not in {1, 2, 5, 10}:
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
if any(clean[key] not in allowed for key in ("system_hz", "servo_hz", "rc_hz", "esc_hz")):
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
self.link_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.link_config = clean
self._apply_link_config()
return clean
def open_logs(self):
@@ -297,14 +452,20 @@ class ControlApi:
os.startfile(path)
return True
def read_console(self, limit=150):
"""Return a bounded, read-only view of the local session console."""
return self.worker.read_console(limit)
def poll(self):
while True:
try:
event = self.events.get_nowait()
except queue.Empty:
break
self._consume(event)
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5:
# Bound a poll even if telemetry arrives continuously.
with self._poll_lock:
for _ in range(500):
try:
event = self.events.get_nowait()
except queue.Empty:
break
self._consume(event)
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
with self.lock:
self.state["link"] = (
"DATA OK / HEARTBEAT DELAYED"
@@ -312,24 +473,49 @@ class ControlApi:
)
return self._snapshot()
def _derive_app_state(self) -> str:
if not self.state.get("connected"):
return "disconnected"
link = self.state.get("link", "")
if link == "CONNECTING":
return "connecting"
if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"):
return "link_degraded"
if self.state.get("armed"):
return "armed"
return "connected_disarmed"
def _snapshot(self):
with self.lock:
return json.loads(json.dumps(self.state))
snap = json.loads(json.dumps(self.state))
snap["app_state"] = self._derive_app_state()
if not self.demo_mode:
snap.update(self.vehicle_state.snapshot())
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
snap["app_state"] = "link_degraded"
snap["battery_stale"] = (self.battery_received_at is None
or time.monotonic() - self.battery_received_at > 5)
return snap
def _consume(self, event):
kind = event[0]
with self.lock:
if kind == "heartbeat":
_, system, autopilot, mode, armed = event
_, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed),
mode=str(mode).upper(),
)
elif kind in ("status", "statustext"):
self.state["status"] = event[1]
elif kind == "ack":
if event[1] == 400:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery":
self.battery_received_at = time.monotonic()
voltage = float(event[1])
cells = self.battery_config["cells"]
per_cell = voltage / cells
@@ -339,6 +525,10 @@ class ControlApi:
"voltage": voltage, "per_cell": per_cell, "percent": percent,
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
}
elif kind == "link_quality":
self.state["link_quality"] = event[1]
elif kind == "heavy_quality":
self.state["heavy_quality"] = event[1]
elif kind == "servo_param":
_, param_id, value = event
servo = int(param_id[5:param_id.index("_")])
@@ -355,9 +545,14 @@ class ControlApi:
self.state["log_path"] = event[1]
elif kind == "error":
self.state["status"] = event[1]
self.state["last_error"] = event[1]
elif kind == "disconnected":
self.vehicle_state.disconnect()
last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, battery=None, status="Disconnected")
mode=None, link_quality=None, status=status_msg)
self.state["last_error"] = None
def _update_servo(self, servo):
function = self.servo_functions.get(servo)
@@ -380,22 +575,49 @@ def _apply_dwm_styling(window):
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
ctypes.windll.dwmapi.DwmSetWindowAttribute(
dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
)
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
ctypes.windll.dwmapi.DwmSetWindowAttribute(
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:
@@ -409,7 +631,6 @@ class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
self.send_header("Content-Type", "model/stl")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(data)
return
@@ -425,14 +646,21 @@ def start_local_server(directory: Path) -> int:
def main():
if os.name == "nt":
# Keep the running window separate from generic Python taskbar groups.
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("SUBControl.Desktop")
from desktop_shortcut import ensure_desktop_shortcut
ensure_desktop_shortcut()
api = ControlApi()
api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent
port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html"
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
win_w, win_h, min_dims = _get_window_size()
window = webview.create_window(
"SUB Control", url=app_url, js_api=api,
width=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,
shadow=True,
)
@@ -441,7 +669,8 @@ def main():
window.events.maximized += lambda: setattr(api, "maximized", True)
window.events.restored += lambda: setattr(api, "maximized", False)
try:
webview.start(debug=True)
icon_path = project_root / "assets" / "sub-control.ico"
webview.start(debug=True, icon=str(icon_path) if icon_path.is_file() else None)
finally:
api.close()
+526 -41
View File
@@ -13,6 +13,9 @@ from datetime import datetime
from pathlib import Path
from tkinter import messagebox, ttk
# ESC_TELEMETRY_1_TO_4 uses a MAVLink 2 message ID. Select the v2 dialect
# before importing pymavlink so received ESC packets can be decoded/logged.
os.environ["MAVLINK20"] = "1"
from pymavlink import mavutil
from serial.tools import list_ports
@@ -42,16 +45,44 @@ class Axes:
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:
def __init__(self, events: queue.Queue):
self.events = events
self.connection = None
self.stop_event = threading.Event()
self.lock = threading.Lock()
# pymavlink writes are not thread-safe. The web/API thread can issue
# commands while this worker sends heartbeats and controls, so serialize
# every packet before it reaches a low-bandwidth serial link.
self.tx_lock = threading.Lock()
self.thread = None
self.axes = Axes()
self.output_axes = Axes()
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.
# At 20 Hz this produces five small steps from neutral to full command.
self.ramp_seconds = 0.25
@@ -63,6 +94,34 @@ class MavlinkWorker:
self.last_rx = 0.0
self.armed = False
self.config_paused = False
# Keep configuration traffic off the link during an arm/disarm
# transition, including the wait for the vehicle's confirmation.
self.command_quiet_until = 0.0
self.disarm_requested_until = 0.0
self.pending_arm_state = None
self.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0
self.arm_confirmation = 0
self.stream_config = deque()
self.next_stream_config_at = 0.0
# Low-rate TIMESYNC echoes measure PC↔FC round trips with minimal traffic.
self.ping_rate_hz = 1
self.heavy_quality_test = None
self.heavy_pending_pings = {}
self.heavy_quality_latest = None
self.telemetry_rates_hz = {
"SYS_STATUS": 1, "SERVO_OUTPUT_RAW": 2,
"RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1,
}
self.ping_sequence = 0
self.next_ping_at = 0.0
self.pending_pings = {}
self.ping_results = deque(maxlen=300)
self.bad_frame_times = deque()
self.bad_frame_bytes = deque()
self.rx_sequence_by_source = {}
self.rx_delivery_results = deque(maxlen=1024)
self.next_quality_publish_at = 0.0
self.param_scan = deque()
self.param_waiting = None
self.param_deadline = 0.0
@@ -72,47 +131,92 @@ class MavlinkWorker:
self.log_lock = threading.Lock()
self.log_file = None
self.log_path = None
self.console_lines = deque(maxlen=500)
self.console_rx_lines = deque(maxlen=300)
self.console_tx_lines = deque(maxlen=300)
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():
print(f"[MavlinkWorker] Thread already alive, ignoring connect()")
return
self.stop_event.clear()
log_dir = Path(__file__).resolve().parent / "logs"
log_dir.mkdir(exist_ok=True)
self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log"
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
self.log("SESSION", f"start port={port} baud={baud} control_hz={self.rate_hz}")
normalized = normalize_endpoint(port)
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} "
f"control_hz={self.rate_hz} rx=MAVLink2 tx=MAVLink2")
self.events.put(("log_path", str(self.log_path)))
self.last_heartbeat = 0.0
self.last_rx = 0.0
self.next_quality_publish_at = 0.0
self.thread = threading.Thread(
target=self._run, args=(port, baud), daemon=True
target=self._run, args=(normalized, baud), daemon=True
)
self.thread.start()
print(f"[MavlinkWorker] worker thread started")
def _run(self, port: str, baud: int):
print(f"[MavlinkWorker._run] ENTERED: port={port!r}, baud={baud}")
try:
print(f"[MavlinkWorker._run] Creating mavlink_connection...")
conn = mavutil.mavlink_connection(
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.events.put(("status", f"Port open; waiting for heartbeat at {baud} baud…"))
next_gcs_hb = 0.0
if port.startswith("tcp:"):
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")
self._send_mav(conn, "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
while not self.stop_event.is_set():
now = time.monotonic()
if now >= next_gcs_hb:
self.log("TX_HEARTBEAT", "GCS")
conn.mav.heartbeat_send(
self._send_mav(conn, "heartbeat_send",
mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
)
next_gcs_hb = now + 1.0
if self.target_system and now >= next_control:
# ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a
# 20 Hz neutral stream alive here only competes with ARM and
# its acknowledgement on constrained links.
if self.target_system and self.armed and now >= next_control:
self._send_control()
next_control = now + (1.0 / max(1, self.rate_hz))
# The first disarm packet is preceded by a neutral command.
# Until disarm is confirmed, keep sending neutral at a
# lower rate instead of consuming 20 Hz on the weak link.
control_hz = min(self.rate_hz, 5) if now < self.disarm_requested_until else self.rate_hz
next_control = now + (1.0 / max(1, control_hz))
elif not self.armed:
next_control = now
if self.target_system:
self._tick_arm_retry(now)
self._tick_link_probe(now)
self._tick_stream_config(now)
self._tick_param_scan(now)
if now >= self.next_quality_publish_at:
self._publish_link_quality()
self.next_quality_publish_at = now + 1.0
msg = conn.recv_match(blocking=False)
if msg:
@@ -120,8 +224,11 @@ class MavlinkWorker:
else:
time.sleep(0.01)
except Exception as exc:
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
self.events.put(("error", f"Connection error: {exc}"))
finally:
if self.heavy_quality_test is not None:
self.cancel_heavy_quality_test()
self._safe_stop()
if self.connection:
try:
@@ -132,6 +239,19 @@ class MavlinkWorker:
self.target_system = 0
self.armed = False
self.config_paused = False
self.command_quiet_until = 0.0
self.disarm_requested_until = 0.0
self.pending_arm_state = None
self.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0
self.arm_confirmation = 0
self.stream_config.clear()
self.pending_pings.clear()
self.ping_results.clear()
self.bad_frame_times.clear()
self.bad_frame_bytes.clear()
self.rx_sequence_by_source.clear()
self.rx_delivery_results.clear()
self.log("SESSION", "disconnected")
with self.log_lock:
if self.log_file:
@@ -141,22 +261,75 @@ class MavlinkWorker:
def log(self, category: str, message: str):
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
line = f"{timestamp} [{category}] {message}"
with self.log_lock:
self.console_lines.append(line[:2000])
if category.startswith("RX") or category == "ONBOARD_LOG_ERROR":
self.console_rx_lines.append(line[:2000])
elif category.startswith("TX") or category == "OPERATOR":
self.console_tx_lines.append(line[:2000])
if self.log_file:
self.log_file.write(f"{timestamp} [{category}] {message}\n")
self.log_file.write(line + "\n")
def read_console(self, limit=150):
with self.log_lock:
limit = max(1, min(300, int(limit)))
return {
"rx": list(self.console_rx_lines)[-limit:],
"tx": list(self.console_tx_lines)[-limit:],
}
def _send_mav(self, connection, method: str, *args):
"""Serialize MAVLink 2 writes and protect the start of each burst."""
with self.tx_lock:
# This full-duplex module pair loses the opening bytes of some
# PC-to-FC bursts after the line has been idle. Put a valid,
# expendable GCS heartbeat first so the following packet remains
# intact and the FC also sees a healthy GCS heartbeat stream.
if method != "heartbeat_send" and hasattr(connection.mav, "heartbeat_send"):
connection.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
)
getattr(connection.mav, method)(*args)
def _handle_message(self, msg):
msg_type = msg.get_type()
if msg_type == "BAD_DATA":
now = time.monotonic()
self.bad_frame_times.append(now)
self.bad_frame_bytes.append((now, len(getattr(msg, "data", b""))))
self._trim_bad_frames(now)
self.log("RX_BAD_DATA", repr(getattr(msg, "data", b"")))
return
self.last_rx = time.monotonic()
self._track_rx_delivery(msg)
try:
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
except Exception:
payload = str(msg)
self.log("RX", payload)
if msg_type == "HEARTBEAT" and msg.get_srcComponent() == 1:
if msg_type == "TIMESYNC" and int(getattr(msg, "tc1", 0)) != 0:
token = int(getattr(msg, "ts1", -1))
heavy_probe = self.heavy_pending_pings.pop(token, None)
if heavy_probe is not None:
stage_index, sent_at = heavy_probe
self._record_heavy_probe(stage_index, (time.monotonic() - sent_at) * 1000.0)
self._publish_heavy_quality()
sent_at = None
else:
sent_at = self.pending_pings.pop(token, None)
if sent_at is not None:
rtt_ms = (time.monotonic() - sent_at) * 1000.0
self.ping_results.append((time.monotonic(), rtt_ms))
self.log("RX_LINK_PROBE", f"timesync={token} rtt_ms={rtt_ms:.1f}")
self._publish_link_quality()
elif 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
self.target_system = msg.get_srcSystem()
self.target_component = msg.get_srcComponent()
@@ -164,26 +337,42 @@ class MavlinkWorker:
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
self.armed = armed
self.config_paused = armed
if self.pending_arm_state is not None and armed == self.pending_arm_state:
self.pending_arm_state = None
if not armed:
self.disarm_requested_until = 0.0
try:
mode = mavutil.mode_string_v10(msg)
except Exception:
mode = str(msg.custom_mode)
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
ap_name = autopilot.name if autopilot else str(msg.autopilot)
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed))
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
if first:
self.next_ping_at = time.monotonic()
self.events.put(("status", "Heartbeat received; controls are neutral."))
self._configure_low_bandwidth_streams()
self._start_servo_scan()
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_name = result.name if result else str(msg.result)
self.events.put(("ack", int(msg.command), result_name))
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
self.log("RX_STREAM_CONFIG_ACK", result_name)
return
if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")):
self.pending_arm_state = None
self.events.put(("ack", int(msg.command), result_name, self.last_rx))
elif msg_type == "STATUSTEXT":
text = msg.text
if isinstance(text, bytes):
text = text.decode(errors="replace")
self.events.put(("statustext", text.rstrip("\x00")))
text = text.rstrip("\x00")
if "/APM/LOGS" in text and "Failed" in text:
self.log("ONBOARD_LOG_ERROR", text)
self.events.put(("statustext", text))
elif msg_type == "SYS_STATUS":
voltage_mv = int(msg.voltage_battery)
if 0 < voltage_mv < 65535:
@@ -210,6 +399,239 @@ class MavlinkWorker:
self.events.put(("motor_direction", int(parts[1]),
int(round(msg.param_value))))
def _trim_bad_frames(self, now):
while self.bad_frame_times and now - self.bad_frame_times[0] > 20.0:
self.bad_frame_times.popleft()
def _track_rx_delivery(self, msg):
"""Estimate downlink loss from MAVLink's per-source packet sequence."""
try:
source = (int(msg.get_srcSystem()), int(msg.get_srcComponent()))
sequence = int(msg.get_seq())
except (AttributeError, TypeError, ValueError):
return
if self.target_system and source != (self.target_system, self.target_component):
return
previous = self.rx_sequence_by_source.get(source)
self.rx_sequence_by_source[source] = sequence
if previous is None:
return
missing = (sequence - previous - 1) & 0xFF
# A large backwards jump is a reset/reordered packet, not 200 losses.
if missing > 127:
return
timestamp = time.monotonic()
self.rx_delivery_results.extend((timestamp, False) for _ in range(missing))
self.rx_delivery_results.append((timestamp, True))
def _publish_link_quality(self):
now = time.monotonic()
self._trim_bad_frames(now)
while self.ping_results and now - self.ping_results[0][0] > 30.0:
self.ping_results.popleft()
while self.rx_delivery_results and now - self.rx_delivery_results[0][0] > 30.0:
self.rx_delivery_results.popleft()
while self.bad_frame_bytes and now - self.bad_frame_bytes[0][0] > 20.0:
self.bad_frame_bytes.popleft()
samples = [sample for _, sample in self.ping_results]
received = [sample for sample in samples if sample is not None]
total = len(samples)
loss_percent = 100.0 * (total - len(received)) / total if total else None
ordered_rtt = sorted(received)
latency_ms = (sum(ordered_rtt) / len(ordered_rtt)) if ordered_rtt else None
median_rtt_ms = ordered_rtt[len(ordered_rtt) // 2] if ordered_rtt else None
p95_rtt_ms = ordered_rtt[min(len(ordered_rtt) - 1, (len(ordered_rtt) * 95 + 99) // 100 - 1)] if ordered_rtt else None
rx_received = sum(1 for _, delivered in self.rx_delivery_results if delivered)
rx_missing = sum(1 for _, delivered in self.rx_delivery_results if not delivered)
rx_loss_percent = 100.0 * rx_missing / (rx_received + rx_missing) if rx_received else None
rx_corrupt_bytes = sum(byte_count for _, byte_count in self.bad_frame_bytes)
heartbeat_age = (now - self.last_heartbeat) if self.last_heartbeat else None
if total < 10 or rx_received < 10 or heartbeat_age is None:
grade = "testing"
elif (heartbeat_age > 3 or loss_percent > 15
or (rx_loss_percent is not None and rx_loss_percent > 5)
or len(self.bad_frame_bytes) >= 5):
grade = "poor"
elif (loss_percent > 3 or (rx_loss_percent is not None and rx_loss_percent > 1)
or (p95_rtt_ms is not None and p95_rtt_ms > 500) or self.bad_frame_bytes):
grade = "fair"
elif (loss_percent > 0.5 or (rx_loss_percent is not None and rx_loss_percent > 0.2)
or (p95_rtt_ms is not None and p95_rtt_ms > 200)):
grade = "good"
else:
grade = "excellent"
self.events.put(("link_quality", {
"grade": grade,
"latency_ms": round(latency_ms, 1) if latency_ms is not None else None,
"rtt_median_ms": round(median_rtt_ms, 1) if median_rtt_ms is not None else None,
"rtt_p95_ms": round(p95_rtt_ms, 1) if p95_rtt_ms is not None else None,
"loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
"probe_loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
"probe_samples": total,
"probe_replies": len(received),
"rx_packets_30s": rx_received,
"rx_sequence_loss_percent": round(rx_loss_percent, 1) if rx_loss_percent is not None else None,
"rx_corrupt_frames_20s": len(self.bad_frame_bytes),
"rx_corrupt_bytes_20s": rx_corrupt_bytes,
"samples": total,
"corrupt_frames_20s": len(self.bad_frame_times),
"heartbeat_age_s": round(heartbeat_age, 1) if heartbeat_age is not None else None,
"probe_supported": total > 0,
}))
def _tick_link_probe(self, now):
if self.heavy_quality_test is not None:
self._tick_heavy_quality(now)
return
expired = [sequence for sequence, sent_at in self.pending_pings.items()
if now - sent_at >= 2.5]
for sequence in expired:
self.pending_pings.pop(sequence, None)
self.ping_results.append((now, None))
self.log("LINK_PROBE_TIMEOUT", f"timesync={sequence}")
if expired:
self._publish_link_quality()
if (now < self.next_ping_at or now < self.command_quiet_until
or not self.connection or not self.target_system):
return
token = time.monotonic_ns()
self.pending_pings[token] = now
self.log("TX_LINK_PROBE", f"timesync={token}")
# ArduPilot implements TIMESYNC even on builds that omit PING. A
# request has tc1=0; the response mirrors our unique ts1 token.
self._send_mav(self.connection, "timesync_send", 0, token)
self.next_ping_at = now + (1.0 / max(1, self.ping_rate_hz))
def start_heavy_quality_test(self):
if not self.connection or not self.target_system:
raise RuntimeError("Connect to the flight controller before testing the link")
if self.armed:
raise RuntimeError("The heavy link test is available while the vehicle is disarmed")
if self.pending_arm_state is not None or time.monotonic() < self.command_quiet_until:
raise RuntimeError("Wait for the current vehicle command to finish")
if self.heavy_quality_test is not None:
raise RuntimeError("A heavy link test is already running")
self.pending_pings.clear()
self.heavy_pending_pings.clear()
self.heavy_quality_latest = {
"running": True, "stage": 1, "stages": 3, "rate_hz": 1,
"status": "Running", "results": [],
}
self.heavy_quality_test = {
"rates": (1, 5, 10), "duration_s": 10.0, "stage_index": 0,
"stage_started": time.monotonic(), "next_probe_at": time.monotonic(),
"stages": [{"rate_hz": rate, "sent": 0, "replies": 0,
"timeouts": 0, "rtts": []} for rate in (1, 5, 10)],
}
self.log("TX_HEAVY_TEST", "started rates=1,5,10Hz stage_seconds=10")
self._publish_heavy_quality()
return self.heavy_quality_latest
def cancel_heavy_quality_test(self):
if self.heavy_quality_test is None:
return self.heavy_quality_latest
self.heavy_pending_pings.clear()
test = self.heavy_quality_test
self.heavy_quality_test = None
self.heavy_quality_latest = {
"running": False, "stage": 0, "stages": len(test["rates"]),
"rate_hz": None, "status": "Cancelled", "results": self._heavy_results(test),
}
self.next_ping_at = time.monotonic()
self.log("TX_HEAVY_TEST", "cancelled")
self.events.put(("heavy_quality", self.heavy_quality_latest))
return self.heavy_quality_latest
@staticmethod
def _heavy_results(test):
results = []
for stage in test["stages"]:
rtts = sorted(stage["rtts"])
sent = stage["sent"]
replies = stage["replies"]
resolved = replies + stage["timeouts"]
results.append({
"rate_hz": stage["rate_hz"], "sent": sent, "replies": replies,
"loss_percent": round(100.0 * stage["timeouts"] / resolved, 1) if resolved else None,
"median_rtt_ms": round(rtts[len(rtts) // 2], 1) if rtts else None,
"p95_rtt_ms": round(rtts[min(len(rtts) - 1, (len(rtts) * 95 + 99) // 100 - 1)], 1) if rtts else None,
})
return results
def _record_heavy_probe(self, stage_index, rtt_ms=None):
test = self.heavy_quality_test
if test is None or not 0 <= stage_index < len(test["stages"]):
return
stage = test["stages"][stage_index]
if rtt_ms is None:
stage["timeouts"] += 1
else:
stage["replies"] += 1
stage["rtts"].append(rtt_ms)
def _publish_heavy_quality(self):
test = self.heavy_quality_test
if test is None:
return
index = test["stage_index"]
stage = test["stages"][index]
self.heavy_quality_latest = {
"running": True, "stage": index + 1, "stages": len(test["rates"]),
"rate_hz": stage["rate_hz"], "sent": stage["sent"],
"replies": stage["replies"], "timeouts": stage["timeouts"],
"status": f"Stage {index + 1} of {len(test['rates'])}: {stage['rate_hz']} Hz",
"results": self._heavy_results(test),
}
self.events.put(("heavy_quality", self.heavy_quality_latest))
def _tick_heavy_quality(self, now):
test = self.heavy_quality_test
if test is None:
return
for token, (stage_index, sent_at) in list(self.heavy_pending_pings.items()):
if now - sent_at >= 2.5:
self.heavy_pending_pings.pop(token, None)
self._record_heavy_probe(stage_index)
self.log("HEAVY_PROBE_TIMEOUT", f"rate={test['rates'][stage_index]}Hz timesync={token}")
index = test["stage_index"]
stage_pending = any(stage_index == index for stage_index, _ in self.heavy_pending_pings.values())
if now - test["stage_started"] >= test["duration_s"] and not stage_pending:
index += 1
if index >= len(test["rates"]):
results = self._heavy_results(test)
self.heavy_quality_test = None
self.heavy_quality_latest = {
"running": False, "stage": 3, "stages": 3, "rate_hz": None,
"status": "Complete", "results": results,
}
self.next_ping_at = now
self.log("TX_HEAVY_TEST", "complete")
self.events.put(("heavy_quality", self.heavy_quality_latest))
return
test["stage_index"] = index
test["stage_started"] = now
test["next_probe_at"] = now
self._publish_heavy_quality()
index = test["stage_index"]
stage = test["stages"][index]
if now >= test["next_probe_at"] and now - test["stage_started"] < test["duration_s"]:
token = time.monotonic_ns()
self.heavy_pending_pings[token] = (index, now)
stage["sent"] += 1
self.log("TX_HEAVY_PROBE", f"rate={stage['rate_hz']}Hz timesync={token}")
self._send_mav(self.connection, "timesync_send", 0, token)
test["next_probe_at"] = now + 1.0 / stage["rate_hz"]
if now >= self.next_quality_publish_at:
self._publish_heavy_quality()
self.next_quality_publish_at = now + 1.0
def set_link_rates(self, ping_hz, telemetry_rates):
self.ping_rate_hz = max(1, min(10, int(ping_hz)))
for name in self.telemetry_rates_hz:
if name in telemetry_rates:
self.telemetry_rates_hz[name] = max(0, min(10, int(telemetry_rates[name])))
if self.connection and self.target_system:
self._configure_low_bandwidth_streams()
def _start_servo_scan(self):
names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)]
# ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal,
@@ -230,31 +652,52 @@ class MavlinkWorker:
self.param_retries = 0
def _configure_low_bandwidth_streams(self):
"""Stop inherited high-rate streams and request only diagnostic essentials."""
"""Pace optional telemetry changes; never burst them at arm time."""
if not self.connection:
return
self.log("TX_STREAM_CONFIG", "stop MAV_DATA_STREAM_ALL")
self.connection.mav.request_data_stream_send(
self._send_mav(self.connection, "request_data_stream_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0,
)
# Heartbeat and STATUSTEXT do not need to be requested. Keep only
# output/input evidence needed to diagnose controls, at 2 Hz each.
for message_id, interval_us, name in (
(mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS, 1_000_000, "SYS_STATUS"),
(mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW, 500_000, "SERVO_OUTPUT_RAW"),
(mavutil.mavlink.MAVLINK_MSG_ID_RC_CHANNELS, 500_000, "RC_CHANNELS"),
):
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
self.connection.mav.command_long_send(
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
0, message_id, interval_us, 0, 0, 0, 0, 0,
)
self.events.put(("status", "Low-bandwidth telemetry configured; reading servo setup…"))
# ATTITUDE and VFR_HUD interval commands were accepted by this FC.
# AHRS2 is also unneeded, but an acknowledgement has not been seen.
# NAMED_VALUE_FLOAT explicitly returned DENIED, so do not retry it.
# A single interval command is sometimes lost, so send two paced
# passes. This does not change persistent parameters or onboard logs.
unwanted = (
"ATTITUDE", "AHRS2", "VFR_HUD",
)
self.stream_config = deque(
(name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), -1)
for _ in range(2) for name in unwanted
)
for name, rate_hz in self.telemetry_rates_hz.items():
interval_us = -1 if rate_hz == 0 else round(1_000_000 / rate_hz)
self.stream_config.append((
name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), interval_us,
))
self.next_stream_config_at = time.monotonic()
self.events.put(("status", "Applying low-bandwidth telemetry rates…"))
def _tick_stream_config(self, now):
if (not self.connection or not self.target_system or self.armed
or now < self.command_quiet_until or now < self.next_stream_config_at
or not self.stream_config):
return
name, message_id, interval_us = self.stream_config.popleft()
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
self._send_mav(self.connection, "command_long_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
0, message_id, interval_us, 0, 0, 0, 0, 0,
)
self.next_stream_config_at = now + 0.5
def _tick_param_scan(self, now):
if not self.connection or self.config_paused or now < self.param_deadline:
if (not self.connection or self.config_paused
or self.stream_config or now < self.next_stream_config_at
or now < self.command_quiet_until or now < self.param_deadline):
return
if self.param_waiting is not None:
if self.param_retries < 2:
@@ -275,7 +718,7 @@ class MavlinkWorker:
def _request_param(self, name: str):
self.log("TX_PARAM_REQUEST", name)
self.connection.mav.param_request_read_send(
self._send_mav(self.connection, "param_request_read_send",
self.target_system, self.target_component, name.encode("ascii"), -1
)
@@ -289,7 +732,7 @@ class MavlinkWorker:
raise RuntimeError(f"{name} has not been read from the autopilot")
self.pending_sets[name] = int(value)
self.log("TX_PARAM_SET", f"{name}={value} type={param_type}")
self.connection.mav.param_set_send(
self._send_mav(self.connection, "param_set_send",
self.target_system, self.target_component, name.encode("ascii"),
float(value), param_type,
)
@@ -300,10 +743,17 @@ class MavlinkWorker:
if not conn or not self.target_system:
return
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()
gain = self.gain
now = time.monotonic()
if force_neutral:
self.output_axes = Axes()
else:
@@ -335,7 +785,7 @@ class MavlinkWorker:
f"x={x} y={y} z={z} r={r} active={active} "
f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})",
)
conn.mav.manual_control_send(self.target_system, x, y, z, r, 0)
self._send_mav(conn, "manual_control_send", self.target_system, x, y, z, r, 0)
@staticmethod
def _approach(current: float, target: float, max_change: float) -> float:
@@ -348,18 +798,45 @@ class MavlinkWorker:
conn = self.connection
if not conn or not self.target_system:
raise RuntimeError("No autopilot heartbeat")
if arm and self.heavy_quality_test is not None:
self.cancel_heavy_quality_test()
now = time.monotonic()
self.command_quiet_until = now + 6.0
if not arm:
self.disarm_requested_until = now + 6.0
self._send_control(force_neutral=True)
else:
# Give the ARM request priority over the low-rate parameter scan.
self.config_paused = True
self.log("TX_COMMAND", "ARM" if arm else "DISARM")
conn.mav.command_long_send(
self.pending_arm_state = bool(arm)
self.next_arm_retry_at = now + 0.25
self.arm_retry_deadline = now + 5.0
self.arm_confirmation = 0
self._send_arm_packet(arm)
def _send_arm_packet(self, arm: bool):
conn = self.connection
if not conn or not self.target_system:
return
self._send_mav(conn, "command_long_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
self.arm_confirmation, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
)
def _tick_arm_retry(self, now):
if self.pending_arm_state is None or now < self.next_arm_retry_at:
return
if now >= self.arm_retry_deadline:
self.log("TX_COMMAND_TIMEOUT", "ARM" if self.pending_arm_state else "DISARM")
self.pending_arm_state = None
return
self.log("TX_COMMAND_RETRY", "ARM" if self.pending_arm_state else "DISARM")
self.arm_confirmation = min(255, self.arm_confirmation + 1)
self._send_arm_packet(self.pending_arm_state)
self.next_arm_retry_at = now + 0.25
def set_manual_mode(self):
conn = self.connection
if not conn or not self.target_system:
@@ -369,13 +846,13 @@ class MavlinkWorker:
if manual is None:
raise RuntimeError("MANUAL mode is not advertised by this firmware")
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
conn.mav.set_mode_send(
self._send_mav(conn, "set_mode_send",
self.target_system,
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
manual,
)
def set_axes(self, axes: Axes):
def set_axes(self, axes: Axes, sequence=None):
def conditioned(value):
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
if abs(value) < 0.04:
@@ -383,6 +860,11 @@ class MavlinkWorker:
return round(max(-1.0, min(1.0, value)), 2)
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(
conditioned(axes.forward), conditioned(axes.lateral),
conditioned(axes.vertical), conditioned(axes.yaw),
@@ -401,7 +883,7 @@ class MavlinkWorker:
if self.connection and self.target_system:
try:
self._send_control(force_neutral=True)
self.connection.mav.command_long_send(
self._send_mav(self.connection, "command_long_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 0, 0, 0, 0, 0, 0, 0,
@@ -887,6 +1369,9 @@ class App(tk.Tk):
def refresh_ports(self):
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)
if ports and self.port.get() not in ports:
self.port.set(ports[0])
@@ -1139,7 +1624,7 @@ class App(tk.Tk):
event = self.events.get_nowait()
kind = event[0]
if kind == "heartbeat":
_, system, autopilot, mode, armed = event
_, system, autopilot, mode, armed = event[:5]
self.vehicle_label.configure(
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+14
View File
@@ -0,0 +1,14 @@
"""Package the original PNG in a Windows ICO without altering its pixels."""
from pathlib import Path
import struct
root = Path(__file__).resolve().parent
png = (root / "assets" / "sub-control-icon.png").read_bytes()
if png[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError("Icon source must be PNG")
width, height = struct.unpack(">II", png[16:24])
if not (1 <= width <= 256 and 1 <= height <= 256):
raise ValueError("Windows icon source must be at most 256 x 256")
header = struct.pack("<HHH", 0, 1, 1)
entry = struct.pack("<BBBBHHII", width % 256, height % 256, 0, 0, 1, 32, len(png), 22)
(root / "assets" / "sub-control.ico").write_bytes(header + entry + png)
+43
View File
@@ -0,0 +1,43 @@
param([switch]$UpdateIcon)
$ErrorActionPreference = 'Stop'
$shortcutProject = $PSScriptRoot
$shortcutMarker = Join-Path $shortcutProject 'runtime\desktop-shortcut-created.txt'
if ((Test-Path -LiteralPath $shortcutMarker) -and -not $UpdateIcon) { return }
try {
# Resolves the real Desktop, including a redirected/OneDrive Desktop.
$shortcutDesktop = [Environment]::GetFolderPath('DesktopDirectory')
if (-not $shortcutDesktop) { throw 'Windows Desktop folder is unavailable.' }
$shortcutPath = Join-Path $shortcutDesktop 'SUB Control.lnk'
# Never replace a shortcut the user already has.
if (-not (Test-Path -LiteralPath $shortcutPath)) {
$shortcutShell = New-Object -ComObject WScript.Shell
$shortcutLink = $shortcutShell.CreateShortcut($shortcutPath)
$shortcutLink.TargetPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$shortcutLink.Arguments = '-NoProfile -WindowStyle Hidden -File "' + (Join-Path $shortcutProject 'run.ps1') + '"'
$shortcutLink.WorkingDirectory = $shortcutProject
$shortcutLink.Description = 'Launch SUB Control'
$shortcutLink.WindowStyle = 7
$shortcutIcon = Join-Path $shortcutProject 'assets\sub-control.ico'
if (Test-Path -LiteralPath $shortcutIcon) { $shortcutLink.IconLocation = "$shortcutIcon,0" }
$shortcutLink.Save()
} elseif ($UpdateIcon) {
$shortcutShell = New-Object -ComObject WScript.Shell
$shortcutLink = $shortcutShell.CreateShortcut($shortcutPath)
$expectedScript = '"' + (Join-Path $shortcutProject 'run.ps1') + '"'
if ($shortcutLink.Arguments.Contains($expectedScript)) {
$shortcutLink.IconLocation = (Join-Path $shortcutProject 'assets\sub-control.ico') + ',0'
$shortcutLink.Save()
} else {
throw 'Existing shortcut belongs to another installation; left unchanged.'
}
}
New-Item -ItemType Directory -Path (Split-Path -Parent $shortcutMarker) -Force | Out-Null
Set-Content -LiteralPath $shortcutMarker -Value $shortcutPath
} catch {
# Desktop permissions must not prevent the controller from starting.
Write-Warning "Could not create the SUB Control desktop shortcut: $_"
}
+21
View File
@@ -0,0 +1,21 @@
"""Best-effort Windows first-run shortcut, including direct Python launches."""
import os
from pathlib import Path
import subprocess
def ensure_desktop_shortcut():
if os.name != "nt":
return
root = Path(__file__).resolve().parent
if (root / "runtime" / "desktop-shortcut-created.txt").exists():
return
powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe"
try:
subprocess.run(
[str(powershell), "-NoProfile", "-NonInteractive", "-File", str(root / "create_shortcut.ps1")],
cwd=root, timeout=10, check=True, capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW,
)
except (OSError, subprocess.SubprocessError) as error:
print(f"Desktop shortcut setup skipped: {error}")
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+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()
+2
View File
@@ -3,6 +3,8 @@ $ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location -LiteralPath $projectRoot
& (Join-Path $projectRoot 'create_shortcut.ps1')
if (-not (Get-Command py -ErrorAction SilentlyContinue)) {
throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again."
}
+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
}
+300
View File
@@ -0,0 +1,300 @@
// 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('model viewport fills the available space above battery status',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.rov-stage \{ flex:1; height:auto; min-height:0; width:100%/);
assert.match(css,/\.telemetry-zone \.model-card \{ flex: 1; min-height: 0;/);
});
test('white wash stays behind controls; right panel is gray and edges reach 50 percent',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.workspace-light \{[^}]*z-index:-1/);
assert.match(css,/\.controls-workspace \{[^}]*isolation:isolate/);
assert.match(css,/\.controls-workspace \{[^}]*background:#ededed/);
assert.match(css,/\.sidebar-deck \{[^}]*background:linear-gradient\(160deg,#333,#242424\)/);
assert.match(css,/\.cursor-light::after \{[^}]*rgba\(255,255,255,\.50\)/);
});
test('keyboard easing publishes intermediate demands to the real Python bridge',async()=>{
const h=harness();h.state();h.run('bridgeReady=true');
keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');
for(let i=0;i<10;i++){h.advance(50);await Promise.resolve();await Promise.resolve();await Promise.resolve();}
const sent=h.calls.filter(call=>call[0]==='axes');
assert.ok(sent.some(call=>call[1]>0&&call[1]<1),'surge ramp is transmitted, not just drawn');
assert.ok(sent.some(call=>call[3]>0&&call[3]<1),'vertical ramp is transmitted, not just drawn');
assert.ok(sent.every(call=>call[1]>=0&&call[1]<=1&&call[3]>=0&&call[3]<=1));
});
test('cursor lighting stays decorative and arrows use centered geometry',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.doesNotMatch(js,/set_axes|setJoyControl|setHeave|requestControl/);
assert.match(js,/workspace\.append\(wash\)/);
assert.match(js,/pointerleave',clear/);
assert.match(css,/\.cursor-light \{[^}]*pointer-events:none/);
assert.match(css,/range-pill button \{[^}]*place-items:center/);
assert.match(css,/#engage\.danger \.engage-label \{ color:#777/);
});
test('edge lighting uses angle; only visible pills get blue and sidebar is excluded',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.match(js,/Math\.atan2/);
assert.match(js,/!host\.closest\('\.sidebar-deck,#engage'\)/);
assert.match(js,/host\.matches\(pillSelector\)/);
assert.match(css,/\.cursor-light\.pill-light::before \{ background:radial-gradient/);
assert.match(css,/\.cursor-light::after \{[^}]*linear-gradient\(var\(--light-angle/);
assert.doesNotMatch(css,/\.cursor-light::after \{[^}]*radial-gradient/);
assert.match(css,/\.rov-stage \{[^}]*border-radius:31px; overflow:hidden/);
});
test('all interface typography uses Helvetica with inherited control fonts',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const families=[...css.matchAll(/font-family\s*:\s*([^;}]+)/g)].map(match=>match[1].trim());
assert.ok(families.includes('"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.ok(families.every(family=>family==='inherit'||family==='"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.match(css,/button, select, input \{ font: inherit; \}/);
});
function keyEvent(h,type,key,repeat=false){h.fire(type,{key,repeat,target:{tagName:'BODY'},preventDefault(){}})}
test('keyboard ramps joystick and vertical together, then eases back on release',()=>{
const h=harness();h.state();
keyEvent(h,'keydown','w');keyEvent(h,'keydown','d');keyEvent(h,'keydown','r');
assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
h.advance(180);
for(const axis of ['forward','turn','vertical'])assert.ok(h.run(`control.${axis}`)>0&&h.run(`control.${axis}`)<1);
keyEvent(h,'keydown','w',true);h.advance(240);
assert.ok(Math.abs(h.run('control.forward')-Math.SQRT1_2)<1e-6);
assert.ok(Math.abs(h.run('control.turn')-Math.SQRT1_2)<1e-6);
assert.equal(h.run('control.vertical'),1);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','d');keyEvent(h,'keyup','r');
h.advance(180);assert.ok(h.run('control.forward')>0);assert.ok(h.run('control.vertical')>0);
h.advance(250);assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
});
test('keyboard reversal is continuous and focus loss cancels its ramp',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(420);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','r');keyEvent(h,'keydown','s');keyEvent(h,'keydown','f');
assert.equal(h.run('control.forward'),1);assert.equal(h.run('control.vertical'),1);
h.advance(200);assert.ok(Math.abs(h.run('control.forward'))<.3);assert.ok(Math.abs(h.run('control.vertical'))<.3);
h.advance(220);assert.equal(h.run('control.forward'),-1);assert.equal(h.run('control.vertical'),-1);
keyEvent(h,'keyup','s');keyEvent(h,'keyup','f');h.fire('blur');h.advance(500);
assert.equal(h.run('control.forward+control.vertical'),0);
});
test('touch takes ownership from a keyboard ramp without later keyboard overwrite',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(100);
h.el('#joystick-canvas').emit('pointerdown',{clientX:300,clientY:160});
h.el('#heave').emit('pointerdown',{pointerId:2,clientY:300});
keyEvent(h,'keydown','a');h.advance(450);
assert.equal(h.run('control.turn'),1);assert.equal(Math.abs(h.run('control.forward')),0);assert.equal(h.run('control.vertical'),-1);
});
test('hold fill and pending engagement share the same green and glow',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.engage-progress-label \{ color:var\(--engage-green\).*filter:var\(--engage-glow\)/);
assert.match(css,/#engage\.pending \.engage-label \{ color:var\(--engage-green\); filter:var\(--engage-glow\)/);
});
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…');
});
+31
View File
@@ -0,0 +1,31 @@
/* Pure conditioning and log parsing tests: no hardware or browser needed. */
const assert=require('node:assert/strict');
const {deadZone,defaults,read,Motion}=require('../web/controller-input.js');
const {parse}=require('../web/console-view.js');
const pad={connected:true,axes:[0,0,0,0]};
assert.equal(deadZone(.06),0);assert.equal(deadZone(-1),-1);assert.equal(deadZone(1),1);
assert.equal(defaults({axes:[0,0]}).vertical,-1);
assert.equal(read({...pad,connected:false},defaults(pad)),null);
assert.equal(read(pad,{...defaults(pad),vertical:10}),null);
const diagonal=read({...pad,axes:[1,-1,0,-1]},defaults(pad));
assert(Math.abs(Math.hypot(diagonal.turn,diagonal.forward)-1)<1e-10);
assert.equal(diagonal.vertical,1);
assert.equal(read({...pad,axes:[-.5,0,0,0]},{...defaults(pad),invertTurn:true}).turn,deadZone(.5));
const motion=new Motion(),target={turn:.6,forward:.8,vertical:1};
assert.deepEqual(motion.step(target,0),{turn:0,forward:0,vertical:0});
const partial=motion.step(target,16);assert(partial.vertical>0&&partial.vertical<.3);
let current;for(let time=32;time<=1000;time+=16){current=motion.step(target,time);assert(Math.hypot(current.turn,current.forward)<=1+1e-10)}
assert(current.vertical>.99);
const neutral={turn:0,forward:0,vertical:0};
assert(motion.step(neutral,1024).vertical>.99);
assert(motion.step(neutral,1200).vertical>0);
assert.deepEqual(motion.step(neutral,1400),neutral);
motion.reset();assert.deepEqual(motion.step(target,1500),neutral);
assert.equal(parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"HEARTBEAT","base_mode":128}').quiet,true);
const ack=parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0}');
assert.equal(ack.time,'10:11:12.123');assert.equal(ack.type,'COMMAND_ACK');assert.equal(ack.tone,'command');
assert.equal(parse('2026-10-01T10:11:12 [TX_LINK_PROBE_TIMEOUT] retry').quiet,false);
assert.equal(parse('2026-10-01T10:11:12 [RX] {"mavpackettype":"STATUSTEXT","severity":3,"text":"warning"}').tone,'warning');
assert.equal(parse('2026-10-01T10:11:12 [RX] null').type,'RX');
assert.equal(parse('unstructured text').payload,'unstructured text');
console.log('PASS: dead zone, mapping/inversion, diagonal cap, ramp, finite center return, resets, console parsing and warning visibility.');
+333
View File
@@ -0,0 +1,333 @@
"""No serial port is opened by these tests."""
import queue
import threading
import time
import unittest
from types import SimpleNamespace
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.control_values(),
(1, 0, 0, NEUTRAL_Z, 0, 0))
def control_values(self):
return self.worker.connection.mav.manual_control_send.call_args.args
def test_manual_control_uses_native_v2_without_forced_long_payload(self):
self.worker._send_mav(self.worker.connection, 'manual_control_send',
1, 50, 0, 525, -20, 0)
self.assertEqual(self.control_values(), (1, 50, 0, 525, -20, 0))
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.kwargs, {})
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_intermediate_demands_reach_mavlink_with_output_limit(self):
with patch("app.time.monotonic", return_value=10):
self.worker.last_control_at = 9.9
self.worker.set_axes(Axes(forward=.25, vertical=.25), 1)
self.worker._send_control()
# 25% input at the default 20% limit: 5% command, not 100%.
self.assertEqual(self.control_values(),
(1, 50, 0, 525, 0, 0))
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())
def test_mavlink_transmissions_are_serialized(self):
first_entered = threading.Event()
release_first = threading.Event()
second_entered = threading.Event()
class Mav:
def first_send(self, **_):
first_entered.set()
release_first.wait(1)
def second_send(self, **_):
second_entered.set()
connection = SimpleNamespace(mav=Mav())
first = threading.Thread(target=self.worker._send_mav,
args=(connection, "first_send"))
second = threading.Thread(target=self.worker._send_mav,
args=(connection, "second_send"))
first.start()
self.assertTrue(first_entered.wait(.5))
second.start()
time.sleep(.05)
self.assertFalse(second_entered.is_set())
release_first.set()
first.join(1)
second.join(1)
self.assertTrue(second_entered.is_set())
def test_live_console_is_read_only_bounded_and_keeps_newest_lines(self):
for index in range(310):
self.worker.log("RX_TEST", f"vehicle {index}")
self.worker.log("TX_TEST", f"computer {index}")
sides = self.worker.read_console(2)
self.assertEqual(set(sides), {"rx", "tx"})
self.assertTrue(sides["rx"][0].endswith("[RX_TEST] vehicle 308"))
self.assertTrue(sides["rx"][1].endswith("[RX_TEST] vehicle 309"))
self.assertTrue(sides["tx"][1].endswith("[TX_TEST] computer 309"))
self.assertEqual(len(self.worker.console_rx_lines), 300)
self.assertEqual(len(self.worker.console_tx_lines), 300)
self.assertEqual(len(self.worker.console_lines), 500)
def test_arm_request_pauses_parameter_traffic(self):
self.worker.armed = False
self.worker.param_scan.append("SERVO1_FUNCTION")
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(True)
self.worker.config_paused = False # A disarmed heartbeat arrived.
self.worker._tick_param_scan(10.1)
self.assertEqual(self.worker.command_quiet_until, 16)
self.worker.connection.mav.param_request_read_send.assert_not_called()
self.worker.connection.mav.command_long_send.assert_called_once()
def test_arm_request_retries_until_state_confirmation(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(True)
self.worker._tick_arm_retry(10.24)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_arm_retry(10.25)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
self.worker.pending_arm_state = None
self.worker._tick_arm_retry(10.50)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
def test_outbound_command_has_valid_heartbeat_lead_in(self):
self.worker._send_mav(self.worker.connection, "timesync_send", 0, 123)
names = [call[0] for call in self.worker.connection.mav.method_calls]
self.assertEqual(names[-2:], ["heartbeat_send", "timesync_send"])
def test_disarm_sends_neutral_and_pauses_parameter_traffic(self):
self.worker.param_scan.append("SERVO1_FUNCTION")
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(False)
self.worker.config_paused = False
self.worker._tick_param_scan(10.1)
self.assertEqual(self.worker.disarm_requested_until, 16)
self.assertEqual(self.control_values(),
(1, 0, 0, NEUTRAL_Z, 0, 0))
self.worker.connection.mav.param_request_read_send.assert_not_called()
def test_telemetry_config_is_paced_and_includes_low_rate_esc(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker._configure_low_bandwidth_streams()
self.assertEqual(len(self.worker.stream_config), 10)
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
self.worker.connection.mav.command_long_send.assert_not_called()
self.worker._tick_stream_config(10)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_stream_config(10.1)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker.command_quiet_until = 16
self.worker._tick_stream_config(11)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_stream_config(16)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
def test_link_probe_uses_configured_rate_and_measures_exact_echo(self):
self.worker.armed = False
self.worker.ping_rate_hz = 5
with patch("app.time.monotonic", return_value=10), \
patch("app.time.monotonic_ns", return_value=10_000_000_000):
self.worker._tick_link_probe(10)
self.worker.connection.mav.timesync_send.assert_called_once_with(
0, 10_000_000_000)
self.assertEqual(self.worker.pending_pings, {10_000_000_000: 10})
self.assertEqual(self.worker.next_ping_at, 10.2)
message = SimpleNamespace(
tc1=1, ts1=10_000_000_000, get_type=lambda: "TIMESYNC",
to_dict=lambda: {"tc1": 1, "ts1": 10_000_000_000})
with patch("app.time.monotonic", return_value=10.04):
self.worker._handle_message(message)
quality = self.worker.events.get_nowait()
self.assertEqual(quality[0], "link_quality")
self.assertEqual(quality[1]["latency_ms"], 40.0)
self.assertEqual(quality[1]["loss_percent"], 0.0)
def test_link_rates_rebuild_requested_telemetry_intervals(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker.set_link_rates(10, {
"SYS_STATUS": 5, "SERVO_OUTPUT_RAW": 10,
"RC_CHANNELS": 0, "ESC_TELEMETRY_1_TO_4": 2,
})
self.assertEqual(self.worker.ping_rate_hz, 10)
configured = {name: interval for name, _, interval in self.worker.stream_config}
self.assertEqual(configured["SYS_STATUS"], 200_000)
self.assertEqual(configured["SERVO_OUTPUT_RAW"], 100_000)
self.assertEqual(configured["RC_CHANNELS"], -1)
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 500_000)
def test_link_probe_counts_timeout_and_pauses_for_commands(self):
self.worker.pending_pings[4] = 5
self.worker.rx_delivery_results.extend(((10, True), (10, False)))
self.worker.next_ping_at = 0
self.worker.command_quiet_until = 20
with patch("app.time.monotonic", return_value=10):
self.worker._tick_link_probe(10)
self.worker.connection.mav.timesync_send.assert_not_called()
self.assertEqual(list(self.worker.ping_results), [(10, None)])
quality = self.worker.events.get_nowait()[1]
self.assertEqual(quality["loss_percent"], 100.0)
self.assertEqual(quality["grade"], "testing")
def test_clean_downlink_cannot_hide_failed_round_trips(self):
self.worker.ping_results.extend([(10, None)] * 20)
self.worker.rx_delivery_results.extend([(10, True)] * 200)
with patch("app.time.monotonic", return_value=10):
self.worker._publish_link_quality()
quality = self.worker.events.get_nowait()[1]
self.assertEqual(quality["loss_percent"], 100.0)
self.assertEqual(quality["grade"], "poor")
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()
+270
View File
@@ -0,0 +1,270 @@
/* Browser regression check. Run: node tests/ui_windows.cjs [path-to-playwright]
Uses a simulated Python bridge; never opens a COM port or sends MAVLink. */
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const http=require('node:http');
const {chromium}=require(process.argv[2]||'playwright');
const root=path.resolve(__dirname,'..'),output=path.join(root,'runtime','ui-check');
fs.mkdirSync(output,{recursive:true});
const server=http.createServer((req,res)=>{
const url=decodeURIComponent(new URL(req.url,'http://localhost').pathname);
let file=path.resolve(root,'.'+url);
if(url==='/model.stl'||url==='/model')file=path.join(root,'runtime','vehicle_model.stl');
if(!file.startsWith(root+path.sep)||!fs.existsSync(file)||!fs.statSync(file).isFile()){
res.writeHead(404);res.end();return;
}
const types={'.html':'text/html','.js':'text/javascript','.css':'text/css','.stl':'model/stl'};
res.setHeader('Content-Type',types[path.extname(file)]||'application/octet-stream');
fs.createReadStream(file).pipe(res);
});
async function main(){
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage({viewport:{width:1080,height:720}});
const errors=[];page.on('pageerror',error=>errors.push(error.message));
const setArmed=value=>page.evaluate(value=>{
Object.assign(testBridge.state,{connected:true,armed:value,armed_known:true,app_state:value?'armed':'connected_disarmed'});render(testBridge.state);
},value);
await page.addInitScript(()=>{
const clone=value=>JSON.parse(JSON.stringify(value));
const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null};
const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null};
const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20};
const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1};
const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[],axes:[],axisVisual:[],stops:0};
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false};
window.testPads=[];Object.defineProperty(navigator,'getGamepads',{value:()=>window.testPads});
window.pywebview={api:{
initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}),
refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state),
get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>{calls.stops++;return true},set_axes:async(...axes)=>{calls.axes.push(axes);calls.axisVisual.push({joy:joystick3D.getState(),vertical:verticalControl.getState().value});return true},
save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)},
save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)},
connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)},
disconnect:async()=>{Object.assign(state,{connected:false,armed_known:false,app_state:'disconnected'});return true},
reverse_motor:async servo=>{calls.reverse++;if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>state.servos[servo].direction*=-1,650);return true},
set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true},
start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)},
cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)},
read_console:async()=>{calls.console++;return {rx:['2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0,"sample":1}', '2026-10-01T10:11:12.124 [RX_BAD_DATA] corrupt_frame='+calls.console, '2026-10-01T10:11:12.125 [RX] {"mavpackettype":"HEARTBEAT"}'],tx:['2026-10-01T10:11:12.124 [TX_COMMAND] ARM '+calls.console]}},
minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true
}};
});
await page.goto('http://127.0.0.1:'+server.address().port+'/web/index.html');
await page.waitForFunction(()=>bridgeReady);
const openLink=()=>page.locator('[data-open="link-modal"]').click();
const close=async id=>{await page.locator('#'+id+' [data-close]').first().click();await page.waitForFunction(id=>!document.getElementById(id).open,id)};
await openLink();
await page.locator('#baud').selectOption('115200');
await page.locator('#link-modal').getByRole('button',{name:'Cancel',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('link-modal').open);
await openLink();assert.equal(await page.locator('#baud').inputValue(),'57600');
await page.locator('#custom-port-input').fill('COM42');
await page.locator('#custom-port-btn').click();
assert(await page.locator('#link-modal').evaluate(d=>d.open));
assert.equal(await page.locator('#port').inputValue(),'COM42');
await page.locator('#baud').selectOption('115200');
assert(await page.locator('#link-form [type="submit"]').evaluate(el=>{el.click();return el.disabled}));
await page.waitForFunction(()=>!document.getElementById('link-modal').open);
await page.locator('#refresh').click();assert.equal(await page.locator('#port').inputValue(),'COM42');
await page.locator('#connect').click();
await page.waitForFunction(()=>testBridge.calls.connect.length===1);
assert.equal(await page.evaluate(()=>testBridge.calls.connect[0].baud),115200);
await page.locator('[data-open="battery-modal"]').click();
await page.locator('#battery-form [name="cells"]').fill('5');
await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.getElementById('battery-modal').open);
await page.locator('[data-open="battery-modal"]').click();
assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'4');
await page.evaluate(()=>testBridge.failBattery=true);
await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click();
await page.locator('#battery-form .form-feedback').waitFor({state:'visible'});
assert(await page.locator('#battery-modal').evaluate(d=>d.open));
assert.equal(await page.locator('#battery-form .form-feedback').textContent(),'Battery save failed');
await page.evaluate(()=>testBridge.failBattery=false);
await page.locator('#battery-form [name="cells"]').fill('5');
await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click();
await page.waitForFunction(()=>!document.getElementById('battery-modal').open);
await page.locator('[data-open="battery-modal"]').click();
assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'5');
await page.screenshot({path:path.join(output,'battery.png')});await close('battery-modal');
await page.locator('[data-open="motors-modal"]').click();
assert.equal(await page.locator('#motor-list > .motor-row').count(),3);
await page.locator('#motor-list > .motor-row').first().getByRole('button').click();
await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Confirming…');
assert(await page.locator('#motor-list > .motor-row').first().getByRole('button').isDisabled());
await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Reversed');
await page.evaluate(()=>testBridge.failMotor=true);
await page.locator('#motor-list > .motor-row').first().locator('select').selectOption('2');
await page.locator('#motor-feedback').waitFor({state:'visible'});
assert.equal(await page.locator('#motor-list > .motor-row').first().locator('select').inputValue(),'1');
await page.screenshot({path:path.join(output,'motors.png')});await close('motors-modal');
await page.locator('#link-quality').click();
assert.equal(await page.locator('#quality-detail-rtt').textContent(),'24 ms');
await page.locator('#heavy-quality-run').click();
assert(await page.locator('#heavy-quality-run').isDisabled());
await page.locator('#heavy-quality-cancel').waitFor({state:'visible'});
assert.equal(await page.evaluate(()=>testBridge.calls.heavy),1);
await page.locator('#heavy-quality-cancel').click();
await page.waitForFunction(()=>!testBridge.state.heavy_quality.running);
await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal');
await page.locator('#console').click();
await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('COMMAND_ACK'));
await page.locator('#console-pause').click();
const paused=await page.locator('#console-rx-output').textContent();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused);
await page.locator('#console-pause').click();
await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused);
await page.screenshot({path:path.join(output,'console.png')});await page.locator('#console-close').click();
for(const viewport of [{width:900,height:560},{width:480,height:720}]){
await page.setViewportSize(viewport);
for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','controller-modal']){
await page.evaluate(id=>openModal(id),id);
const box=await page.locator('#'+id).boundingBox();
assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport');
assert.equal(await page.evaluate(()=>canDrive()),false);
const closeGeometry=await page.locator('#'+id+' .modal-close').evaluate(button=>{
const b=button.getBoundingClientRect(),s=button.querySelector('svg').getBoundingClientRect();
return {dx:Math.abs(b.x+b.width/2-s.x-s.width/2),dy:Math.abs(b.y+b.height/2-s.y-s.height/2),bg:getComputedStyle(button).backgroundColor};
});
assert(closeGeometry.dx<.5&&closeGeometry.dy<.5,id+' close icon must be centered');
assert.equal(closeGeometry.bg,'rgba(0, 0, 0, 0)');
await close(id);
}
}
await page.setViewportSize({width:1080,height:720});await openLink();
await page.screenshot({path:path.join(output,'link.png')});await close('link-modal');
await page.setViewportSize({width:1080,height:720});
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:true,armed:false,armed_known:true,app_state:'connected_disarmed',battery:{voltage:15.8,percent:72,cells:4,low:false}});focusChanged(true);render(testBridge.state)});
await page.locator('#engage').focus();
assert.equal(await page.locator('#engage').evaluate(el=>getComputedStyle(el).outlineStyle),'none');
await page.evaluate(()=>startEngageHold());
assert.equal(await page.locator('#input-joystick').isDisabled(),true);
await page.evaluate(()=>cancelEngageHold(false));
assert.equal(await page.locator('#input-joystick').isDisabled(),false);
await page.locator('#input-joystick').click();
for(const pending of ['arm','disarm']){
await page.evaluate(pending=>{testBridge.state.pending_command=pending;render(testBridge.state)},pending);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
assert.equal(await page.evaluate(()=>{setInputMode('keyboard');return inputMode}),'joystick');
}
await page.evaluate(()=>{delete testBridge.state.pending_command;testBridge.state.armed_known=false;render(testBridge.state)});
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await setArmed(true);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await page.waitForFunction(()=>document.getElementById('controller-status').textContent.includes('Waiting'));
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[1,-1,0,-1]}]);
await page.waitForFunction(()=>document.getElementById('controller-status').textContent==='Center sticks to resume');
assert.deepEqual(await page.evaluate(()=>({...control})),{turn:0,forward:0,vertical:0});
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
await page.waitForFunction(()=>!controllerNeedCenter);
await page.keyboard.down('w');await page.waitForTimeout(420);await page.keyboard.up('w');
assert.equal(await page.evaluate(()=>control.forward),0);
await page.evaluate(()=>testPads[0].axes=[1,-1,0,-1]);
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.8);
await page.waitForFunction(()=>control.vertical>.99);
const matching=await page.evaluate(()=>{
const joy=joystick3D.getState(),sent=testBridge.calls.axes.at(-1);
return {length:Math.hypot(control.turn,control.forward),joy,vertical:control.vertical,sent,visual:testBridge.calls.axisVisual.at(-1),control:{...control}};
});
assert(matching.length<=1.00001);
assert.equal(matching.joy.x,matching.control.turn);assert.equal(matching.joy.y,-matching.control.forward);
assert.equal(matching.sent[0],-matching.visual.joy.y);assert.equal(matching.sent[1],matching.visual.joy.x);assert.equal(matching.sent[2],matching.visual.vertical);
// Dispatch even though CSS excludes pointers, to exercise the handler gate.
await page.locator('#joystick-canvas').dispatchEvent('pointerdown',{pointerId:91,clientX:20,clientY:20,button:0});
await page.locator('#heave').dispatchEvent('pointerdown',{pointerId:92,clientX:20,clientY:20,button:0});
assert.equal(await page.evaluate(()=>joystick3D.getState().active||verticalControl.getState().active),false);
await page.locator('#console').click();
await page.waitForFunction(()=>!document.getElementById('console-dock').hidden);
await page.waitForFunction(()=>document.querySelector('.rx-dot').classList.contains('lit')&&document.querySelector('.tx-dot').classList.contains('lit'));
await page.locator('#console-pause').click();
await page.waitForTimeout(500);
assert.equal(await page.locator('.rx-dot').evaluate(el=>el.classList.contains('lit')),false);
assert.equal(await page.locator('.tx-dot').evaluate(el=>el.classList.contains('lit')),false);
await page.locator('#console-pause').click();
assert.equal(await page.evaluate(()=>canDrive()),true);
assert.equal(await page.locator('#console-rx-output .console-row').count(),2);
await page.locator('#console-filter').selectOption('all');
assert.equal(await page.locator('#console-rx-output .console-row').count(),3);
await page.locator('#console-rx-output details').first().locator('summary').click();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output details').first().evaluate(el=>el.open),true);
await page.screenshot({path:path.join(output,'controller-console.png')});await page.locator('#console-close').click();
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.95);
await page.waitForFunction(()=>control.vertical===0&&control.turn===0&&control.forward===0);
await setArmed(false);
assert.equal(await page.locator('#input-keyboard').isDisabled(),false);
await page.locator('#controller-settings').click();
await page.screenshot({path:path.join(output,'controller-setup.png')});
await page.locator('#controller-form [name="invertTurn"]').check();
await page.locator('#controller-form').getByRole('button',{name:'Cancel',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
await page.locator('#controller-settings').click();
assert.equal(await page.locator('#controller-form [name="invertTurn"]').isChecked(),false);
await page.locator('#controller-form [name="invertTurn"]').check();
await page.locator('#controller-form').getByRole('button',{name:'Save mapping',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
await setArmed(true);
await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);
await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>testPads=[]);
await page.waitForFunction(()=>control.turn===0);
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[.5,0,0,0]}]);
await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>focusChanged(false));assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>focusChanged(true));await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>testPads[0].id='Different controller');
await page.waitForFunction(()=>control.turn===0);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await setArmed(false);
await page.locator('#input-keyboard').click();
await setArmed(true);
await page.keyboard.down('w');await page.waitForFunction(()=>control.forward>.9);await page.keyboard.up('w');
await page.waitForFunction(()=>control.forward===0);
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:false,armed_known:false});render(testBridge.state)});
assert.equal(await page.evaluate(()=>canDrive()),false);
await setArmed(false);
const deckLayout=await page.evaluate(()=>{
const battery=document.querySelector('.battery-card'),sidebar=document.querySelector('.sidebar-deck'),actions=document.querySelector('.activity-actions'),heading=document.querySelector('.activity-heading');
return {last:battery===document.querySelector('.telemetry-zone').lastElementChild,title:heading.textContent,
actionHeight:actions.querySelector('button').getBoundingClientRect().height,
batteryHeight:battery.getBoundingClientRect().height,connectHeight:document.querySelector('#connect').getBoundingClientRect().height,
bottomGap:sidebar.getBoundingClientRect().bottom-battery.getBoundingClientRect().bottom,
overlay:getComputedStyle(document.querySelector('.battery-copy')).zIndex,
actionRightGap:actions.getBoundingClientRect().right-actions.lastElementChild.getBoundingClientRect().right,
sidebarPadding:getComputedStyle(sidebar).paddingTop,
workspacePadding:getComputedStyle(document.querySelector('.controls-workspace')).paddingTop};
});
assert.equal(deckLayout.last,true);assert.equal(deckLayout.title,'Activity:');
assert.equal(deckLayout.actionHeight,32);assert.equal(deckLayout.batteryHeight,62);assert.equal(deckLayout.connectHeight,62);
assert(deckLayout.bottomGap>=10&&deckLayout.bottomGap<=24);assert.equal(deckLayout.overlay,'1');
assert.equal(deckLayout.actionRightGap,0);assert.equal(deckLayout.sidebarPadding,deckLayout.workspacePadding);
const batteryReadout=await page.locator('.battery-card').evaluate(el=>{
const percent=el.querySelector('#battery-percent'),voltage=el.querySelector('#battery-title');
return {percent:percent.textContent,voltage:voltage.textContent,border:getComputedStyle(el).borderTopWidth,
mainSize:parseFloat(getComputedStyle(percent).fontSize),voltageSize:parseFloat(getComputedStyle(voltage).fontSize)};
});
assert.match(batteryReadout.percent,/^\d+%$/);assert.match(batteryReadout.voltage,/^\d+\.\d{2} V$/);
assert.equal(batteryReadout.border,'0px');assert(batteryReadout.mainSize>batteryReadout.voltageSize);
await page.screenshot({path:path.join(output,'ui-cleanup.png')});
assert.deepEqual(errors,[]);
console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, five dialogs at two window sizes; controller conditioning/read-only graphics, actual bridge values, mapping, focus/unplug/identity safety and dock filtering; mode locking during hold/armed/pending/unknown states, centered SVG close icons and bottom battery layout.');
}catch(error){
const page=browser.contexts()[0]?.pages()[0];
if(page){
await page.screenshot({path:path.join(output,'failure.png')});
console.error(await page.evaluate(()=>[...document.querySelectorAll('input')].filter(el=>!el.checkValidity()).map(el=>({name:el.name,value:el.value,error:el.validationMessage}))));
}
throw error;
}finally{await browser.close();await new Promise(resolve=>server.close(resolve))}
}
main().catch(error=>{console.error(error);server.close();process.exitCode=1});
+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}
+948 -35
View File
@@ -1,46 +1,959 @@
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 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={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
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){const changed=row.textContent!==(activity[i]||'');row.textContent=activity[i]||'';row.hidden=!activity[i];if(changed&&!row.hidden&&!matchMedia('(prefers-reduced-motion:reduce)').matches)row.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'})}
});
}
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&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive;
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
const controllerMotion=new ControllerInput.Motion();
const controllerId=pad=>pad.index+':'+pad.id;
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
function loadControllerConfig(pad){
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
}
function selectController(pad){
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
}
function setInputMode(mode){
if(mode===inputMode||inputConfigurationLocked())return false;
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
$('#controller-settings').hidden=mode!=='joystick';
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys');
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
return true;
}
function inputConfigurationLocked(){
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
}
function syncInputModeControls(){
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
$('.input-mode-switch').dataset.locked=String(locked);
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
}
$('#controller-settings').disabled=locked||!pads.length;
$('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup';
if($('#controller-modal').open){
const form=$('#controller-form');
for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad;
$('#controller-device').disabled=locked||!pads.length;
const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.';
if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback);
}
}
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
$('#input-joystick').onclick=()=>setInputMode('joystick');
function fillControllerForm(){
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
if(!pads.length)device.add(new Option('No controller detected',''));
device.value=controllerKey||'';
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
const config=controllerConfig||ControllerInput.defaults(pad);
for(const name of ['turn','forward','vertical']){
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
const invert='invert'+name[0].toUpperCase()+name.slice(1);
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
}
form.elements.deadZone.value=Math.round(config.deadZone*100);
device.disabled=inputConfigurationLocked()||!pads.length;
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
}
$('#controller-device').onchange=e=>{
if(inputConfigurationLocked())return;
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
if(pad){selectController(pad);fillControllerForm()}
};
$('#controller-form').onsubmit=e=>{
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
const config={deadZone:Number(form.elements.deadZone.value)/100};
for(const name of ['turn','forward','vertical']){
config[name]=Number(form.elements[name].value);
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
}
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
}
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
resetInputs();closeModal($('#controller-modal'));
};
function pollController(now){
try{
if(inputMode==='joystick'){
const pads=getControllers();
if(controllerKey===null&&pads.length)selectController(pads[0]);
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id;
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
if(!target||!canDrive()){
if(controllerWasDriving)resetInputs();
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
}else{
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
if(!controllerNeedCenter){
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
control.turn=demand.turn;control.forward=demand.forward;
window.joystick3D?.setVisual(demand.turn,-demand.forward);
setHeave(demand.vertical,false);updateReadouts();sendAxes();
}
}
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
if($('#controller-modal').open){
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes;
if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm();
}
}else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'}
}catch(error){
if(controllerWasDriving)resetInputs();
$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard';
}
requestAnimationFrame(pollController);
}
document.body.dataset.inputMode=inputMode;
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},link_quality:null,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)}
window.showToast=toast;
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
const manualPorts=new Map();
function populatePorts(ports){
const p=$('#port');
if(!p)return;
const old=p.value||'';
p.innerHTML='';
const joy=$('#joystick'),stick=$('#stick');let joyDrag=false,returnAnim=0;
function setJoy(x,y,send=true){const r=joy.clientWidth*.34,n=Math.hypot(x,y);if(n>1){x/=n;y/=n}control.turn=x;control.forward=-y;stick.style.transform=`translate(calc(-50% + ${x*r}px),calc(-50% + ${y*r}px))`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)}
function joyEvent(e){const b=joy.getBoundingClientRect();setJoy((e.clientX-b.left-b.width/2)/(b.width*.34),(e.clientY-b.top-b.height/2)/(b.height*.34))}
joy.addEventListener('pointerdown',e=>{joyDrag=true;cancelAnimationFrame(returnAnim);joy.setPointerCapture(e.pointerId);joyEvent(e)});joy.addEventListener('pointermove',e=>{if(joyDrag)joyEvent(e)});joy.addEventListener('pointerup',()=>{joyDrag=false;easeJoy()});
function easeJoy(){const sx=control.turn,sy=-control.forward,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setJoy(sx*(1-smooth),sy*(1-smooth));if(p<1)returnAnim=requestAnimationFrame(frame)}returnAnim=requestAnimationFrame(frame)}
const allList=[];
const seen=new Set();
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)}
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);
manualPorts.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(!window.canTouchControl())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))}
heave.addEventListener('pointerdown',e=>{heaveDrag=true;cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;easeHeave()});
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
function 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(!window.canTouchControl()||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(target=0){cancelAnimationFrame(heaveAnim);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+(target-sv)*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()}
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
addEventListener('blur',()=>{keys.clear();if(!joyDrag)easeJoy();if(!heaveDrag)easeHeave()});
function applyKeys(){if(inputMode!=='keyboard'||!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)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(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(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;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();});
function resetInputs(){
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
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());
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=()=>api('toggle_maximize_window');$('#window-close').onclick=()=>api('close_window');$('.window-drag').ondblclick=()=>api('toggle_maximize_window');
const modelInput=$('#model-input'),modelButton=$('#load-model');
modelButton.onclick=()=>{modelInput.value='';modelInput.click()};
function bytesToBase64(bytes){let value='';for(let i=0;i<bytes.length;i+=0x8000)value+=String.fromCharCode(...bytes.subarray(i,i+0x8000));return btoa(value)}
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)}
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}};
$('#refresh').onclick=async()=>populatePorts(await api('refresh_ports'));$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}};
$('#manual').onclick=()=>api('command','manual').catch(e=>toast(e.message,true));$('#arm').onclick=()=>openModal('arm-dialog');$('#confirm-arm').onclick=()=>{api('command','arm').catch(e=>toast(e.message,true));$('#arm-dialog').close()};$('#disarm').onclick=()=>api('command','disarm').catch(e=>toast(e.message,true));$('#logs').onclick=()=>api('open_logs');
$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)};
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);$('#battery-modal').close();toast('Battery settings saved')}catch(err){toast(err.message,true)}};
function setFeedback(el,message=''){
if(!el)return;
el.textContent=message;el.hidden=!message;
}
function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id;
if(!el||el.open)return;
if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return}
resetInputs();
el._returnFocus=document.activeElement;
if(el.id==='battery-modal')fillBatteryForm();
if(el.id==='controller-modal')fillControllerForm();
if(el.id==='link-modal'){
fillLinkForm();
$('#custom-port-status').textContent='';
$('#custom-port-input').value='';
}
el.querySelectorAll('.form-feedback').forEach(feedback=>setFeedback(feedback));
el.classList.remove('closing');
el.showModal();
}
function closeModal(dialog){
if(!dialog||!dialog.open||dialog.classList.contains('closing')||dialog.querySelector('[data-busy="true"]'))return;
dialog.classList.add('closing');
let done=false;
const finish=()=>{
if(done)return;
done=true;
dialog.removeEventListener('animationend',onEnd);
dialog.classList.remove('closing');
dialog.close();
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
};
const onEnd=e=>{if(e.target===dialog)finish()};
dialog.addEventListener('animationend',onEnd);
setTimeout(finish,180);
}
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
$('#link-quality').onclick=()=>openModal('link-quality-modal');
let heavyActionBusy=false;
async function requestHeavyTest(action){
if(heavyActionBusy)return;
heavyActionBusy=true;
setFeedback($('#heavy-quality-error'));
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
try{
const test=await api(action);
vehicleSnapshot.heavy_quality=test;
renderHeavyQuality(test,vehicleSnapshot.connected,vehicleSnapshot.armed);
}catch(e){setFeedback($('#heavy-quality-error'),e.message||'Link test unavailable. Try again.')}
finally{
heavyActionBusy=false;
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
}
}
$('#heavy-quality-run').onclick=()=>requestHeavyTest('start_heavy_quality_test');
$('#heavy-quality-cancel').onclick=()=>requestHeavyTest('cancel_heavy_quality_test');
$$('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,+linkConfig.baud||57600,+linkConfig.control_hz||20);
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){$('#custom-port-status').textContent='Enter a port first.';customPortInput.focus();return;}
if(vehicleSnapshot.connected)return;
manualPorts.set(val,{device:val,label:`Custom (${val})`});
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;
}
$('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
};
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%');
syncInputModeControls();
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');
syncInputModeControls();
}
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');
const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0;
const consolePulseTimers={};
function pulseConsole(side){
const dot=$('.'+side+'-dot');
clearTimeout(consolePulseTimers[side]);dot.classList.add('lit');
consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260);
}
function renderConsole(){
const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache);
if(signature===consoleSignature)return;
consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter);
}
function setConsoleOpen(open){
consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open);
if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')}
else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180);
$('#console').setAttribute('aria-expanded',String(open));
if(open)refreshConsole();else $('#console').focus({preventScroll:true});
}
$('#console').setAttribute('aria-controls','console-dock');
$('#console').setAttribute('aria-expanded','false');
$('#console').onclick=()=>setConsoleOpen(!consoleOpen);
$('#console-close').onclick=()=>setConsoleOpen(false);
$('#console-filter').onchange=renderConsole;
$('#console-pause').onclick=()=>{
consolePaused=!consolePaused;
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
$('#console-state').textContent=consolePaused?'Paused':'';
$('#console-state').hidden=!consolePaused;
if(!consolePaused)refreshConsole();
};
async function refreshConsole(){
if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
consoleRefreshBusy=true;
try{
const sides=await withTimeout(api('read_console',150),1500);
if(consolePaused||!consoleOpen)return;
const filter=$('#console-filter').value;
for(const side of ['rx','tx']){
const previous=new Set(consoleCache[side]||[]);
if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side);
}
consoleCache=sides;renderConsole();
$('#console-state').textContent='';$('#console-state').hidden=true;
}catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}}
finally{consoleRefreshBusy=false}
}
setInterval(refreshConsole,750);
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))}});
async function saveSettings(form,method,commit,message){
if(form.dataset.busy==='true'||!form.reportValidity())return;
const data=Object.fromEntries(new FormData(form));
const button=form.querySelector('[type="submit"]'),label=button.textContent;
const disabled=new Map([...form.querySelectorAll('input,select,button')].map(el=>[el,el.disabled]));
form.dataset.busy='true';disabled.forEach((_,el)=>el.disabled=true);button.textContent='Saving…';
setFeedback(form.querySelector('.form-feedback'));
let saved=false;
try{commit(await api(method,data));saved=true;toast(message)}
catch(error){setFeedback(form.querySelector('.form-feedback'),error.message||'Could not save settings. Try again.')}
finally{
delete form.dataset.busy;disabled.forEach((value,el)=>el.disabled=value);button.textContent=label;
if(saved)closeModal(form.closest('dialog'));
}
}
$('#battery-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_battery',value=>batteryConfig=value,'Battery settings saved')};
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 render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';$('#link').textContent=s.link;$('.link-state').classList.toggle('online',s.link==='LINK OK');$('#vehicle').textContent=s.vehicle;$('#status').textContent=s.status;const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}S`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='Waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)}
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
let booted=false;
async function boot(){if(booted||!window.pywebview?.api)return;booted=true;bridgeReady=true;try{const init=await api('initial_state');batteryConfig=init.battery_config;populatePorts(init.ports);fillBatteryForm();render(init.state);const saved=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)}}
function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)}
addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge();
$('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
const motorEdits=new Map();
async function requestMotorEdit(servo,kind,value){
if(motorEdits.has(servo))return;
const s=vehicleSnapshot.servos?.[String(servo)]||{};
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
setFeedback($('#motor-feedback'));
if(demoActive){
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
else s.direction=expected;
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
}
motorEdits.set(servo,{kind,expected,started:performance.now()});
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
try{
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
}catch(error){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
}
}
function renderMotors(servos){
for(const [servo,edit] of motorEdits){
const s=servos[String(servo)]||{};
if((edit.kind==='function'?s.function:s.direction)===edit.expected){
motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`);
}else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`);
}
}
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
const assigned=Object.values(servos).filter(s=>s.motor).length;
$('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…';
const signature=JSON.stringify([servos,editable,[...motorEdits]]);
if(signature===servoSignature)return;
servoSignature=signature;
const list=$('#motor-list'),expanded=list.querySelector('details')?.open;
list.replaceChildren();
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded;
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary);
for(let i=1;i<=16;i++){
const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row';
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
const select=row.querySelector('select');
select.add(new Option(s.function==null?'Reading…':'Unassigned','0'));
for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor)));
select.value=String(s.motor||0);
select.disabled=!editable||!!pending||s.function==null;
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal';
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null;
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`);
reverse.onclick=()=>requestMotorEdit(i,'direction');
(s.motor?list:other).append(row);
}
if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)}
if(assigned<16)list.append(other);
}
function renderHeavyQuality(test,connected,armed){
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
if(!status||!results||!run||!stop)return;
const quality=demoActive?null:vehicleSnapshot.link_quality;
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
const running=!!test?.running;
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
stop.hidden=!running;
stop.disabled=heavyActionBusy;
results.replaceChildren();
for(const row of test?.results||[]){
const line=document.createElement('div');line.className='heavy-result-row';
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
const stats=document.createElement('span');
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
line.append(rate,stats);results.append(line);
}
}
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});
syncInputModeControls();
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
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;
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
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)}%`;
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
$('.battery-card').title=`Estimated charge from voltage · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
$('#battery-fill').style.width=`${b.percent}%`;
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
$('.battery-card').dataset.low=String(!!b.low);
}else{
$('#battery-title').textContent='No voltage';
$('#battery-percent').textContent='—';
$('.battery-card').dataset.stale='false';
$('.battery-card').title='Waiting for battery voltage · Battery settings';
$('#battery-fill').style.width='0';
$('.battery-card').dataset.low='false';
}
const quality=s.link_quality,qualityCard=$('#link-quality');
if(qualityCard){
if(demoActive){
qualityCard.dataset.grade='offline';
$('#link-quality-grade').textContent='Demo';
$('#link-quality-roundtrip').textContent='no hardware link test';
$('#link-quality-downlink').textContent='';
renderMotors(s.servos||{});
return;
}
const grade=quality?.grade||(s.connected?'testing':'offline');
qualityCard.dataset.grade=grade;
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
if(quality&&quality.probe_samples){
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
const downLoss=percent(quality.rx_sequence_loss_percent);
const badFrames=quality.rx_corrupt_frames_20s??0;
const badBytes=quality.rx_corrupt_bytes_20s??0;
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
if(quality.probe_samples<10||quality.rx_packets_30s<10){
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
}
}else{
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
$('#link-quality-downlink').textContent='';
}
}
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||{};
linkConfig=init.link_config||{};
populatePorts(init.ports);
fillBatteryForm();
fillLinkForm();
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();
}
requestAnimationFrame(pollController);
+70
View File
@@ -0,0 +1,70 @@
/* Read-only presentation of the existing local RX/TX logs. */
((root)=>{
const quiet=/^(HEARTBEAT|TIMESYNC|LINK_PROBE|HEAVY_PROBE|GCS)/;
function parse(line){
const match=String(line).match(/^(\S+) \[([^\]]+)\] ([\s\S]*)$/);
if(!match)return {time:'',type:'Message',payload:String(line),raw:String(line),tone:'normal',quiet:false};
const [,timestamp,category,message]=match;
let payload=message,type=category.replace(/^(RX|TX)_?/,'')||category;
try{payload=JSON.parse(message);type=payload.mavpackettype||type}catch(_){}
const tone=/BAD_DATA|ERROR|TIMEOUT|FAIL/.test(category)||type==='STATUSTEXT'&&Number(payload.severity)<=4?'warning':/COMMAND|ACK|PARAM_SET|OPERATOR/.test(category+' '+type)?'command':'normal';
const time=timestamp.match(/T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)?.[1]||timestamp;
const pairs=typeof payload==='string'?[...payload.matchAll(/(?:^|\s)([a-zA-Z_]\w*)=([^\s]+)/g)]:[];
const fields=pairs.length?Object.fromEntries(pairs.map(([,key,value])=>[key,value])):payload;
return {time,type,payload,fields,raw:String(line),tone,quiet:tone!=='warning'&&(quiet.test(type)||/LINK_PROBE|HEAVY_PROBE|HEARTBEAT/.test(category))};
}
function valueText(value){
if(typeof value==='number')return Number.isInteger(value)?String(value):Number(value.toFixed(3)).toString();
if(Array.isArray(value))return value.slice(0,6).map(valueText).join(', ')+(value.length>6?'…':'');
return typeof value==='object'?JSON.stringify(value):String(value);
}
function makeRow(row,previous){
if(previous&&previous.dataset.key===row.raw&&previous.dataset.tone===row.tone)return previous;
const details=previous||document.createElement('details');
details.className='console-row';details.dataset.key=row.raw;details.dataset.tone=row.tone;
let summary=details.querySelector('summary'),time,body,type,fields,raw;
if(!summary){
details.replaceChildren();summary=document.createElement('summary');time=document.createElement('time');
body=document.createElement('span');body.className='console-message';type=document.createElement('strong');
fields=document.createElement('span');fields.className='console-fields';body.append(type,fields);summary.append(time,body);
raw=document.createElement('pre');details.append(summary,raw);
}else{
time=summary.querySelector('time');body=summary.querySelector('.console-message');
type=body.querySelector('strong');fields=body.querySelector('.console-fields');raw=details.querySelector('pre');
}
time.textContent=row.time;type.textContent=row.type;fields.replaceChildren();
const values=row.fields??row.payload;
if(typeof values==='object'&&values!==null){
for(const [key,value] of Object.entries(values).filter(([key])=>key!=='mavpackettype').slice(0,6)){
const item=document.createElement('span'),label=document.createElement('i'),text=document.createElement('b');
label.textContent=key+' ';text.textContent=valueText(value);item.append(label,text);fields.append(item);
}
}else fields.textContent=row.payload;
raw.textContent=typeof row.payload==='object'?JSON.stringify(row.payload,null,2):row.raw;
return details;
}
function render(host,lines,filter){
const atBottom=host.scrollHeight-host.scrollTop-host.clientHeight<36,scroll=host.scrollTop;
const rows=lines.map(parse).filter(row=>filter==='all'||!row.quiet);
const existing=new Map();
for(const node of host.querySelectorAll('details.console-row')){
const key=node.dataset.key,list=existing.get(key)||[];list.push(node);existing.set(key,list);
}
if(!rows.length){
if(!host.querySelector('.console-empty')){
const empty=document.createElement('p');empty.className='console-empty';
empty.textContent='No messages';
empty.title=filter==='all'?'Waiting for session messages.':'Routine heartbeat and probe traffic is hidden. Choose All traffic to see it.';
host.replaceChildren(empty);
}
return;
}
const wanted=rows.map(row=>makeRow(row,existing.get(row.raw)?.shift()||null));
let cursor=host.firstElementChild;
for(const node of wanted){if(node!==cursor)host.insertBefore(node,cursor);cursor=node.nextElementSibling}
while(cursor){const next=cursor.nextElementSibling;cursor.remove();cursor=next}
if(atBottom&&!host.querySelector('details[open]'))host.scrollTop=host.scrollHeight;else host.scrollTop=scroll;
}
root.ConsoleView={parse,render};
if(typeof module!=='undefined')module.exports=root.ConsoleView;
})(typeof window!=='undefined'?window:globalThis);
+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);
+53
View File
@@ -0,0 +1,53 @@
/* Controller conditioning is independent of drawing and MAVLink transport. */
((root)=>{
const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0));
function deadZone(value,zone=.08){
value=clamp(value);zone=Math.max(0,Math.min(.4,zone));
return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone);
}
function defaults(pad){
return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1,
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
}
function read(pad,config){
if(!pad?.connected||!pad.axes)return null;
const axis=(index,invert)=>{
if(index===-1)return 0;
if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null;
return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1);
};
let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical);
if(turn===null||forward===null||vertical===null)return null;
const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length}
return {turn,forward,vertical};
}
class Smoother{
constructor(){this.reset()}
reset(){this.value=0;this.last=null;this.release=null}
step(target,now){
target=clamp(target);
const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now;
if(target===0){
if(!this.release)this.release={at:now,start:this.value};
const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t);
this.value=this.release.start*(1-ease);
}else{
this.release=null;
this.value+=(target-this.value)*(1-Math.exp(-dt/.1));
if(Math.abs(target-this.value)<.001)this.value=target;
}
return this.value;
}
}
class Motion{
constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}}
reset(){Object.values(this.axes).forEach(axis=>axis.reset())}
step(target,now){
const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)]));
const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length}
return demand;
}
}
root.ControllerInput={deadZone,defaults,read,Motion};
if(typeof module!=='undefined')module.exports=root.ControllerInput;
})(typeof window!=='undefined'?window:globalThis);
+42
View File
@@ -0,0 +1,42 @@
/* Decorative only: no control demands or vehicle state are accessed here. */
(() => {
const workspace=document.querySelector('.controls-workspace');
if(!workspace)return;
const wash=document.createElement('span');
wash.className='workspace-light';wash.setAttribute('aria-hidden','true');workspace.append(wash);
// Explicit visual surfaces, not every semantic button. The entire sidebar
// and text-only Engage control are excluded from local lighting.
const pillSelector='.config-btn,.modal .button,.range-pill,.number-pill';
const shapeSelector=`${pillSelector},.heave-track,.heave-knob,.joystick,.modal`;
const layers=[...document.querySelectorAll(shapeSelector)].filter(host=>!host.closest('.sidebar-deck,#engage')).map(host=>{
host.classList.add('light-surface');
const layer=document.createElement('span');layer.className='cursor-light';
layer.setAttribute('aria-hidden','true');
layer.classList.toggle('pill-light',host.matches(pillSelector));
host.append(layer);return {host,layer};
});
let frame=0,point=null;
const clear=()=>{point=null;cancelAnimationFrame(frame);frame=0;wash.classList.remove('lit');layers.forEach(({layer})=>layer.classList.remove('lit','near'))};
function paint(){
frame=0;if(!point)return;
const {x,y}=point,r=workspace.getBoundingClientRect();
wash.style.setProperty('--light-x',`${x-r.left}px`);wash.style.setProperty('--light-y',`${y-r.top}px`);
wash.classList.toggle('lit',x>=r.left&&x<=r.right&&y>=r.top&&y<=r.bottom);
for(const {host,layer} of layers){
const b=host.getBoundingClientRect();
layer.style.setProperty('--light-x',`${x-b.left}px`);layer.style.setProperty('--light-y',`${y-b.top}px`);
const inside=x>=b.left&&x<=b.right&&y>=b.top&&y<=b.bottom;
// CSS gradient angles start at up; atan2 is measured from the x axis.
const angle=Math.atan2(y-(b.top+b.height/2),x-(b.left+b.width/2))*180/Math.PI+90;
layer.style.setProperty('--light-angle',`${angle}deg`);
layer.classList.toggle('lit',inside);layer.classList.add('near');
}
}
document.addEventListener('pointermove',event=>{
if(event.pointerType==='touch'){clear();return}
point={x:event.clientX,y:event.clientY};if(!frame)frame=requestAnimationFrame(paint);
},{passive:true});
document.documentElement.addEventListener('pointerleave',clear);
document.addEventListener('pointercancel',clear);window.addEventListener('blur',clear);
document.addEventListener('visibilitychange',()=>{if(document.hidden)clear()});
})();
+204 -89
View File
@@ -3,117 +3,232 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SUB Control</title>
<link rel="stylesheet" href="styles.css">
<title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20261002-spacing-motion-v2">
</head>
<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">
<aside class="vehicle-rail">
<header class="brand"><span class="brand-mark">S</span><span>SUB CONTROL</span><small>03</small></header>
<div class="eyebrow">LIVE VEHICLE</div>
<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>
<div class="model-tools">
<button id="load-model">Load STL</button>
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden>
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
<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">
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<div id="model-status" class="model-status">Initializing…</div>
<div class="model-tools">
<button id="load-model">Load STL</button>
<input id="model-input" type="file" accept=".stl,.STL" hidden>
</div>
</div>
</div>
<div class="vehicle-name"><small>VEHICLE</small><strong id="vehicle">No heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
<div class="rail-bottom">
<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>
<b><em id="battery-fill"></em></b>
</button>
<div class="link-state"><span id="link-dot"></span><strong id="link">DISCONNECTED</strong></div>
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
<span class="link-quality-dot" aria-hidden="true"></span>
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
<i><span id="link-quality-roundtrip">waiting for connection</span><span id="link-quality-downlink"></span></i>
</button>
<div class="deck-status-bar">
<header class="activity-heading">Activity:</header>
<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>
<div class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></div>
</div>
<button class="battery-card ghost" data-open="battery-modal" aria-label="Battery status and settings">
<span class="battery-meter" aria-hidden="true"><em id="battery-fill"></em></span>
<span class="battery-copy"><span class="battery-summary"><strong id="battery-percent">—</strong><small id="battery-title">No voltage</small></span></span>
</button>
</section>
</aside>
<section class="workspace">
<header class="topbar">
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div>
<div class="connection">
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select>
<button id="refresh" class="icon-button" title="Refresh ports">↻</button>
<button class="icon-button" data-open="link-modal" title="Link settings">•••</button>
<button id="connect" class="button connect-button">Connect</button>
<!-- ── PRIMARY CONTROL ZONE ── -->
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
<!-- Frameless Drag & Window Controls Header -->
<div class="workspace-topbar">
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
<div class="workspace-drag-area pywebview-drag-region"></div>
<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-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>
</header>
</div>
<section class="drive-grid">
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
<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><strong>Differential drive</strong></div><span class="key-hint">W A S D</span></header>
<div id="joystick" class="joystick" touch-action="none">
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div>
<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>
<header>
<div title="Steer and throttle · W A S D"></div>
</header>
<div class="joystick-pad">
<div id="joystick" class="joystick" touch-action="none">
<canvas id="joystick-canvas" title="Steer and throttle · W A S D" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
</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>
<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>
<!-- Vertical Thruster Slider -->
<article class="control-card vertical-card">
<header><div><small>VERTICAL</small><strong>Center thruster</strong></div><span class="key-hint">R / F</span></header>
<div class="vertical-control">
<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>
<span>DIVE</span>
</div>
<footer><span><i class="dot depth"></i><b id="heave-readout">0</b>% vertical</span></footer>
</article>
</section>
<header>
<div title="Vertical thrust · R / F"></div>
<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>
<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>
<button class="button subtle" data-open="motors-modal">Motor setup</button>
<button id="logs" class="icon-button" title="Open session logs">⌁</button>
</header>
<div class="heave-pad">
<div class="vertical-control">
<span>rise</span>
<div id="heave" title="Vertical thrust · R / F" 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>
</div>
</div>
<footer>
<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"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></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"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
</div>
</label>
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
</div>
</section>
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
</section>
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
<header class="console-dock-header"><div><h2 id="console-title" title="Local session messages. Select a message to expand its details.">Console</h2></div><button id="console-close" type="button" class="console-close" aria-label="Hide console" title="Hide console"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
<div class="console-toolbar"><label><span class="visually-hidden">Console filter</span><select id="console-filter"><option value="useful">Useful traffic</option><option value="all">All traffic</option></select></label><button id="console-pause" type="button" aria-pressed="false">Pause</button></div>
<p id="console-state" class="console-state" role="status" title="Pausing the viewer does not stop session logging." hidden></p>
<div class="console-streams"><section class="console-side"><header><i class="rx-dot" aria-hidden="true" title="Vehicle to PC · new displayed message"></i><span>RX</span></header><div id="console-rx-output" class="console-output" tabindex="0" aria-label="Received messages"></div></section><section class="console-side"><header><i class="tx-dot" aria-hidden="true" title="PC to vehicle · new displayed message"></i><span>TX</span></header><div id="console-tx-output" class="console-output" tabindex="0" aria-label="Sent messages"></div></section></div>
</aside>
</main>
<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>
<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="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="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>
<!-- Modals -->
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="link-title">Link settings</h2>
<form id="link-form" title="Baud changes apply on the next connection.">
<section class="settings-section"><h3>Serial connection</h3>
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
<script src="model-viewer.js"></script>
<script src="app.js"></script>
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
<p id="custom-port-status" class="note" role="status"></p>
</section>
<section class="settings-section"><h3 title="Lower rates leave more bandwidth for controls. Rates reset after a reboot.">Telemetry rates</h3>
<div class="form-grid two">
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
</div>
</section>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
</form>
</dialog>
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="quality-title">Link quality</h2>
<div class="quality-summary" title="Round-trip replies, incoming packet loss, and heartbeat freshness."><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
<section class="heavy-test-panel"><h3 title="Tests 1, 5 and 10 Hz for about 35 seconds while disarmed. The live check resumes afterward.">Extended test</h3>
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
<div id="heavy-quality-results" class="heavy-quality-results"></div>
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button id="heavy-quality-run" type="button" class="button modal-primary">Run extended test</button><button id="heavy-quality-cancel" type="button" class="button subtle" hidden>Stop test</button></div>
</section>
</dialog>
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery settings</h2>
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save battery</button></div>
</form>
</dialog>
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
<form id="controller-form" title="Center sticks after changing input mode or reconnecting before movement resumes.">
<label><span>Controller</span><select id="controller-device"></select></label>
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
<div class="controller-mapping-row"><span>Turn</span><select name="turn" aria-label="Turn axis"></select><input name="invertTurn" type="checkbox" aria-label="Invert Turn"></div>
<div class="controller-mapping-row"><span>Forward / back</span><select name="forward" aria-label="Forward / back axis"></select><input name="invertForward" type="checkbox" aria-label="Invert Forward / back"></div>
<div class="controller-mapping-row"><span>Rise / dive</span><select name="vertical" aria-label="Rise / dive axis"></select><input name="invertVertical" type="checkbox" aria-label="Invert Rise / dive"></div>
<label class="controller-deadzone"><span>Dead zone · %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save mapping</button></div>
</form>
</dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20261002-spacing-motion-v2"></script>
<script src="control-state.js?v=20260827-state-v1"></script>
<script src="controller-input.js?v=20261001-controller-v1"></script>
<script src="console-view.js?v=20261002-console-v2"></script>
<script src="app.js?v=20261002-spacing-motion-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
/* 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;
const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819);
handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939');
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;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 easeTo(nextX,nextY){
cancelAnimationFrame(returnFrame);
const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}
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+(nextX-startX)*smooth,startY+(nextY-startY)*smooth);if(p<1)returnFrame=requestAnimationFrame(tick)};
returnFrame=requestAnimationFrame(tick);
}
function returnToCenter(){easeTo(0,0)}
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.canTouchControl?.()||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);new ResizeObserver(resize).observe(host);resize();
window.joystick3D={setVisual(nextX,nextY){setPosition(nextX,nextY,false)},setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(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}}};
})();
+514 -459
View File
File diff suppressed because it is too large Load Diff
+1474 -12
View File
File diff suppressed because it is too large Load Diff
+416 -92
View File
@@ -7,13 +7,16 @@ import threading
import time
import base64
import shutil
import uuid
import http.server
import socketserver
import urllib.parse
from pathlib import Path
import webview
from serial.tools import list_ports
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
from vehicle_state import VehicleState
class ControlApi:
@@ -22,25 +25,33 @@ class ControlApi:
def __init__(self):
self.events: queue.Queue = queue.Queue()
self.worker = MavlinkWorker(self.events)
self.worker.require_input_lease = True
self.vehicle_state = VehicleState()
self.battery_received_at = None
self.demo_mode = False
self.lock = threading.Lock()
self._poll_lock = threading.Lock()
self.state = {
"connected": False,
"link": "DISCONNECTED",
"vehicle": "No heartbeat",
"armed": False,
"mode": None,
"app_state": "disconnected",
"status": "Ready. Select a USB port to begin.",
"battery": None,
"link_quality": None,
"heavy_quality": None,
"log_path": None,
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
}
self.servo_functions: dict[int, int] = {}
self.motor_directions: dict[int, int] = {}
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.link_path = Path(__file__).resolve().parent / "link_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
self.model_uploads: dict[str, tuple[object, Path, str, int, int]] = {}
self.model_lock = threading.Lock()
# Leading underscore keeps the native WebView object out of js_api
# serialization. Only the small public methods below are exposed.
self._window = None
@@ -54,17 +65,40 @@ class ControlApi:
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
self.link_config = {
"baud": 57600, "control_hz": 20,
"ping_hz": 1, "system_hz": 1, "servo_hz": 2,
"rc_hz": 2, "esc_hz": 1,
}
try:
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
self._apply_link_config()
def initial_state(self):
return {
"ports": [p.device for p in list_ports.comports()],
"ports": self.refresh_ports(),
"battery_config": self.battery_config,
"saved_model": self.get_saved_model_info(),
"link_config": self.link_config,
"state": self._snapshot(),
"model_info": self.get_saved_model_info(),
}
def refresh_ports(self):
return [p.device for p in list_ports.comports()]
ports = []
# Add physical and virtual serial COM ports
for p in list_ports.comports():
desc = p.description or ""
if desc and desc != "n/a" and desc != p.device:
label = f"{p.device} ({desc})"
else:
label = p.device
ports.append({"device": p.device, "label": label, "type": "serial"})
# Built-in UI testing: no external process, port, or MAVLink socket.
ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"})
return ports
def minimize_window(self):
if self._window:
@@ -86,62 +120,83 @@ class ControlApi:
self._window.destroy()
return True
def begin_model_save(self, name, size):
def choose_model(self):
selected = None
# 1. Native Windows topmost open file dialog (instant, reliable)
try:
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
file_path = filedialog.askopenfilename(
title="Select 3D STL Vehicle Model",
initialdir=str(Path.home() / "Downloads"),
filetypes=[("STL 3D Meshes (*.stl)", "*.stl"), ("All files (*.*)", "*.*")],
)
root.destroy()
if file_path:
selected = (file_path,)
except Exception:
pass
# 2. Fallback to pywebview create_file_dialog
if not selected and self._window:
try:
selected = self._window.create_file_dialog(
webview.FileDialog.OPEN,
directory=str(Path.home()),
allow_multiple=False,
file_types=("STL mesh (*.stl)", "All files (*.*)"),
)
except Exception:
selected = None
if not selected:
return None
path = Path(selected[0])
if not path.is_file() or not path.suffix.lower().endswith(".stl"):
raise ValueError("Choose an STL mesh")
if path.stat().st_size > 30 * 1024 * 1024:
raise ValueError("The STL is larger than 30 MB; export a lighter mesh")
content = path.read_bytes()
self.model_cache.parent.mkdir(exist_ok=True)
self.model_cache.write_bytes(content)
self.model_cache_name.write_text(path.name, encoding="utf-8")
try:
self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8")
except Exception:
pass
return {"name": path.name, "data": base64.b64encode(content).decode("ascii")}
def save_model(self, name, data):
safe_name = Path(str(name)).name
if not safe_name.lower().endswith(".stl"):
raise ValueError("Choose an STL mesh")
size = int(size)
if not 0 < size <= 30 * 1024 * 1024:
try:
content = base64.b64decode(data, validate=True)
except (ValueError, TypeError) as exc:
raise ValueError("Invalid STL data") from exc
if not content or len(content) > 30 * 1024 * 1024:
raise ValueError("STL must be between 1 byte and 30 MB")
self.model_cache.parent.mkdir(exist_ok=True)
token = uuid.uuid4().hex
temp = self.model_cache.parent / f".{token}.upload"
with self.model_lock:
self.model_uploads[token] = (temp.open("wb"), temp, safe_name, size, 0)
return token
def append_model_chunk(self, token, data):
try:
chunk = base64.b64decode(data, validate=True)
except (ValueError, TypeError) as exc:
raise ValueError("Invalid STL data chunk") from exc
with self.model_lock:
upload = self.model_uploads.get(str(token))
if not upload:
raise ValueError("Model upload expired")
handle, temp, name, expected, received = upload
if received + len(chunk) > expected:
raise ValueError("Model upload is larger than expected")
handle.write(chunk)
self.model_uploads[str(token)] = (handle, temp, name, expected, received + len(chunk))
return True
def finish_model_save(self, token):
with self.model_lock:
upload = self.model_uploads.pop(str(token), None)
if not upload:
raise ValueError("Model upload expired")
handle, temp, name, expected, received = upload
handle.close()
if received != expected:
temp.unlink(missing_ok=True)
raise ValueError("Model upload was incomplete")
os.replace(temp, self.model_cache)
self.model_cache_name.write_text(name, encoding="utf-8")
self.model_cache.write_bytes(content)
self.model_cache_name.write_text(safe_name, encoding="utf-8")
return True
def get_saved_model_info(self):
self._migrate_legacy_model()
try:
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "vehicle_model.stl"
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
return {"name": name, "size": self.model_cache.stat().st_size}
except (OSError, ValueError):
pass
return None
def read_saved_model_chunk(self, offset, size=1048576):
offset, size = int(offset), min(int(size), 1048576)
def read_saved_model_chunk(self, offset, size=262144):
offset, size = int(offset), min(int(size), 524288)
if offset < 0 or size < 1 or not self.model_cache.is_file():
raise ValueError("Saved model is unavailable")
with self.model_cache.open("rb") as stream:
@@ -150,49 +205,128 @@ class ControlApi:
return {"offset": offset, "data": base64.b64encode(content).decode("ascii"), "eof": offset + len(content) >= self.model_cache.stat().st_size}
def _migrate_legacy_model(self):
if self.model_cache.is_file():
return
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
if default_asset.is_file():
if not self.model_cache.is_file():
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
if default_asset.is_file():
try:
self.model_cache.parent.mkdir(exist_ok=True)
shutil.copyfile(default_asset, self.model_cache)
self.model_cache_name.write_text(default_asset.name, encoding="utf-8")
return
except Exception:
pass
try:
self.model_cache.parent.mkdir(exist_ok=True)
shutil.copyfile(default_asset, self.model_cache)
self.model_cache_name.write_text(default_asset.name, encoding="utf-8")
return
if self.model_path.is_file():
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
self.model_cache.parent.mkdir(exist_ok=True)
shutil.copyfile(path, self.model_cache)
self.model_cache_name.write_text(path.name, encoding="utf-8")
except Exception:
pass
def saved_model(self):
self._migrate_legacy_model()
try:
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
self.model_cache.parent.mkdir(exist_ok=True)
shutil.copyfile(path, self.model_cache)
self.model_cache_name.write_text(path.name, encoding="utf-8")
except (OSError, ValueError, TypeError, KeyError):
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
except (OSError, ValueError):
pass
return None
def connect(self, port, baud=57600, rate=20):
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
if not port:
raise ValueError("Select a USB serial port")
if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
self.demo_mode = True
self.servo_functions = {1: 33, 2: 34, 3: 35}
self.motor_directions = {1: 1, 2: 1, 3: 1}
with self.lock:
self.state.update(
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
link_quality={"grade": "excellent", "latency_ms": 12.0,
"loss_percent": 0.0, "samples": 20,
"corrupt_frames_20s": 0, "heartbeat_age_s": 0.1},
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
"cells": 4, "low": False},
)
for servo in range(1, 17):
function = self.servo_functions.get(servo, 0)
motor = motor_number_for_function(function)
self.state["servos"][str(servo)] = {
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
}
return self._snapshot()
self.worker.rate_hz = int(rate)
self.worker.connect(str(port), int(baud))
with self.lock:
self.vehicle_state.disconnect()
self.state.update(
connected=True, link="CONNECTING",
status=f"Opening {port} at {baud} baud…",
)
return True
self.worker.connect(str(port), int(baud))
print(f"[web_ui] connect() dispatched to worker thread OK")
# Return only once the worker has either seen a heartbeat or reported a
# connection failure. This makes the button independent of the regular
# UI polling loop and prevents an indefinite visual "Connecting" state.
deadline = time.monotonic() + 8.0
while time.monotonic() < deadline:
snapshot = self.poll()
if snapshot["app_state"] != "connecting":
return snapshot
time.sleep(0.05)
self.worker.disconnect()
time.sleep(0.1)
self.poll()
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self):
if self.worker.heavy_quality_test is not None:
self.worker.cancel_heavy_quality_test()
if self.demo_mode:
self.demo_mode = False
with self.lock:
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, mode=None, battery=None, link_quality=None,
status="Demo mode disconnected")
return True
self.worker.disconnect()
return True
def set_axes(self, forward, turn, vertical):
self.worker.set_axes(Axes(
max(-1.0, min(1.0, float(forward))),
0.0,
max(-1.0, min(1.0, float(vertical))),
max(-1.0, min(1.0, float(turn))),
))
def start_heavy_quality_test(self):
if self.demo_mode:
raise RuntimeError("The heavy link test requires a real flight-controller connection")
return self.worker.start_heavy_quality_test()
def cancel_heavy_quality_test(self):
return self.worker.cancel_heavy_quality_test()
def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode:
with self.lock:
self.state["status"] = (
f"Demo controls • surge {float(forward):+.2f} • "
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
)
return True
with self.lock:
allowed = self.vehicle_state.snapshot()["controls_allowed"]
self.worker.set_axes(Axes(
max(-1.0, min(1.0, float(forward))), 0.0,
max(-1.0, min(1.0, float(vertical))),
max(-1.0, min(1.0, float(turn))),
) if allowed else Axes(), sequence)
return True
def stop_controls(self, sequence=None):
"""Invalidate queued input without claiming that the vehicle disarmed."""
with self.lock:
self.worker.set_axes(Axes(), sequence)
self.worker._send_control(force_neutral=True)
return True
def set_gain(self, value):
@@ -200,18 +334,45 @@ class ControlApi:
self.worker.gain = value / 100.0
return value
def command(self, name):
def command(self, name, sequence=None, expires_at=None):
if self.demo_mode:
if name not in ("manual", "arm", "disarm"):
raise ValueError(f"Unknown command: {name}")
armed = name == "arm" if name != "manual" else self.state.get("armed", False)
with self.lock:
self.state.update(armed=armed, mode="MANUAL",
vehicle=f"DEMO • {'ARMED' if armed else 'DISARMED'}",
status=f"Demo: {'Manual mode' if name == 'manual' else name.capitalize()} confirmed")
return True
if not self.worker.connection or not self.worker.target_system:
if name == "manual":
raise RuntimeError("Connect to vehicle to enable Manual mode")
elif name == "arm":
raise RuntimeError("Connect to vehicle before arming")
elif name == "disarm":
raise RuntimeError("Connect to vehicle before disarming")
raise RuntimeError("No autopilot heartbeat")
if name in ("arm", "disarm"):
self.poll()
with self.lock:
if name == "arm":
if expires_at is not None and time.time() * 1000 > expires_at:
raise RuntimeError("Engage request expired before it could be sent")
if sequence is not None and sequence <= self.worker.last_input_sequence:
raise RuntimeError("Engage canceled by a newer input reset")
self.vehicle_state.request(name)
self.worker.set_axes(Axes(), sequence)
try:
self.worker.command_arm(name == "arm")
except Exception as exc:
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
raise
return self._snapshot()
if name == "manual":
self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode()
elif name == "arm":
self.worker.log("OPERATOR", "clicked ARM")
self.worker.command_arm(True)
elif name == "disarm":
self.worker.log("OPERATOR", "clicked DISARM")
self.worker.command_arm(False)
else:
raise ValueError("Unknown command")
raise ValueError(f"Unknown command: {name}")
return True
def set_servo_function(self, servo, motor):
@@ -219,6 +380,10 @@ class ControlApi:
if not 1 <= servo <= 16 or not 0 <= motor <= 12:
raise ValueError("Invalid servo or motor")
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
if self.demo_mode:
self.servo_functions[servo] = function
self._update_servo(servo)
return True
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
return True
@@ -230,6 +395,10 @@ class ControlApi:
current = self.motor_directions.get(motor)
if current is None:
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
if self.demo_mode:
self.motor_directions[motor] = -1 if current == 1 else 1
self._update_servo(servo)
return True
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
return True
@@ -248,8 +417,33 @@ class ControlApi:
raise ValueError("Full voltage per cell must be greater than empty voltage")
if not 0 <= clean["warning_percent"] <= 100:
raise ValueError("Warning percentage must be between 0 and 100")
self.battery_config = clean
self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.battery_config = clean
return clean
def _apply_link_config(self):
self.worker.rate_hz = self.link_config["control_hz"]
self.worker.set_link_rates(self.link_config["ping_hz"], {
"SYS_STATUS": self.link_config["system_hz"],
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
"RC_CHANNELS": self.link_config["rc_hz"],
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
})
def save_link_settings(self, config):
allowed = {0, 1, 2, 5, 10}
clean = {key: int(config.get(key, value)) for key, value in self.link_config.items()}
if clean["baud"] not in {9600, 19200, 38400, 57600, 115200}:
raise ValueError("Choose a supported baud rate")
if clean["control_hz"] not in {10, 20}:
raise ValueError("Control rate must be 10 or 20 Hz")
if clean["ping_hz"] not in {1, 2, 5, 10}:
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
if any(clean[key] not in allowed for key in ("system_hz", "servo_hz", "rc_hz", "esc_hz")):
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
self.link_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.link_config = clean
self._apply_link_config()
return clean
def open_logs(self):
@@ -258,14 +452,20 @@ class ControlApi:
os.startfile(path)
return True
def read_console(self, limit=150):
"""Return a bounded, read-only view of the local session console."""
return self.worker.read_console(limit)
def poll(self):
while True:
try:
event = self.events.get_nowait()
except queue.Empty:
break
self._consume(event)
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5:
# Bound a poll even if telemetry arrives continuously.
with self._poll_lock:
for _ in range(500):
try:
event = self.events.get_nowait()
except queue.Empty:
break
self._consume(event)
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
with self.lock:
self.state["link"] = (
"DATA OK / HEARTBEAT DELAYED"
@@ -273,24 +473,49 @@ class ControlApi:
)
return self._snapshot()
def _derive_app_state(self) -> str:
if not self.state.get("connected"):
return "disconnected"
link = self.state.get("link", "")
if link == "CONNECTING":
return "connecting"
if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"):
return "link_degraded"
if self.state.get("armed"):
return "armed"
return "connected_disarmed"
def _snapshot(self):
with self.lock:
return json.loads(json.dumps(self.state))
snap = json.loads(json.dumps(self.state))
snap["app_state"] = self._derive_app_state()
if not self.demo_mode:
snap.update(self.vehicle_state.snapshot())
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
snap["app_state"] = "link_degraded"
snap["battery_stale"] = (self.battery_received_at is None
or time.monotonic() - self.battery_received_at > 5)
return snap
def _consume(self, event):
kind = event[0]
with self.lock:
if kind == "heartbeat":
_, system, autopilot, mode, armed = event
_, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed),
mode=str(mode).upper(),
)
elif kind in ("status", "statustext"):
self.state["status"] = event[1]
elif kind == "ack":
if event[1] == 400:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery":
self.battery_received_at = time.monotonic()
voltage = float(event[1])
cells = self.battery_config["cells"]
per_cell = voltage / cells
@@ -300,6 +525,10 @@ class ControlApi:
"voltage": voltage, "per_cell": per_cell, "percent": percent,
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
}
elif kind == "link_quality":
self.state["link_quality"] = event[1]
elif kind == "heavy_quality":
self.state["heavy_quality"] = event[1]
elif kind == "servo_param":
_, param_id, value = event
servo = int(param_id[5:param_id.index("_")])
@@ -316,9 +545,14 @@ class ControlApi:
self.state["log_path"] = event[1]
elif kind == "error":
self.state["status"] = event[1]
self.state["last_error"] = event[1]
elif kind == "disconnected":
self.vehicle_state.disconnect()
last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, battery=None, status="Disconnected")
mode=None, link_quality=None, status=status_msg)
self.state["last_error"] = None
def _update_servo(self, servo):
function = self.servo_functions.get(servo)
@@ -334,23 +568,113 @@ class ControlApi:
self.worker.disconnect()
def _apply_dwm_styling(window):
try:
hwnd = None
if hasattr(window, "native") and window.native:
hwnd = int(window.native.Handle.ToInt64())
if hwnd:
import ctypes
user32 = ctypes.windll.user32
dwmapi = ctypes.windll.dwmapi
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
)
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
)
user32.SetForegroundWindow(ctypes.c_void_p(hwnd))
except Exception:
pass
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
target_w, target_h = 1100, 650
min_w, min_h = 960, 580
try:
import ctypes
user32 = ctypes.windll.user32
sw = user32.GetSystemMetrics(0)
sh = user32.GetSystemMetrics(1)
if sw > 0 and sh > 0:
target_w = min(target_w, max(min_w, int(sw * 0.92)))
target_h = min(target_h, max(min_h, int(sh * 0.90)))
min_w = min(min_w, target_w)
min_h = min(min_h, target_h)
except Exception:
pass
return target_w, target_h, (min_w, min_h)
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args):
pass # Suppress HTTP request logs in console
def end_headers(self):
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def do_GET(self):
unquoted = urllib.parse.unquote(self.path)
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
project_root = Path(__file__).resolve().parent
model_file = project_root / "assets" / "Main Assembly V1 (2).stl"
if not model_file.is_file():
model_file = project_root / "runtime" / "vehicle_model.stl"
if model_file.is_file():
data = model_file.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "model/stl")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
return
return super().do_GET()
def start_local_server(directory: Path) -> int:
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
server = socketserver.TCPServer(("127.0.0.1", 0), handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
return port
def main():
if os.name == "nt":
# Keep the running window separate from generic Python taskbar groups.
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("SUBControl.Desktop")
from desktop_shortcut import ensure_desktop_shortcut
ensure_desktop_shortcut()
api = ControlApi()
ui_path = Path(__file__).resolve().parent / "web" / "index.html"
api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent
port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
win_w, win_h, min_dims = _get_window_size()
window = webview.create_window(
"SUB Control", url=str(ui_path), js_api=api,
width=1120, height=760, min_size=(900, 650),
"SUB Control", url=app_url, js_api=api,
width=win_w, height=win_h, min_size=min_dims,
background_color="#111719", frameless=True, easy_drag=False,
shadow=True,
)
api._window = window
window.events.shown += lambda: _apply_dwm_styling(window)
window.events.maximized += lambda: setattr(api, "maximized", True)
window.events.restored += lambda: setattr(api, "maximized", False)
try:
webview.start(debug=False)
icon_path = project_root / "assets" / "sub-control.ico"
webview.start(debug=True, icon=str(icon_path) if icon_path.is_file() else None)
finally:
api.close()
if __name__ == "__main__":
main()