14 Commits

Author SHA1 Message Date
PitresAli 8ccd7689f7 Build Argo V2 control mappings and telemetry dashboard
Add configurable Xbox motion and action bindings, frame-aware six-axis MAVLink controls, sensor logging, camera viewing and recording, and a faster startup path. Synchronize both app copies and document companion hardware setup and verification limits.
2026-10-22 20:22:05 +02:00
PitresAli 5e91c28f1e Save pre-V2 motor controls and UI checkpoint 2026-10-22 19:31:28 +02:00
PitresAli a4d8f1b7af Refine battery status pill 2026-10-02 08:11:25 +02:00
PitresAli 31f720a316 Improve controller input, console, and UI consistency 2026-10-02 08:02:41 +02:00
PitresAli 0093f3f4aa Clean up UI windows and strengthen settings workflows 2026-09-30 15:22:09 +02:00
PitresAli 7c85397af0 Added App Icon 2026-08-28 17:40:43 +02:00
PitresAli 73c20a0ff2 UI improvements, and control softening 2026-08-27 20:11:37 +02:00
PitresAli 84ec93543b Shorten README to essential setup and controls 2026-08-27 19:39:12 +02:00
PitresAli 25bbefda71 V2: update README interface screenshot 2026-08-27 19:35:21 +02:00
PitresAli 10cd37eea7 V2 2026-08-27 18:24:22 +02:00
PitresAli 3d47f479e6 Redesign touch controls and engagement interaction 2026-08-26 19:29:39 +02:00
PitresAli 7a9ab0e553 Restore complete control layout and window interaction 2026-08-26 19:12:51 +02:00
PitresAli bd3e9b20d1 Make demo mode fully frontend-only 2026-08-26 11:48:16 +02:00
PitresAli b8e299e95f Add reliable built-in demo mode 2026-08-26 11:44:14 +02:00
75 changed files with 15611 additions and 3785 deletions
+6
View File
@@ -3,4 +3,10 @@ __pycache__/
*.py[cod] *.py[cod]
logs/ logs/
model_settings.json model_settings.json
link_settings.json
runtime/ runtime/
motor_settings.json
station_settings.json
controller_profiles.json
*.json.tmp
+26 -110
View File
@@ -1,124 +1,40 @@
# SUB Control # Argo V2 · SUB Control
A deliberately small, low-bandwidth MAVLink controller for bench-testing an A Windows control station for ArduSub ROVs over serial or network MAVLink.
ArduSub vehicle over a USB serial connection. It sends no parameter-list
request and no telemetry-stream request.
![SUB Control interface](docs/sub-control-interface.png) V2 adds programmable Xbox inputs, six-axis motion, a camera dashboard, sensor readings and data logging. See the [V2 guide](docs/V2.md) for controls, supported frames, Raspberry Pi/fibre architecture, camera setup and hardware verification limits.
The desktop window uses a local HTML/CSS/JavaScript interface rendered by the ![SUB Control V2 interface in Demo mode](docs/v2-dashboard.jpg)
Windows WebView. Python remains the only launcher and owns the MAVLink worker,
arming, logging, output limiting, and parameter writes. It does not run a web
server, open a browser tab, or require an internet connection.
The WebView is frameless and uses the compact title bar drawn by the interface. ## Quick start
The vehicle rail includes a local STL viewer: choose **Load STL** to start with
an orthographic top-view silhouette, then drag to orbit or use the mouse wheel
to zoom. A cached STL copy is stored locally under the ignored `runtime/`
directory so the model can be restored on the next launch.
STEP/STP CAD solids must first be tessellated/exported as STL because the
embedded renderer intentionally does not include a full CAD kernel.
Binary and ASCII STL faces are preserved in full; large assemblies are not
rendered by skipping triangles. Imports are limited to 30 MB to keep startup
and interactive orbiting responsive.
## Safety first Install Python 3.11+ with the Python launcher, then run:
- Remove propellers for initial tests and restrain the vehicle. ```powershell
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range, ./run.ps1
leak/failsafe, and emergency-stop arrangements before arming. ```
- The application starts disarmed and limits pilot demand to 20% by default.
Once armed, control movement is sent directly with no additional gate.
- Closing or disconnecting sends neutral controls and a disarm request, but a
physical power cutoff is still required.
The controls are pilot-demand axes, not direct PWM values. ArduSub performs The script creates a virtual environment and installs dependencies automatically.
the frame mixing and generates the configured ESC outputs. For ArduSub, On first launch, it also adds a **SUB Control** desktop shortcut.
`MANUAL_CONTROL.z = 500` is neutral vertical thrust. 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.
## Run on Windows ## UI design rule
1. Install Python 3.11 or newer. A virtual environment installs packages, but Keep UI changes minimal and consistent with the existing app. Do not add explanatory text. Use labels of at most two words, prefer flat rows over nested containers, and keep controls from overlapping.
it does not install Python itself.
2. Open PowerShell in this folder and run `./run.ps1` (or right-click it and
choose **Run with PowerShell**).
3. Select the autopilot's COM port, then click **Connect**. Baud and control-rate
settings are under the small `•••` link button (normally 57600 baud / 20 Hz).
4. Confirm `ArduSub` and a live heartbeat are shown.
5. Click **Set MANUAL**, then **ARM** only when the bench is safe.
6. Move the controls. Once armed, no additional enable button is needed.
`run.ps1` automatically creates a private `.venv` on the first run, installs ## Controls
the versions declared in `requirements.txt`, and starts SUB Control. Later runs
reuse the same environment and update missing dependencies when necessary.
The `.venv` directory is intentionally ignored by Git: virtual environments
contain machine-specific executables and can be recreated from
`requirements.txt`.
## Repository layout - 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: choose **Joystick**, then **Mapping** while disarmed. The trigger preset uses RT/LT for forward/back, B/A for rise/dive, and left stick X for turning. **Six axes** adds strafe, pitch and roll for compatible frames/firmware.
- Hold **engage** for 0.6 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.
The Gitea repository is named `argo`. `SUB Control` is a normal directory inside Includes a 0–100% input limit, motor setup, live battery current/consumption when available, an STL viewer, WebRTC/HTTP camera viewing and WebM recording. Sensor CSV and session logs are in `logs/`; desktop videos are in `runtime/recordings/`.
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, ## Safety
Q/E alternative yaw control, and R/F rise/dive.
Releasing movement keys eases their controls back to neutral.
## Three-thruster limitation 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.
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.
+6
View File
@@ -3,4 +3,10 @@ __pycache__/
*.py[cod] *.py[cod]
logs/ logs/
model_settings.json model_settings.json
link_settings.json
runtime/ runtime/
motor_settings.json
station_settings.json
controller_profiles.json
*.json.tmp
+26 -110
View File
@@ -1,124 +1,40 @@
# SUB Control # Argo V2 · SUB Control
A deliberately small, low-bandwidth MAVLink controller for bench-testing an A Windows control station for ArduSub ROVs over serial or network MAVLink.
ArduSub vehicle over a USB serial connection. It sends no parameter-list
request and no telemetry-stream request.
![SUB Control interface](docs/sub-control-interface.png) V2 adds programmable Xbox inputs, six-axis motion, a camera dashboard, sensor readings and data logging. See the [V2 guide](docs/V2.md) for controls, supported frames, Raspberry Pi/fibre architecture, camera setup and hardware verification limits.
The desktop window uses a local HTML/CSS/JavaScript interface rendered by the ![SUB Control V2 interface in Demo mode](docs/v2-dashboard.jpg)
Windows WebView. Python remains the only launcher and owns the MAVLink worker,
arming, logging, output limiting, and parameter writes. It does not run a web
server, open a browser tab, or require an internet connection.
The WebView is frameless and uses the compact title bar drawn by the interface. ## Quick start
The vehicle rail includes a local STL viewer: choose **Load STL** to start with
an orthographic top-view silhouette, then drag to orbit or use the mouse wheel
to zoom. A cached STL copy is stored locally under the ignored `runtime/`
directory so the model can be restored on the next launch.
STEP/STP CAD solids must first be tessellated/exported as STL because the
embedded renderer intentionally does not include a full CAD kernel.
Binary and ASCII STL faces are preserved in full; large assemblies are not
rendered by skipping triangles. Imports are limited to 30 MB to keep startup
and interactive orbiting responsive.
## Safety first Install Python 3.11+ with the Python launcher, then run:
- Remove propellers for initial tests and restrain the vehicle. ```powershell
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range, ./run.ps1
leak/failsafe, and emergency-stop arrangements before arming. ```
- The application starts disarmed and limits pilot demand to 20% by default.
Once armed, control movement is sent directly with no additional gate.
- Closing or disconnecting sends neutral controls and a disarm request, but a
physical power cutoff is still required.
The controls are pilot-demand axes, not direct PWM values. ArduSub performs The script creates a virtual environment and installs dependencies automatically.
the frame mixing and generates the configured ESC outputs. For ArduSub, On first launch, it also adds a **SUB Control** desktop shortcut.
`MANUAL_CONTROL.z = 500` is neutral vertical thrust. 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.
## Run on Windows ## UI design rule
1. Install Python 3.11 or newer. A virtual environment installs packages, but Keep UI changes minimal and consistent with the existing app. Do not add explanatory text. Use labels of at most two words, prefer flat rows over nested containers, and keep controls from overlapping.
it does not install Python itself.
2. Open PowerShell in this folder and run `./run.ps1` (or right-click it and
choose **Run with PowerShell**).
3. Select the autopilot's COM port, then click **Connect**. Baud and control-rate
settings are under the small `•••` link button (normally 57600 baud / 20 Hz).
4. Confirm `ArduSub` and a live heartbeat are shown.
5. Click **Set MANUAL**, then **ARM** only when the bench is safe.
6. Move the controls. Once armed, no additional enable button is needed.
`run.ps1` automatically creates a private `.venv` on the first run, installs ## Controls
the versions declared in `requirements.txt`, and starts SUB Control. Later runs
reuse the same environment and update missing dependencies when necessary.
The `.venv` directory is intentionally ignored by Git: virtual environments
contain machine-specific executables and can be recreated from
`requirements.txt`.
## Repository layout - 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: choose **Joystick**, then **Mapping** while disarmed. The trigger preset uses RT/LT for forward/back, B/A for rise/dive, and left stick X for turning. **Six axes** adds strafe, pitch and roll for compatible frames/firmware.
- Hold **engage** for 0.6 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.
The Gitea repository is named `argo`. `SUB Control` is a normal directory inside Includes a 0–100% input limit, motor setup, live battery current/consumption when available, an STL viewer, WebRTC/HTTP camera viewing and WebM recording. Sensor CSV and session logs are in `logs/`; desktop videos are in `runtime/recordings/`.
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, ## Safety
Q/E alternative yaw control, and R/F rise/dive.
Releasing movement keys eases their controls back to neutral.
## Three-thruster limitation 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.
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.
+748 -844
View File
File diff suppressed because it is too large Load Diff
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}")
+44
View File
@@ -0,0 +1,44 @@
# V2 verification — 22 October 2026
## Passing checks
- 41 Python tests: vehicle-state confirmation, control leases/watchdogs, six-axis packet values, unsupported firmware/frame gating, endpoint handling, telemetry conversion/staleness, persisted names/profiles, recording chunks, and relay acknowledgement.
- `node tests/controller_v2.cjs`: synthetic Xbox triggers, B/A direction, opposing input cancellation, six axes, custom buttons, limits, invalid/missing inputs, migration, and actions.
- `node tests/controller_input.cjs`: existing stick conditioning and console parser checks.
- Python compilation, JavaScript syntax checks, and `git diff --check`.
No tests opened a real COM port or armed a physical vehicle.
## Rendered browser checks
Used the supported local in-app browser with the real app assets in Demo Mode:
- Mapping dialog: three-axis and six-axis presets, saving, reopening after a full page reload, and retained LB/RB roll bindings.
- Vehicle settings: saved BlueROV1 six-thruster preview, independent accessory relay selectors, and consistent FC/demo status.
- Drive: all six command controls at 1024×768, 960×580 (the native window minimum), and 700×572; no overlap with the output toolbar.
- Active-output list: six rows for the six-thruster preview, with scrolling and reversal buttons. General frames disable the SimpleROV-3-specific independent-scale mode.
- Engagement: held F2 in Demo Mode, inspected rendered **engaging**, then **disengaging** and their settled states. Both words are spelled correctly, centered, and have no trailing dots.
- Dashboard: camera-off state, active simulated inspection view, missing/offline readings, explicit Demo labeling, sensor values and video controls at desktop and compact sizes.
- Recording: started/stopped a canvas preview recording; the browser produced a 1,386,065-byte WebM file.
- Accessories: demo lights toggle, camera power toggle, and viewing stops after demo power-off.
Reduced-motion CSS is included. The connected browser offers viewport testing but no media-preference emulation, so a rendered reduced-motion override was not verified.
## Existing harness limitation
The older `tests/control_ui.test.cjs` fake-DOM harness is not passing. It reports 23 failures on the saved pre-V2 checkpoint and 21 on V2. Most fail before app initialization because the harness lacks `ResizeObserver`; it also has obsolete markup/animation assertions and omits browser dependencies. It is not counted as a passing check. Actual browser checks above establish the rendered behavior of this implementation.
## Startup measurement
Three fresh-process worker-module imports on this laptop:
| Build | Samples | Median |
| --- | --- | --- |
| Pre-V2 checkpoint | 781.8 / 616.3 / 655.3 ms | 655.3 ms |
| V2 | 402.8 / 362.4 / 399.1 ms | 399.1 ms |
This measures the module import, not total app startup. The launcher also avoids pip on subsequent launches when requirements have not changed, and starts the web interface directly.
## Physical verification still required
Real Xbox hardware, live six-axis FC motor allocation, live sensor calibration, WHEP video from a Raspberry Pi, native WebView full-video recording, physical relay outputs, and tether/underwater behavior were not verified. The browser recording and native chunk-writing path were checked separately. BlueOS native camera discovery/signaling is not integrated; this release uses an explicit WHEP, HTTP video, or MJPEG URL.
+127
View File
@@ -0,0 +1,127 @@
# Argo V2
V2 separates controller mapping, vehicle motion, ArduSub motor allocation, and camera transport. The pre-V2 app is preserved in commit `5e91c28` on `main`; development is on `v2`. A portable Git bundle was also saved locally. The checkpoint is also uploaded as `checkpoint/pre-v2-20261022`.
## Try it
1. Run `run.ps1`, select **Demo Mode**, and connect.
2. Open **Vehicle**, select a **Preview frame**, and save. This changes the demo, never the connected FC's `FRAME_TYPE`.
3. Open **Mapping**, choose **Triggers · 3** or **Six axes**, and save.
4. Open **Dashboard** and press **Start** to see the simulated inspection camera. **Record** produces a WebM clip. The camera badge and sensor status identify demo data.
No physical port is opened in Demo Mode. Real TCP and UDP endpoints now connect to MAVLink instead of silently entering the demo.
## Xbox controls
| Motion | Triggers · 3 | Six axes |
| --- | --- | --- |
| Forward / back | RT / LT | RT / LT |
| Rise / dive | B / A | B / A |
| Turn | Left stick X | Left stick X |
| Sideways | Unassigned | Right stick X |
| Pitch | Unassigned | Left stick Y |
| Roll | Unassigned | RB / LB |
| Lights | X | X |
| View stream | Y | Y |
| Record | View | View |
Each motion has separate negative and positive bindings. Either direction can use a stick half, an analog trigger, a D-pad direction, or a button. Opposite commands cancel. Each motion has a limit; the profile also has dead zone and exponential response. The reverse arrow swaps its bindings. The **Buttons** disclosure configures optional accessory and media actions. Buttons do not arm the ROV.
Profiles are stored by controller ID in `controller_profiles.json`; **Xbox · offline** supplies a default before connecting a controller. Browser preview uses local storage. Existing stick mappings are migrated. Configuration is locked while armed or while the FC state is uncertain. Controls must return to neutral after input mode changes, reconnects, focus changes, or interruption. Accessory buttons also have to be released before they can trigger again.
Keyboard movement remains W/S, A/D (or Q/E), R/F. Additional axes use J/L for strafe, I/K for pitch, U/O for roll. The extra on-screen sliders return to center when released. F2 retains the 0.6-second engage hold; F3 requests disarm.
## Six thrusters and six axes
Your proposed arrangement can provide full control when the paired thrusters have useful moment arms:
| Pair | Shared thrust | Differential thrust |
| --- | --- | --- |
| Port / starboard, pointing forward | Surge | Yaw |
| Front / rear, pointing vertically | Heave | Pitch |
| Top / bottom, pointing sideways | Sway | Roll |
Translation uses the pair in the same direction; rotation uses opposite directions. Thruster directions, lever arms, balance and saturation still determine the result. The allocation matrix, whose columns are force direction and its moment `r × f`, must have rank six. Motor count alone does not establish this.
V2 sends body-motion demands to the FC. The autopilot owns its mixer and stabilization. Select/configure a firmware frame that matches the physical vehicle before using it. The app reads `FRAME_TYPE`; it does not infer a mixer from connected outputs or write a new frame automatically. Your orthogonal-pair design may require a custom firmware mixer if no stock frame matches its geometry.
| ArduSub frame | Motors | Available motion |
| --- | --- | --- |
| SimpleROV-3 (4) | 3 | Forward, vertical, yaw |
| SimpleROV-4 (5) | 4 | Forward, vertical, yaw, roll |
| SimpleROV-5 (6) | 5 | Forward, lateral, vertical, yaw, roll |
| BlueROV1 (0) | 6 | All six axes |
| Vectored (1) | 6 | All except pitch |
| Vectored 6DOF (2, 3) | 8 | All six axes |
The verified six-axis wire mapping is ArduSub's `MANUAL_CONTROL`: X forward, Y lateral, Z vertical (500 neutral), R yaw, S pitch, T roll. Some generic joystick documentation describes S/T differently; this implementation follows ArduSub source. Firmware version is requested at connection; pitch/roll remain unavailable until compatible firmware is identified. ArduSub 4.5 uses S/T as ±400 PWM offsets; the current 4.8 development implementation scales normalized S/T. These encodings have automated checks; physical behavior is unverified.
The shared output limit applies to all motion axes. Full-frame demands use a conservative shared envelope before the FC mixer. This is an input-demand limit, not a calibrated thrust or electrical-current limit. The earlier independent motor-scale reconstruction is specific to SimpleROV-3 and is unavailable on other frames; applying it to a general mixer would be incorrect. Motor names, assignments, and direction controls remain available for active outputs.
References: [ArduSub frame configurations](https://ardupilot.org/sub/docs/sub-frames.html), [ArduSub 4.5 joystick source](https://github.com/ArduPilot/ardupilot/blob/Sub-4.5/ArduSub/joystick.cpp), [current joystick source](https://github.com/ArduPilot/ardupilot/blob/master/ArduSub/joystick.cpp), [Cockpit mapping and vehicle-version support](https://blueos.cloud/cockpit/docs/latest/usage/advanced/).
## Companion computer and tether
```mermaid
flowchart LR
PC[Argo V2 on Windows] <-- Ethernet --> TOP[Topside media converter]
TOP <-- Fibre tether --> ROV[Onboard media converter]
ROV <-- Ethernet --> PI[Raspberry Pi / BlueOS]
PI <-- USB or UART / MAVLink --> FC[ArduSub FC]
CAM[USB or CSI camera] --> PI
SENSOR[Pressure and power sensors] --> FC
FC --> ESC[ESCs / thrusters]
```
This is a proposed architecture, not installed hardware. Use a compatible Ethernet-to-fibre interface at each end, with optics, cable, terminations and housings selected for the tether length and environment. Fibre carries the data link; onboard power or a separate power path is still required. Ethernet lets telemetry and compressed video share a network while remaining separate application streams.
BlueOS is the researched companion option. It routes the FC's MAVLink traffic and manages cameras on supported Raspberry Pi hardware. In BlueOS, configure a MAVLink UDP output to the topside computer's tether-interface IP on port 14550. In Argo, choose **Network · UDP 14550**, or enter an explicit endpoint in Link settings. The listener uses `udpin:0.0.0.0:14550`; choose an interface-specific address with a custom endpoint if needed. The PC and Pi must be reachable on the same tether network.
Video uses an independent browser media connection. This release accepts **WebRTC · WHEP**, a browser-playable **HTTP video** URL, or **MJPEG**. It does not directly decode RTSP or raw UDP H.264. BlueOS's camera service has its own signaling interface, so entering its normal web-dashboard address as a WHEP URL will not work. Use a compatible WHEP endpoint, such as MediaMTX connected to the Pi camera or an RTSP source. Integrating BlueOS's native camera discovery/signaling is a separate follow-up.
Example MediaMTX path for a supported Pi camera:
```yaml
paths:
cam:
source: rpiCamera
```
With the server configured on the tether network, a typical WHEP endpoint is `http://<pi-address>:8889/cam/whep`. Verify it against your MediaMTX configuration. The browser needs CORS permission for the local Argo origin, and the negotiated WebRTC media ports must be reachable. Use a browser-compatible codec. No firewall, authentication, or origin policy is changed automatically by Argo.
**Start/Stop** controls viewing. **Camera power** is a separate optional relay action. **Record** records the incoming browser video; desktop clips are written incrementally to `runtime/recordings/`, and browser-preview clips download as WebM. MJPEG playback has no video recording control. For recordings that must survive tether loss, configure recording on the Pi as well.
References: [BlueOS companion hardware](https://blueos.cloud/docs/latest/integrations/hardware/required/onboard-computer/), [tether overview](https://blueos.cloud/docs/latest/integrations/hardware/required/tether/), [BlueOS endpoints and video services](https://blueos.cloud/docs/latest/usage/advanced/), [MediaMTX Pi cameras](https://mediamtx.org/docs/static-sources/raspberry-pi-cameras), [browser playback](https://mediamtx.org/docs/read/web-browsers).
## Sensors and electricity
The dashboard displays FC telemetry only when its group is fresh. Missing or stale values show a dash. Demo values are explicitly marked.
| Reading | Source | Units |
| --- | --- | --- |
| Pitch, roll, heading and angular rates | ATTITUDE | degrees, degrees/s |
| Depth estimate relative to surface/home | GLOBAL_POSITION_INT.relative_alt | m, positive down |
| Acceleration, gyroscope, magnetic field | SCALED_IMU | m/s², rad/s, gauss |
| External pressure / temperature | SCALED_PRESSURE2 | hPa, °C |
| Battery voltage, current and charge | SYS_STATUS / battery 0 BATTERY_STATUS | V, A, mAh |
Depth needs a correctly configured and calibrated external pressure sensor. Raw accelerometer values include gravity; they are not speed or a gravity-compensated acceleration estimate. Heading is FC-estimated yaw, not a separately read compass bearing.
Current is measured in **amps (A)** at the present moment. Power is **watts (W) = V × A**. Consumption is charge integrated over time, displayed as **mAh**; it is not amps per second. Use a correctly sized, calibrated voltage/current power monitor connected to the FC. Voltage alone cannot measure current. The app prefers reported consumption and otherwise integrates fresh current as a session estimate; source information is in Battery settings. A voltage update does not make an old current reading fresh.
**Log data** writes received normalized sensor values to `logs/sensors_<timestamp>.csv`. Existing raw MAVLink session logs remain in `logs/`. Rates for attitude, depth, IMU and pressure are independently configurable. Begin with low rates on RS-485; use a higher-bandwidth tether for video and increased telemetry.
Lights and camera power require configured, separate FC relay outputs. Configure relay assignments on the FC, then select their zero-based relay numbers in **Vehicle**. Buttons wait for command acknowledgement and expose timeout/rejection status. An accepted command confirms FC acceptance, not measured physical illumination or camera power.
Reference: [ArduSub battery monitor calibration](https://ardupilot.org/sub/docs/common-power-module-configuration-in-mission-planner.html), [MAVLink message definitions](https://mavlink.io/en/messages/common.html).
## Performance and verification
- The launcher only installs dependencies when the requirements fingerprint changes.
- The production path launches `web_ui.py` directly. The old Tk frontend is loaded lazily from `legacy_ui.py`.
- The asset server is threaded. Video frames stay in the browser's media pipeline.
- Network MAVLink skips the redundant packet-leading heartbeat needed by the existing serial modules.
- V2 controller input has one command ramp in the worker, avoiding the previous additional browser smoothing delay.
- V2 controller-profile and station files are validated and replaced atomically. Existing input sequence, stale-bridge and heartbeat watchdogs cover all six motion axes.
Automated checks use synthetic gamepads, mock MAVLink transports, temporary configuration files and telemetry fixtures. Browser checks use Demo Mode and the actual rendered app. No live vehicle has been armed or commanded during development. Real controller hardware, Pi streaming, FC accessories, sensor calibration and water performance still need physical verification.
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 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()
+814
View File
@@ -0,0 +1,814 @@
"""Optional Tk frontend. Imported only when explicitly requested."""
import queue
import json
import os
import threading
import time
import tkinter as tk
import customtkinter as ctk
from pathlib import Path
from tkinter import messagebox, ttk
from serial.tools import list_ports
from app import Axes, MavlinkWorker, MOTOR_FUNCTIONS, NEUTRAL_Z, normalize_endpoint
class Joystick(tk.Canvas):
def __init__(self, master, callback, **kwargs):
self.size = 276
self.center_point = self.size / 2
self.radius = 108
self.knob_radius = 24
super().__init__(master, width=self.size, height=self.size, bg="#f5f5f2",
highlightthickness=0, bd=0, **kwargs)
self.callback = callback
self.value = (0.0, 0.0)
self.return_job = None
c, r, k = self.center_point, self.radius, self.knob_radius
self.create_oval(c-r, c-r, c+r, c+r, fill="#e9e9e5", outline="#d8d8d2", width=2)
self.create_oval(c-70, c-70, c+70, c+70, outline="#deded8", width=1)
self.create_line(c, c-r+12, c, c+r-12, fill="#d2d2cc", dash=(3, 6))
self.create_line(c-r+12, c, c+r-12, c, fill="#d2d2cc", dash=(3, 6))
self.create_text(c, 13, text="FORWARD", fill="#8a8a84", font=("Helvetica", 8, "bold"))
self.create_text(c, self.size-13, text="REVERSE", fill="#8a8a84", font=("Helvetica", 8, "bold"))
self.create_text(25, c, text="TURN\nLEFT", fill="#8a8a84", font=("Helvetica", 8, "bold"))
self.create_text(self.size-25, c, text="TURN\nRIGHT", fill="#8a8a84", font=("Helvetica", 8, "bold"))
self.knob = self.create_oval(c-k, c-k, c+k, c+k,
fill="#575c61", outline="#42474b", width=2)
self.bind("<Button-1>", self._move)
self.bind("<B1-Motion>", self._move)
self.bind("<ButtonRelease-1>", self._release)
def _move(self, event):
if self.return_job is not None:
self.after_cancel(self.return_job)
self.return_job = None
dx, dy = event.x - self.center_point, event.y - self.center_point
length = max(1.0, (dx * dx + dy * dy) ** 0.5)
if length > self.radius:
dx, dy = dx * self.radius / length, dy * self.radius / length
self.set_immediate(dx / self.radius, -dy / self.radius)
def set_immediate(self, x: float, y: float):
if self.return_job is not None:
self.after_cancel(self.return_job)
self.return_job = None
self.value = (x, y)
c, k = self.center_point, self.knob_radius
dx, dy = x * self.radius, -y * self.radius
self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy)
self.callback()
def _release(self, _event=None):
start_x, start_y = self.value
started = time.monotonic()
duration = 0.40
def ease_back():
elapsed = time.monotonic() - started
progress = min(1.0, elapsed / duration)
# Smoothstep has zero slope at both ends, avoiding a motor kick.
eased = progress * progress * (3.0 - 2.0 * progress)
remaining = 1.0 - eased
x, y = start_x * remaining, start_y * remaining
self.value = (x, y)
c, k = self.center_point, self.knob_radius
dx, dy = x * self.radius, -y * self.radius
self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy)
self.callback()
if progress < 1.0:
self.return_job = self.after(20, ease_back)
else:
self.return_job = None
if self.return_job is not None:
self.after_cancel(self.return_job)
ease_back()
def center(self):
if self.return_job is not None:
self.after_cancel(self.return_job)
self.return_job = None
self.value = (0.0, 0.0)
c, k = self.center_point, self.knob_radius
self.coords(self.knob, c-k, c-k, c+k, c+k)
self.callback()
class SpringScale(ctk.CTkSlider):
"""A Scale that smoothly returns to zero when the pointer is released."""
def __init__(self, master, **kwargs):
self.callback = kwargs.pop("command", None)
kwargs.pop("showvalue", None)
kwargs.pop("bg", None)
kwargs.pop("troughcolor", None)
kwargs.pop("activebackground", None)
kwargs.pop("highlightthickness", None)
kwargs.pop("bd", None)
kwargs.pop("sliderrelief", None)
if "orient" in kwargs:
kwargs["orientation"] = kwargs.pop("orient")
length = kwargs.pop("length", 200)
if kwargs.get("orientation", "horizontal") == "vertical":
kwargs.setdefault("height", length)
kwargs.setdefault("width", 18)
else:
kwargs.setdefault("width", length)
kwargs.setdefault("height", 18)
super().__init__(master, command=self._changed, corner_radius=10,
button_corner_radius=10, button_length=22,
fg_color="#dbe3ee", progress_color="#60a5fa",
button_color="#2563eb", button_hover_color="#1d4ed8",
**kwargs)
self.return_job = None
self.bind("<ButtonPress-1>", self._cancel_return, add="+")
self.bind("<ButtonRelease-1>", self._released, add="+")
def _changed(self, value):
if self.callback:
self.callback(value)
def _set_and_notify(self, value):
self.set(value)
if self.callback:
self.callback(value)
def _cancel_return(self, _event=None):
if self.return_job is not None:
self.after_cancel(self.return_job)
self.return_job = None
def _released(self, _event=None):
# Wait until Tk's native Scale binding has applied its final value.
self.after_idle(self.spring_to_center)
def set_immediate(self, value):
self._cancel_return()
self._set_and_notify(value)
def spring_to_center(self):
self._cancel_return()
start = float(self.get())
if abs(start) < 0.5:
self._set_and_notify(0)
return
started = time.monotonic()
duration = 0.40
def ease_back():
progress = min(1.0, (time.monotonic() - started) / duration)
eased = progress * progress * (3.0 - 2.0 * progress)
self._set_and_notify(start * (1.0 - eased))
if progress < 1.0:
self.return_job = self.after(20, ease_back)
else:
self._set_and_notify(0)
self.return_job = None
ease_back()
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("SUB Control")
self.geometry("1040x800")
self.minsize(920, 720)
self.events = queue.Queue()
self.worker = MavlinkWorker(self.events)
self.keys = set()
self.keyboard_yaw = 0
self.keyboard_vertical = 0
self.keyboard_planar = (0, 0)
self.servo_functions = {}
self.motor_directions = {}
self.battery_config_path = Path(__file__).resolve().parent / "battery_settings.json"
self.battery_config = {
"cells": 4,
"capacity_mah": 5500,
"c_rating": 35,
"full_v_per_cell": 4.20,
"empty_v_per_cell": 3.30,
"warning_percent": 20,
}
self.battery_voltage = None
self.battery_voltage_smoothed = None
self._load_battery_settings()
self._configure_styles()
self.protocol("WM_DELETE_WINDOW", self.close)
self._build()
self.refresh_ports()
self.bind_all("<KeyPress>", self._key_down)
self.bind_all("<KeyRelease>", self._key_up)
self.after(50, self._poll)
self.after(500, self._link_watchdog)
def _configure_styles(self):
self.configure(bg="#e8e9e7")
style = ttk.Style(self)
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure("TFrame", background="#fbfbf9")
style.configure("TLabel", background="#fbfbf9", foreground="#202224",
font=("Helvetica", 9))
style.configure("TLabelframe", background="#f4f6f8", bordercolor="#d8dee8",
borderwidth=1, relief="solid")
style.configure("TLabelframe.Label", background="#f4f6f8", foreground="#475569",
font=("Segoe UI", 9, "bold"))
style.configure("TButton", font=("Segoe UI", 9), padding=(10, 7),
background="#ffffff", foreground="#1e293b", bordercolor="#cbd5e1")
style.map("TButton", background=[("active", "#e8eef7")])
style.configure("Primary.TButton", background="#2563eb", foreground="#ffffff",
bordercolor="#2563eb", font=("Segoe UI", 9, "bold"))
style.map("Primary.TButton", background=[("active", "#1d4ed8")])
style.configure("Danger.TButton", background="#dc2626", foreground="#ffffff",
bordercolor="#dc2626", font=("Segoe UI", 9, "bold"))
style.map("Danger.TButton", background=[("active", "#b91c1c")])
style.configure("Title.TLabel", font=("Segoe UI", 18, "bold"), foreground="#0f172a")
style.configure("Subtitle.TLabel", font=("Segoe UI", 9), foreground="#64748b")
style.configure("Status.TLabel", font=("Segoe UI", 9, "bold"))
style.configure("Footer.TLabel", background="#e9eef5", foreground="#475569",
font=("Segoe UI", 9))
style.configure("Treeview", rowheight=28, background="#ffffff", fieldbackground="#ffffff",
foreground="#26282a", bordercolor="#e1e1dc", font=("Helvetica", 9))
style.configure("Treeview.Heading", font=("Helvetica", 9, "bold"), padding=(6, 6),
background="#f0f0ed", foreground="#4c4f52")
style.map("Treeview", background=[("selected", "#dbeafe")],
foreground=[("selected", "#1e3a8a")])
style.configure("Horizontal.TProgressbar", troughcolor="#e2e8f0", background="#22c55e",
bordercolor="#e2e8f0", lightcolor="#22c55e", darkcolor="#22c55e")
def _console_button(self, master, text, command, kind="neutral", width=112):
colors = {
"neutral": ("#f4f4f1", "#e9e9e4", "#343739", "#deded8"),
"primary": ("#dcebd5", "#cde2c4", "#27401f", "#c4dcb9"),
"danger": ("#f7dfdc", "#f1cfcb", "#7b3029", "#edcec9"),
}
color, hover, text_color, border = colors[kind]
return ctk.CTkButton(
master, text=text, command=command, width=width, height=34,
corner_radius=11, fg_color=color, hover_color=hover,
text_color=text_color, border_color=border, border_width=1,
font=ctk.CTkFont("Helvetica", 12, "bold" if kind != "neutral" else "normal"),
bg_color="transparent",
)
def _console_combo(self, master, values, width):
return ctk.CTkComboBox(
master, values=[str(value) for value in values], width=width, height=34,
corner_radius=10, border_width=1, border_color="#cbd5e1",
fg_color="#ffffff", button_color="#e8eef7",
button_hover_color="#dbeafe", dropdown_fg_color="#ffffff",
dropdown_hover_color="#dbeafe", text_color="#1e293b",
bg_color="transparent", state="readonly",
)
def _build(self):
shell = ctk.CTkFrame(self, fg_color="#f8f8f6", corner_radius=24,
border_width=1, border_color="#d5d6d3")
shell.pack(fill="both", expand=True, padx=18, pady=18)
shell.grid_rowconfigure(0, weight=1)
shell.grid_columnconfigure(1, weight=1)
rail = ctk.CTkFrame(shell, width=248, fg_color="#282d30", corner_radius=22)
rail.grid(row=0, column=0, sticky="nsew")
rail.grid_propagate(False)
workspace = ctk.CTkFrame(shell, fg_color="#fbfbf9", corner_radius=22)
workspace.grid(row=0, column=1, sticky="nsew")
ctk.CTkLabel(rail, text="SUB CONTROL / 03", text_color="#f4f4f1",
font=ctk.CTkFont("Helvetica", 13, "bold")).pack(
anchor="w", padx=24, pady=(24, 0))
ctk.CTkLabel(rail, text="ArduSub vehicle", text_color="#8f9699",
font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(2, 14))
vehicle_art = tk.Canvas(rail, width=204, height=250, bg="#282d30",
highlightthickness=0, bd=0)
vehicle_art.pack(padx=20, pady=(5, 4))
vehicle_art.create_oval(47, 27, 157, 223, fill="#353b3e", outline="#555c60", width=2)
vehicle_art.create_oval(68, 49, 136, 201, fill="#303538", outline="#444a4e")
vehicle_art.create_oval(80, 102, 124, 146, fill="#5e666a", outline="#788084", width=2)
vehicle_art.create_oval(21, 57, 62, 100, fill="#202426", outline="#62696d", width=2)
vehicle_art.create_oval(142, 57, 183, 100, fill="#202426", outline="#62696d", width=2)
vehicle_art.create_line(42, 79, 72, 93, fill="#757d81", width=3)
vehicle_art.create_line(162, 79, 132, 93, fill="#757d81", width=3)
vehicle_art.create_text(102, 236, text="TOP VIEW • 3 THRUSTERS",
fill="#7e8689", font=("Helvetica", 8, "bold"))
ctk.CTkLabel(rail, text="Vehicle", text_color="#959c9f",
font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(5, 0))
self.vehicle_label = ctk.CTkLabel(rail, text="No heartbeat", text_color="#f4f4f1",
font=ctk.CTkFont("Helvetica", 14, "bold"))
self.vehicle_label.pack(anchor="w", padx=24)
rail_bottom = ctk.CTkFrame(rail, fg_color="transparent")
rail_bottom.pack(side="bottom", fill="x", padx=24, pady=22)
self.battery_summary = ctk.CTkLabel(
rail_bottom, text="Battery —", text_color="#f4f4f1",
font=ctk.CTkFont("Helvetica", 13, "bold"), anchor="w"
)
self.battery_summary.pack(fill="x")
self.battery_meta = ctk.CTkLabel(
rail_bottom, text="Waiting for voltage", text_color="#92999c",
font=ctk.CTkFont("Helvetica", 9), anchor="w"
)
self.battery_meta.pack(fill="x", pady=(0, 5))
self.battery_bar = ctk.CTkProgressBar(
rail_bottom, height=7, corner_radius=4, fg_color="#454b4e",
progress_color="#c4e46c"
)
self.battery_bar.set(0)
self.battery_bar.pack(fill="x", pady=(0, 18))
self.link_label = ctk.CTkLabel(
rail_bottom, text="● DISCONNECTED", text_color="#f08a82",
font=ctk.CTkFont("Helvetica", 10, "bold"), anchor="w"
)
self.link_label.pack(fill="x")
header = ctk.CTkFrame(workspace, fg_color="transparent")
header.pack(fill="x", padx=28, pady=(24, 14))
title = ctk.CTkFrame(header, fg_color="transparent")
title.pack(side="left")
ctk.CTkLabel(title, text="Drive", text_color="#17191a",
font=ctk.CTkFont("Helvetica", 24, "bold")).pack(anchor="w")
ctk.CTkLabel(title, text="Differential steering • MANUAL control",
text_color="#8a8c8e", font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w")
top = ctk.CTkFrame(header, fg_color="transparent")
top.pack(side="right")
self.port = self._console_combo(top, [], 190)
self.port.grid(row=0, column=0, padx=4)
self._console_button(top, "↻", self.refresh_ports, width=38).grid(row=0, column=1, padx=4)
self.baud = self._console_combo(top, (9600, 19200, 38400, 57600, 115200), 105)
self.baud.set("57600")
self.rate = self._console_combo(top, (10, 20), 72)
self.rate.set("20")
self.link_settings_button = self._console_button(
top, "•••", self._toggle_link_settings, width=38
)
self.link_settings_button.grid(row=0, column=2, padx=4)
self.connect_button = self._console_button(
top, "Connect", self.toggle_connect, kind="primary", width=110
)
self.connect_button.grid(row=0, column=3, padx=(4, 0))
separator = ctk.CTkFrame(workspace, height=1, fg_color="#e7e7e3")
separator.pack(fill="x", padx=28)
controls = ctk.CTkFrame(workspace, fg_color="transparent")
controls.pack(fill="both", expand=True, padx=28, pady=18)
controls.grid_rowconfigure(0, weight=1)
controls.grid_columnconfigure(0, weight=1)
controls.grid_columnconfigure(1, minsize=214)
drive_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18)
drive_card.grid(row=0, column=0, sticky="nsew", padx=(0, 12))
ctk.CTkLabel(drive_card, text="STEER + THROTTLE", text_color="#66696b",
font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 0))
self.joystick = Joystick(drive_card, self._axes_changed)
self.joystick.pack(expand=True, pady=(4, 0))
ctk.CTkLabel(drive_card, text="W / S drive • A / D steer",
text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(0, 16))
heave_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18)
heave_card.grid(row=0, column=1, sticky="nsew")
ctk.CTkLabel(heave_card, text="VERTICAL", text_color="#66696b",
font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 2))
ctk.CTkLabel(heave_card, text="RISE", text_color="#969896",
font=ctk.CTkFont("Helvetica", 8, "bold")).pack()
self.vertical = SpringScale(
heave_card, from_=100, to=-100, orient="vertical", length=230,
command=lambda _: self._axes_changed()
)
self.vertical.set(0)
self.vertical.pack(expand=True, pady=8)
ctk.CTkLabel(heave_card, text="DIVE", text_color="#969896",
font=ctk.CTkFont("Helvetica", 8, "bold")).pack()
ctk.CTkLabel(heave_card, text="R / F • springs to neutral",
text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(4, 16))
self.battery_cells = tk.StringVar(value=str(self.battery_config["cells"]))
self.battery_capacity = tk.StringVar(value=str(self.battery_config["capacity_mah"]))
self.battery_c_rating = tk.StringVar(value=str(self.battery_config["c_rating"]))
self.battery_full_cell = tk.StringVar(value=str(self.battery_config["full_v_per_cell"]))
self.battery_empty_cell = tk.StringVar(value=str(self.battery_config["empty_v_per_cell"]))
self.battery_warning = tk.StringVar(value=str(self.battery_config["warning_percent"]))
fields = (
("Cells (S)", self.battery_cells, 5),
("Capacity mAh", self.battery_capacity, 8),
("C rating", self.battery_c_rating, 5),
("Full V/cell", self.battery_full_cell, 6),
("Empty V/cell", self.battery_empty_cell, 6),
("Warn %", self.battery_warning, 5),
)
self.battery_settings_frame = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14)
for column, (label, variable, width) in enumerate(fields):
group = ctk.CTkFrame(self.battery_settings_frame, fg_color="transparent")
group.grid(row=0, column=column, padx=(0, 8), sticky="w")
ctk.CTkLabel(group, text=label, text_color="#66696b",
font=ctk.CTkFont("Helvetica", 9)).pack(anchor="w")
ctk.CTkEntry(group, textvariable=variable, width=max(62, width * 9), height=30,
corner_radius=8, border_color="#d0d0cb").pack(anchor="w")
self._console_button(self.battery_settings_frame, "Apply", self._apply_battery_settings,
kind="primary", width=82).grid(
row=0, column=6, padx=(8, 0), sticky="sew"
)
self.battery_settings_frame.pack_forget()
actions = ctk.CTkFrame(workspace, fg_color="transparent")
actions.pack(fill="x", padx=28, pady=(0, 14))
self._console_button(actions, "MANUAL", self.set_manual, width=94).pack(side="left", padx=3)
self._console_button(actions, "ARM", self.arm, kind="primary", width=82).pack(side="left", padx=3)
self._console_button(actions, "DISARM", self.disarm, kind="danger", width=92).pack(side="left", padx=3)
ctk.CTkLabel(actions, text="Output", text_color="#777a7c",
font=ctk.CTkFont("Helvetica", 10)).pack(side="left", padx=(18, 4))
self.gain = ctk.CTkSlider(
actions, from_=5, to=50, number_of_steps=45, width=170, height=18,
command=self._gain_changed, corner_radius=10, button_corner_radius=10,
button_length=22, fg_color="#dbe3ee", progress_color="#60a5fa",
button_color="#2563eb", button_hover_color="#1d4ed8",
bg_color="transparent",
)
self.gain.set(20)
self.gain.pack(side="left")
self.gain_text = ctk.CTkLabel(actions, text="20%", text_color="#55585a",
font=ctk.CTkFont("Helvetica", 10, "bold"))
self.gain_text.pack(side="left")
self._console_button(actions, "Motor setup", self._toggle_motor_setup, width=112).pack(
side="right", padx=3
)
self._console_button(actions, "Battery", self._toggle_battery_settings, width=88).pack(
side="right", padx=3
)
assignments = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14)
self.assignments_frame = assignments
self.servo_tree = ttk.Treeview(
assignments, columns=("output", "function", "reversed"),
show="headings", height=5, selectmode="browse",
)
self.servo_tree.heading("output", text="Output")
self.servo_tree.heading("function", text="Function")
self.servo_tree.heading("reversed", text="Motor reversed")
self.servo_tree.column("output", width=90, anchor="center", stretch=False)
self.servo_tree.column("function", width=220, anchor="w")
self.servo_tree.column("reversed", width=90, anchor="center", stretch=False)
for number in range(1, 17):
self.servo_tree.insert("", "end", iid=str(number),
values=(f"SERVO{number}", "Waiting…", "Waiting…"))
self.servo_tree.pack(side="left", fill="x", expand=True)
scrollbar = ttk.Scrollbar(assignments, orient="vertical", command=self.servo_tree.yview)
scrollbar.pack(side="left", fill="y")
self.servo_tree.configure(yscrollcommand=scrollbar.set)
edit = ctk.CTkFrame(assignments, fg_color="transparent")
edit.pack(side="right", fill="y")
ctk.CTkLabel(edit, text="Selected output function", text_color="#55585a").pack(anchor="w")
motor_choices = ["Unassigned (0)"] + [f"Motor {number}" for number in range(1, 13)]
self.motor_function = self._console_combo(edit, motor_choices, 165)
self.motor_function.set("Motor 1")
self.motor_function.pack(fill="x", pady=(2, 5))
self._console_button(edit, "Apply function", self.apply_servo_function, width=165).pack(fill="x", pady=2)
self._console_button(edit, "Reverse motor", self.toggle_servo_reversed, width=165).pack(fill="x", pady=2)
ctk.CTkLabel(edit, text="Changes require DISARMED", text_color="#a16207").pack(pady=(8, 0))
log_frame = ctk.CTkFrame(workspace, fg_color="transparent")
log_frame.pack(fill="x", padx=28, pady=(0, 12))
self.log_frame = log_frame
self.log_path_text = tk.StringVar(value="Session log starts when connected")
ctk.CTkLabel(log_frame, textvariable=self.log_path_text, anchor="w", text_color="#8b8d8f",
font=ctk.CTkFont("Helvetica", 9)).pack(side="left", fill="x", expand=True)
self._console_button(log_frame, "Open logs", self.open_logs_folder, width=96).pack(side="right")
self.status_text = tk.StringVar(value="Ready. Controls are neutral; vehicle is not connected.")
ctk.CTkLabel(workspace, textvariable=self.status_text, anchor="w", height=34,
corner_radius=12, fg_color="#eeeeea", text_color="#65686a",
font=ctk.CTkFont("Helvetica", 9)).pack(fill="x", padx=28, pady=(0, 18))
def refresh_ports(self):
ports = [p.device for p in list_ports.comports()]
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])
def _toggle_battery_settings(self):
if self.battery_settings_frame.winfo_ismapped():
self.battery_settings_frame.pack_forget()
else:
self.battery_settings_frame.pack(
fill="x", padx=28, pady=(0, 12), before=self.log_frame
)
def _toggle_link_settings(self):
if self.baud.winfo_ismapped():
self.baud.grid_remove()
self.rate.grid_remove()
else:
self.baud.grid(row=1, column=0, sticky="e", padx=4, pady=(6, 0))
self.rate.grid(row=1, column=1, sticky="w", padx=4, pady=(6, 0))
def _toggle_motor_setup(self):
if self.assignments_frame.winfo_ismapped():
self.assignments_frame.pack_forget()
else:
self.assignments_frame.pack(
fill="x", padx=28, pady=(0, 12), before=self.log_frame
)
def _load_battery_settings(self):
try:
saved = json.loads(self.battery_config_path.read_text(encoding="utf-8"))
for key in self.battery_config:
if key in saved:
self.battery_config[key] = saved[key]
except (OSError, ValueError, TypeError):
pass
def _apply_battery_settings(self):
try:
config = {
"cells": int(self.battery_cells.get()),
"capacity_mah": int(self.battery_capacity.get()),
"c_rating": float(self.battery_c_rating.get()),
"full_v_per_cell": float(self.battery_full_cell.get()),
"empty_v_per_cell": float(self.battery_empty_cell.get()),
"warning_percent": int(self.battery_warning.get()),
}
if not 1 <= config["cells"] <= 12:
raise ValueError("cell count must be between 1 and 12")
if config["capacity_mah"] <= 0 or config["c_rating"] <= 0:
raise ValueError("capacity and C rating must be positive")
if not config["full_v_per_cell"] > config["empty_v_per_cell"] > 0:
raise ValueError("full V/cell must be greater than empty V/cell")
if not 0 <= config["warning_percent"] <= 100:
raise ValueError("warning percentage must be between 0 and 100")
self.battery_config = config
self.battery_config_path.write_text(
json.dumps(config, indent=2) + "\n", encoding="utf-8"
)
self._update_battery_display()
self.status_text.set("Battery settings saved")
except (ValueError, OSError) as exc:
messagebox.showerror("Invalid battery settings", str(exc))
def _battery_percent(self, voltage):
cells = self.battery_config["cells"]
per_cell = voltage / cells
empty = self.battery_config["empty_v_per_cell"]
full = self.battery_config["full_v_per_cell"]
return max(0.0, min(100.0, (per_cell - empty) * 100.0 / (full - empty)))
def _update_battery_display(self):
voltage = self.battery_voltage_smoothed
if voltage is None:
self.battery_summary.configure(text="Battery —", text_color="#f4f4f1")
self.battery_meta.configure(text="Waiting for voltage")
self.battery_bar.set(0)
return
cells = self.battery_config["cells"]
percent = self._battery_percent(voltage)
per_cell = voltage / cells
warning = percent <= self.battery_config["warning_percent"]
self.battery_summary.configure(
text=f"Battery {percent:.0f}%",
text_color="#ffaaa2" if warning else "#f4f4f1",
)
self.battery_meta.configure(
text=f"{voltage:.2f} V • {per_cell:.2f} V/cell • {cells}S"
+ (" • LOW" if warning else "")
)
self.battery_bar.configure(progress_color="#f08a82" if warning else "#c4e46c")
self.battery_bar.set(percent / 100.0)
def toggle_connect(self):
if self.worker.thread and self.worker.thread.is_alive():
self.worker.disconnect()
self.connect_button.configure(state="disabled")
return
if not self.port.get():
messagebox.showerror("No port", "Select a USB serial COM port.")
return
self.worker.rate_hz = int(self.rate.get())
self.worker.connect(self.port.get(), int(self.baud.get()))
self.connect_button.configure(text="Disconnect")
self.status_text.set("Opening serial port…")
def _axes_changed(self):
turn, forward = self.joystick.value
self.worker.set_axes(Axes(forward, 0.0,
float(self.vertical.get()) / 100,
turn))
def _gain_changed(self, value):
value = int(float(value))
self.worker.gain = value / 100
self.gain_text.configure(text=f"{value}%")
def _key_down(self, event):
key = event.keysym.lower()
if key in "wasdqerf":
self.keys.add(key)
self._apply_keys()
def _key_up(self, event):
key = event.keysym.lower()
self.keys.discard(key)
self._apply_keys()
def _apply_keys(self):
turn = (1 if ("d" in self.keys or "e" in self.keys) else 0) - (
1 if ("a" in self.keys or "q" in self.keys) else 0
)
forward = (1 if "w" in self.keys else 0) - (1 if "s" in self.keys else 0)
vertical = (1 if "r" in self.keys else 0) - (1 if "f" in self.keys else 0)
planar = (turn, forward)
if planar != self.keyboard_planar:
if turn or forward:
self.joystick.set_immediate(float(turn), float(forward))
else:
self.joystick._release()
self.keyboard_planar = planar
if vertical != self.keyboard_vertical:
if vertical:
self.vertical.set_immediate(vertical * 100)
else:
self.vertical.spring_to_center()
self.keyboard_vertical = vertical
self._axes_changed()
def arm(self):
if not messagebox.askyesno("Arm vehicle", "Propellers removed or vehicle securely restrained?\n\nArm now?"):
return
try:
self.worker.log("OPERATOR", "clicked ARM")
self.worker.command_arm(True)
self.status_text.set("Arm command sent; waiting for ACK/heartbeat…")
except Exception as exc:
messagebox.showerror("Cannot arm", str(exc))
def disarm(self):
try:
self.worker.log("OPERATOR", "clicked DISARM")
self.worker.command_arm(False)
self.status_text.set("Disarm command sent; neutral sent")
except Exception as exc:
messagebox.showerror("Cannot disarm", str(exc))
def set_manual(self):
try:
self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode()
self.status_text.set("MANUAL mode request sent")
except Exception as exc:
messagebox.showerror("Cannot set mode", str(exc))
def open_logs_folder(self):
log_dir = Path(__file__).resolve().parent / "logs"
log_dir.mkdir(exist_ok=True)
os.startfile(log_dir)
def _selected_servo(self):
selection = self.servo_tree.selection()
if not selection:
raise RuntimeError("Select a SERVO output in the table first")
return int(selection[0])
def apply_servo_function(self):
try:
servo = self._selected_servo()
choice = self.motor_function.get()
if choice.startswith("Motor "):
motor = int(choice.split()[1])
function = 32 + motor if motor <= 8 else 73 + motor
else:
function = 0
description = MOTOR_FUNCTIONS.get(function, "Unassigned")
if not messagebox.askyesno(
"Change output function",
f"Set SERVO{servo}_FUNCTION to {description} ({function})?\n\n"
"This changes the autopilot configuration.",
):
return
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
except Exception as exc:
messagebox.showerror("Cannot change function", str(exc))
def toggle_servo_reversed(self):
try:
servo = self._selected_servo()
function = self.servo_functions.get(servo)
motor = motor_number_for_function(function)
if motor is None:
raise RuntimeError(
f"SERVO{servo} is not assigned to an ArduSub Motor function"
)
current = self.motor_directions.get(motor)
if current is None:
raise RuntimeError(f"MOT_{motor}_DIRECTION has not been read yet")
new_value = -1 if current == 1 else 1
if not messagebox.askyesno(
"Reverse motor output",
f"SERVO{servo} is Motor {motor}.\n"
f"Set MOT_{motor}_DIRECTION to {new_value}?\n\n"
"Test direction with propellers removed after changing this.",
):
return
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", new_value)
except Exception as exc:
messagebox.showerror("Cannot reverse output", str(exc))
def _refresh_servo_row(self, number):
function = self.servo_functions.get(number)
motor = motor_number_for_function(function)
direction = self.motor_directions.get(motor) if motor is not None else None
if function is None:
function_text = "Waiting…"
else:
function_text = f"{MOTOR_FUNCTIONS.get(function, 'Other / unassigned')} ({function})"
if motor is None:
reversed_text = "—"
elif direction is None:
reversed_text = "Waiting…"
else:
reversed_text = "Yes" if direction == -1 else "No"
self.servo_tree.item(str(number), values=(f"SERVO{number}", function_text, reversed_text))
def _poll(self):
try:
while True:
event = self.events.get_nowait()
kind = event[0]
if kind == "heartbeat":
_, system, autopilot, mode, armed = event[:5]
self.vehicle_label.configure(
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
)
self.link_label.configure(text="● LINK OK", text_color="#c4e46c")
elif kind in ("status", "statustext"):
self.status_text.set(event[1])
elif kind == "ack":
self.status_text.set(f"Command {event[1]} ACK: {event[2]}")
elif kind == "servo_param":
_, param_id, value = event
number = int(param_id[5:param_id.index("_")])
self.servo_functions[number] = value
self._refresh_servo_row(number)
elif kind == "motor_direction":
_, motor, value = event
self.motor_directions[motor] = value
for number in range(1, 17):
if motor_number_for_function(self.servo_functions.get(number)) == motor:
self._refresh_servo_row(number)
elif kind == "battery":
self.battery_voltage = event[1]
if self.battery_voltage_smoothed is None:
self.battery_voltage_smoothed = self.battery_voltage
else:
self.battery_voltage_smoothed = (
0.75 * self.battery_voltage_smoothed + 0.25 * self.battery_voltage
)
self._update_battery_display()
elif kind == "param_saved":
self.status_text.set(f"Confirmed: {event[1]}={event[2]}")
elif kind == "param_missing":
self.status_text.set(f"No response for {event[1]}; continuing scan…")
elif kind == "param_scan_complete":
count = sum(value in MOTOR_FUNCTIONS for value in self.servo_functions.values())
self.status_text.set(f"Servo scan complete: {count} motor output(s) assigned")
elif kind == "log_path":
self.log_path_text.set(f"Log: {event[1]}")
elif kind == "error":
self.status_text.set(event[1])
messagebox.showerror("MAVLink", event[1])
elif kind == "disconnected":
self.connect_button.configure(text="Connect", state="normal")
self.link_label.configure(text="● DISCONNECTED", text_color="#f08a82")
self.vehicle_label.configure(text="No heartbeat")
self.battery_voltage = None
self.battery_voltage_smoothed = None
self._update_battery_display()
self.servo_functions.clear()
self.motor_directions.clear()
for number in range(1, 17):
self.servo_tree.item(str(number), values=(f"SERVO{number}", "Waiting…", "Waiting…"))
except queue.Empty:
pass
self.after(50, self._poll)
def _link_watchdog(self):
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5.0:
if time.monotonic() - self.worker.last_rx < 2.0:
text = "● DATA OK / HEARTBEAT DELAYED"
else:
text = "● ALL LINK DATA LOST"
self.link_label.configure(text=text, text_color="#f08a82")
self.worker._send_control(force_neutral=True)
self.after(500, self._link_watchdog)
def close(self):
self.worker.disconnect()
self.after(150, self.destroy)
+132
View File
@@ -0,0 +1,132 @@
"""Validated V2 settings and telemetry, independent of the desktop renderer."""
from __future__ import annotations
import json
import math
import re
import time
from pathlib import Path
from urllib.parse import urlsplit
AXES = ("forward", "lateral", "vertical", "turn", "pitch", "roll")
FRAMES = {
0: ("BlueROV1", 6, AXES),
1: ("Vectored", 6, tuple(axis for axis in AXES if axis != "pitch")),
2: ("Vectored 6DOF", 8, AXES),
3: ("Vectored 90", 8, AXES),
4: ("SimpleROV 3", 3, ("forward", "vertical", "turn")),
5: ("SimpleROV 4", 4, ("forward", "vertical", "turn", "roll")),
6: ("SimpleROV 5", 5, ("forward", "lateral", "vertical", "turn", "roll")),
}
INPUT = re.compile(r"^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$")
def number(value, minimum, maximum):
result = float(value)
if not math.isfinite(result) or not minimum <= result <= maximum:
raise ValueError(f"Value must be between {minimum} and {maximum}")
return result
def clean_profile(config):
if not isinstance(config, dict) or config.get("version") != 2:
raise ValueError("Invalid controller profile")
if not isinstance(config.get("bindings", {}), dict) or not isinstance(config.get("actions", {}), dict):
raise ValueError("Invalid controller bindings")
bindings = {}
for axis in AXES:
item = config.get("bindings", {}).get(axis, {})
if not isinstance(item, dict):
raise ValueError("Invalid motion binding")
negative, positive = item.get("negative", "off"), item.get("positive", "off")
if not isinstance(negative, str) or not INPUT.fullmatch(negative):
raise ValueError("Invalid negative input")
if not isinstance(positive, str) or not INPUT.fullmatch(positive):
raise ValueError("Invalid positive input")
if negative == positive and positive != "off":
raise ValueError("Choose different inputs for each direction")
bindings[axis] = {"negative": negative, "positive": positive,
"scale": number(item.get("scale", 1), 0, 1)}
actions = {}
for action in ("lights", "stream", "record"):
source = config.get("actions", {}).get(action, "off")
if source != "off" and (not isinstance(source, str) or not re.fullmatch(r"button:(?:[0-9]|[12][0-9]|3[01])", source)):
raise ValueError("Actions require a button")
actions[action] = source
return {"version": 2, "preset": str(config.get("preset", "custom"))[:24],
"deadZone": number(config.get("deadZone", .08), 0, .3),
"expo": number(config.get("expo", .15), 0, 1),
"bindings": bindings, "actions": actions}
def clean_station(config):
if not isinstance(config, dict):
raise ValueError("Invalid station settings")
frame_value = number(config.get("frame", 4), 0, 6)
if not frame_value.is_integer():
raise ValueError("Invalid frame")
frame = int(frame_value)
if frame not in FRAMES:
raise ValueError("Choose an ArduSub frame")
mode = config.get("video_mode", "whep")
if mode not in ("whep", "video", "mjpeg"):
raise ValueError("Unsupported stream type")
url = str(config.get("video_url", "")).strip()
if url:
parsed = urlsplit(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("Use an HTTP stream URL without embedded credentials")
relays = {}
for name in ("lights_relay", "camera_relay"):
value = number(config.get(name, -1), -1, 15)
if not value.is_integer():
raise ValueError("Invalid relay")
relays[name] = int(value)
if relays["lights_relay"] >= 0 and relays["lights_relay"] == relays["camera_relay"]:
raise ValueError("Lights and camera need separate relays")
return {"frame": frame, "video_mode": mode, "video_url": url[:2048], **relays}
def save_json(path: Path, data):
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n", encoding="utf-8")
temporary.replace(path)
class Telemetry:
"""Timestamp every group; missing or stale measurements stay unavailable."""
def __init__(self, clock=time.monotonic):
self.clock = clock
self.groups = {}
def update(self, group, values, received_at=None):
clean = {key: value if isinstance(value, (int, float)) and math.isfinite(value) else None
for key, value in values.items()}
self.groups[group] = {"values": clean, "at": self.clock() if received_at is None else received_at}
def snapshot(self, connected=True):
now = self.clock()
return {name: {"values": item["values"], "age_s": max(0, now - item["at"]),
"stale": not connected or now - item["at"] > 3}
for name, item in self.groups.items()}
def decode_telemetry(msg):
kind = msg.get_type()
if kind == "ATTITUDE":
return "attitude", {"roll": math.degrees(msg.roll), "pitch": math.degrees(msg.pitch),
"heading": math.degrees(msg.yaw) % 360,
"roll_rate": math.degrees(msg.rollspeed),
"pitch_rate": math.degrees(msg.pitchspeed),
"yaw_rate": math.degrees(msg.yawspeed)}
if kind == "GLOBAL_POSITION_INT":
# ArduSub reports height relative to its surface/home origin, positive up.
return "depth", {"depth_m": -msg.relative_alt / 1000.0, "vertical_ms": msg.vz / 100.0}
if kind in ("SCALED_IMU", "SCALED_IMU2", "SCALED_IMU3"):
return "imu", {"ax": msg.xacc * .00980665, "ay": msg.yacc * .00980665,
"az": msg.zacc * .00980665, "gx": msg.xgyro / 1000.0,
"gy": msg.ygyro / 1000.0, "gz": msg.zgyro / 1000.0,
"mx": msg.xmag / 1000.0, "my": msg.ymag / 1000.0, "mz": msg.zmag / 1000.0}
if kind == "SCALED_PRESSURE2":
return "pressure", {"pressure_hpa": msg.press_abs, "temperature_c": msg.temperature / 100.0}
return None
+14 -5
View File
@@ -3,13 +3,22 @@ $ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location -LiteralPath $projectRoot Set-Location -LiteralPath $projectRoot
if (-not (Get-Command py -ErrorAction SilentlyContinue)) { & (Join-Path $projectRoot 'create_shortcut.ps1')
throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again."
}
if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) { if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) {
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."
}
& py -3 -m venv .venv & py -3 -m venv .venv
if ($LASTEXITCODE -ne 0) { throw 'Virtual environment setup failed.' }
} }
& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt $dependencyHash = (Get-FileHash -LiteralPath 'requirements.txt' -Algorithm SHA256).Hash
& .venv\Scripts\python.exe app.py $dependencyMarker = Join-Path $projectRoot '.venv\requirements.sha256'
$installedHash = if (Test-Path -LiteralPath $dependencyMarker) { (Get-Content -LiteralPath $dependencyMarker -Raw).Trim() } else { '' }
if ($installedHash -ne $dependencyHash) {
& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' }
Set-Content -LiteralPath $dependencyMarker -Value $dependencyHash
}
& .venv\Scripts\python.exe web_ui.py
+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.');
+29
View File
@@ -0,0 +1,29 @@
/* Synthetic Xbox input: no device, serial port, or browser is opened. */
const assert=require('node:assert/strict');
const input=require('../web/controller-input.js');
const pad=()=>({connected:true,mapping:'standard',axes:[0,0,0,0],buttons:Array.from({length:17},()=>({value:0,pressed:false}))});
const press=(pad,index,value=1)=>{pad.buttons[index]={value,pressed:value>.5};return pad};
const three=input.preset(),six=input.preset('sixaxis');
assert(input.validProfile(three));assert(input.validProfile(six));
assert.deepEqual(input.read(pad(),three),Object.fromEntries(input.names.map(name=>[name,0])));
assert.equal(input.read(press(pad(),1),three).vertical,1);
assert.equal(input.read(press(pad(),0),three).vertical,-1);
assert.equal(input.read(press(press(pad(),0),1),three).vertical,0);
assert.equal(input.read(press(pad(),7),three).forward,1);
assert.equal(input.read(press(pad(),6),three).forward,-1);
assert.equal(input.read(press(press(pad(),6),7),three).forward,0);
assert(input.read(press(pad(),7,.5),three).forward>0);
assert(input.read(press(pad(),7,.5),three).forward<.5);
let p=pad();p.axes[0]=1;p.axes[1]=-1;p.axes[2]=1;press(p,4);
assert.deepEqual(input.read(p,six),{forward:0,lateral:1,vertical:0,turn:1,pitch:-1,roll:-1});
let custom=structuredClone(six);custom.bindings.vertical={negative:'button:12',positive:'button:13',scale:.25};
assert.equal(input.read(press(pad(),13),custom).vertical,.25);
custom.bindings.turn.scale=0;assert.equal(input.read(p,custom).turn,0);
custom.bindings.pitch.negative='axis:90:-';assert.equal(input.read(p,custom),null);
assert.equal(input.read({...pad(),connected:false},three),null);
p=pad();p.axes[0]=NaN;assert.equal(input.read(p,three),null);
p=pad();p.buttons=[];assert.equal(input.read(p,three),null);
assert.equal(input.actions(press(pad(),2),three).lights,true);
assert.equal(input.migrate({turn:0,forward:1,vertical:3,invertForward:true,invertVertical:true,deadZone:.1}).bindings.forward.positive,'axis:1:-');
const duplicate=structuredClone(three);duplicate.bindings.forward.negative='button:7';assert.equal(input.validProfile(duplicate),false);
console.log('PASS: Xbox triggers, B/A, cancellation, six axes, custom buttons, limits, invalid devices, migration and actions.');
+165
View File
@@ -0,0 +1,165 @@
"""V2 protocol and persistence checks. Never opens a vehicle connection."""
import math
import queue
import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app import Axes, MavlinkWorker, normalize_endpoint
from rov_v2 import Telemetry, clean_station, clean_profile, decode_telemetry, save_json
from web_ui import ControlApi
class V2Tests(unittest.TestCase):
def worker(self, frame=0, version=(4, 5, 3)):
worker = MavlinkWorker(queue.Queue())
worker.connection = Mock()
worker.target_system = 1
worker.frame_type = frame
worker.firmware_version = version
worker.gain = .5
worker.last_control_at = time.monotonic() - 1
return worker
def test_six_axis_mapping_and_envelope(self):
worker = self.worker()
worker.set_axes(Axes(forward=1, lateral=1, vertical=1, yaw=1, pitch=1, roll=1))
worker._send_control()
args = worker.connection.mav.manual_control_send.call_args.args
self.assertEqual(args[6:8], (0, 3))
self.assertEqual(args[8:], (33, 33)) # S=pitch, T=roll; ArduSub 4.5 ±400.
self.assertEqual(args[1:5], (83, 83, 542, 83))
def test_modern_normalized_attitude(self):
worker = self.worker(version=(4, 8, 0))
worker.set_axes(Axes(pitch=.5, roll=-.5))
worker._send_control()
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args[8:], (250, -250))
def test_unsupported_frame_and_old_firmware(self):
worker = self.worker(frame=1)
worker.set_axes(Axes(pitch=1, roll=.2))
worker._send_control()
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args[8], 0)
worker.firmware_version = (4, 1, 0)
worker._send_control()
self.assertEqual(len(worker.connection.mav.manual_control_send.call_args.args), 6)
def test_watchdog_neutralizes_extended_axes(self):
worker = self.worker()
worker.require_input_lease = True
worker.armed = True
worker.last_heartbeat = time.monotonic()
worker.set_axes(Axes(pitch=1, roll=-1), 1)
worker.last_input_at -= 2
worker._send_control()
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args, (1, 0, 0, 500, 0, 0, 0, 3, 0, 0))
self.assertEqual(worker.axes, Axes())
def test_endpoints_preserve_configured_network_host(self):
for endpoint in ('udpin:0.0.0.0:14550', 'udpout:192.168.2.2:14550', 'tcp:192.168.2.2:5760'):
self.assertEqual(normalize_endpoint(endpoint), endpoint)
def test_sensor_units_and_staleness(self):
msg = SimpleNamespace(get_type=lambda: 'ATTITUDE', roll=math.pi/2, pitch=0, yaw=-math.pi/2, rollspeed=1, pitchspeed=0, yawspeed=0)
group, values = decode_telemetry(msg)
self.assertEqual(values['roll'], 90)
self.assertEqual(values['heading'], 270)
clock = [10]
store = Telemetry(lambda: clock[0])
store.update(group, values)
clock[0] = 14
self.assertTrue(store.snapshot()[group]['stale'])
msg = SimpleNamespace(get_type=lambda: 'GLOBAL_POSITION_INT', relative_alt=-1200, vz=25)
self.assertEqual(decode_telemetry(msg)[1], {'depth_m': 1.2, 'vertical_ms': .25})
def test_config_validation_and_atomic_roundtrip(self):
with self.assertRaises(ValueError):
clean_station({'video_url': 'javascript:alert(1)'})
with self.assertRaises(ValueError):
clean_station({'lights_relay': 1, 'camera_relay': 1})
with self.assertRaises(ValueError):
clean_profile({'version': 2, 'expo': float('nan')})
with tempfile.TemporaryDirectory() as folder:
target = Path(folder) / 'station.json'
save_json(target, clean_station({'frame': 2}))
import json
self.assertEqual(json.loads(target.read_text())['frame'], 2)
self.assertFalse(target.with_suffix('.json.tmp').exists())
def test_stale_current_is_not_refreshed_by_voltage(self):
api = ControlApi()
api.state['connected'] = True
with patch('web_ui.time.monotonic', return_value=10):
api._consume_battery({'current_a': 4, 'voltage': 16})
with patch('web_ui.time.monotonic', return_value=16):
api._consume_battery({'voltage': 15.9})
self.assertIsNone(api._snapshot()['battery']['current_a'])
with patch('web_ui.time.monotonic', return_value=17):
api._consume_battery({'current_a': None})
self.assertIsNone(api.state['battery']['current_a'])
def test_attitude_stream_is_never_disabled_when_requested(self):
worker = self.worker()
worker._configure_low_bandwidth_streams()
rates = [(name, interval) for name, _, interval in worker.stream_config]
self.assertIn(('ATTITUDE', 500000), rates)
self.assertNotIn(('ATTITUDE', -1), rates)
def test_network_control_omits_serial_priming_heartbeat(self):
worker = self.worker()
worker.network_link = True
worker._send_control()
worker.connection.mav.heartbeat_send.assert_not_called()
def test_profile_saves_atomically_and_cannot_change_while_armed(self):
api = ControlApi()
profile = {'version': 2, 'bindings': {}, 'actions': {}}
with tempfile.TemporaryDirectory() as folder:
api.profiles_path = Path(folder) / 'profiles.json'
api.save_controller_profile('$xbox', profile)
self.assertTrue(api.profiles_path.is_file())
before = api.profiles_path.read_bytes()
api.state.update(connected=True, armed=True)
with self.assertRaises(RuntimeError):
api.save_controller_profile('$xbox', profile)
self.assertEqual(before, api.profiles_path.read_bytes())
def test_native_recording_chunk_roundtrip_and_expired_token(self):
import base64
api = ControlApi()
with tempfile.TemporaryDirectory() as folder, patch('web_ui.__file__', str(Path(folder) / 'web_ui.py')):
token = api.recording_begin()
api.recording_chunk(token, base64.b64encode(b'video chunk').decode())
path = api.recording_end(token)
self.assertEqual(Path(path).read_bytes(), b'video chunk')
with self.assertRaises(ValueError):
api.recording_chunk(token, 'AA==')
def test_relay_action_waits_for_ack(self):
api = ControlApi()
api.station_settings['lights_relay'] = 0
worker = api.worker
worker.connection = Mock()
worker.target_system = worker.target_component = 1
worker.last_heartbeat = time.monotonic()
api.set_accessory('lights', True)
self.assertIsNone(api.state['accessories']['lights']['accepted'])
api._consume(('accessory_ack', 'lights', True, 2))
self.assertFalse(api.state['accessories']['lights']['accepted'])
self.assertEqual(api.state['accessories']['lights']['error'], 'Rejected')
def test_motor_names_survive_app_restart(self):
with tempfile.TemporaryDirectory() as folder, patch('web_ui.__file__', str(Path(folder) / 'web_ui.py')):
first = ControlApi()
settings = first.motor_settings
settings['motors']['1']['name'] = 'Port'
first.save_motor_settings(settings)
self.assertEqual(ControlApi().motor_settings['motors']['1']['name'], 'Port')
if __name__ == '__main__':
unittest.main()
+335
View File
@@ -0,0 +1,335 @@
"""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()
configured = {name: interval for name, _, interval in self.worker.stream_config}
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 1_000_000)
self.assertEqual(configured["ATTITUDE"], 500_000)
self.assertEqual(configured["GLOBAL_POSITION_INT"], 500_000)
self.assertNotIn(("ATTITUDE", 30, -1), self.worker.stream_config)
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}
+821 -86
View File
@@ -1,49 +1,304 @@
const STATUS_LABELS = {
disconnected: 'Disconnected — select a port and connect',
connecting: 'Connecting…',
connected_disarmed: 'Connected · Disarmed',
armed: 'Connected · Armed',
link_degraded: 'Signal lost — check tether',
};
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0; const control={forward:0,turn:0,vertical:0,lateral:0,pitch:0,roll:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,battery_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
const api=(name,...args)=>window.pywebview.api[name](...args); const api=(name,...args)=>window.pywebview.api[name](...args);
addEventListener('pointermove',e=>{const button=e.target.closest?.('button:not(.battery-card), .stick');if(!button)return;const rect=button.getBoundingClientRect();button.style.setProperty('--glow-x',`${e.clientX-rect.left}px`);button.style.setProperty('--glow-y',`${e.clientY-rect.top}px`)}); const activity=[];
let lastReportedStatus='';
function recordActivity(message){
if(!message||activity[0]===message)return;
activity.unshift(String(message));activity.length=Math.min(activity.length,3);
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
const row=$(selector);if(row){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,controllerDraft=null,controllerNeedCenter=true,controllerWasDriving=false;
let controllerProfiles={};try{controllerProfiles=JSON.parse(localStorage.getItem('sub-v2-profiles'))||{}}catch(_){}
const controllerMotion=new ControllerInput.Motion();
let controllerActionState={},controllerActionsReady=false;
const controllerId=pad=>pad.index+':'+pad.id;
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
function loadControllerConfig(pad){
let saved=controllerProfiles[pad?.id||'$xbox']||controllerProfiles.$xbox;
if(!saved&&pad)try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
const config=ControllerInput.migrate(saved,pad);
return ControllerInput.validProfile(config)?config:ControllerInput.preset();
}
function selectController(pad){
resetInputs();controllerKey=pad?controllerId(pad):null;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'));
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller output':'Steering and throttle joystick; W A S D keys');
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output':'Vertical thrust control');
return true;
}
function inputConfigurationLocked(){return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known))}
function syncInputModeControls(){
const locked=inputConfigurationLocked();
$('.input-mode-switch').dataset.locked=String(locked);
for(const button of [$('#input-keyboard'),$('#input-joystick'),$('#controller-settings')])button.disabled=locked;
if($('#controller-modal').open)for(const field of $('#controller-form').querySelectorAll('input,select,button[type=submit],button[data-swap]'))field.disabled=locked;
}
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
$('#input-joystick').onclick=()=>setInputMode('joystick');
const axisLabels={forward:'Forward',lateral:'Strafe',vertical:'Vertical',turn:'Turn',pitch:'Pitch',roll:'Roll'};
function fillControllerForm(keepDraft=false){
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
device.replaceChildren(new Option('Xbox · offline',''),...pads.map(pad=>new Option(pad.id,controllerId(pad))));
device.value=controllerKey||'';
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
if(!keepDraft)controllerDraft=structuredClone(controllerConfig||loadControllerConfig(pad));
const config=controllerDraft;
$('#controller-preset').value=['triggers3','sixaxis','classic'].includes(config.preset)?config.preset:'custom';
const rows=$('#controller-bindings');rows.replaceChildren();
for(const name of ControllerInput.names){
const row=document.createElement('div');row.className='binding-row';row.dataset.axis=name;
const label=document.createElement('span');label.textContent=axisLabels[name];
const meter=document.createElement('meter');meter.min=-1;meter.max=1;meter.value=0;meter.setAttribute('aria-label',axisLabels[name]+' demand');label.append(meter);row.append(label);
for(const direction of ['negative','positive']){
const select=document.createElement('select');select.name=name+'_'+direction;select.setAttribute('aria-label',axisLabels[name]+' '+direction);
for(const [value,text] of ControllerInput.sources(pad))select.add(new Option(text,value));
select.value=config.bindings[name][direction];
select.onchange=()=>{config.bindings[name][direction]=select.value;config.preset='custom';$('#controller-preset').value='custom'};
row.append(select);
}
const swap=document.createElement('button');swap.type='button';swap.dataset.swap=name;swap.className='binding-swap';swap.textContent='⇄';swap.title='Reverse '+axisLabels[name];swap.setAttribute('aria-label','Reverse '+axisLabels[name]);
swap.onclick=()=>{const item=config.bindings[name];[item.negative,item.positive]=[item.positive,item.negative];config.preset='custom';fillControllerForm(true)};row.append(swap);
const scale=document.createElement('input');scale.type='number';scale.min=0;scale.max=100;scale.step=1;scale.value=Math.round(config.bindings[name].scale*100);scale.setAttribute('aria-label',axisLabels[name]+' limit');
scale.oninput=()=>{config.bindings[name].scale=Number(scale.value)/100;config.preset='custom';$('#controller-preset').value='custom'};row.append(scale);rows.append(row);
}
for(const action of ['lights','stream','record']){
const select=form.elements[action];select.replaceChildren(...ControllerInput.sources(pad).filter(([value])=>!value.startsWith('axis:')).map(([value,text])=>new Option(text,value)));
select.value=config.actions[action];select.onchange=()=>config.actions[action]=select.value;
}
form.elements.deadZone.value=Math.round(config.deadZone*100);form.elements.expo.value=Math.round(config.expo*100);
form.elements.deadZone.oninput=()=>config.deadZone=Number(form.elements.deadZone.value)/100;
form.elements.expo.oninput=()=>config.expo=Number(form.elements.expo.value)/100;
syncInputModeControls();
}
$('#controller-device').onchange=e=>{
if(inputConfigurationLocked())return;
selectController(getControllers().find(pad=>controllerId(pad)===e.target.value));fillControllerForm();
};
$('#controller-preset').onchange=e=>{if(e.target.value==='custom')return;controllerDraft=ControllerInput.preset(e.target.value);fillControllerForm(true)};
$('#controller-form').onsubmit=async e=>{
e.preventDefault();const form=e.currentTarget,feedback=form.querySelector('.form-feedback');
if(inputConfigurationLocked()){setFeedback(feedback,'Disengage first');return}
if(!ControllerInput.validProfile(controllerDraft)){setFeedback(feedback,'Check inputs');return}
const pad=getControllers().find(pad=>controllerId(pad)===controllerKey),device=pad?.id||'$xbox';
try{
const clean=bridgeReady?await api('save_controller_profile',device,controllerDraft):structuredClone(controllerDraft);
controllerProfiles[device]=clean;localStorage.setItem('sub-v2-profiles',JSON.stringify(controllerProfiles));controllerConfig=clean;
resetInputs();closeModal($('#controller-modal'));toast('Mapping saved');
}catch(error){setFeedback(feedback,error.message||String(error))}
};
function pollController(now){
try{
if(inputMode==='joystick'||$('#controller-modal').open){
const pads=getControllers();
if(controllerKey===null&&pads.length&&!$('#controller-modal').open)selectController(pads[0]);
const pad=pads.find(pad=>controllerId(pad)===controllerKey),raw=pad&&ControllerInput.read(pad,controllerConfig||loadControllerConfig(pad));
const target=raw&&(window.V2?.filterDemand(raw)||raw);
let status=!pad?'No controller':!raw?'Check mapping':controllerNeedCenter&&canDrive()?'Center controls':'Controller ready';
if(inputMode==='joystick'){
if(!target||!canDrive()){
if(controllerWasDriving)resetInputs();controllerNeedCenter=true;controllerWasDriving=false;
}else{
if(controllerNeedCenter&&!Object.values(raw).some(value=>value!==0)){controllerNeedCenter=false;status='Controller ready'}
if(!controllerNeedCenter){
controllerWasDriving=true;
Object.assign(control,target);window.joystick3D?.setVisual(target.turn,-target.forward);
setHeave(target.vertical,false);updateReadouts();sendAxes();
}
}
const active=ControllerInput.actions(pad,controllerConfig);
if(!Object.values(active).some(Boolean))controllerActionsReady=true;
if(controllerActionsReady&&inputFocused&&!document.hidden&&!document.querySelector('dialog[open]'))for(const [action,pressed] of Object.entries(active))
if(pressed&&!controllerActionState[action])window.V2?.action(action);
controllerActionState=active;
}
if($('#controller-modal').open){
const demand=pad&&ControllerInput.read(pad,controllerDraft);
for(const meter of $('#controller-bindings').querySelectorAll('meter'))meter.value=demand?.[meter.closest('[data-axis]').dataset.axis]||0;
$('#controller-axes').textContent=pad?.mapping==='standard'?'Xbox layout':pad?'Raw inputs':'Xbox · offline';
}
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
$('#controller-status').dataset.quiet=String(!!pad&&!!raw);
}else{$('#controller-status').dataset.quiet='true';$('#controller-status').textContent='Keyboard + touch'}
}catch(error){if(controllerWasDriving)resetInputs();$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable'}
requestAnimationFrame(pollController);
}
document.body.dataset.inputMode=inputMode;
requestAnimationFrame(pollController);
function makeDemoState(){const frame=window.V2?.station().frame??4,count=({0:6,1:6,2:8,3:8,4:3,5:4,6:5})[frame],servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?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,current_a:3.2,consumed_mah:320,remaining_mah:5180,consumed_source:'Demo'},capabilities:{frame,firmware:[4,5,3]},accessories:{},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}))}); document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)} function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
window.showToast=toast; window.showToast=toast;
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old} const manualPorts=new Map();
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 populatePorts(ports){
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 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=[{device:'udpin:0.0.0.0:14550',label:'Network · UDP 14550',type:'network'}];
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,control.lateral,control.pitch,control.roll))
.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(){ window.V2?.updateDemand(control);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)} function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0; const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes()} function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();}
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
heave.addEventListener('pointerdown',e=>{heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()}); function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
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)} 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()} function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const extra=window.V2?.filterDemand({lateral:(pressedKeys.has('l')?1:0)-(pressedKeys.has('j')?1:0),pitch:(pressedKeys.has('k')?1:0)-(pressedKeys.has('i')?1:0),roll:(pressedKeys.has('o')?1:0)-(pressedKeys.has('u')?1:0)});Object.assign(control,extra);updateReadouts();sendAxes();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(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startArmHold();return}const shortcuts={F1:'#manual',F3:'#disarm'};if(shortcuts[e.key]){e.preventDefault();if(!e.repeat)$(shortcuts[e.key]).click();return}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopArmHold(true);return}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()}); addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(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('wasdqerfjlikuo'.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();});
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave()}); 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');
for(const axis of Object.keys(control))control[axis]=0;setHeave(0,false);window.V2?.updateDemand(control);controllerActionState={};controllerActionsReady=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){ function openModal(id){
const el=typeof id==='string'?document.getElementById(id):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'); el.classList.remove('closing');
if(!el.open)el.showModal(); el.showModal();
} }
function closeModal(dialog){ 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'); dialog.classList.add('closing');
let done=false; let done=false;
const finish=()=>{ const finish=()=>{
if(done)return; if(done)return;
done=true; done=true;
dialog.removeEventListener('animationend',finish); dialog.removeEventListener('animationend',onEnd);
dialog.classList.remove('closing'); dialog.classList.remove('closing');
dialog.close(); dialog.close();
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
}; };
dialog.addEventListener('animationend',finish,{once:true}); const onEnd=e=>{if(e.target===dialog)finish()};
setTimeout(finish,300); dialog.addEventListener('animationend',onEnd);
setTimeout(finish,180);
} }
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open)); $$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog'))); $$('[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=>{ $$('dialog').forEach(d=>{
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)}); d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
d.addEventListener('click',e=>{ d.addEventListener('click',e=>{
@@ -51,7 +306,7 @@ $$('dialog').forEach(d=>{
if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d); if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d);
}); });
}); });
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};$('.topbar').ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}}; $('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};const windowBar=$('.workspace-topbar');if(windowBar)windowBar.ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}};
const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage'); const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage');
async function handleStlFile(file){ async function handleStlFile(file){
@@ -119,68 +374,520 @@ if(rovStage){
if(file)handleStlFile(file); if(file)handleStlFile(file);
}); });
} }
$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)populatePorts(await api('refresh_ports'))};$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}}; $('#refresh').onclick=async e=>{
$('#manual').onclick=async()=>{try{await api('command','manual');toast('Manual mode command sent')}catch(e){toast(e.message,true)}}; const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);
const armButton=$('#arm'); try{
let armHoldTimer=null, armStart=0, armCharged=false; if(typeof window.pywebview?.api?.refresh_ports!=='function')throw new Error('Desktop connection is not ready. Open the app using run.ps1.');
const ARM_HOLD_MS=1000; populatePorts(await withTimeout(api('refresh_ports'),4000));
if(!booted)boot();
}catch(error){recordActivity(`Port refresh failed: ${error.message}`);toast(error.message,true)}
};
$('#connect').onclick=async()=>{
const btn=$('#connect');
const label=btn.querySelector('.btn-label')||btn;
const p=$('#port');
const port=p?p.value:'';
function updateArmProgress(){ if(port==='demo'||demoActive){
if(!armStart)return; resetInputs();clearTimeout(demoCommandTimer);requestSerial++;localRequest=null;commandError='';bridgeStale=false;
const elapsed=performance.now()-armStart; if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')}
const progress=Math.min(1,elapsed/ARM_HOLD_MS); else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')}
armButton.style.setProperty('--arm-progress',`${progress*100}%`);
if(progress>=1){
armCharged=true;
stopArmHold(false);
armButton.classList.add('charged');
setTimeout(()=>armButton.classList.remove('charged'),600);
api('command','arm').then(()=>toast('Vehicle armed')).catch(e=>toast(e.message,true));
return; return;
} }
armHoldTimer=requestAnimationFrame(updateArmProgress);
}
function startArmHold(e){ if(appState==='connecting'){
if(armStart||armCharged)return; label.textContent='Canceling…';
if(e&&e.button!==undefined&&e.button!==0)return; try{ await api('disconnect'); }catch(_){}
armStart=performance.now(); return;
armCharged=false;
armButton.classList.add('charging');
armButton.style.setProperty('--arm-progress','0%');
armHoldTimer=requestAnimationFrame(updateArmProgress);
}
function stopArmHold(wasCancelled=true){
if(!armStart)return;
const elapsed=performance.now()-armStart;
cancelAnimationFrame(armHoldTimer);
armHoldTimer=null;
armStart=0;
armButton.classList.remove('charging');
armButton.style.setProperty('--arm-progress','0%');
if(wasCancelled&&!armCharged&&elapsed<ARM_HOLD_MS&&elapsed>70){
toast('Hold for 1s to Arm vehicle');
} }
armCharged=false;
if(appState!=='disconnected'){
resetInputs();
label.textContent='Disconnecting…';
try{
await api('disconnect');
toast('Disconnected');
}catch(e){
toast(e.message,true);
}
}else{
if(!port){
toast('Select or enter a port to connect',true);
return;
}
btn.classList.add('is-connecting');
label.textContent='Connecting…';
const statusEl=$('#status-text');
recordActivity(`Connecting to ${port}…`);
toast(`Connecting to ${port}…`);
try{
const confirmedState=await api('connect',port,+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 engageLabelValue=$('#engage-label')?.textContent||'engage';
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null,engageAppendFrame=null;
const ENGAGE_HOLD_MS=600;
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();
// Update the state label without delaying the requested control action.
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 0.6 seconds to engage (F2)':next.message;
const key=`${next.label}/${next.tone}`;
if(engageVisualState===key)return;
const previous=engageVisualState;engageVisualState=key;
if(engageAppendFrame)cancelAnimationFrame(engageAppendFrame);
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%');
const mainLabel=$('#engage-label'),previousLabel=engageLabelValue;
const appended=previousLabel==='engage'&&label==='engaging'||previousLabel==='disengage'&&label==='disengaging';
let suffix=null;
if(appended){
const previousWidth=mainLabel.getBoundingClientRect().width;
const stem=previousLabel.slice(0,-1);
mainLabel.replaceChildren(document.createTextNode(stem));
suffix=document.createElement('span');suffix.className='engage-suffix';suffix.textContent=label.slice(stem.length);mainLabel.append(suffix);
const appendedWidth=mainLabel.getBoundingClientRect().width;
mainLabel.style.setProperty('--engage-append-shift',`${(appendedWidth-previousWidth)/2}px`);
mainLabel.classList.add('is-appending');
}else{
mainLabel.classList.remove('is-appending');
mainLabel.style.removeProperty('--engage-append-shift');
mainLabel.textContent=label;
}
engageLabelValue=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');
if(suffix)engageAppendFrame=requestAnimationFrame(()=>suffix.isConnected&&suffix.classList.add('is-visible'));
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),200);
}
}
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); if(engageButton){
armButton.addEventListener('pointerup',()=>stopArmHold(true)); engageButton.addEventListener('pointerdown',startEngageHold);
armButton.addEventListener('pointerleave',()=>stopArmHold(true)); engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;try{engageButton.releasePointerCapture(e.pointerId)}catch(_){}suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
armButton.addEventListener('pointercancel',()=>stopArmHold(true)); engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
armButton.addEventListener('contextmenu',e=>e.preventDefault()); engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null});
engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)});
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
engageButton.onclick=()=>{
if(suppressEngageClick){suppressEngageClick=false;return}
if(view.action==='disarm')requestControl('disarm');
};
}
$('#disarm').onclick=async()=>{try{await api('command','disarm');toast('Disarm command sent')}catch(e){toast(e.message,true)}};
$('#logs').onclick=()=>api('open_logs'); $('#logs').onclick=()=>api('open_logs');
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)} 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 syncMotorScalingUI(){
const independent=!!motorSettings.independent;
document.body.dataset.motorScaling=independent?'independent':'shared';
const toggle=$('#motor-scale-independent');if(toggle)toggle.checked=independent;
const gain=$('#gain'),down=$('#gain-down'),up=$('#gain-up');
if(gain){gain.disabled=independent;gain.title=independent?'Set motor scales in Motor setup':'Shared limit for every motor';}
if(down)down.disabled=independent;if(up)up.disabled=independent;
}
function pushMotorSettings(persist=true){
if(!bridgeReady)return Promise.resolve(motorSettings);
const payload=JSON.parse(JSON.stringify(motorSettings));
motorSettingsQueue=motorSettingsQueue.catch(()=>{}).then(()=>api('save_motor_settings',payload,persist)).then(saved=>{motorSettings=saved;syncMotorScalingUI();if(persist){servoSignature='';renderMotors(vehicleSnapshot.servos||{});toast('Motor settings saved')}return saved}).catch(error=>{setFeedback($('#motor-feedback'),error.message||'Could not save motor settings.');throw error});
return motorSettingsQueue;
}
function motorScale(motor){return motorSettings.independent?(motorSettings.motors[String(motor)]?.scale??100):+$('#gain').value}
function setGain(value){if(motorSettings.independent)return;const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;servoSignature='';renderMotors(vehicleSnapshot.servos||{});if(bridgeReady)api('set_gain',+gain.value)}
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5); $('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
$('#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 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)}} $('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';const linkText=s.link==='LINK OK'?'connected':(s.link?s.link.toLowerCase():'disconnected');$('#link').textContent=linkText;$('.link-state').classList.toggle('online',s.link==='LINK OK'||s.link==='connected');$('#vehicle').textContent=s.vehicle?s.vehicle.toLowerCase():'no heartbeat';$('#status').textContent=s.status?s.status.toLowerCase():'ready';const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)} function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
const motorEdits=new Map();
$('#motor-scale-independent').onchange=e=>{
if(e.target.checked&&!motorSettings.customized){for(let motor=1;motor<=12;motor++)motorSettings.motors[String(motor)].scale=+$('#gain').value;motorSettings.customized=true}
motorSettings={...motorSettings,independent:e.target.checked};syncMotorScalingUI();servoSignature='';renderMotors(vehicleSnapshot.servos||{});pushMotorSettings(true).catch(()=>{});
};
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 state=servos[String(servo)]||{};
if((edit.kind==='function'?state.function:state.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.`)}
}
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
const assignments=Object.entries(servos).filter(([,state])=>state.motor).sort((a,b)=>a[1].motor-b[1].motor);
const servoByMotor=new Map(assignments.map(([servo,state])=>[String(state.motor),{servo,...state}]));
$('#motor-status').textContent=!vehicleSnapshot.connected?'Offline':vehicleSnapshot.armed?'Armed':`${assignments.length} outputs`;
const shownMotors=[...new Set(assignments.map(([,state])=>state.motor))].sort((a,b)=>a-b);
const structure=JSON.stringify([assignments.map(([port,state])=>[port,state.function,state.motor,state.direction]),editable,[...motorEdits],motorSettings.independent,shownMotors,Object.values(motorSettings.motors).map(m=>m.name)]);
if(structure!==servoSignature){
servoSignature=structure;const list=$('#motor-list');list.replaceChildren();
for(const motor of shownMotors){
const row=document.createElement('article');row.className='motor-row';row.dataset.motor=String(motor);
row.innerHTML='<input class="motor-name" type="text" maxlength="24" autocomplete="off" aria-label="Motor name"><input class="motor-scale" type="range" min="0" max="100" step="1" aria-label="Motor output scale"><output class="motor-scale-value"></output><button type="button" class="button subtle motor-reverse">Reverse</button><span class="motor-channel"></span>';
const name=row.querySelector('.motor-name');name.onchange=()=>{motorSettings.motors[String(motor)].name=name.value.trim().slice(0,24)||`Motor ${motor}`;name.value=motorSettings.motors[String(motor)].name;pushMotorSettings(true).catch(()=>{})};
const scale=row.querySelector('.motor-scale');scale.oninput=()=>{motorSettings.customized=true;motorSettings.motors[String(motor)].scale=+scale.value;row.querySelector('output').textContent=`${scale.value}%`;pushMotorSettings(false).catch(()=>{})};scale.onchange=()=>pushMotorSettings(true).catch(()=>{});
row.querySelector('.motor-reverse').onclick=()=>{const state=servoByMotor.get(String(motor));if(state?.servo)requestMotorEdit(state.servo,'direction')};list.append(row);
}
const details=document.createElement('details');details.className='motor-mapping';const summary=document.createElement('summary');summary.textContent='Assignments';details.append(summary);
for(let i=1;i<=16;i++){
const state=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-map-row';row.innerHTML='<span class="motor-map-port"></span><select aria-label="Servo output motor assignment"></select><span class="motor-map-direction"></span>';
row.querySelector('.motor-map-port').textContent=`SERVO${i}`;const select=row.querySelector('select');select.add(new Option(state.function==null?'Reading…':'Unassigned','0'));for(let motor=1;motor<=12;motor++)select.add(new Option(motorSettings.motors[String(motor)]?.name||`Motor ${motor}`,String(motor)));select.value=String(state.motor||0);select.disabled=!editable||!!pending||state.function==null;select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
row.querySelector('.motor-map-direction').textContent=pending?'Saving…':state.direction==null?'—':state.direction===-1?'Reversed':'Normal';details.append(row);
}
list.append(details);
}
for(const motor of shownMotors){
const row=$(`#motor-list .motor-row[data-motor="${motor}"]`),state=servoByMotor.get(String(motor))||{},settings=motorSettings.motors[String(motor)]||{name:`Motor ${motor}`,scale:20};if(!row)continue;
const servo=state.servo||null,name=row.querySelector('.motor-name');if(document.activeElement!==name)name.value=settings.name;
const scale=row.querySelector('.motor-scale');scale.disabled=!motorSettings.independent;if(document.activeElement!==scale)scale.value=motorScale(motor);
row.querySelector('.motor-scale-value').textContent=`${motorScale(motor)}%`;row.querySelector('.motor-channel').textContent=servo?`SERVO${servo} · ${state.output_us||'—'} µs`:'Unassigned';
const reverse=row.querySelector('.motor-reverse');reverse.disabled=!editable||!servo||state.direction==null||motorEdits.has(servo);reverse.textContent=state.direction===-1?'Restore':'Reverse';reverse.setAttribute('aria-label',`${reverse.textContent} ${settings.name}`);
}
}
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();
window.V2?.render(s);
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;
const driveEnabled=view.drive;
document.body.dataset.controlsEnabled=String(driveEnabled);
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=false;
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=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'Voltage unavailable';
$('#battery-percent').textContent=Number.isFinite(b.percent)?`${Math.round(b.percent)}%`:'—';
$('#battery-live-voltage').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'—';
$('#battery-live-current').textContent=Number.isFinite(b.current_a)?`${b.current_a.toFixed(1)} A`:'Unavailable';
$('#battery-live-consumed').textContent=Number.isFinite(b.consumed_mah)?`${Math.round(b.consumed_mah).toLocaleString()} mAh`:'Unavailable';
$('#battery-live-consumed-source').textContent=b.consumed_source||'';
$('#battery-live-remaining').textContent=Number.isFinite(b.remaining_mah)?`${Math.round(b.remaining_mah).toLocaleString()} mAh`:Number.isFinite(b.remaining_percent)?`${Math.round(b.remaining_percent)}%`:'Unavailable';
$('#battery-live-note').textContent=s.battery_stale?'Battery telemetry is stale. Check the telemetry rate and battery monitor.':b.charge_source==='Voltage estimate'?'Charge percentage is estimated from voltage. Add a calibrated current sensor for consumption readings.':'Live values come from the flight controller; accuracy depends on its battery monitor setup.';
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
$('.battery-card').title=`${b.charge_source||'Battery status'} · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
$('#battery-fill').style.width=`${Number.isFinite(b.percent)?b.percent:0}%`;
$('#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-live-voltage').textContent='—';$('#battery-live-current').textContent='—';
$('#battery-live-consumed').textContent='—';$('#battery-live-consumed-source').textContent='';
$('#battery-live-remaining').textContent='—';$('#battery-live-note').textContent='Waiting for battery telemetry.';
$('.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; let modelLoadPromise=null;
async function loadDefaultModel(){ async function loadDefaultModel(){
if(modelLoadPromise)return modelLoadPromise; if(modelLoadPromise)return modelLoadPromise;
@@ -254,36 +961,64 @@ async function loadDefaultModel(){
return modelLoadPromise; return modelLoadPromise;
} }
let booted=false; let booted=false,booting=false,bootRetry=0,portRefreshTimer=0;
function hasDesktopApi(){return ['initial_state','refresh_ports','poll'].every(name=>typeof window.pywebview?.api?.[name]==='function')}
async function boot(){ async function boot(){
if(booted||!window.pywebview?.api)return; if(booted||booting||!hasDesktopApi())return;
booted=true; booting=true;clearTimeout(bootRetry);
bridgeReady=true;
loadDefaultModel();
try{ try{
const init=await api('initial_state'); const init=await withTimeout(api('initial_state'),4000);
batteryConfig=init.battery_config; batteryConfig=init.battery_config||{};
populatePorts(init.ports); linkConfig=init.link_config||{};
motorSettings=init.motor_settings||motorSettings;
controllerProfiles=init.controller_profiles||controllerProfiles;
controllerConfig=null;
window.V2?.setStation(init.station_settings||{});
syncMotorScalingUI();
fillBatteryForm(); fillBatteryForm();
render(init.state); fillLinkForm();
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state);
poll(); poll();
setInterval(async()=>{ // COM discovery can block on Windows drivers; finish the first paint first.
if(!connected){ setTimeout(()=>withTimeout(api('refresh_ports'),4000).then(populatePorts).catch(()=>{}),0);
try{populatePorts(await api('refresh_ports'))}catch(_){} // Let the controls paint before fetching and parsing the large STL mesh.
const startModelLoad=()=>{void loadDefaultModel()};
if('requestIdleCallback' in window)requestIdleCallback(startModelLoad,{timeout:2500});
else setTimeout(startModelLoad,1500);
if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
if(appState==='disconnected'||demoActive){
try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
catch(error){recordActivity(`Port scan failed: ${error.message}`)}
} }
},2000); },2000);
}catch(e){ }catch(e){
booted=false;bridgeReady=false;
recordActivity(`Desktop startup failed; retrying: ${e.message}`);
toast(`Python bridge: ${e.message}`,true); toast(`Python bridge: ${e.message}`,true);
bootRetry=setTimeout(boot,1000);
}finally{
booting=false;
} }
} }
function waitForBridge(){ function waitForBridge(){
if(window.pywebview?.api)boot(); if(booted)return;
else setTimeout(waitForBridge,50); if(hasDesktopApi())boot();
setTimeout(waitForBridge,250);
} }
window.V2.init({
api,bridge:()=>bridgeReady,demo:()=>demoActive,toast,open:openModal,close:closeModal,
locked:inputConfigurationLocked,reset:resetInputs,touch:()=>window.canTouchControl(),demand:()=>control,
axis:(name,value)=>{control[name]=value;updateReadouts();sendAxes()},
scaling:allowed=>{const toggle=$('#motor-scale-independent');toggle.disabled=!allowed||inputConfigurationLocked();toggle.title=allowed?'Independent scales':'SimpleROV 3 only';if(!allowed&&motorSettings.independent){motorSettings.independent=false;syncMotorScalingUI();servoSignature=''}},
demoAccessory:(name,enabled)=>{if(demoState){demoState.accessories??={};demoState.accessories[name]={requested:enabled,accepted:true,demo:true};render(demoState)}},
previewFrame:(frame,count)=>{if(demoState){demoState.capabilities={frame,firmware:[4,5,3]};for(let i=1;i<=16;i++)demoState.servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?1:null};servoSignature='';render(demoState)}}
});
addEventListener('pywebviewready',boot); addEventListener('pywebviewready',boot);
render(vehicleSnapshot);
if(document.readyState==='loading'){ if(document.readyState==='loading'){
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()}); addEventListener('DOMContentLoaded',waitForBridge);
}else{ }else{
loadDefaultModel();
waitForBridge(); waitForBridge();
} }
+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 0.6 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);
+125
View File
@@ -0,0 +1,125 @@
/* 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(config?.version===2)return readProfile(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;
}
}
const names=['forward','lateral','vertical','turn','pitch','roll'];
const pair=(negative='off',positive='off')=>({negative,positive,scale:1});
function preset(name='triggers3'){
const bindings=Object.fromEntries(names.map(axis=>[axis,pair()]));
bindings.forward=pair('button:6','button:7');
bindings.vertical=pair('button:0','button:1');
bindings.turn=pair('axis:0:-','axis:0:+');
if(name==='sixaxis'){
bindings.lateral=pair('axis:2:-','axis:2:+');
bindings.pitch=pair('axis:1:-','axis:1:+');
bindings.roll=pair('button:4','button:5');
}else if(name==='classic'){
bindings.forward=pair('axis:1:+','axis:1:-');
bindings.vertical=pair('axis:3:+','axis:3:-');
}
return {version:2,preset:name,deadZone:.08,expo:.15,bindings,
actions:{lights:'button:2',stream:'button:3',record:'button:8'}};
}
function migrate(config,pad){
if(config?.version===2)return structuredClone(config);
if(!config)return preset(pad&&pad.mapping!=='standard'?'classic':'triggers3');
const result=preset('classic');
for(const axis of ['forward','vertical','turn']){
const index=config[axis],invert=config['invert'+axis[0].toUpperCase()+axis.slice(1)];
result.bindings[axis]=Number.isInteger(index)&&index>=0?
pair(`axis:${index}:${invert?'+':'-'}`,`axis:${index}:${invert?'-':'+'}`):pair();
}
result.deadZone=config.deadZone??.08;
result.actions={lights:'off',stream:'off',record:'off'};
return result;
}
function validProfile(config){
const source=value=>typeof value==='string'&&/^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$/.test(value);
return config?.version===2&&Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&
Number.isFinite(config.expo)&&config.expo>=0&&config.expo<=1&&names.every(axis=>{
const item=config.bindings?.[axis];return item&&source(item.negative)&&source(item.positive)&&
(item.negative==='off'||item.negative!==item.positive)&&Number.isFinite(item.scale)&&item.scale>=0&&item.scale<=1;
})&&['lights','stream','record'].every(action=>source(config.actions?.[action])&&!config.actions[action].startsWith('axis:'));
}
function inputValue(pad,source,zone){
if(source==='off')return 0;
const parts=source.split(':'),index=Number(parts[1]);
if(parts[0]==='button'){
const button=pad.buttons?.[index];if(!button||!Number.isFinite(button.value))return null;
return index===6||index===7?Math.max(0,deadZone(button.value,zone)):(button.pressed?1:0);
}
const value=pad.axes?.[index];
return Number.isFinite(value)?Math.max(0,deadZone(value,zone)*(parts[2]==='-'?-1:1)):null;
}
function readProfile(pad,config){
if(!pad?.connected||!validProfile(config))return null;
const result={};
for(const name of names){
const item=config.bindings[name],negative=inputValue(pad,item.negative,config.deadZone),positive=inputValue(pad,item.positive,config.deadZone);
if(negative===null||positive===null)return null;
const value=clamp(positive-negative);
result[name]=((1-config.expo)*value+config.expo*value**3)*item.scale;
}
return result;
}
function actions(pad,config){
return Object.fromEntries(Object.entries(config?.actions||{}).map(([name,source])=>[name,!!pad&&inputValue(pad,source,0)>.5]));
}
function sources(pad){
const choices=[['off','Off']],axes=['Left X','Left Y','Right X','Right Y'];
for(let index=0;index<Math.max(4,pad?.axes?.length||0);index++)for(const sign of ['-','+'])
choices.push([`axis:${index}:${sign}`,`${axes[index]||'Axis '+index} ${sign==='-'?'−':'+'}`]);
const buttons=['A','B','X','Y','LB','RB','LT','RT','View','Menu','Left click','Right click','D-pad up','D-pad down','D-pad left','D-pad right','Guide'];
for(let index=0;index<Math.max(17,pad?.buttons?.length||0);index++)choices.push([`button:${index}`,buttons[index]||'Button '+index]);
return choices;
}
root.ControllerInput={deadZone,defaults,read,Motion,names,preset,migrate,validProfile,actions,sources};
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()});
})();
+229
View File
@@ -0,0 +1,229 @@
/* Video stays in the browser's media pipeline; no frames cross the control bridge. */
(()=>{
const $=id=>document.getElementById(id);
const frames={0:['BlueROV1',6,['forward','lateral','vertical','turn','pitch','roll']],1:['Vectored',6,['forward','lateral','vertical','turn','roll']],2:['Full 6DOF',8,['forward','lateral','vertical','turn','pitch','roll']],3:['6DOF 90°',8,['forward','lateral','vertical','turn','pitch','roll']],4:['SimpleROV 3',3,['forward','vertical','turn']],5:['SimpleROV 4',4,['forward','vertical','turn','roll']],6:['SimpleROV 5',5,['forward','lateral','vertical','turn','roll']]};
let station={frame:4,video_mode:'whep',video_url:'',lights_relay:-1,camera_relay:-1};
try{station={...station,...JSON.parse(localStorage.getItem('sub-v2-station'))}}catch(_){}
let host,snapshot={},workspace='drive',streaming=false,connecting=false,generation=0,peer=null,sessionUrl=null,abort=null;
let recorder=null,recordToken=null,recordQueue=Promise.resolve(),recordChunks=[],recordBytes=0,ownedStream=null,recordError=null,saving=false;
let demoFrame=0,lastPaint=0,streamStarted=0,auxBusy=false,streamTimer=0,capabilitySignature='',demoLastClock=-1;
const notify=(message,error=false)=>host?.toast(message,error);
const call=(name,...args)=>host.api(name,...args);
const demo=()=>!!host?.demo();
const frame=()=>frames[(demo()||!snapshot.connected)?station.frame:snapshot.capabilities?.frame]||frames[4];
function supported(){
let axes=frame()[2];
const firmware=snapshot.capabilities?.firmware;
if(snapshot.connected&&!demo()&&!(firmware&&(firmware[0]>4||firmware[0]===4&&(firmware[1]===5||firmware[1]>=8))))axes=axes.filter(axis=>axis!=='pitch'&&axis!=='roll');
return axes;
}
function filterDemand(demand){return Object.fromEntries(Object.entries(demand).map(([name,value])=>[name,supported().includes(name)?value:0]))}
function view(name){
if(name===workspace)return;
host.reset();workspace=name;document.body.dataset.view=name;
$('dashboard').hidden=name!=='dashboard';document.querySelector('.primary-control-zone').hidden=name!=='drive';
$('view-drive').setAttribute('aria-pressed',String(name==='drive'));$('view-dashboard').setAttribute('aria-pressed',String(name==='dashboard'));
}
function fillStation(){
const form=$('station-form');
for(const [name,value] of Object.entries(station))if(form.elements[name])form.elements[name].value=value;
for(const field of form.querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
}
function state(text){$('camera-state').textContent=text;$('camera-empty').hidden=false}
function showStream(){
clearTimeout(streamTimer);connecting=false;
$('camera-empty').hidden=true;$('camera-record').disabled=station.video_mode==='mjpeg'&&!demo();
$('camera-badge').textContent=demo()?'Demo':'Stream';streamStarted=performance.now();demoLastClock=-1;$('stream-clock').textContent='00:00';
}
async function startStream(){
if(streaming||connecting)return;
if(!demo()&&!station.video_url){host.open('station-modal');return}
const current=++generation;connecting=true;state('Connecting');$('camera-toggle').textContent='Stop';
clearTimeout(streamTimer);streamTimer=setTimeout(()=>{if(current===generation)void failStream('Stream timeout')},10000);
try{
if(demo()){
$('camera-demo').hidden=false;streaming=true;showStream();demoFrame=requestAnimationFrame(drawDemo);
}else if(station.video_mode==='mjpeg'){
const img=$('camera-image');img.onload=()=>{if(current!==generation)return;streaming=true;img.hidden=false;showStream()};
img.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};img.src=station.video_url;
}else if(station.video_mode==='video'){
const video=$('camera-video');video.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};video.onended=()=>{if(current===generation)void stopStream()};video.src=station.video_url;video.hidden=false;await video.play();
if(current!==generation)return;streaming=true;showStream();
}else{
abort=new AbortController();const timeout=setTimeout(()=>abort?.abort(),10000);
try{
peer=new RTCPeerConnection({iceServers:[]});const connection=peer;
connection.addTransceiver('video',{direction:'recvonly'});
connection.ontrack=event=>{if(current!==generation)return;$('camera-video').srcObject=event.streams[0]||new MediaStream([event.track]);$('camera-video').hidden=false;void $('camera-video').play().catch(()=>{});streaming=true;showStream()};
connection.onconnectionstatechange=()=>{if(current===generation&&connection.connectionState==='failed')void failStream('Camera disconnected')};
await connection.setLocalDescription(await connection.createOffer());
if(connection.iceGatheringState!=='complete')await new Promise(resolve=>{
const timer=setTimeout(resolve,2000);connection.addEventListener('icegatheringstatechange',()=>{if(connection.iceGatheringState==='complete'){clearTimeout(timer);resolve()}},{once:true});
});
if(current!==generation)return;
const response=await fetch(station.video_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:connection.localDescription.sdp,signal:abort.signal});
if(!response.ok)throw new Error('WHEP '+response.status);
const location=response.headers.get('Location');let resource=location?new URL(location,station.video_url).href:null;
if(resource&&new URL(resource).origin!==new URL(station.video_url).origin)resource=null;
if(current!==generation){if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});return}
sessionUrl=resource;
const sdp=await response.text();if(current!==generation)return;
await connection.setRemoteDescription({type:'answer',sdp});
}finally{clearTimeout(timeout)}
}
}catch(error){if(current===generation){await stopStream();state('Unavailable');notify(error.message||'Stream unavailable',true)}}
finally{if(current===generation&&streaming)connecting=false}
}
async function failStream(message){await stopStream();state(message);notify(message,true)}
async function stopStream(){
++generation;connecting=false;streaming=false;abort?.abort();abort=null;
clearTimeout(streamTimer);
if(recorder&&recorder.state!=='inactive')recorder.stop();
peer?.close();peer=null;
if(sessionUrl){void fetch(sessionUrl,{method:'DELETE'}).catch(()=>{});sessionUrl=null}
cancelAnimationFrame(demoFrame);demoFrame=0;
const video=$('camera-video');video.onerror=video.onended=null;video.pause();video.srcObject=null;video.removeAttribute('src');video.load();video.hidden=true;
const img=$('camera-image');img.onload=img.onerror=null;img.removeAttribute('src');img.hidden=true;
$('camera-demo').hidden=true;$('camera-record').disabled=true;$('camera-toggle').textContent='Start';$('stream-clock').textContent='';state('Camera off');
}
function drawDemo(now){
if(!streaming||!demo())return;
demoFrame=requestAnimationFrame(drawDemo);
if(workspace!=='dashboard'||document.hidden||now-lastPaint<40)return;lastPaint=now;
const seconds=Math.floor((now-streamStarted)/1000);
if(seconds!==demoLastClock){demoLastClock=seconds;$('stream-clock').textContent=String(Math.floor(seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0')}
const canvas=$('camera-demo'),rect=canvas.getBoundingClientRect();
const width=Math.max(320,Math.round(rect.width)),height=Math.max(180,Math.round(rect.height));
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
const c=canvas.getContext('2d'),gradient=c.createLinearGradient(0,0,0,height);gradient.addColorStop(0,'#28444a');gradient.addColorStop(1,'#101e25');c.fillStyle=gradient;c.fillRect(0,0,width,height);
const light=c.createRadialGradient(width*.52,height*.62,4,width*.52,height*.62,width*.6);light.addColorStop(0,'#90aaa324');light.addColorStop(1,'#00000000');c.fillStyle=light;c.fillRect(0,0,width,height);
c.strokeStyle='#9eb2a92a';c.lineWidth=1;
for(let i=-4;i<8;i++){c.beginPath();c.moveTo(width*.5+i*16,height*.45);c.lineTo(width*.5+i*110,height);c.stroke()}
for(let i=0;i<8;i++){const y=height*.45+(i/8)**2*height*.55;c.beginPath();c.moveTo(0,y);c.lineTo(width,y);c.stroke()}
const pipe=c.createLinearGradient(0,height*.58,0,height*.76);pipe.addColorStop(0,'#a1afa0');pipe.addColorStop(.4,'#6e8980');pipe.addColorStop(1,'#263e3d');c.fillStyle=pipe;c.fillRect(width*.16,height*.58,width*.72,height*.18);
c.strokeStyle='#b3c2b166';c.strokeRect(width*.16,height*.58,width*.72,height*.18);
c.fillStyle='#c3d7c977';for(let i=0;i<32;i++){const x=(i*89.7+Math.sin(now/5000+i)*9)%width,y=(i*37.9+now*.004)%height;c.fillRect(x,y,1,1)}
c.strokeStyle='#e1f0e477';c.beginPath();c.moveTo(width/2-10,height/2);c.lineTo(width/2+10,height/2);c.moveTo(width/2,height/2-10);c.lineTo(width/2,height/2+10);c.stroke();
}
async function toggleRecord(){
if(saving)return;
if(recorder&&recorder.state!=='inactive'){recorder.stop();return}
if(!streaming)return;
try{
const stream=demo()?$('camera-demo').captureStream(24):$('camera-video').srcObject||$('camera-video').captureStream?.();
if(!stream?.getVideoTracks().length)throw new Error('Recording unavailable');
ownedStream=demo()?stream:null;recordToken=host.bridge()?await call('recording_begin'):null;recordQueue=Promise.resolve();recordChunks=[];recordBytes=0;recordError=null;
const mime=['video/webm;codecs=vp8','video/webm'].find(type=>MediaRecorder.isTypeSupported(type));
recorder=new MediaRecorder(stream,mime?{mimeType:mime,videoBitsPerSecond:4000000}:undefined);
recorder.ondataavailable=event=>{
if(!event.data.size)return;
if(recordToken){const token=recordToken;recordQueue=recordQueue.then(async()=>{
if(recordError)return;
const data=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=reject;reader.readAsDataURL(event.data)});
await call('recording_chunk',token,data);
}).catch(error=>{recordError=error;if(recorder?.state==='recording')recorder.stop()})}
else{recordChunks.push(event.data);recordBytes+=event.data.size;if(recordBytes>256*1024*1024&&recorder.state==='recording')recorder.stop()}
};
recorder.onstop=async()=>{
saving=true;$('camera-record').textContent='Saving';$('recording-badge').hidden=true;
try{
await recordQueue;
if(recordToken){const path=await call('recording_end',recordToken);notify(recordError?'Recording interrupted':'Video saved',!!recordError);if(path)$('camera-record').title=path}
else{const blob=new Blob(recordChunks,{type:recorder.mimeType}),url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download='argo-'+Date.now()+'.webm';link.click();setTimeout(()=>URL.revokeObjectURL(url),60000);notify('Video saved')}
}catch(error){notify(error.message||'Save failed',true)}
finally{ownedStream?.getTracks().forEach(track=>track.stop());ownedStream=null;recordToken=null;recordChunks=[];saving=false;$('camera-record').textContent='Record';$('camera-record').setAttribute('aria-pressed','false');recorder=null}
};
recorder.start(1000);$('camera-record').textContent='Stop record';$('camera-record').setAttribute('aria-pressed','true');$('recording-badge').hidden=false;
}catch(error){if(recordToken)await call('recording_end',recordToken).catch(()=>{});recordToken=null;notify(error.message||'Recording unavailable',true)}
}
async function accessory(name){
if(auxBusy||!snapshot.connected)return;
const relay=station[name+'_relay'];if(relay<0){host.open('station-modal');return}
auxBusy=true;
try{
const previous=snapshot.accessories?.[name],enabled=!(previous?.accepted&&previous.requested);
if(demo())host.demoAccessory(name,enabled);else await call('set_accessory',name,enabled);
if(name==='camera'&&!enabled)await stopStream();
}catch(error){notify(error.message||'Accessory unavailable',true)}finally{auxBusy=false}
}
function updateDemand(demand){
if(!host)return;
const axes=supported();$('extra-controls').hidden=!axes.some(axis=>['lateral','pitch','roll'].includes(axis));
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
const axis=label.dataset.motion;label.hidden=!axes.includes(axis);label.querySelector('input').value=Math.round((demand[axis]||0)*100);
label.querySelector('input').disabled=!host.touch();label.querySelector('output').value=Math.round((demand[axis]||0)*100);
}
}
function render(s){
snapshot=s;
if(!host)return;
const signature=String(!!s.connected)+':'+supported().join(',');
if(capabilitySignature&&signature!==capabilitySignature)host.reset();
capabilitySignature=signature;
host.scaling((demo()||!s.connected)?station.frame===4:s.capabilities?.frame===4);
if($('station-modal').open)for(const field of $('station-form').querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
const telemetry=demo()&&s.connected?{
attitude:{values:{heading:124,pitch:4.2,roll:-2.1},stale:false},depth:{values:{depth_m:1.8},stale:false},
imu:{values:{ax:.12,ay:-.08,az:9.78,gx:.01,gy:.02,gz:0,mx:.21,my:.04,mz:.43},stale:false},pressure:{values:{pressure_hpa:1190,temperature_c:18.6},stale:false}
}:s.telemetry||{};
const value=(group,key)=>s.connected&&!telemetry[group]?.stale?telemetry[group]?.values?.[key]:null;
const format=(value,unit,digits=1)=>Number.isFinite(value)?value.toFixed(digits)+unit:'—';
const show=(id,number,unit,digits)=>{$(id).textContent=format(number,unit,digits);$(id).parentElement.dataset.stale=String(!Number.isFinite(number))};
show('live-depth',value('depth','depth_m'),' m',2);show('live-heading',value('attitude','heading'),'°',0);show('live-pitch',value('attitude','pitch'),'°',1);show('live-roll',value('attitude','roll'),'°',1);
const battery=s.battery||{},fresh=s.connected&&!s.battery_stale;
const current=fresh&&!battery.current_stale?battery.current_a:null;
show('live-current',current,' A',1);show('live-power',fresh&&Number.isFinite(current)&&Number.isFinite(battery.voltage)?current*battery.voltage:null,' W',1);
show('live-charge',fresh?battery.consumed_mah:null,' mAh',0);
for(const [id,keys,unit] of [['live-accel',['ax','ay','az'],'m/s²'],['live-gyro',['gx','gy','gz'],'rad/s'],['live-mag',['mx','my','mz'],'G']]){
const values=keys.map(key=>value('imu',key));$(id).textContent=values.every(Number.isFinite)?values.map(value=>value.toFixed(2)).join(' / ')+' '+unit:'—';
}
show('live-temperature',value('pressure','temperature_c'),' °C',1);show('live-pressure',value('pressure','pressure_hpa'),' hPa',1);
$('sensor-state').textContent=demo()&&s.connected?'Demo':!s.connected?'Offline':Object.values(telemetry).some(group=>!group.stale)?'Receiving':'No data';
$('detected-frame').textContent=s.connected&&!demo()?(frames[s.capabilities?.frame]?.[0]||'Detecting'):demo()?'Demo':'Offline';
for(const [name,id] of [['lights','lights-toggle'],['camera','camera-power']]){
const item=s.accessories?.[name],pending=item&&item.accepted===null&&!item.error;
$(id).disabled=!s.connected||station[name+'_relay']<0||!!pending;$(id).setAttribute('aria-pressed',String(!!(item?.accepted&&item.requested)));
$(id).title=item?.error||(pending?'Awaiting ACK':station[name+'_relay']<0?'Configure relay':'Relay '+station[name+'_relay']);
}
if(streaming)$('stream-clock').textContent=new Date(performance.now()-streamStarted).toISOString().slice(14,19);
$('sensor-record').setAttribute('aria-pressed',String(!!s.sensor_recording));$('sensor-record').textContent=s.sensor_recording?'Stop log':'Log data';
$('sensor-record').disabled=!s.connected||demo();
updateDemand(host.demand());
}
function action(name){if(name==='stream')void(streaming||connecting?stopStream():startStream());else if(name==='record')void toggleRecord();else if(name==='lights')void accessory('lights')}
function init(callbacks){
host=callbacks;
$('view-drive').onclick=()=>view('drive');$('view-dashboard').onclick=()=>view('dashboard');
$('camera-toggle').onclick=()=>action('stream');$('camera-record').onclick=()=>action('record');$('lights-toggle').onclick=()=>accessory('lights');$('camera-power').onclick=()=>accessory('camera');
$('sensor-record').onclick=async()=>{try{if(demo())throw new Error('Live telemetry required');if(!host.bridge())throw new Error('Connect desktop');const path=await call('sensor_record',!snapshot.sensor_recording);if(path)$('sensor-record').title=path}catch(error){notify(error.message,true)}};
for(const name of ['lights_relay','camera_relay']){
const select=$('station-form').elements[name];select.add(new Option('Off','-1'));for(let index=0;index<16;index++)select.add(new Option('Relay '+index,String(index)));
}
const stationObserver=new MutationObserver(()=>{if($('station-modal').open)fillStation()});stationObserver.observe($('station-modal'),{attributes:true,attributeFilter:['open']});
document.querySelector('[data-open=station-modal]').addEventListener('click',fillStation);
$('station-form').onsubmit=async event=>{
event.preventDefault();const form=event.currentTarget,feedback=form.querySelector('.form-feedback');
if(host.locked()){feedback.textContent='Disengage first';feedback.hidden=false;return}
const config=Object.fromEntries(new FormData(form));for(const name of ['frame','lights_relay','camera_relay'])config[name]=Number(config[name]);
try{
if(config.video_url){const url=new URL(config.video_url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw new Error('Use an HTTP stream URL')}
if(config.lights_relay>=0&&config.lights_relay===config.camera_relay)throw new Error('Use separate relays');
if(streaming||connecting)await stopStream();
station=host.bridge()?await call('save_station_settings',config):config;localStorage.setItem('sub-v2-station',JSON.stringify(station));
host.previewFrame(station.frame,frames[station.frame][1]);host.reset();host.close($('station-modal'));notify('Vehicle saved');render(snapshot);
}catch(error){feedback.textContent=error.message||String(error);feedback.hidden=false}
};
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
const input=label.querySelector('input'),axis=label.dataset.motion;
input.oninput=()=>{if(host.touch())host.axis(axis,Number(input.value)/100)};
const release=()=>{if(host.touch())host.axis(axis,0);input.value=0};
input.onpointerup=release;input.onpointercancel=release;input.onblur=release;input.onkeyup=release;
}
for(const [name,text,value] of [['attitude_hz','Attitude · Hz',2],['depth_hz','Depth · Hz',2],['imu_hz','IMU · Hz',1],['pressure_hz','Pressure · Hz',1]]){
const label=document.createElement('label'),span=document.createElement('span'),select=document.createElement('select');span.textContent=text;select.name=name;
for(const rate of [0,1,2,5,10])select.add(new Option(rate===0?'Off':String(rate),String(rate)));select.value=String(value);label.append(span,select);$('link-form').querySelectorAll('.form-grid')[1].append(label);
}
addEventListener('pagehide',()=>void stopStream());document.body.dataset.view='drive';render(snapshot);
}
window.V2={init,render,filterDemand,action,updateDemand,station:()=>station,setStation:config=>{station={...station,...config};render(snapshot)}};
})();
+224 -55
View File
@@ -3,18 +3,39 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title> <title>Argo V2</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css?v=20261022-v2">
<link rel="stylesheet" href="v2.css?v=20261022-v2">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">
<aside class="vehicle-rail">
<header class="brand pywebview-drag-region"> <!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
<span>Sub Control</span> <aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
</header> <div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
<div class="eyebrow">live vehicle</div> <!-- Connection Controls -->
<div class="rov-stage" aria-label="Top view of three-thruster ROV"> <div class="deck-connection">
<div class="water-ring r1"></div><div class="water-ring r2"></div> <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="Vehicle model">
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas> <canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<div id="model-status" class="model-status">Initializing…</div> <div id="model-status" class="model-status">Initializing…</div>
<div class="model-tools"> <div class="model-tools">
@@ -22,75 +43,223 @@
<input id="model-input" type="file" accept=".stl,.STL" hidden> <input id="model-input" type="file" accept=".stl,.STL" hidden>
</div> </div>
</div> </div>
<div class="vehicle-name"><small>vehicle</small><strong id="vehicle">no heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
<div 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>
</div> </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> </aside>
<section class="workspace"> <!-- ── PRIMARY CONTROL ZONE ── -->
<header class="topbar"> <section class="controls-workspace" aria-label="Primary Vehicle Controls">
<div class="connection"> <!-- Frameless Drag & Window Controls Header -->
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select> <div class="workspace-topbar">
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg></button> <div class="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">Mapping</button></div>
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings"><svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg></button> <div class="workspace-drag-area pywebview-drag-region"></div>
<button id="connect" class="button connect-button">Connect</button>
</div>
<div class="topbar-spacer pywebview-drag-region"></div>
<div class="window-actions"> <div class="window-actions">
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button> <button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
<button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button> <button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button>
<button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button> <button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
</div> </div>
</header>
<section class="drive-grid">
<article class="control-card steer-card">
<header><div><small>steer + throttle</small><strong>differential drive</strong></div><span class="key-hint">w a s d</span></header>
<div id="joystick" class="joystick" touch-action="none">
<canvas id="joystick-canvas" aria-label="3D Interactive Spherical Joystick"></canvas>
</div> </div>
<footer><span class="steer-readout-left">surge <b id="surge-readout">0</b></span><span class="steer-readout-right"><b id="yaw-readout">0</b> turn</span></footer>
<div class="workspace-nav"><div class="view-switch" role="group" aria-label="Workspace"><button id="view-drive" type="button" aria-pressed="true">Drive</button><button id="view-dashboard" type="button" aria-pressed="false">Dashboard</button></div><span class="version-mark">V2</span><button class="quiet-button" data-open="station-modal" type="button">Vehicle</button></div>
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
<section id="dashboard" class="dashboard" aria-label="Vehicle dashboard" hidden>
<div class="camera-stage">
<video id="camera-video" muted autoplay playsinline hidden></video><img id="camera-image" alt="Camera stream" hidden><canvas id="camera-demo" aria-label="Simulated inspection camera" hidden></canvas>
<div id="camera-empty" class="camera-empty"><svg viewBox="0 0 48 48" aria-hidden="true"><rect x="5" y="12" width="27" height="24" rx="6"/><path d="m32 20 11-6v20l-11-6"/></svg><span id="camera-state">Camera off</span></div>
<div class="camera-overlay"><span id="camera-badge">Preview</span><span id="recording-badge" hidden>● REC</span><span id="stream-clock"></span></div>
</div>
<div class="dashboard-actions"><button id="camera-toggle" type="button">Start</button><button id="camera-record" type="button" aria-pressed="false" disabled>Record</button><button id="lights-toggle" type="button" aria-pressed="false" disabled>Lights</button><button id="camera-power" type="button" aria-pressed="false" disabled>Camera power</button><button id="sensor-record" type="button" aria-pressed="false">Log data</button></div>
<div class="telemetry-strip" aria-label="Live telemetry"><div><span>Depth</span><strong id="live-depth">—</strong></div><div><span>Heading</span><strong id="live-heading">—</strong></div><div><span>Current</span><strong id="live-current">—</strong></div><div><span>Pitch</span><strong id="live-pitch">—</strong></div><div><span>Roll</span><strong id="live-roll">—</strong></div><div><span>Power</span><strong id="live-power">—</strong></div></div>
<details class="sensor-details"><summary>Sensors <span id="sensor-state">Offline</span></summary><div class="sensor-values"><div><span>Acceleration</span><strong id="live-accel">—</strong></div><div><span>Gyroscope</span><strong id="live-gyro">—</strong></div><div><span>Magnetometer</span><strong id="live-mag">—</strong></div><div><span>Temperature</span><strong id="live-temperature">—</strong></div><div><span>Pressure</span><strong id="live-pressure">—</strong></div><div><span>Charge used</span><strong id="live-charge">—</strong></div></div></details>
</section>
<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 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">—</b></span>
<span class="steer-readout-right"><b id="yaw-readout">—</b> turn</span>
</footer>
</article> </article>
<!-- Vertical Thruster Slider -->
<article class="control-card vertical-card"> <article class="control-card vertical-card">
<header><div><small>vertical</small><strong>center thruster</strong></div><span class="key-hint">r / f</span></header> <header>
<div title="Vertical thrust · R / F"></div>
</header>
<div class="heave-pad">
<div class="vertical-control"> <div class="vertical-control">
<span>rise</span> <span>rise</span>
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div> <div id="heave" 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> <span>dive</span>
</div> </div>
<footer><span><b id="heave-readout">0</b> vertical</span></footer>
</article>
</section>
<section class="bottom-actions">
<section class="command-bar">
<div class="commands">
<button id="manual" class="button subtle"><span>Manual</span></button>
<button id="arm" class="button arm" aria-label="Hold to arm" title="Hold to arm (F2)"><span class="arm-copy"><span class="arm-label">Arm</span><span class="arm-holding-label">Holding…</span></span></button>
<button id="disarm" class="button danger"><span>Disarm</span></button>
</div> </div>
<label class="output-limit"><span>output limit <b id="gain-value">20%</b></span><div class="range-pill"><button id="gain-down" type="button" aria-label="Decrease output limit">‹</button><input id="gain" type="range" min="5" max="50" value="20"><button id="gain-up" type="button" aria-label="Increase output limit">›</button></div></label> <footer>
<button class="button subtle" data-open="motors-modal">Motor Setup</button> <span><b id="heave-readout">—</b> vertical</span>
</footer>
</article>
</div>
<div class="control-toolbar">
<label class="output-limit">
<span>Output limit <b id="gain-value">20%</b></span>
<div class="range-pill">
<button id="gain-down" type="button" aria-label="Decrease output limit"><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="Shared motor output limit" type="range" min="0" max="100" step="1" 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>
<div id="extra-controls" class="extra-controls" hidden><label data-motion="lateral"><span>Strafe</span><input type="range" min="-100" max="100" value="0" aria-label="Strafe demand"><output>0</output></label><label data-motion="pitch"><span>Pitch</span><input type="range" min="-100" max="100" value="0" aria-label="Pitch demand"><output>0</output></label><label data-motion="roll"><span>Roll</span><input type="range" min="-100" max="100" value="0" aria-label="Roll demand"><output>0</output></label></div>
</section> </section>
</section> </section>
<div class="status-line"><span></span><p id="status">ready. select a USB port to begin.</p><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">logs</a></div> <aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
</section> <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> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog> <!-- Modals -->
<dialog id="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="link-modal" class="modal" aria-labelledby="link-title">
<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> <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> <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>
<script src="joystick-3d.js"></script> <p id="custom-port-status" class="note" role="status"></p>
<script src="app.js"></script> </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>Battery detail · Hz</span><select name="battery_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</h2>
<section class="battery-live" aria-label="Live battery readings">
<div class="battery-live-grid">
<div><span>Pack voltage</span><strong id="battery-live-voltage">—</strong></div>
<div><span>Current draw</span><strong id="battery-live-current">—</strong></div>
<div><span>Charge used</span><strong id="battery-live-consumed">—</strong><small id="battery-live-consumed-source"></small></div>
<div><span>Capacity left</span><strong id="battery-live-remaining">—</strong></div>
</div>
<p id="battery-live-note" class="note">Waiting for battery telemetry.</p>
</section>
<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">Offline</p>
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
<div class="motor-scale-heading"><span>Output</span><label class="switch-control"><input id="motor-scale-independent" type="checkbox"><span class="switch-track" aria-hidden="true"></span><span>Independent</span></label></div>
<div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="controller-modal" class="modal controller-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">
<div class="form-grid two"><label><span>Controller</span><select id="controller-device"></select></label><label><span>Preset</span><select id="controller-preset"><option value="triggers3">Triggers · 3</option><option value="sixaxis">Six axes</option><option value="classic">Classic sticks</option><option value="custom">Custom</option></select></label></div>
<p id="controller-axes" class="note" hidden>Xbox · offline</p>
<div class="binding-head"><span>Motion</span><span>Negative</span><span>Positive</span><span></span><span>Limit %</span></div>
<div id="controller-bindings"></div>
<div class="form-grid two mapping-tuning"><label><span>Dead zone %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label><label><span>Expo %</span><input name="expo" type="number" min="0" max="100" step="1" required></label></div>
<details class="mapping-actions"><summary>Buttons</summary><div class="form-grid three"><label><span>Lights</span><select name="lights"></select></label><label><span>Stream</span><select name="stream"></select></label><label><span>Record</span><select name="record"></select></label></div></details>
<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>
<dialog id="station-modal" class="modal station-modal" aria-labelledby="station-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="station-title">Vehicle</h2><form id="station-form">
<div class="form-grid two"><label><span>Preview frame</span><select name="frame"><option value="4">SimpleROV · 3</option><option value="0">BlueROV1 · 6</option><option value="1">Vectored · 6</option><option value="2">Full 6DOF · 8</option><option value="3">6DOF 90° · 8</option><option value="5">SimpleROV · 4</option><option value="6">SimpleROV · 5</option></select></label><div class="detected-frame"><span>FC frame</span><strong id="detected-frame">Offline</strong></div></div>
<section class="settings-section"><h3>Camera</h3><label><span>Stream type</span><select name="video_mode"><option value="whep">WebRTC · WHEP</option><option value="video">HTTP video</option><option value="mjpeg">MJPEG</option></select></label><label><span>Stream URL</span><input name="video_url" type="url" placeholder="http://192.168.2.2:8889/cam/whep" autocomplete="off"></label></section>
<section class="settings-section"><h3>Accessories</h3><div class="form-grid two"><label><span>Lights relay</span><select name="lights_relay"></select></label><label><span>Camera relay</span><select name="camera_relay"></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">Save</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=20261022-v2"></script>
<script src="dashboard.js?v=20261022-v2"></script>
<script src="console-view.js?v=20261002-console-v2"></script>
<script src="app.js?v=20261022-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body> </body>
</html> </html>
+40 -306
View File
@@ -1,317 +1,51 @@
/* joystick-3d.js – Clean Canvas joystick with tactile press animation, /* Visual joystick. Control transport remains in app.js through setJoyControl. */
expanded soft red boundary glow, clean lowercase curved labels, and 20% larger knob. */
(() => { (() => {
'use strict'; 'use strict';
const host=document.getElementById('joystick'),canvas=document.getElementById('joystick-canvas');
if(!host||!canvas)return;
const ctx=canvas.getContext('2d');
let width=0,height=0,dpr=1,x=0,y=0,activePointer=null,returnFrame=0;
const host = document.getElementById('joystick'); function geometry(){
if (!host) return; const radius=Math.min(width,height)*.43;
return {cx:width/2,cy:height/2,radius,travel:radius*.67,knob:Math.max(25,Math.min(34,radius*.22))};
let canvas = document.getElementById('joystick-canvas');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'joystick-canvas';
host.appendChild(canvas);
} }
function resize(){const rect=host.getBoundingClientRect();width=rect.width;height=rect.height;dpr=Math.min(devicePixelRatio||1,2);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;draw()}
function draw(){
if(!width||!height)return;
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
canvas.style.cssText = ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eee';ctx.fill();ctx.strokeStyle='#d3d3d3';ctx.lineWidth=1.5;ctx.stroke();
'position:absolute;inset:0;width:100%;height:100%;z-index:5;touch-action:none;cursor:grab;'; ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
const ctx = canvas.getContext('2d'); ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
let W = 0, H = 0, dpr = 1; ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
let nx = 0, ny = 0; // normalised knob position: -1 to +1 const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819);
let dragging = false; handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939');
let animId = 0; ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;ctx.fill();ctx.restore();
let pressAnimId = 0; ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
let pressScale = 1.0; // smooth press scale: 1.0 down to 0.93 ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
function resize() {
const r = host.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = r.width;
H = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
draw();
} }
window.addEventListener('resize', resize); function publish(){window.setJoyControl?.(x,y)}
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
const TAU = Math.PI * 2; 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){
function drawCurvedText(text, cx, cy, radius, centerAngle, flip = false) { cancelAnimationFrame(returnFrame);
ctx.save(); const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}
ctx.font = '300 11.5px Inter, "SF Pro Display", "Helvetica Neue", Arial, sans-serif'; const startX=x,startY=y,start=performance.now();
ctx.fillStyle = '#656f6c'; 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)};
ctx.textAlign = 'center'; returnFrame=requestAnimationFrame(tick);
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;
} }
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()}
const totalAngle = totalWidth / radius; 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)});
if (!flip) { canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
let currentAngle = centerAngle - totalAngle / 2; canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
for (let i = 0; i < text.length; i++) { addEventListener('resize',resize);new ResizeObserver(resize).observe(host);resize();
const charAngle = charWidths[i] / radius; 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}}};
const midAngle = currentAngle + charAngle / 2;
const x = cx + radius * Math.cos(midAngle);
const y = cy + radius * Math.sin(midAngle);
ctx.save();
ctx.translate(x, y);
ctx.rotate(midAngle + Math.PI / 2);
ctx.fillText(text[i], 0, 0);
ctx.restore();
currentAngle += charAngle;
}
} else {
let currentAngle = centerAngle + totalAngle / 2;
for (let i = 0; i < text.length; i++) {
const charAngle = charWidths[i] / radius;
const midAngle = currentAngle - charAngle / 2;
const x = cx + radius * Math.cos(midAngle);
const y = cy + radius * Math.sin(midAngle);
ctx.save();
ctx.translate(x, y);
ctx.rotate(midAngle - Math.PI / 2);
ctx.fillText(text[i], 0, 0);
ctx.restore();
currentAngle -= charAngle;
}
}
ctx.restore();
}
function draw() {
if (!W || !H) return;
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
const cx = W / 2;
const cy = H / 2;
const R = Math.min(W, H) * 0.45;
const baseKnobR = R * 0.22; // 20% larger knob
const knobR = baseKnobR * pressScale;
const travel = R * 0.58;
const dist = Math.hypot(nx, ny);
/* ── 1. SPHERICAL BOWL BASE ── */
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.clip();
// Base smooth light background (clean, no blue edge gradient)
const bg = ctx.createRadialGradient(cx, cy - R * 0.1, R * 0.1, cx, cy, R);
bg.addColorStop(0, '#ffffff');
bg.addColorStop(0.55, '#f7f9f8');
bg.addColorStop(0.85, '#edf1f0');
bg.addColorStop(1, '#e3e8e7');
ctx.fillStyle = bg;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
// 30% larger, 20% lighter transparent red boundary glow when pulled near edge (dist > 0.65)
if (dist > 0.65) {
const redFactor = Math.min(1, (dist - 0.65) / 0.35);
const angle = Math.atan2(ny, nx);
const edgeX = cx + R * Math.cos(angle);
const edgeY = cy + R * Math.sin(angle);
const redGrad = ctx.createRadialGradient(edgeX, edgeY, 0, edgeX, edgeY, R * 0.85);
redGrad.addColorStop(0, `rgba(240, 110, 95, ${0.14 * redFactor})`);
redGrad.addColorStop(0.5, `rgba(240, 110, 95, ${0.045 * redFactor})`);
redGrad.addColorStop(1, 'rgba(240, 110, 95, 0)');
ctx.fillStyle = redGrad;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
}
// Straight vertical and horizontal axis lines
ctx.strokeStyle = 'rgba(155, 175, 178, 0.32)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, cy - R * 0.85);
ctx.lineTo(cx, cy + R * 0.85);
ctx.moveTo(cx - R * 0.85, cy);
ctx.lineTo(cx + R * 0.85, cy);
ctx.stroke();
// ── Curved Lowercase Directional Labels ──
const labelR = R * 0.84;
drawCurvedText('forward', cx, cy, labelR, -Math.PI / 2, false);
drawCurvedText('reverse', cx, cy, labelR, Math.PI / 2, true);
drawCurvedText('turn left', cx, cy, labelR, Math.PI, false);
drawCurvedText('turn right', cx, cy, labelR, 0, false);
ctx.restore(); // un-clip
// Outer rim border
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.strokeStyle = '#cdd6d4';
ctx.lineWidth = 1.5;
ctx.stroke();
/* ── 2. SOFT KNOB SHADOW ── */
const kx = cx + nx * travel;
const ky = cy + ny * travel;
const shadowY = ky + 2.5;
const sg = ctx.createRadialGradient(kx, shadowY, knobR * 0.3, kx, shadowY, knobR * 1.25);
sg.addColorStop(0, 'rgba(18, 28, 24, 0.15)');
sg.addColorStop(0.55, 'rgba(18, 28, 24, 0.05)');
sg.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.beginPath();
ctx.arc(kx, shadowY, knobR * 1.25, 0, TAU);
ctx.fillStyle = sg;
ctx.fill();
/* ── 3. SINGLE 20% LARGER LIGHTER KNOB ── */
ctx.save();
ctx.translate(kx, ky);
// Lighter, clean metallic/slate gradient
const kg = ctx.createLinearGradient(0, -knobR, 0, knobR);
kg.addColorStop(0, '#687271');
kg.addColorStop(0.45, '#515958');
kg.addColorStop(1, '#3c4443');
ctx.beginPath();
ctx.arc(0, 0, knobR, 0, TAU);
ctx.fillStyle = kg;
ctx.fill();
// Clean 1px top highlight bevel
const bevel = ctx.createLinearGradient(0, -knobR, 0, knobR);
bevel.addColorStop(0, 'rgba(255, 255, 255, 0.55)');
bevel.addColorStop(0.4, 'rgba(255, 255, 255, 0.05)');
bevel.addColorStop(0.7, 'rgba(0, 0, 0, 0.1)');
bevel.addColorStop(1, 'rgba(0, 0, 0, 0.40)');
ctx.strokeStyle = bevel;
ctx.lineWidth = 1;
ctx.stroke();
// Outer light edge border (matching vertical knob)
ctx.strokeStyle = '#d7dedc';
ctx.lineWidth = 1;
ctx.stroke();
// Subtle cyan light glow ONLY when actively holding/dragging
if (dragging) {
const gg = ctx.createRadialGradient(0, 0, 0, 0, 0, knobR * 1.15);
gg.addColorStop(0, 'rgba(255, 255, 255, 0.32)');
gg.addColorStop(0.4, 'rgba(200, 245, 255, 0.18)');
gg.addColorStop(0.75, 'rgba(114, 216, 232, 0.10)');
gg.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.beginPath();
ctx.arc(0, 0, knobR, 0, TAU);
ctx.fillStyle = gg;
ctx.fill();
}
ctx.restore(); // un-knob translate
ctx.restore(); // top-level save
}
function setPos(x, y) {
const len = Math.hypot(x, y);
if (len > 1) { x /= len; y /= len; }
nx = x;
ny = y;
draw();
if (window.setJoyControl) window.setJoyControl(nx, ny);
}
function pointerToNorm(e) {
const r = canvas.getBoundingClientRect();
const cx = r.width / 2;
const cy = r.height / 2;
const travel = Math.min(r.width, r.height) * 0.45 * 0.58;
const px = e.clientX - r.left;
const py = e.clientY - r.top;
setPos((px - cx) / travel, (py - cy) / travel);
}
/* ── Tactile Press Animation (matching vertical knob) ── */
function animatePress(toPressed) {
cancelAnimationFrame(pressAnimId);
const startScale = pressScale;
const targetScale = toPressed ? 0.93 : 1.0;
const duration = toPressed ? 110 : 450;
const startTime = performance.now();
function step(now) {
const p = Math.min(1, (now - startTime) / duration);
const curve = toPressed
? p * (2 - p)
: 1 + Math.sin((p - 1) * Math.PI * 2.5) * Math.pow(1 - p, 2) * 0.35;
pressScale = startScale + (targetScale - startScale) * (toPressed ? curve : p);
if (!toPressed) {
pressScale = 1.0 + (startScale - 1.0) * Math.pow(1 - p, 2) + Math.sin(p * Math.PI) * 0.02 * (1 - p);
}
draw();
if (p < 1) {
pressAnimId = requestAnimationFrame(step);
} else {
pressScale = targetScale;
draw();
}
}
pressAnimId = requestAnimationFrame(step);
}
function easeBack() {
cancelAnimationFrame(animId);
const sx = nx, sy = ny, t0 = performance.now();
(function step(now) {
const p = Math.min(1, (now - t0) / 320);
const s = p * p * (3 - 2 * p);
nx = sx * (1 - s);
ny = sy * (1 - s);
draw();
if (window.setJoyControl) window.setJoyControl(nx, ny);
if (p < 1) animId = requestAnimationFrame(step);
})(t0);
}
canvas.addEventListener('pointerdown', e => {
dragging = true;
canvas.style.cursor = 'grabbing';
cancelAnimationFrame(animId);
animatePress(true);
canvas.setPointerCapture(e.pointerId);
pointerToNorm(e);
});
canvas.addEventListener('pointermove', e => {
if (dragging) pointerToNorm(e);
});
canvas.addEventListener('pointerup', () => {
dragging = false;
canvas.style.cursor = 'grab';
animatePress(false);
easeBack();
});
canvas.addEventListener('pointercancel', () => {
dragging = false;
canvas.style.cursor = 'grab';
animatePress(false);
easeBack();
});
resize();
window.joystick3D = {
setPos(x, y) { cancelAnimationFrame(animId); setPos(x, y); },
easeBack() { if (!dragging) easeBack(); }
};
})(); })();
+1099 -647
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
/* V2 uses the existing deck, type scale, and flat settings rows. */
:root { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
[hidden] { display:none !important; }
.workspace-nav { display:flex; align-items:center; gap:12px; margin:0 0 10px; min-height:32px; }
.view-switch { display:flex; gap:2px; padding:3px; background:#e1e1e1; border-radius:20px; }
.view-switch button,.quiet-button { border:0; background:transparent; color:#737575; border-radius:18px; padding:6px 14px; cursor:pointer; font-size:12px; }
.view-switch [aria-pressed=true] { background:#fafafa; color:#242929; box-shadow:0 1px 3px #0000000c; }
.workspace-nav .quiet-button { margin-left:auto; }
.version-mark { color:#9a9e9d; font-size:10px; letter-spacing:.08em; }
#controller-settings { display:block; }
.controller-modal { width:min(730px,calc(100vw - 28px)); max-height:calc(100dvh - 28px); overflow:auto; }
.binding-head,.binding-row { display:grid; grid-template-columns:80px minmax(100px,1fr) minmax(100px,1fr) 28px 54px; gap:8px; align-items:center; }
.binding-head { font-size:10px; color:#858b89; padding:8px 0; }
.binding-row { padding:9px 0; border-top:1px solid #dedfdd; }
.binding-row > span { font-size:12px; font-weight:500; }
.binding-row meter { display:block; width:50px; height:3px; margin-top:7px; accent-color:#6d9c8b; }
.binding-row select,.binding-row input { height:34px; min-height:34px; padding:0 8px; border-radius:8px; font-size:12px; min-width:0; }
.binding-row input { text-align:center; padding:0 2px; font-variant-numeric:tabular-nums; }
.binding-swap { background:transparent; border:0; height:32px; color:#6a7470; font-size:19px; cursor:pointer; border-radius:7px; }
.binding-swap:hover { background:#e0e5e1; }
.mapping-tuning { margin-top:14px; }
.mapping-actions { margin-top:16px; font-size:12px; }
.mapping-actions summary { padding:8px 0; cursor:pointer; color:#727b76; }
.form-grid.three { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
.dashboard { display:flex; flex:1; flex-direction:column; min-height:0; gap:0; overflow:auto; }
.camera-stage { flex:1 1 220px; min-height:150px; position:relative; border-radius:14px; overflow:hidden; background:#18282d; color:#f2f6f5; isolation:isolate; }
.camera-stage video,.camera-stage img,.camera-stage canvas { display:block; width:100%; height:100%; position:absolute; inset:0; object-fit:contain; }
.camera-empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:#8fa5aa; font-size:12px; }
.camera-empty svg { width:40px; fill:none; stroke:currentColor; stroke-width:1.4; opacity:.65; }
.camera-overlay { pointer-events:none; position:absolute; top:14px; left:16px; right:16px; display:flex; align-items:center; gap:10px; font-size:10px; text-shadow:0 1px 3px #000; color:#d7e5e4; }
#camera-badge { border:1px solid #a8c1c245; border-radius:10px; padding:3px 8px; background:#16282c55; }
#recording-badge { color:#ff9e93; font-weight:600; }
#stream-clock { margin-left:auto; font-variant-numeric:tabular-nums; }
.dashboard-actions { display:flex; align-items:center; gap:5px; padding:10px 0; flex-wrap:wrap; }
.dashboard-actions button { min-height:32px; padding:6px 12px; border-radius:18px; border:0; background:#e0e2df; color:#46524b; font-size:11px; cursor:pointer; white-space:nowrap; }
.dashboard-actions button[aria-pressed=true] { background:#d0ddd5; color:#345844; }
.dashboard-actions button:disabled { opacity:.4; cursor:default; }
.telemetry-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); row-gap:12px; border-top:1px solid #d9deda; padding:14px 0; }
.telemetry-strip > div { min-width:0; display:flex; flex-direction:column; gap:3px; }
.telemetry-strip span { font-size:10px; color:#7b8480; }
.telemetry-strip strong { font-weight:400; font-size:clamp(18px,2.2vw,26px); color:#36463d; font-variant-numeric:tabular-nums; letter-spacing:-.035em; white-space:nowrap; }
.telemetry-strip [data-stale=true] strong { color:#a4aaa5; }
.sensor-details { border-top:1px solid #d9deda; padding:10px 0 0; font-size:11px; }
.sensor-details summary { cursor:pointer; color:#58665d; }
.sensor-details summary span { float:right; color:#939c96; font-size:10px; }
.sensor-values { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; padding:14px 0; }
.sensor-values div { min-width:0; display:flex; flex-direction:column; gap:4px; }
.sensor-values span { color:#87908a; font-size:10px; }
.sensor-values strong { font-size:11px; font-weight:500; color:#4b5c50; font-variant-numeric:tabular-nums; }
.detected-frame { display:flex; flex-direction:column; gap:9px; color:#859086; font-size:12px; padding-top:4px; }
.detected-frame strong { color:#4b5c50; font-weight:500; }
.extra-controls { display:flex; gap:16px; margin-top:8px; padding:10px 0 0; border-top:1px solid #d9deda; }
.extra-controls label { display:grid; flex:1; min-width:0; grid-template-columns:1fr 24px; gap:4px; color:#7e8981; font-size:10px; }
.extra-controls label input { width:100%; min-width:0; grid-row:2; grid-column:1/-1; accent-color:#668c78; height:18px; }
.extra-controls output { text-align:right; font-variant-numeric:tabular-nums; }
.primary-control-zone { grid-template-rows:clamp(64px,11vh,90px) minmax(0,1fr) auto auto; }
.controls-grid { --joy-size:min(34vh,calc(100cqw - 146px),370px); }
.workspace-topbar { margin-bottom:8px; }
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):active { transform:scale(.97); }
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):focus-visible { outline:2px solid #6b9a84; outline-offset:2px; }
.station-modal { width:min(560px,calc(100vw - 28px)); }
body[data-view=dashboard] #controller-status { position:absolute; clip:rect(0,0,0,0); }
@media (max-width:760px) {
.binding-head,.binding-row { grid-template-columns:66px minmax(0,1fr) minmax(0,1fr) 24px 46px; gap:6px; }
.binding-row select { padding:0 4px; font-size:11px; }
.controller-modal { padding:22px 18px; }
.controls-workspace { padding:16px; }
.dashboard-actions { gap:4px; }.dashboard-actions button { padding:6px 9px; }
}
@media (max-width:600px) {
.app-shell { grid-template-columns:170px minmax(0,1fr); }
.sidebar-deck { padding:14px; }
.input-mode-switch button { padding:6px 8px; }
.controls-grid { --joy-size:min(30vh,calc(100cqw - 95px),300px); grid-template-columns:minmax(0,1fr) 70px; gap:6px; }
.control-toolbar { flex-wrap:wrap; gap:8px; }.control-toolbar .range-pill { width:100px; }
.control-toolbar .output-limit > span { width:auto; flex-basis:auto; }
.window-actions { gap:0; }.window-actions button { width:24px; }
.extra-controls { gap:8px; }.workspace-nav { gap:6px; }
.view-switch button { padding:6px 9px; }.workspace-nav .quiet-button { padding:6px; }
.version-mark { display:none; }
}
@media (prefers-reduced-motion:reduce) { :is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button) { transition:none; transform:none !important; } }
+601 -47
View File
@@ -8,14 +8,18 @@ import time
import base64 import base64
import shutil import shutil
import http.server import http.server
import socketserver
import urllib.parse import urllib.parse
import csv
import uuid
import math
from pathlib import Path from pathlib import Path
import webview import webview
from serial.tools import list_ports from serial.tools import list_ports
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
from vehicle_state import VehicleState
from rov_v2 import Telemetry, clean_profile, clean_station, save_json, number
class ControlApi: class ControlApi:
@@ -24,20 +28,73 @@ class ControlApi:
def __init__(self): def __init__(self):
self.events: queue.Queue = queue.Queue() self.events: queue.Queue = queue.Queue()
self.worker = MavlinkWorker(self.events) self.worker = MavlinkWorker(self.events)
self.worker.require_input_lease = True
self.vehicle_state = VehicleState()
self.battery_received_at = None
self.battery_last_current = None
self.battery_last_current_at = None
self.battery_session_consumed_mah = 0.0
self.battery_fields_at = {}
self.demo_mode = False
self.lock = threading.Lock() self.lock = threading.Lock()
self._poll_lock = threading.Lock()
self.telemetry = Telemetry()
self.station_path = Path(__file__).resolve().parent / "station_settings.json"
self.profiles_path = self.station_path.with_name("controller_profiles.json")
self.station_settings = clean_station({})
self.controller_profiles = {}
try:
self.station_settings = clean_station(json.loads(self.station_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
try:
self.controller_profiles = {str(key)[:256]: clean_profile(value) for key, value in
json.loads(self.profiles_path.read_text(encoding="utf-8")).items()}
except (OSError, ValueError, TypeError, AttributeError):
pass
self._recording_lock = threading.Lock()
self._recording = None
self._sensor_file = None
self._sensor_writer = None
self.state = { self.state = {
"connected": False, "connected": False,
"link": "DISCONNECTED", "link": "DISCONNECTED",
"vehicle": "No heartbeat", "vehicle": "No heartbeat",
"armed": False, "armed": False,
"mode": None,
"app_state": "disconnected",
"status": "Ready. Select a USB port to begin.", "status": "Ready. Select a USB port to begin.",
"battery": None, "battery": None,
"link_quality": None,
"heavy_quality": None,
"log_path": None, "log_path": None,
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)}, "motor_test_active": False,
"capabilities": {"frame": None, "firmware": None},
"accessories": {},
"sensor_recording": False,
"servos": {str(i): {"function": None, "direction": None, "output_us": None} for i in range(1, 17)},
} }
self.servo_functions: dict[int, int] = {} self.servo_functions: dict[int, int] = {}
self.motor_directions: dict[int, int] = {} self.motor_directions: dict[int, int] = {}
self.motor_settings_path = Path(__file__).resolve().parent / "motor_settings.json"
self.motor_settings = {
"independent": False,
"customized": False,
"motors": {str(i): {"name": f"Motor {i}", "scale": 20} for i in range(1, 13)},
}
try:
saved_motor_settings = json.loads(self.motor_settings_path.read_text(encoding="utf-8"))
self.motor_settings = self._clean_motor_settings(saved_motor_settings)
except (OSError, ValueError, TypeError):
pass
if self.station_settings["frame"] != 4:
self.motor_settings["independent"] = False
self.worker.set_motor_scaling(
self.motor_settings["independent"],
{int(i): entry["scale"] for i, entry in self.motor_settings["motors"].items()},
)
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json" 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_path = Path(__file__).resolve().parent / "model_settings.json"
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl" self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt" self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
@@ -54,17 +111,42 @@ class ControlApi:
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8"))) self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
pass pass
self.link_config = {
"baud": 57600, "control_hz": 20,
"ping_hz": 1, "system_hz": 1, "battery_hz": 1, "servo_hz": 2,
"rc_hz": 2, "esc_hz": 1,
"attitude_hz": 2, "depth_hz": 2, "imu_hz": 1, "pressure_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): def initial_state(self):
return { return {
"ports": [p.device for p in list_ports.comports()],
"battery_config": self.battery_config, "battery_config": self.battery_config,
"link_config": self.link_config,
"motor_settings": self.motor_settings,
"station_settings": self.station_settings,
"controller_profiles": self.controller_profiles,
"state": self._snapshot(), "state": self._snapshot(),
"model_info": self.get_saved_model_info(),
} }
def refresh_ports(self): def refresh_ports(self):
return [p.device for p in list_ports.comports()] ports = []
# Add physical and virtual serial COM ports
for p in list_ports.comports():
desc = p.description or ""
if desc and desc != "n/a" and desc != p.device:
label = f"{p.device} ({desc})"
else:
label = p.device
ports.append({"device": p.device, "label": label, "type": "serial"})
# Built-in UI testing: no external process, port, or MAVLink socket.
ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"})
return ports
def minimize_window(self): def minimize_window(self):
if self._window: if self._window:
@@ -202,36 +284,276 @@ class ControlApi:
return None return None
def connect(self, port, baud=57600, rate=20): def connect(self, port, baud=57600, rate=20):
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
if not port: if not port:
raise ValueError("Select a USB serial port") raise ValueError("Select a USB serial port")
self.worker.rate_hz = int(rate) self.telemetry = Telemetry()
self.worker.connect(str(port), int(baud)) self.state["capabilities"] = {"frame": None, "firmware": None}
self.state["accessories"] = {}
self.state["battery"] = None
self.battery_fields_at = {}
if str(port).lower() == "demo":
self.demo_mode = True
self.servo_functions = {1: 33, 2: 34, 3: 35}
self.motor_directions = {1: 1, 2: 1, 3: 1}
with self.lock: with self.lock:
self.state.update(
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
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,
"output_us": None,
}
return self._snapshot()
self.battery_last_current = None
self.battery_last_current_at = None
self.battery_session_consumed_mah = 0.0
self.worker.rate_hz = int(rate)
with self.lock:
self.vehicle_state.disconnect()
self.state.update( self.state.update(
connected=True, link="CONNECTING", connected=True, link="CONNECTING",
status=f"Opening {port} at {baud} baud…", status=f"Opening {port} at {baud} baud…",
) )
return True self.worker.connect(str(port), int(baud))
print(f"[web_ui] connect() dispatched to worker thread OK")
# Return only once the worker has either seen a heartbeat or reported a
# connection failure. This makes the button independent of the regular
# UI polling loop and prevents an indefinite visual "Connecting" state.
deadline = time.monotonic() + 8.0
while time.monotonic() < deadline:
snapshot = self.poll()
if snapshot["app_state"] != "connecting":
return snapshot
time.sleep(0.05)
self.worker.disconnect()
time.sleep(0.1)
self.poll()
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self): def disconnect(self):
self.sensor_record(False)
self.worker.stop_motor_test()
with self.lock:
self.state["motor_test_active"] = False
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() self.worker.disconnect()
return True return True
def set_axes(self, forward, turn, vertical): def start_heavy_quality_test(self):
self.worker.set_axes(Axes( if self.demo_mode:
max(-1.0, min(1.0, float(forward))), raise RuntimeError("The heavy link test requires a real flight-controller connection")
0.0, return self.worker.start_heavy_quality_test()
max(-1.0, min(1.0, float(vertical))),
max(-1.0, min(1.0, float(turn))), def cancel_heavy_quality_test(self):
)) return self.worker.cancel_heavy_quality_test()
def start_motor_test(self, motor, throttle_percent):
motor, throttle_percent = int(motor), int(throttle_percent)
if self.demo_mode:
raise RuntimeError("Motor tests require a real flight-controller connection")
with self.lock:
if not self.state.get("connected") or not self.state.get("armed"):
raise RuntimeError("Connect and arm the vehicle before motor testing")
assignment = next((item for item in self.state["servos"].values() if item.get("motor") == motor), None)
if assignment is None:
raise ValueError(f"Motor {motor} is not assigned to a servo output")
if self.state.get("motor_test_active"):
raise RuntimeError("A motor test is already active")
effective_percent = round(throttle_percent * self.worker.motor_output_scale(motor))
self.state["motor_test_active"] = True
self.state["status"] = f"Motor {motor} test active at {effective_percent}% output"
try:
return self.worker.start_motor_test(motor, effective_percent)
except Exception:
with self.lock:
self.state["motor_test_active"] = False
raise
def stop_motor_test(self):
self.worker.stop_motor_test()
with self.lock:
self.state["status"] = "Motor test released; waiting for flight-controller stop"
return True
def set_axes(self, forward, turn, vertical, sequence=None, lateral=0, pitch=0, roll=0):
values = [number(value, -1, 1) for value in (forward, lateral, vertical, turn, pitch, roll)]
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(*values) if allowed else Axes(), sequence)
return True
def _require_settings(self):
if self.state.get("connected") and (self.state.get("armed") or
(not self.demo_mode and not self.vehicle_state.snapshot()["armed_known"])):
raise RuntimeError("Disengage first")
def save_controller_profile(self, device, profile):
with self.lock:
self._require_settings()
clean = clean_profile(profile)
updated = {**self.controller_profiles, str(device)[:256]: clean}
save_json(self.profiles_path, updated)
self.controller_profiles = updated
self.worker.set_axes(Axes())
return clean
def save_station_settings(self, config):
with self.lock:
self._require_settings()
clean = clean_station(config)
save_json(self.station_path, clean)
self.station_settings = clean
if clean["frame"] != 4 and (self.demo_mode or not self.state.get("connected")):
self.motor_settings["independent"] = False
self.worker.set_motor_scaling(False, {int(key): item["scale"] for key, item in self.motor_settings["motors"].items()})
# Frame selection describes the preview; actual FRAME_TYPE is read
# from the connected autopilot and never silently overwritten.
return clean
def set_accessory(self, name, enabled):
if name not in ("lights", "camera") or not isinstance(enabled, bool):
raise ValueError("Invalid accessory")
relay = self.station_settings[name + "_relay"]
if relay < 0:
raise RuntimeError("Set relay first")
if self.demo_mode:
self.state["accessories"][name] = {"requested": enabled, "accepted": True, "demo": True}
return True
self.worker.accessory(name, relay, enabled)
with self.lock:
self.state["accessories"][name] = {"requested": enabled, "accepted": None, "at": time.monotonic()}
return True
def sensor_record(self, enabled):
with self.lock:
if enabled and self._sensor_file is None:
folder = Path(__file__).resolve().parent / "logs"
folder.mkdir(exist_ok=True)
self._sensor_file = (folder / f"sensors_{time.time_ns()}.csv").open("w", newline="", encoding="utf-8")
self._sensor_writer = csv.writer(self._sensor_file)
self._sensor_writer.writerow(("monotonic_s", "group", "field", "value"))
elif not enabled and self._sensor_file is not None:
self._sensor_file.close()
self._sensor_file = self._sensor_writer = None
self.state["sensor_recording"] = bool(enabled)
return self._sensor_file.name if self._sensor_file else None
def recording_begin(self):
with self._recording_lock:
if self._recording:
raise RuntimeError("Already recording")
folder = Path(__file__).resolve().parent / "runtime" / "recordings"
folder.mkdir(parents=True, exist_ok=True)
path = folder / f"rov_{time.time_ns()}.webm"
token = uuid.uuid4().hex
self._recording = (token, path.open("wb"), path)
return token
def recording_chunk(self, token, data):
if not isinstance(data, str) or len(data) > 12 * 1024 * 1024:
raise ValueError("Invalid video chunk")
chunk = base64.b64decode(data, validate=True)
with self._recording_lock:
if not self._recording or token != self._recording[0]:
raise ValueError("Recording expired")
self._recording[1].write(chunk)
return True
def recording_end(self, token):
with self._recording_lock:
if not self._recording or token != self._recording[0]:
return None
_, handle, path = self._recording
handle.close()
self._recording = None
return str(path)
def stop_controls(self, sequence=None):
"""Invalidate queued input without claiming that the vehicle disarmed."""
with self.lock:
self.worker.set_axes(Axes(), sequence)
self.worker._send_control(force_neutral=True)
return True return True
def set_gain(self, value): def set_gain(self, value):
value = max(5, min(50, int(value))) value = max(0, min(100, int(value)))
if self.motor_settings["independent"]:
raise RuntimeError("Use per-motor scales while independent scaling is enabled")
with self.worker.lock:
self.worker.gain = value / 100.0 self.worker.gain = value / 100.0
return value return value
def command(self, name): @staticmethod
def _clean_motor_settings(settings):
if not isinstance(settings, dict):
raise ValueError("Motor settings must be an object")
requested = settings.get("motors", {})
if not isinstance(requested, dict):
raise ValueError("Motor settings are invalid")
motors = {}
for motor in range(1, 13):
entry = requested.get(str(motor), {})
if not isinstance(entry, dict):
entry = {}
name = str(entry.get("name", f"Motor {motor}")).strip()[:24] or f"Motor {motor}"
try:
scale = max(0, min(100, int(entry.get("scale", 20))))
except (TypeError, ValueError):
scale = 20
motors[str(motor)] = {"name": name, "scale": scale}
return {"independent": bool(settings.get("independent", False)), "customized": bool(settings.get("customized", False)), "motors": motors}
def save_motor_settings(self, settings, persist=True):
clean = self._clean_motor_settings(settings)
if clean["independent"] != self.motor_settings["independent"]:
self._require_settings()
if clean["independent"] and self.worker.frame_type not in (None, 4):
raise ValueError("Independent scales require SimpleROV 3")
if persist:
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.motor_settings = clean
self.worker.set_motor_scaling(
clean["independent"],
{int(i): entry["scale"] for i, entry in clean["motors"].items()},
)
return clean
def command(self, name, sequence=None, expires_at=None):
if self.demo_mode:
if name not in ("manual", "arm", "disarm"):
raise ValueError(f"Unknown command: {name}")
armed = name == "arm" if name != "manual" else self.state.get("armed", False)
with self.lock:
self.state.update(armed=armed, mode="MANUAL",
vehicle=f"DEMO • {'ARMED' if armed else 'DISARMED'}",
status=f"Demo: {'Manual mode' if name == 'manual' else name.capitalize()} confirmed")
return True
if not self.worker.connection or not self.worker.target_system: if not self.worker.connection or not self.worker.target_system:
if name == "manual": if name == "manual":
raise RuntimeError("Connect to vehicle to enable Manual mode") raise RuntimeError("Connect to vehicle to enable Manual mode")
@@ -240,15 +562,25 @@ class ControlApi:
elif name == "disarm": elif name == "disarm":
raise RuntimeError("Connect to vehicle before disarming") raise RuntimeError("Connect to vehicle before disarming")
raise RuntimeError("No autopilot heartbeat") raise RuntimeError("No autopilot heartbeat")
if name in ("arm", "disarm"):
self.poll()
with self.lock:
if name == "arm":
if expires_at is not None and time.time() * 1000 > expires_at:
raise RuntimeError("Engage request expired before it could be sent")
if sequence is not None and sequence <= self.worker.last_input_sequence:
raise RuntimeError("Engage canceled by a newer input reset")
self.vehicle_state.request(name)
self.worker.set_axes(Axes(), sequence)
try:
self.worker.command_arm(name == "arm")
except Exception as exc:
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
raise
return self._snapshot()
if name == "manual": if name == "manual":
self.worker.log("OPERATOR", "clicked SET MANUAL") self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode() self.worker.set_manual_mode()
elif name == "arm":
self.worker.log("OPERATOR", "clicked ARM")
self.worker.command_arm(True)
elif name == "disarm":
self.worker.log("OPERATOR", "clicked DISARM")
self.worker.command_arm(False)
else: else:
raise ValueError(f"Unknown command: {name}") raise ValueError(f"Unknown command: {name}")
return True return True
@@ -258,6 +590,10 @@ class ControlApi:
if not 1 <= servo <= 16 or not 0 <= motor <= 12: if not 1 <= servo <= 16 or not 0 <= motor <= 12:
raise ValueError("Invalid servo or motor") raise ValueError("Invalid servo or motor")
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor) function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
if self.demo_mode:
self.servo_functions[servo] = function
self._update_servo(servo)
return True
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function) self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
return True return True
@@ -269,6 +605,10 @@ class ControlApi:
current = self.motor_directions.get(motor) current = self.motor_directions.get(motor)
if current is None: if current is None:
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet") raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
if self.demo_mode:
self.motor_directions[motor] = -1 if current == 1 else 1
self._update_servo(servo)
return True
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1) self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
return True return True
@@ -287,8 +627,38 @@ class ControlApi:
raise ValueError("Full voltage per cell must be greater than empty voltage") raise ValueError("Full voltage per cell must be greater than empty voltage")
if not 0 <= clean["warning_percent"] <= 100: if not 0 <= clean["warning_percent"] <= 100:
raise ValueError("Warning percentage must be between 0 and 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_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"],
"BATTERY_STATUS": self.link_config["battery_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"],
"ATTITUDE": self.link_config["attitude_hz"],
"GLOBAL_POSITION_INT": self.link_config["depth_hz"],
"SCALED_IMU": self.link_config["imu_hz"],
"SCALED_PRESSURE2": self.link_config["pressure_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", "battery_hz", "servo_hz", "rc_hz", "esc_hz", "attitude_hz", "depth_hz", "imu_hz", "pressure_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 return clean
def open_logs(self): def open_logs(self):
@@ -297,14 +667,20 @@ class ControlApi:
os.startfile(path) os.startfile(path)
return True 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): def poll(self):
while True: # Bound a poll even if telemetry arrives continuously.
with self._poll_lock:
for _ in range(500):
try: try:
event = self.events.get_nowait() event = self.events.get_nowait()
except queue.Empty: except queue.Empty:
break break
self._consume(event) self._consume(event)
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5: if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
with self.lock: with self.lock:
self.state["link"] = ( self.state["link"] = (
"DATA OK / HEARTBEAT DELAYED" "DATA OK / HEARTBEAT DELAYED"
@@ -312,33 +688,107 @@ class ControlApi:
) )
return self._snapshot() return self._snapshot()
def _derive_app_state(self) -> str:
if not self.state.get("connected"):
return "disconnected"
link = self.state.get("link", "")
if link == "CONNECTING":
return "connecting"
if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"):
return "link_degraded"
if self.state.get("armed"):
return "armed"
return "connected_disarmed"
def _snapshot(self): def _snapshot(self):
with self.lock: with self.lock:
return json.loads(json.dumps(self.state)) snap = json.loads(json.dumps(self.state))
snap["motor_settings"] = self.motor_settings
snap["telemetry"] = self.telemetry.snapshot(bool(snap.get("connected")))
if snap.get("battery") and not self.demo_mode:
now = time.monotonic()
snap["battery"]["current_stale"] = now - self.battery_fields_at.get("current_a", 0) > 5
if snap["battery"]["current_stale"]:
snap["battery"]["current_a"] = None
for field in ("voltage", "per_cell", "consumed_mah", "remaining_percent"):
key = "voltage" if field == "per_cell" else field
if now - self.battery_fields_at.get(key, 0) > 5:
snap["battery"][field] = None
battery = snap["battery"]
if battery.get("remaining_percent") is not None:
battery["percent"] = battery["remaining_percent"]
elif battery.get("per_cell") is not None:
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
battery["percent"] = max(0, min(100, (battery["per_cell"] - low) * 100 / (high - low)))
battery["charge_source"] = "Voltage estimate"
else:
battery["percent"] = None
capacity = self.battery_config["capacity_mah"]
battery["remaining_mah"] = (max(0, capacity - battery["consumed_mah"]) if battery.get("consumed_mah") is not None else
round(capacity * battery["remaining_percent"] / 100) if battery.get("remaining_percent") is not None else None)
for accessory in snap["accessories"].values():
if accessory.get("accepted") is None and time.monotonic() - accessory.get("at", 0) > 3:
accessory["error"] = "No ACK"
snap["app_state"] = self._derive_app_state()
if not self.demo_mode:
snap.update(self.vehicle_state.snapshot())
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
snap["app_state"] = "link_degraded"
snap["battery_stale"] = (self.battery_received_at is None
or time.monotonic() - self.battery_received_at > 5)
return snap
def _consume(self, event): def _consume(self, event):
kind = event[0] kind = event[0]
with self.lock: with self.lock:
if kind == "heartbeat": if kind == "heartbeat":
_, system, autopilot, mode, armed = event _, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
if not armed and self.state.get("motor_test_active"):
self.state["motor_test_active"] = False
self.state["status"] = "Flight controller reports disarmed after motor test"
self.state.update( self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}", connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed), armed=bool(armed),
mode=str(mode).upper(),
) )
elif kind in ("status", "statustext"): elif kind in ("status", "statustext"):
self.state["status"] = event[1] self.state["status"] = event[1]
elif kind == "ack": elif kind == "ack":
if event[1] == 400:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}" self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery": elif kind == "battery":
voltage = float(event[1]) self._consume_battery(event[1])
cells = self.battery_config["cells"] elif kind == "capabilities":
per_cell = voltage / cells self.state["capabilities"].update(event[1])
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"] if event[1].get("frame", 4) != 4:
percent = max(0, min(100, (per_cell - low) * 100 / (high - low))) self.motor_settings["independent"] = False
self.state["battery"] = { self.worker.set_motor_scaling(False, {int(key): item["scale"] for key, item in self.motor_settings["motors"].items()})
"voltage": voltage, "per_cell": per_cell, "percent": percent, elif kind == "telemetry":
"cells": cells, "low": percent <= self.battery_config["warning_percent"], self.telemetry.update(event[1], event[2], event[3])
} if self._sensor_writer:
self._sensor_writer.writerows((event[3], event[1], key, value) for key, value in event[2].items())
self._sensor_file.flush()
elif kind == "accessory_ack":
self.state["accessories"][event[1]] = {"requested": event[2], "accepted": event[3] == 0,
"error": None if event[3] == 0 else "Rejected"}
elif kind == "servo_output":
for servo, output_us in event[1].items():
if servo in self.state["servos"]:
self.state["servos"][servo]["output_us"] = output_us
elif kind == "motor_test_ack":
self.state["status"] = "Motor test accepted by flight controller"
elif kind == "motor_test_rejected":
self.state["motor_test_active"] = False
self.state["status"] = f"Motor test rejected: {event[1]}"
elif kind == "motor_test_stopped":
self.state["motor_test_active"] = False
self.state["status"] = f"Motor test ended: {event[1]}"
elif kind == "link_quality":
self.state["link_quality"] = event[1]
elif kind == "heavy_quality":
self.state["heavy_quality"] = event[1]
elif kind == "servo_param": elif kind == "servo_param":
_, param_id, value = event _, param_id, value = event
servo = int(param_id[5:param_id.index("_")]) servo = int(param_id[5:param_id.index("_")])
@@ -355,9 +805,76 @@ class ControlApi:
self.state["log_path"] = event[1] self.state["log_path"] = event[1]
elif kind == "error": elif kind == "error":
self.state["status"] = event[1] self.state["status"] = event[1]
self.state["last_error"] = event[1]
elif kind == "disconnected": elif kind == "disconnected":
self.vehicle_state.disconnect()
last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, battery=None, status="Disconnected") mode=None, link_quality=None, status=status_msg, motor_test_active=False)
self.state["last_error"] = None
def _consume_battery(self, data):
self.battery_received_at = time.monotonic()
now = self.battery_received_at
previous = self.state.get("battery") or {}
battery = dict(previous)
battery["cells"] = self.battery_config["cells"]
for field in ("voltage", "current_a", "consumed_mah", "remaining_percent"):
if field in data:
value = data[field]
if isinstance(value, (float, int)) and math.isfinite(value) and value >= 0:
self.battery_fields_at[field] = now
else:
battery[field] = None
self.battery_fields_at.pop(field, None)
voltage = data.get("voltage")
if voltage is not None and float(voltage) > 0:
battery["voltage"] = float(voltage)
battery["per_cell"] = float(voltage) / self.battery_config["cells"]
current = data.get("current_a")
if current is not None and float(current) >= 0:
current = float(current)
if self.battery_last_current is not None and self.battery_last_current_at is not None:
elapsed = now - self.battery_last_current_at
if 0 < elapsed <= 10:
average = (current + self.battery_last_current) / 2
self.battery_session_consumed_mah += average * 1000 * elapsed / 3600
self.battery_last_current = current
self.battery_last_current_at = now
battery["current_a"] = current
elif "current_a" in data:
self.battery_last_current = None
self.battery_last_current_at = None
consumed = data.get("consumed_mah")
if consumed is not None and int(consumed) >= 0:
battery["consumed_mah"] = int(consumed)
battery["consumed_source"] = "Flight controller"
elif self.battery_last_current is not None:
battery["consumed_mah"] = round(self.battery_session_consumed_mah)
battery["consumed_source"] = "Session estimate"
self.battery_fields_at["consumed_mah"] = now
remaining = data.get("remaining_percent")
if remaining is not None and 0 <= int(remaining) <= 100:
battery["remaining_percent"] = int(remaining)
battery["charge_source"] = "Flight controller"
capacity = self.battery_config["capacity_mah"]
if battery.get("consumed_mah") is not None:
battery["remaining_mah"] = max(0, capacity - battery["consumed_mah"])
elif battery.get("remaining_percent") is not None:
battery["remaining_mah"] = round(capacity * battery["remaining_percent"] / 100)
if battery.get("remaining_percent") is not None:
percent = battery["remaining_percent"]
elif battery.get("per_cell") is not None:
low = self.battery_config["empty_v_per_cell"]
high = self.battery_config["full_v_per_cell"]
percent = max(0, min(100, (battery["per_cell"] - low) * 100 / (high - low)))
battery["charge_source"] = "Voltage estimate"
else:
percent = None
battery["percent"] = percent
battery["low"] = percent is not None and percent <= self.battery_config["warning_percent"]
self.state["battery"] = battery
def _update_servo(self, servo): def _update_servo(self, servo):
function = self.servo_functions.get(servo) function = self.servo_functions.get(servo)
@@ -367,9 +884,13 @@ class ControlApi:
"label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…", "label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
"motor": motor, "motor": motor,
"direction": self.motor_directions.get(motor) if motor else None, "direction": self.motor_directions.get(motor) if motor else None,
"output_us": (self.state["servos"].get(str(servo)) or {}).get("output_us"),
} }
def close(self): def close(self):
self.sensor_record(False)
if self._recording:
self.recording_end(self._recording[0])
self.worker.disconnect() self.worker.disconnect()
@@ -380,22 +901,49 @@ def _apply_dwm_styling(window):
hwnd = int(window.native.Handle.ToInt64()) hwnd = int(window.native.Handle.ToInt64())
if hwnd: if hwnd:
import ctypes import ctypes
user32 = ctypes.windll.user32
dwmapi = ctypes.windll.dwmapi
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
ctypes.windll.dwmapi.DwmSetWindowAttribute( dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref) ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
) )
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
ctypes.windll.dwmapi.DwmSetWindowAttribute( dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode) ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
) )
user32.SetForegroundWindow(ctypes.c_void_p(hwnd))
except Exception: except Exception:
pass pass
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
target_w, target_h = 1100, 650
min_w, min_h = 960, 580
try:
import ctypes
user32 = ctypes.windll.user32
sw = user32.GetSystemMetrics(0)
sh = user32.GetSystemMetrics(1)
if sw > 0 and sh > 0:
target_w = min(target_w, max(min_w, int(sw * 0.92)))
target_h = min(target_h, max(min_h, int(sh * 0.90)))
min_w = min(min_w, target_w)
min_h = min(min_h, target_h)
except Exception:
pass
return target_w, target_h, (min_w, min_h)
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler): class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
pass # Suppress HTTP request logs in console pass # Suppress HTTP request logs in console
def end_headers(self):
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def do_GET(self): def do_GET(self):
unquoted = urllib.parse.unquote(self.path) unquoted = urllib.parse.unquote(self.path)
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted: if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
@@ -409,7 +957,6 @@ class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
self.send_header("Content-Type", "model/stl") self.send_header("Content-Type", "model/stl")
self.send_header("Content-Length", str(len(data))) self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)
return return
@@ -418,21 +965,27 @@ class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
def start_local_server(directory: Path) -> int: def start_local_server(directory: Path) -> int:
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs) handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
server = socketserver.TCPServer(("127.0.0.1", 0), handler) server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = server.server_address[1] port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start() threading.Thread(target=server.serve_forever, daemon=True).start()
return port return port
def main(): 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 = ControlApi()
api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent project_root = Path(__file__).resolve().parent
port = start_local_server(project_root) port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html" app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
win_w, win_h, min_dims = _get_window_size()
window = webview.create_window( window = webview.create_window(
"SUB Control", url=app_url, js_api=api, "Argo V2", url=app_url, js_api=api,
width=1400, height=808, min_size=(1125, 690), width=win_w, height=win_h, min_size=min_dims,
background_color="#111719", frameless=True, easy_drag=False, background_color="#111719", frameless=True, easy_drag=False,
shadow=True, shadow=True,
) )
@@ -441,7 +994,8 @@ def main():
window.events.maximized += lambda: setattr(api, "maximized", True) window.events.maximized += lambda: setattr(api, "maximized", True)
window.events.restored += lambda: setattr(api, "maximized", False) window.events.restored += lambda: setattr(api, "maximized", False)
try: try:
webview.start(debug=True) icon_path = project_root / "assets" / "sub-control.ico"
webview.start(debug=os.environ.get("SUB_CONTROL_DEBUG") == "1", icon=str(icon_path) if icon_path.is_file() else None)
finally: finally:
api.close() api.close()
+748 -844
View File
File diff suppressed because it is too large Load Diff
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}")
+44
View File
@@ -0,0 +1,44 @@
# V2 verification — 22 October 2026
## Passing checks
- 41 Python tests: vehicle-state confirmation, control leases/watchdogs, six-axis packet values, unsupported firmware/frame gating, endpoint handling, telemetry conversion/staleness, persisted names/profiles, recording chunks, and relay acknowledgement.
- `node tests/controller_v2.cjs`: synthetic Xbox triggers, B/A direction, opposing input cancellation, six axes, custom buttons, limits, invalid/missing inputs, migration, and actions.
- `node tests/controller_input.cjs`: existing stick conditioning and console parser checks.
- Python compilation, JavaScript syntax checks, and `git diff --check`.
No tests opened a real COM port or armed a physical vehicle.
## Rendered browser checks
Used the supported local in-app browser with the real app assets in Demo Mode:
- Mapping dialog: three-axis and six-axis presets, saving, reopening after a full page reload, and retained LB/RB roll bindings.
- Vehicle settings: saved BlueROV1 six-thruster preview, independent accessory relay selectors, and consistent FC/demo status.
- Drive: all six command controls at 1024×768, 960×580 (the native window minimum), and 700×572; no overlap with the output toolbar.
- Active-output list: six rows for the six-thruster preview, with scrolling and reversal buttons. General frames disable the SimpleROV-3-specific independent-scale mode.
- Engagement: held F2 in Demo Mode, inspected rendered **engaging**, then **disengaging** and their settled states. Both words are spelled correctly, centered, and have no trailing dots.
- Dashboard: camera-off state, active simulated inspection view, missing/offline readings, explicit Demo labeling, sensor values and video controls at desktop and compact sizes.
- Recording: started/stopped a canvas preview recording; the browser produced a 1,386,065-byte WebM file.
- Accessories: demo lights toggle, camera power toggle, and viewing stops after demo power-off.
Reduced-motion CSS is included. The connected browser offers viewport testing but no media-preference emulation, so a rendered reduced-motion override was not verified.
## Existing harness limitation
The older `tests/control_ui.test.cjs` fake-DOM harness is not passing. It reports 23 failures on the saved pre-V2 checkpoint and 21 on V2. Most fail before app initialization because the harness lacks `ResizeObserver`; it also has obsolete markup/animation assertions and omits browser dependencies. It is not counted as a passing check. Actual browser checks above establish the rendered behavior of this implementation.
## Startup measurement
Three fresh-process worker-module imports on this laptop:
| Build | Samples | Median |
| --- | --- | --- |
| Pre-V2 checkpoint | 781.8 / 616.3 / 655.3 ms | 655.3 ms |
| V2 | 402.8 / 362.4 / 399.1 ms | 399.1 ms |
This measures the module import, not total app startup. The launcher also avoids pip on subsequent launches when requirements have not changed, and starts the web interface directly.
## Physical verification still required
Real Xbox hardware, live six-axis FC motor allocation, live sensor calibration, WHEP video from a Raspberry Pi, native WebView full-video recording, physical relay outputs, and tether/underwater behavior were not verified. The browser recording and native chunk-writing path were checked separately. BlueOS native camera discovery/signaling is not integrated; this release uses an explicit WHEP, HTTP video, or MJPEG URL.
+127
View File
@@ -0,0 +1,127 @@
# Argo V2
V2 separates controller mapping, vehicle motion, ArduSub motor allocation, and camera transport. The pre-V2 app is preserved in commit `5e91c28` on `main`; development is on `v2`. A portable Git bundle was also saved locally. The checkpoint is also uploaded as `checkpoint/pre-v2-20261022`.
## Try it
1. Run `run.ps1`, select **Demo Mode**, and connect.
2. Open **Vehicle**, select a **Preview frame**, and save. This changes the demo, never the connected FC's `FRAME_TYPE`.
3. Open **Mapping**, choose **Triggers · 3** or **Six axes**, and save.
4. Open **Dashboard** and press **Start** to see the simulated inspection camera. **Record** produces a WebM clip. The camera badge and sensor status identify demo data.
No physical port is opened in Demo Mode. Real TCP and UDP endpoints now connect to MAVLink instead of silently entering the demo.
## Xbox controls
| Motion | Triggers · 3 | Six axes |
| --- | --- | --- |
| Forward / back | RT / LT | RT / LT |
| Rise / dive | B / A | B / A |
| Turn | Left stick X | Left stick X |
| Sideways | Unassigned | Right stick X |
| Pitch | Unassigned | Left stick Y |
| Roll | Unassigned | RB / LB |
| Lights | X | X |
| View stream | Y | Y |
| Record | View | View |
Each motion has separate negative and positive bindings. Either direction can use a stick half, an analog trigger, a D-pad direction, or a button. Opposite commands cancel. Each motion has a limit; the profile also has dead zone and exponential response. The reverse arrow swaps its bindings. The **Buttons** disclosure configures optional accessory and media actions. Buttons do not arm the ROV.
Profiles are stored by controller ID in `controller_profiles.json`; **Xbox · offline** supplies a default before connecting a controller. Browser preview uses local storage. Existing stick mappings are migrated. Configuration is locked while armed or while the FC state is uncertain. Controls must return to neutral after input mode changes, reconnects, focus changes, or interruption. Accessory buttons also have to be released before they can trigger again.
Keyboard movement remains W/S, A/D (or Q/E), R/F. Additional axes use J/L for strafe, I/K for pitch, U/O for roll. The extra on-screen sliders return to center when released. F2 retains the 0.6-second engage hold; F3 requests disarm.
## Six thrusters and six axes
Your proposed arrangement can provide full control when the paired thrusters have useful moment arms:
| Pair | Shared thrust | Differential thrust |
| --- | --- | --- |
| Port / starboard, pointing forward | Surge | Yaw |
| Front / rear, pointing vertically | Heave | Pitch |
| Top / bottom, pointing sideways | Sway | Roll |
Translation uses the pair in the same direction; rotation uses opposite directions. Thruster directions, lever arms, balance and saturation still determine the result. The allocation matrix, whose columns are force direction and its moment `r × f`, must have rank six. Motor count alone does not establish this.
V2 sends body-motion demands to the FC. The autopilot owns its mixer and stabilization. Select/configure a firmware frame that matches the physical vehicle before using it. The app reads `FRAME_TYPE`; it does not infer a mixer from connected outputs or write a new frame automatically. Your orthogonal-pair design may require a custom firmware mixer if no stock frame matches its geometry.
| ArduSub frame | Motors | Available motion |
| --- | --- | --- |
| SimpleROV-3 (4) | 3 | Forward, vertical, yaw |
| SimpleROV-4 (5) | 4 | Forward, vertical, yaw, roll |
| SimpleROV-5 (6) | 5 | Forward, lateral, vertical, yaw, roll |
| BlueROV1 (0) | 6 | All six axes |
| Vectored (1) | 6 | All except pitch |
| Vectored 6DOF (2, 3) | 8 | All six axes |
The verified six-axis wire mapping is ArduSub's `MANUAL_CONTROL`: X forward, Y lateral, Z vertical (500 neutral), R yaw, S pitch, T roll. Some generic joystick documentation describes S/T differently; this implementation follows ArduSub source. Firmware version is requested at connection; pitch/roll remain unavailable until compatible firmware is identified. ArduSub 4.5 uses S/T as ±400 PWM offsets; the current 4.8 development implementation scales normalized S/T. These encodings have automated checks; physical behavior is unverified.
The shared output limit applies to all motion axes. Full-frame demands use a conservative shared envelope before the FC mixer. This is an input-demand limit, not a calibrated thrust or electrical-current limit. The earlier independent motor-scale reconstruction is specific to SimpleROV-3 and is unavailable on other frames; applying it to a general mixer would be incorrect. Motor names, assignments, and direction controls remain available for active outputs.
References: [ArduSub frame configurations](https://ardupilot.org/sub/docs/sub-frames.html), [ArduSub 4.5 joystick source](https://github.com/ArduPilot/ardupilot/blob/Sub-4.5/ArduSub/joystick.cpp), [current joystick source](https://github.com/ArduPilot/ardupilot/blob/master/ArduSub/joystick.cpp), [Cockpit mapping and vehicle-version support](https://blueos.cloud/cockpit/docs/latest/usage/advanced/).
## Companion computer and tether
```mermaid
flowchart LR
PC[Argo V2 on Windows] <-- Ethernet --> TOP[Topside media converter]
TOP <-- Fibre tether --> ROV[Onboard media converter]
ROV <-- Ethernet --> PI[Raspberry Pi / BlueOS]
PI <-- USB or UART / MAVLink --> FC[ArduSub FC]
CAM[USB or CSI camera] --> PI
SENSOR[Pressure and power sensors] --> FC
FC --> ESC[ESCs / thrusters]
```
This is a proposed architecture, not installed hardware. Use a compatible Ethernet-to-fibre interface at each end, with optics, cable, terminations and housings selected for the tether length and environment. Fibre carries the data link; onboard power or a separate power path is still required. Ethernet lets telemetry and compressed video share a network while remaining separate application streams.
BlueOS is the researched companion option. It routes the FC's MAVLink traffic and manages cameras on supported Raspberry Pi hardware. In BlueOS, configure a MAVLink UDP output to the topside computer's tether-interface IP on port 14550. In Argo, choose **Network · UDP 14550**, or enter an explicit endpoint in Link settings. The listener uses `udpin:0.0.0.0:14550`; choose an interface-specific address with a custom endpoint if needed. The PC and Pi must be reachable on the same tether network.
Video uses an independent browser media connection. This release accepts **WebRTC · WHEP**, a browser-playable **HTTP video** URL, or **MJPEG**. It does not directly decode RTSP or raw UDP H.264. BlueOS's camera service has its own signaling interface, so entering its normal web-dashboard address as a WHEP URL will not work. Use a compatible WHEP endpoint, such as MediaMTX connected to the Pi camera or an RTSP source. Integrating BlueOS's native camera discovery/signaling is a separate follow-up.
Example MediaMTX path for a supported Pi camera:
```yaml
paths:
cam:
source: rpiCamera
```
With the server configured on the tether network, a typical WHEP endpoint is `http://<pi-address>:8889/cam/whep`. Verify it against your MediaMTX configuration. The browser needs CORS permission for the local Argo origin, and the negotiated WebRTC media ports must be reachable. Use a browser-compatible codec. No firewall, authentication, or origin policy is changed automatically by Argo.
**Start/Stop** controls viewing. **Camera power** is a separate optional relay action. **Record** records the incoming browser video; desktop clips are written incrementally to `runtime/recordings/`, and browser-preview clips download as WebM. MJPEG playback has no video recording control. For recordings that must survive tether loss, configure recording on the Pi as well.
References: [BlueOS companion hardware](https://blueos.cloud/docs/latest/integrations/hardware/required/onboard-computer/), [tether overview](https://blueos.cloud/docs/latest/integrations/hardware/required/tether/), [BlueOS endpoints and video services](https://blueos.cloud/docs/latest/usage/advanced/), [MediaMTX Pi cameras](https://mediamtx.org/docs/static-sources/raspberry-pi-cameras), [browser playback](https://mediamtx.org/docs/read/web-browsers).
## Sensors and electricity
The dashboard displays FC telemetry only when its group is fresh. Missing or stale values show a dash. Demo values are explicitly marked.
| Reading | Source | Units |
| --- | --- | --- |
| Pitch, roll, heading and angular rates | ATTITUDE | degrees, degrees/s |
| Depth estimate relative to surface/home | GLOBAL_POSITION_INT.relative_alt | m, positive down |
| Acceleration, gyroscope, magnetic field | SCALED_IMU | m/s², rad/s, gauss |
| External pressure / temperature | SCALED_PRESSURE2 | hPa, °C |
| Battery voltage, current and charge | SYS_STATUS / battery 0 BATTERY_STATUS | V, A, mAh |
Depth needs a correctly configured and calibrated external pressure sensor. Raw accelerometer values include gravity; they are not speed or a gravity-compensated acceleration estimate. Heading is FC-estimated yaw, not a separately read compass bearing.
Current is measured in **amps (A)** at the present moment. Power is **watts (W) = V × A**. Consumption is charge integrated over time, displayed as **mAh**; it is not amps per second. Use a correctly sized, calibrated voltage/current power monitor connected to the FC. Voltage alone cannot measure current. The app prefers reported consumption and otherwise integrates fresh current as a session estimate; source information is in Battery settings. A voltage update does not make an old current reading fresh.
**Log data** writes received normalized sensor values to `logs/sensors_<timestamp>.csv`. Existing raw MAVLink session logs remain in `logs/`. Rates for attitude, depth, IMU and pressure are independently configurable. Begin with low rates on RS-485; use a higher-bandwidth tether for video and increased telemetry.
Lights and camera power require configured, separate FC relay outputs. Configure relay assignments on the FC, then select their zero-based relay numbers in **Vehicle**. Buttons wait for command acknowledgement and expose timeout/rejection status. An accepted command confirms FC acceptance, not measured physical illumination or camera power.
Reference: [ArduSub battery monitor calibration](https://ardupilot.org/sub/docs/common-power-module-configuration-in-mission-planner.html), [MAVLink message definitions](https://mavlink.io/en/messages/common.html).
## Performance and verification
- The launcher only installs dependencies when the requirements fingerprint changes.
- The production path launches `web_ui.py` directly. The old Tk frontend is loaded lazily from `legacy_ui.py`.
- The asset server is threaded. Video frames stay in the browser's media pipeline.
- Network MAVLink skips the redundant packet-leading heartbeat needed by the existing serial modules.
- V2 controller input has one command ramp in the worker, avoiding the previous additional browser smoothing delay.
- V2 controller-profile and station files are validated and replaced atomically. Existing input sequence, stale-bridge and heartbeat watchdogs cover all six motion axes.
Automated checks use synthetic gamepads, mock MAVLink transports, temporary configuration files and telemetry fixtures. Browser checks use Demo Mode and the actual rendered app. No live vehicle has been armed or commanded during development. Real controller hardware, Pi streaming, FC accessories, sensor calibration and water performance still need physical verification.
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 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()
+814
View File
@@ -0,0 +1,814 @@
"""Optional Tk frontend. Imported only when explicitly requested."""
import queue
import json
import os
import threading
import time
import tkinter as tk
import customtkinter as ctk
from pathlib import Path
from tkinter import messagebox, ttk
from serial.tools import list_ports
from app import Axes, MavlinkWorker, MOTOR_FUNCTIONS, NEUTRAL_Z, normalize_endpoint
class Joystick(tk.Canvas):
def __init__(self, master, callback, **kwargs):
self.size = 276
self.center_point = self.size / 2
self.radius = 108
self.knob_radius = 24
super().__init__(master, width=self.size, height=self.size, bg="#f5f5f2",
highlightthickness=0, bd=0, **kwargs)
self.callback = callback
self.value = (0.0, 0.0)
self.return_job = None
c, r, k = self.center_point, self.radius, self.knob_radius
self.create_oval(c-r, c-r, c+r, c+r, fill="#e9e9e5", outline="#d8d8d2", width=2)
self.create_oval(c-70, c-70, c+70, c+70, outline="#deded8", width=1)
self.create_line(c, c-r+12, c, c+r-12, fill="#d2d2cc", dash=(3, 6))
self.create_line(c-r+12, c, c+r-12, c, fill="#d2d2cc", dash=(3, 6))
self.create_text(c, 13, text="FORWARD", fill="#8a8a84", font=("Helvetica", 8, "bold"))
self.create_text(c, self.size-13, text="REVERSE", fill="#8a8a84", font=("Helvetica", 8, "bold"))
self.create_text(25, c, text="TURN\nLEFT", fill="#8a8a84", font=("Helvetica", 8, "bold"))
self.create_text(self.size-25, c, text="TURN\nRIGHT", fill="#8a8a84", font=("Helvetica", 8, "bold"))
self.knob = self.create_oval(c-k, c-k, c+k, c+k,
fill="#575c61", outline="#42474b", width=2)
self.bind("<Button-1>", self._move)
self.bind("<B1-Motion>", self._move)
self.bind("<ButtonRelease-1>", self._release)
def _move(self, event):
if self.return_job is not None:
self.after_cancel(self.return_job)
self.return_job = None
dx, dy = event.x - self.center_point, event.y - self.center_point
length = max(1.0, (dx * dx + dy * dy) ** 0.5)
if length > self.radius:
dx, dy = dx * self.radius / length, dy * self.radius / length
self.set_immediate(dx / self.radius, -dy / self.radius)
def set_immediate(self, x: float, y: float):
if self.return_job is not None:
self.after_cancel(self.return_job)
self.return_job = None
self.value = (x, y)
c, k = self.center_point, self.knob_radius
dx, dy = x * self.radius, -y * self.radius
self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy)
self.callback()
def _release(self, _event=None):
start_x, start_y = self.value
started = time.monotonic()
duration = 0.40
def ease_back():
elapsed = time.monotonic() - started
progress = min(1.0, elapsed / duration)
# Smoothstep has zero slope at both ends, avoiding a motor kick.
eased = progress * progress * (3.0 - 2.0 * progress)
remaining = 1.0 - eased
x, y = start_x * remaining, start_y * remaining
self.value = (x, y)
c, k = self.center_point, self.knob_radius
dx, dy = x * self.radius, -y * self.radius
self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy)
self.callback()
if progress < 1.0:
self.return_job = self.after(20, ease_back)
else:
self.return_job = None
if self.return_job is not None:
self.after_cancel(self.return_job)
ease_back()
def center(self):
if self.return_job is not None:
self.after_cancel(self.return_job)
self.return_job = None
self.value = (0.0, 0.0)
c, k = self.center_point, self.knob_radius
self.coords(self.knob, c-k, c-k, c+k, c+k)
self.callback()
class SpringScale(ctk.CTkSlider):
"""A Scale that smoothly returns to zero when the pointer is released."""
def __init__(self, master, **kwargs):
self.callback = kwargs.pop("command", None)
kwargs.pop("showvalue", None)
kwargs.pop("bg", None)
kwargs.pop("troughcolor", None)
kwargs.pop("activebackground", None)
kwargs.pop("highlightthickness", None)
kwargs.pop("bd", None)
kwargs.pop("sliderrelief", None)
if "orient" in kwargs:
kwargs["orientation"] = kwargs.pop("orient")
length = kwargs.pop("length", 200)
if kwargs.get("orientation", "horizontal") == "vertical":
kwargs.setdefault("height", length)
kwargs.setdefault("width", 18)
else:
kwargs.setdefault("width", length)
kwargs.setdefault("height", 18)
super().__init__(master, command=self._changed, corner_radius=10,
button_corner_radius=10, button_length=22,
fg_color="#dbe3ee", progress_color="#60a5fa",
button_color="#2563eb", button_hover_color="#1d4ed8",
**kwargs)
self.return_job = None
self.bind("<ButtonPress-1>", self._cancel_return, add="+")
self.bind("<ButtonRelease-1>", self._released, add="+")
def _changed(self, value):
if self.callback:
self.callback(value)
def _set_and_notify(self, value):
self.set(value)
if self.callback:
self.callback(value)
def _cancel_return(self, _event=None):
if self.return_job is not None:
self.after_cancel(self.return_job)
self.return_job = None
def _released(self, _event=None):
# Wait until Tk's native Scale binding has applied its final value.
self.after_idle(self.spring_to_center)
def set_immediate(self, value):
self._cancel_return()
self._set_and_notify(value)
def spring_to_center(self):
self._cancel_return()
start = float(self.get())
if abs(start) < 0.5:
self._set_and_notify(0)
return
started = time.monotonic()
duration = 0.40
def ease_back():
progress = min(1.0, (time.monotonic() - started) / duration)
eased = progress * progress * (3.0 - 2.0 * progress)
self._set_and_notify(start * (1.0 - eased))
if progress < 1.0:
self.return_job = self.after(20, ease_back)
else:
self._set_and_notify(0)
self.return_job = None
ease_back()
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("SUB Control")
self.geometry("1040x800")
self.minsize(920, 720)
self.events = queue.Queue()
self.worker = MavlinkWorker(self.events)
self.keys = set()
self.keyboard_yaw = 0
self.keyboard_vertical = 0
self.keyboard_planar = (0, 0)
self.servo_functions = {}
self.motor_directions = {}
self.battery_config_path = Path(__file__).resolve().parent / "battery_settings.json"
self.battery_config = {
"cells": 4,
"capacity_mah": 5500,
"c_rating": 35,
"full_v_per_cell": 4.20,
"empty_v_per_cell": 3.30,
"warning_percent": 20,
}
self.battery_voltage = None
self.battery_voltage_smoothed = None
self._load_battery_settings()
self._configure_styles()
self.protocol("WM_DELETE_WINDOW", self.close)
self._build()
self.refresh_ports()
self.bind_all("<KeyPress>", self._key_down)
self.bind_all("<KeyRelease>", self._key_up)
self.after(50, self._poll)
self.after(500, self._link_watchdog)
def _configure_styles(self):
self.configure(bg="#e8e9e7")
style = ttk.Style(self)
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure("TFrame", background="#fbfbf9")
style.configure("TLabel", background="#fbfbf9", foreground="#202224",
font=("Helvetica", 9))
style.configure("TLabelframe", background="#f4f6f8", bordercolor="#d8dee8",
borderwidth=1, relief="solid")
style.configure("TLabelframe.Label", background="#f4f6f8", foreground="#475569",
font=("Segoe UI", 9, "bold"))
style.configure("TButton", font=("Segoe UI", 9), padding=(10, 7),
background="#ffffff", foreground="#1e293b", bordercolor="#cbd5e1")
style.map("TButton", background=[("active", "#e8eef7")])
style.configure("Primary.TButton", background="#2563eb", foreground="#ffffff",
bordercolor="#2563eb", font=("Segoe UI", 9, "bold"))
style.map("Primary.TButton", background=[("active", "#1d4ed8")])
style.configure("Danger.TButton", background="#dc2626", foreground="#ffffff",
bordercolor="#dc2626", font=("Segoe UI", 9, "bold"))
style.map("Danger.TButton", background=[("active", "#b91c1c")])
style.configure("Title.TLabel", font=("Segoe UI", 18, "bold"), foreground="#0f172a")
style.configure("Subtitle.TLabel", font=("Segoe UI", 9), foreground="#64748b")
style.configure("Status.TLabel", font=("Segoe UI", 9, "bold"))
style.configure("Footer.TLabel", background="#e9eef5", foreground="#475569",
font=("Segoe UI", 9))
style.configure("Treeview", rowheight=28, background="#ffffff", fieldbackground="#ffffff",
foreground="#26282a", bordercolor="#e1e1dc", font=("Helvetica", 9))
style.configure("Treeview.Heading", font=("Helvetica", 9, "bold"), padding=(6, 6),
background="#f0f0ed", foreground="#4c4f52")
style.map("Treeview", background=[("selected", "#dbeafe")],
foreground=[("selected", "#1e3a8a")])
style.configure("Horizontal.TProgressbar", troughcolor="#e2e8f0", background="#22c55e",
bordercolor="#e2e8f0", lightcolor="#22c55e", darkcolor="#22c55e")
def _console_button(self, master, text, command, kind="neutral", width=112):
colors = {
"neutral": ("#f4f4f1", "#e9e9e4", "#343739", "#deded8"),
"primary": ("#dcebd5", "#cde2c4", "#27401f", "#c4dcb9"),
"danger": ("#f7dfdc", "#f1cfcb", "#7b3029", "#edcec9"),
}
color, hover, text_color, border = colors[kind]
return ctk.CTkButton(
master, text=text, command=command, width=width, height=34,
corner_radius=11, fg_color=color, hover_color=hover,
text_color=text_color, border_color=border, border_width=1,
font=ctk.CTkFont("Helvetica", 12, "bold" if kind != "neutral" else "normal"),
bg_color="transparent",
)
def _console_combo(self, master, values, width):
return ctk.CTkComboBox(
master, values=[str(value) for value in values], width=width, height=34,
corner_radius=10, border_width=1, border_color="#cbd5e1",
fg_color="#ffffff", button_color="#e8eef7",
button_hover_color="#dbeafe", dropdown_fg_color="#ffffff",
dropdown_hover_color="#dbeafe", text_color="#1e293b",
bg_color="transparent", state="readonly",
)
def _build(self):
shell = ctk.CTkFrame(self, fg_color="#f8f8f6", corner_radius=24,
border_width=1, border_color="#d5d6d3")
shell.pack(fill="both", expand=True, padx=18, pady=18)
shell.grid_rowconfigure(0, weight=1)
shell.grid_columnconfigure(1, weight=1)
rail = ctk.CTkFrame(shell, width=248, fg_color="#282d30", corner_radius=22)
rail.grid(row=0, column=0, sticky="nsew")
rail.grid_propagate(False)
workspace = ctk.CTkFrame(shell, fg_color="#fbfbf9", corner_radius=22)
workspace.grid(row=0, column=1, sticky="nsew")
ctk.CTkLabel(rail, text="SUB CONTROL / 03", text_color="#f4f4f1",
font=ctk.CTkFont("Helvetica", 13, "bold")).pack(
anchor="w", padx=24, pady=(24, 0))
ctk.CTkLabel(rail, text="ArduSub vehicle", text_color="#8f9699",
font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(2, 14))
vehicle_art = tk.Canvas(rail, width=204, height=250, bg="#282d30",
highlightthickness=0, bd=0)
vehicle_art.pack(padx=20, pady=(5, 4))
vehicle_art.create_oval(47, 27, 157, 223, fill="#353b3e", outline="#555c60", width=2)
vehicle_art.create_oval(68, 49, 136, 201, fill="#303538", outline="#444a4e")
vehicle_art.create_oval(80, 102, 124, 146, fill="#5e666a", outline="#788084", width=2)
vehicle_art.create_oval(21, 57, 62, 100, fill="#202426", outline="#62696d", width=2)
vehicle_art.create_oval(142, 57, 183, 100, fill="#202426", outline="#62696d", width=2)
vehicle_art.create_line(42, 79, 72, 93, fill="#757d81", width=3)
vehicle_art.create_line(162, 79, 132, 93, fill="#757d81", width=3)
vehicle_art.create_text(102, 236, text="TOP VIEW • 3 THRUSTERS",
fill="#7e8689", font=("Helvetica", 8, "bold"))
ctk.CTkLabel(rail, text="Vehicle", text_color="#959c9f",
font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(5, 0))
self.vehicle_label = ctk.CTkLabel(rail, text="No heartbeat", text_color="#f4f4f1",
font=ctk.CTkFont("Helvetica", 14, "bold"))
self.vehicle_label.pack(anchor="w", padx=24)
rail_bottom = ctk.CTkFrame(rail, fg_color="transparent")
rail_bottom.pack(side="bottom", fill="x", padx=24, pady=22)
self.battery_summary = ctk.CTkLabel(
rail_bottom, text="Battery —", text_color="#f4f4f1",
font=ctk.CTkFont("Helvetica", 13, "bold"), anchor="w"
)
self.battery_summary.pack(fill="x")
self.battery_meta = ctk.CTkLabel(
rail_bottom, text="Waiting for voltage", text_color="#92999c",
font=ctk.CTkFont("Helvetica", 9), anchor="w"
)
self.battery_meta.pack(fill="x", pady=(0, 5))
self.battery_bar = ctk.CTkProgressBar(
rail_bottom, height=7, corner_radius=4, fg_color="#454b4e",
progress_color="#c4e46c"
)
self.battery_bar.set(0)
self.battery_bar.pack(fill="x", pady=(0, 18))
self.link_label = ctk.CTkLabel(
rail_bottom, text="● DISCONNECTED", text_color="#f08a82",
font=ctk.CTkFont("Helvetica", 10, "bold"), anchor="w"
)
self.link_label.pack(fill="x")
header = ctk.CTkFrame(workspace, fg_color="transparent")
header.pack(fill="x", padx=28, pady=(24, 14))
title = ctk.CTkFrame(header, fg_color="transparent")
title.pack(side="left")
ctk.CTkLabel(title, text="Drive", text_color="#17191a",
font=ctk.CTkFont("Helvetica", 24, "bold")).pack(anchor="w")
ctk.CTkLabel(title, text="Differential steering • MANUAL control",
text_color="#8a8c8e", font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w")
top = ctk.CTkFrame(header, fg_color="transparent")
top.pack(side="right")
self.port = self._console_combo(top, [], 190)
self.port.grid(row=0, column=0, padx=4)
self._console_button(top, "↻", self.refresh_ports, width=38).grid(row=0, column=1, padx=4)
self.baud = self._console_combo(top, (9600, 19200, 38400, 57600, 115200), 105)
self.baud.set("57600")
self.rate = self._console_combo(top, (10, 20), 72)
self.rate.set("20")
self.link_settings_button = self._console_button(
top, "•••", self._toggle_link_settings, width=38
)
self.link_settings_button.grid(row=0, column=2, padx=4)
self.connect_button = self._console_button(
top, "Connect", self.toggle_connect, kind="primary", width=110
)
self.connect_button.grid(row=0, column=3, padx=(4, 0))
separator = ctk.CTkFrame(workspace, height=1, fg_color="#e7e7e3")
separator.pack(fill="x", padx=28)
controls = ctk.CTkFrame(workspace, fg_color="transparent")
controls.pack(fill="both", expand=True, padx=28, pady=18)
controls.grid_rowconfigure(0, weight=1)
controls.grid_columnconfigure(0, weight=1)
controls.grid_columnconfigure(1, minsize=214)
drive_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18)
drive_card.grid(row=0, column=0, sticky="nsew", padx=(0, 12))
ctk.CTkLabel(drive_card, text="STEER + THROTTLE", text_color="#66696b",
font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 0))
self.joystick = Joystick(drive_card, self._axes_changed)
self.joystick.pack(expand=True, pady=(4, 0))
ctk.CTkLabel(drive_card, text="W / S drive • A / D steer",
text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(0, 16))
heave_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18)
heave_card.grid(row=0, column=1, sticky="nsew")
ctk.CTkLabel(heave_card, text="VERTICAL", text_color="#66696b",
font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 2))
ctk.CTkLabel(heave_card, text="RISE", text_color="#969896",
font=ctk.CTkFont("Helvetica", 8, "bold")).pack()
self.vertical = SpringScale(
heave_card, from_=100, to=-100, orient="vertical", length=230,
command=lambda _: self._axes_changed()
)
self.vertical.set(0)
self.vertical.pack(expand=True, pady=8)
ctk.CTkLabel(heave_card, text="DIVE", text_color="#969896",
font=ctk.CTkFont("Helvetica", 8, "bold")).pack()
ctk.CTkLabel(heave_card, text="R / F • springs to neutral",
text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(4, 16))
self.battery_cells = tk.StringVar(value=str(self.battery_config["cells"]))
self.battery_capacity = tk.StringVar(value=str(self.battery_config["capacity_mah"]))
self.battery_c_rating = tk.StringVar(value=str(self.battery_config["c_rating"]))
self.battery_full_cell = tk.StringVar(value=str(self.battery_config["full_v_per_cell"]))
self.battery_empty_cell = tk.StringVar(value=str(self.battery_config["empty_v_per_cell"]))
self.battery_warning = tk.StringVar(value=str(self.battery_config["warning_percent"]))
fields = (
("Cells (S)", self.battery_cells, 5),
("Capacity mAh", self.battery_capacity, 8),
("C rating", self.battery_c_rating, 5),
("Full V/cell", self.battery_full_cell, 6),
("Empty V/cell", self.battery_empty_cell, 6),
("Warn %", self.battery_warning, 5),
)
self.battery_settings_frame = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14)
for column, (label, variable, width) in enumerate(fields):
group = ctk.CTkFrame(self.battery_settings_frame, fg_color="transparent")
group.grid(row=0, column=column, padx=(0, 8), sticky="w")
ctk.CTkLabel(group, text=label, text_color="#66696b",
font=ctk.CTkFont("Helvetica", 9)).pack(anchor="w")
ctk.CTkEntry(group, textvariable=variable, width=max(62, width * 9), height=30,
corner_radius=8, border_color="#d0d0cb").pack(anchor="w")
self._console_button(self.battery_settings_frame, "Apply", self._apply_battery_settings,
kind="primary", width=82).grid(
row=0, column=6, padx=(8, 0), sticky="sew"
)
self.battery_settings_frame.pack_forget()
actions = ctk.CTkFrame(workspace, fg_color="transparent")
actions.pack(fill="x", padx=28, pady=(0, 14))
self._console_button(actions, "MANUAL", self.set_manual, width=94).pack(side="left", padx=3)
self._console_button(actions, "ARM", self.arm, kind="primary", width=82).pack(side="left", padx=3)
self._console_button(actions, "DISARM", self.disarm, kind="danger", width=92).pack(side="left", padx=3)
ctk.CTkLabel(actions, text="Output", text_color="#777a7c",
font=ctk.CTkFont("Helvetica", 10)).pack(side="left", padx=(18, 4))
self.gain = ctk.CTkSlider(
actions, from_=5, to=50, number_of_steps=45, width=170, height=18,
command=self._gain_changed, corner_radius=10, button_corner_radius=10,
button_length=22, fg_color="#dbe3ee", progress_color="#60a5fa",
button_color="#2563eb", button_hover_color="#1d4ed8",
bg_color="transparent",
)
self.gain.set(20)
self.gain.pack(side="left")
self.gain_text = ctk.CTkLabel(actions, text="20%", text_color="#55585a",
font=ctk.CTkFont("Helvetica", 10, "bold"))
self.gain_text.pack(side="left")
self._console_button(actions, "Motor setup", self._toggle_motor_setup, width=112).pack(
side="right", padx=3
)
self._console_button(actions, "Battery", self._toggle_battery_settings, width=88).pack(
side="right", padx=3
)
assignments = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14)
self.assignments_frame = assignments
self.servo_tree = ttk.Treeview(
assignments, columns=("output", "function", "reversed"),
show="headings", height=5, selectmode="browse",
)
self.servo_tree.heading("output", text="Output")
self.servo_tree.heading("function", text="Function")
self.servo_tree.heading("reversed", text="Motor reversed")
self.servo_tree.column("output", width=90, anchor="center", stretch=False)
self.servo_tree.column("function", width=220, anchor="w")
self.servo_tree.column("reversed", width=90, anchor="center", stretch=False)
for number in range(1, 17):
self.servo_tree.insert("", "end", iid=str(number),
values=(f"SERVO{number}", "Waiting…", "Waiting…"))
self.servo_tree.pack(side="left", fill="x", expand=True)
scrollbar = ttk.Scrollbar(assignments, orient="vertical", command=self.servo_tree.yview)
scrollbar.pack(side="left", fill="y")
self.servo_tree.configure(yscrollcommand=scrollbar.set)
edit = ctk.CTkFrame(assignments, fg_color="transparent")
edit.pack(side="right", fill="y")
ctk.CTkLabel(edit, text="Selected output function", text_color="#55585a").pack(anchor="w")
motor_choices = ["Unassigned (0)"] + [f"Motor {number}" for number in range(1, 13)]
self.motor_function = self._console_combo(edit, motor_choices, 165)
self.motor_function.set("Motor 1")
self.motor_function.pack(fill="x", pady=(2, 5))
self._console_button(edit, "Apply function", self.apply_servo_function, width=165).pack(fill="x", pady=2)
self._console_button(edit, "Reverse motor", self.toggle_servo_reversed, width=165).pack(fill="x", pady=2)
ctk.CTkLabel(edit, text="Changes require DISARMED", text_color="#a16207").pack(pady=(8, 0))
log_frame = ctk.CTkFrame(workspace, fg_color="transparent")
log_frame.pack(fill="x", padx=28, pady=(0, 12))
self.log_frame = log_frame
self.log_path_text = tk.StringVar(value="Session log starts when connected")
ctk.CTkLabel(log_frame, textvariable=self.log_path_text, anchor="w", text_color="#8b8d8f",
font=ctk.CTkFont("Helvetica", 9)).pack(side="left", fill="x", expand=True)
self._console_button(log_frame, "Open logs", self.open_logs_folder, width=96).pack(side="right")
self.status_text = tk.StringVar(value="Ready. Controls are neutral; vehicle is not connected.")
ctk.CTkLabel(workspace, textvariable=self.status_text, anchor="w", height=34,
corner_radius=12, fg_color="#eeeeea", text_color="#65686a",
font=ctk.CTkFont("Helvetica", 9)).pack(fill="x", padx=28, pady=(0, 18))
def refresh_ports(self):
ports = [p.device for p in list_ports.comports()]
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])
def _toggle_battery_settings(self):
if self.battery_settings_frame.winfo_ismapped():
self.battery_settings_frame.pack_forget()
else:
self.battery_settings_frame.pack(
fill="x", padx=28, pady=(0, 12), before=self.log_frame
)
def _toggle_link_settings(self):
if self.baud.winfo_ismapped():
self.baud.grid_remove()
self.rate.grid_remove()
else:
self.baud.grid(row=1, column=0, sticky="e", padx=4, pady=(6, 0))
self.rate.grid(row=1, column=1, sticky="w", padx=4, pady=(6, 0))
def _toggle_motor_setup(self):
if self.assignments_frame.winfo_ismapped():
self.assignments_frame.pack_forget()
else:
self.assignments_frame.pack(
fill="x", padx=28, pady=(0, 12), before=self.log_frame
)
def _load_battery_settings(self):
try:
saved = json.loads(self.battery_config_path.read_text(encoding="utf-8"))
for key in self.battery_config:
if key in saved:
self.battery_config[key] = saved[key]
except (OSError, ValueError, TypeError):
pass
def _apply_battery_settings(self):
try:
config = {
"cells": int(self.battery_cells.get()),
"capacity_mah": int(self.battery_capacity.get()),
"c_rating": float(self.battery_c_rating.get()),
"full_v_per_cell": float(self.battery_full_cell.get()),
"empty_v_per_cell": float(self.battery_empty_cell.get()),
"warning_percent": int(self.battery_warning.get()),
}
if not 1 <= config["cells"] <= 12:
raise ValueError("cell count must be between 1 and 12")
if config["capacity_mah"] <= 0 or config["c_rating"] <= 0:
raise ValueError("capacity and C rating must be positive")
if not config["full_v_per_cell"] > config["empty_v_per_cell"] > 0:
raise ValueError("full V/cell must be greater than empty V/cell")
if not 0 <= config["warning_percent"] <= 100:
raise ValueError("warning percentage must be between 0 and 100")
self.battery_config = config
self.battery_config_path.write_text(
json.dumps(config, indent=2) + "\n", encoding="utf-8"
)
self._update_battery_display()
self.status_text.set("Battery settings saved")
except (ValueError, OSError) as exc:
messagebox.showerror("Invalid battery settings", str(exc))
def _battery_percent(self, voltage):
cells = self.battery_config["cells"]
per_cell = voltage / cells
empty = self.battery_config["empty_v_per_cell"]
full = self.battery_config["full_v_per_cell"]
return max(0.0, min(100.0, (per_cell - empty) * 100.0 / (full - empty)))
def _update_battery_display(self):
voltage = self.battery_voltage_smoothed
if voltage is None:
self.battery_summary.configure(text="Battery —", text_color="#f4f4f1")
self.battery_meta.configure(text="Waiting for voltage")
self.battery_bar.set(0)
return
cells = self.battery_config["cells"]
percent = self._battery_percent(voltage)
per_cell = voltage / cells
warning = percent <= self.battery_config["warning_percent"]
self.battery_summary.configure(
text=f"Battery {percent:.0f}%",
text_color="#ffaaa2" if warning else "#f4f4f1",
)
self.battery_meta.configure(
text=f"{voltage:.2f} V • {per_cell:.2f} V/cell • {cells}S"
+ (" • LOW" if warning else "")
)
self.battery_bar.configure(progress_color="#f08a82" if warning else "#c4e46c")
self.battery_bar.set(percent / 100.0)
def toggle_connect(self):
if self.worker.thread and self.worker.thread.is_alive():
self.worker.disconnect()
self.connect_button.configure(state="disabled")
return
if not self.port.get():
messagebox.showerror("No port", "Select a USB serial COM port.")
return
self.worker.rate_hz = int(self.rate.get())
self.worker.connect(self.port.get(), int(self.baud.get()))
self.connect_button.configure(text="Disconnect")
self.status_text.set("Opening serial port…")
def _axes_changed(self):
turn, forward = self.joystick.value
self.worker.set_axes(Axes(forward, 0.0,
float(self.vertical.get()) / 100,
turn))
def _gain_changed(self, value):
value = int(float(value))
self.worker.gain = value / 100
self.gain_text.configure(text=f"{value}%")
def _key_down(self, event):
key = event.keysym.lower()
if key in "wasdqerf":
self.keys.add(key)
self._apply_keys()
def _key_up(self, event):
key = event.keysym.lower()
self.keys.discard(key)
self._apply_keys()
def _apply_keys(self):
turn = (1 if ("d" in self.keys or "e" in self.keys) else 0) - (
1 if ("a" in self.keys or "q" in self.keys) else 0
)
forward = (1 if "w" in self.keys else 0) - (1 if "s" in self.keys else 0)
vertical = (1 if "r" in self.keys else 0) - (1 if "f" in self.keys else 0)
planar = (turn, forward)
if planar != self.keyboard_planar:
if turn or forward:
self.joystick.set_immediate(float(turn), float(forward))
else:
self.joystick._release()
self.keyboard_planar = planar
if vertical != self.keyboard_vertical:
if vertical:
self.vertical.set_immediate(vertical * 100)
else:
self.vertical.spring_to_center()
self.keyboard_vertical = vertical
self._axes_changed()
def arm(self):
if not messagebox.askyesno("Arm vehicle", "Propellers removed or vehicle securely restrained?\n\nArm now?"):
return
try:
self.worker.log("OPERATOR", "clicked ARM")
self.worker.command_arm(True)
self.status_text.set("Arm command sent; waiting for ACK/heartbeat…")
except Exception as exc:
messagebox.showerror("Cannot arm", str(exc))
def disarm(self):
try:
self.worker.log("OPERATOR", "clicked DISARM")
self.worker.command_arm(False)
self.status_text.set("Disarm command sent; neutral sent")
except Exception as exc:
messagebox.showerror("Cannot disarm", str(exc))
def set_manual(self):
try:
self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode()
self.status_text.set("MANUAL mode request sent")
except Exception as exc:
messagebox.showerror("Cannot set mode", str(exc))
def open_logs_folder(self):
log_dir = Path(__file__).resolve().parent / "logs"
log_dir.mkdir(exist_ok=True)
os.startfile(log_dir)
def _selected_servo(self):
selection = self.servo_tree.selection()
if not selection:
raise RuntimeError("Select a SERVO output in the table first")
return int(selection[0])
def apply_servo_function(self):
try:
servo = self._selected_servo()
choice = self.motor_function.get()
if choice.startswith("Motor "):
motor = int(choice.split()[1])
function = 32 + motor if motor <= 8 else 73 + motor
else:
function = 0
description = MOTOR_FUNCTIONS.get(function, "Unassigned")
if not messagebox.askyesno(
"Change output function",
f"Set SERVO{servo}_FUNCTION to {description} ({function})?\n\n"
"This changes the autopilot configuration.",
):
return
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
except Exception as exc:
messagebox.showerror("Cannot change function", str(exc))
def toggle_servo_reversed(self):
try:
servo = self._selected_servo()
function = self.servo_functions.get(servo)
motor = motor_number_for_function(function)
if motor is None:
raise RuntimeError(
f"SERVO{servo} is not assigned to an ArduSub Motor function"
)
current = self.motor_directions.get(motor)
if current is None:
raise RuntimeError(f"MOT_{motor}_DIRECTION has not been read yet")
new_value = -1 if current == 1 else 1
if not messagebox.askyesno(
"Reverse motor output",
f"SERVO{servo} is Motor {motor}.\n"
f"Set MOT_{motor}_DIRECTION to {new_value}?\n\n"
"Test direction with propellers removed after changing this.",
):
return
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", new_value)
except Exception as exc:
messagebox.showerror("Cannot reverse output", str(exc))
def _refresh_servo_row(self, number):
function = self.servo_functions.get(number)
motor = motor_number_for_function(function)
direction = self.motor_directions.get(motor) if motor is not None else None
if function is None:
function_text = "Waiting…"
else:
function_text = f"{MOTOR_FUNCTIONS.get(function, 'Other / unassigned')} ({function})"
if motor is None:
reversed_text = "—"
elif direction is None:
reversed_text = "Waiting…"
else:
reversed_text = "Yes" if direction == -1 else "No"
self.servo_tree.item(str(number), values=(f"SERVO{number}", function_text, reversed_text))
def _poll(self):
try:
while True:
event = self.events.get_nowait()
kind = event[0]
if kind == "heartbeat":
_, system, autopilot, mode, armed = event[:5]
self.vehicle_label.configure(
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
)
self.link_label.configure(text="● LINK OK", text_color="#c4e46c")
elif kind in ("status", "statustext"):
self.status_text.set(event[1])
elif kind == "ack":
self.status_text.set(f"Command {event[1]} ACK: {event[2]}")
elif kind == "servo_param":
_, param_id, value = event
number = int(param_id[5:param_id.index("_")])
self.servo_functions[number] = value
self._refresh_servo_row(number)
elif kind == "motor_direction":
_, motor, value = event
self.motor_directions[motor] = value
for number in range(1, 17):
if motor_number_for_function(self.servo_functions.get(number)) == motor:
self._refresh_servo_row(number)
elif kind == "battery":
self.battery_voltage = event[1]
if self.battery_voltage_smoothed is None:
self.battery_voltage_smoothed = self.battery_voltage
else:
self.battery_voltage_smoothed = (
0.75 * self.battery_voltage_smoothed + 0.25 * self.battery_voltage
)
self._update_battery_display()
elif kind == "param_saved":
self.status_text.set(f"Confirmed: {event[1]}={event[2]}")
elif kind == "param_missing":
self.status_text.set(f"No response for {event[1]}; continuing scan…")
elif kind == "param_scan_complete":
count = sum(value in MOTOR_FUNCTIONS for value in self.servo_functions.values())
self.status_text.set(f"Servo scan complete: {count} motor output(s) assigned")
elif kind == "log_path":
self.log_path_text.set(f"Log: {event[1]}")
elif kind == "error":
self.status_text.set(event[1])
messagebox.showerror("MAVLink", event[1])
elif kind == "disconnected":
self.connect_button.configure(text="Connect", state="normal")
self.link_label.configure(text="● DISCONNECTED", text_color="#f08a82")
self.vehicle_label.configure(text="No heartbeat")
self.battery_voltage = None
self.battery_voltage_smoothed = None
self._update_battery_display()
self.servo_functions.clear()
self.motor_directions.clear()
for number in range(1, 17):
self.servo_tree.item(str(number), values=(f"SERVO{number}", "Waiting…", "Waiting…"))
except queue.Empty:
pass
self.after(50, self._poll)
def _link_watchdog(self):
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5.0:
if time.monotonic() - self.worker.last_rx < 2.0:
text = "● DATA OK / HEARTBEAT DELAYED"
else:
text = "● ALL LINK DATA LOST"
self.link_label.configure(text=text, text_color="#f08a82")
self.worker._send_control(force_neutral=True)
self.after(500, self._link_watchdog)
def close(self):
self.worker.disconnect()
self.after(150, self.destroy)
+132
View File
@@ -0,0 +1,132 @@
"""Validated V2 settings and telemetry, independent of the desktop renderer."""
from __future__ import annotations
import json
import math
import re
import time
from pathlib import Path
from urllib.parse import urlsplit
AXES = ("forward", "lateral", "vertical", "turn", "pitch", "roll")
FRAMES = {
0: ("BlueROV1", 6, AXES),
1: ("Vectored", 6, tuple(axis for axis in AXES if axis != "pitch")),
2: ("Vectored 6DOF", 8, AXES),
3: ("Vectored 90", 8, AXES),
4: ("SimpleROV 3", 3, ("forward", "vertical", "turn")),
5: ("SimpleROV 4", 4, ("forward", "vertical", "turn", "roll")),
6: ("SimpleROV 5", 5, ("forward", "lateral", "vertical", "turn", "roll")),
}
INPUT = re.compile(r"^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$")
def number(value, minimum, maximum):
result = float(value)
if not math.isfinite(result) or not minimum <= result <= maximum:
raise ValueError(f"Value must be between {minimum} and {maximum}")
return result
def clean_profile(config):
if not isinstance(config, dict) or config.get("version") != 2:
raise ValueError("Invalid controller profile")
if not isinstance(config.get("bindings", {}), dict) or not isinstance(config.get("actions", {}), dict):
raise ValueError("Invalid controller bindings")
bindings = {}
for axis in AXES:
item = config.get("bindings", {}).get(axis, {})
if not isinstance(item, dict):
raise ValueError("Invalid motion binding")
negative, positive = item.get("negative", "off"), item.get("positive", "off")
if not isinstance(negative, str) or not INPUT.fullmatch(negative):
raise ValueError("Invalid negative input")
if not isinstance(positive, str) or not INPUT.fullmatch(positive):
raise ValueError("Invalid positive input")
if negative == positive and positive != "off":
raise ValueError("Choose different inputs for each direction")
bindings[axis] = {"negative": negative, "positive": positive,
"scale": number(item.get("scale", 1), 0, 1)}
actions = {}
for action in ("lights", "stream", "record"):
source = config.get("actions", {}).get(action, "off")
if source != "off" and (not isinstance(source, str) or not re.fullmatch(r"button:(?:[0-9]|[12][0-9]|3[01])", source)):
raise ValueError("Actions require a button")
actions[action] = source
return {"version": 2, "preset": str(config.get("preset", "custom"))[:24],
"deadZone": number(config.get("deadZone", .08), 0, .3),
"expo": number(config.get("expo", .15), 0, 1),
"bindings": bindings, "actions": actions}
def clean_station(config):
if not isinstance(config, dict):
raise ValueError("Invalid station settings")
frame_value = number(config.get("frame", 4), 0, 6)
if not frame_value.is_integer():
raise ValueError("Invalid frame")
frame = int(frame_value)
if frame not in FRAMES:
raise ValueError("Choose an ArduSub frame")
mode = config.get("video_mode", "whep")
if mode not in ("whep", "video", "mjpeg"):
raise ValueError("Unsupported stream type")
url = str(config.get("video_url", "")).strip()
if url:
parsed = urlsplit(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("Use an HTTP stream URL without embedded credentials")
relays = {}
for name in ("lights_relay", "camera_relay"):
value = number(config.get(name, -1), -1, 15)
if not value.is_integer():
raise ValueError("Invalid relay")
relays[name] = int(value)
if relays["lights_relay"] >= 0 and relays["lights_relay"] == relays["camera_relay"]:
raise ValueError("Lights and camera need separate relays")
return {"frame": frame, "video_mode": mode, "video_url": url[:2048], **relays}
def save_json(path: Path, data):
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n", encoding="utf-8")
temporary.replace(path)
class Telemetry:
"""Timestamp every group; missing or stale measurements stay unavailable."""
def __init__(self, clock=time.monotonic):
self.clock = clock
self.groups = {}
def update(self, group, values, received_at=None):
clean = {key: value if isinstance(value, (int, float)) and math.isfinite(value) else None
for key, value in values.items()}
self.groups[group] = {"values": clean, "at": self.clock() if received_at is None else received_at}
def snapshot(self, connected=True):
now = self.clock()
return {name: {"values": item["values"], "age_s": max(0, now - item["at"]),
"stale": not connected or now - item["at"] > 3}
for name, item in self.groups.items()}
def decode_telemetry(msg):
kind = msg.get_type()
if kind == "ATTITUDE":
return "attitude", {"roll": math.degrees(msg.roll), "pitch": math.degrees(msg.pitch),
"heading": math.degrees(msg.yaw) % 360,
"roll_rate": math.degrees(msg.rollspeed),
"pitch_rate": math.degrees(msg.pitchspeed),
"yaw_rate": math.degrees(msg.yawspeed)}
if kind == "GLOBAL_POSITION_INT":
# ArduSub reports height relative to its surface/home origin, positive up.
return "depth", {"depth_m": -msg.relative_alt / 1000.0, "vertical_ms": msg.vz / 100.0}
if kind in ("SCALED_IMU", "SCALED_IMU2", "SCALED_IMU3"):
return "imu", {"ax": msg.xacc * .00980665, "ay": msg.yacc * .00980665,
"az": msg.zacc * .00980665, "gx": msg.xgyro / 1000.0,
"gy": msg.ygyro / 1000.0, "gz": msg.zgyro / 1000.0,
"mx": msg.xmag / 1000.0, "my": msg.ymag / 1000.0, "mz": msg.zmag / 1000.0}
if kind == "SCALED_PRESSURE2":
return "pressure", {"pressure_hpa": msg.press_abs, "temperature_c": msg.temperature / 100.0}
return None
+14 -5
View File
@@ -3,13 +3,22 @@ $ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location -LiteralPath $projectRoot Set-Location -LiteralPath $projectRoot
if (-not (Get-Command py -ErrorAction SilentlyContinue)) { & (Join-Path $projectRoot 'create_shortcut.ps1')
throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again."
}
if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) { if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) {
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."
}
& py -3 -m venv .venv & py -3 -m venv .venv
if ($LASTEXITCODE -ne 0) { throw 'Virtual environment setup failed.' }
} }
& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt $dependencyHash = (Get-FileHash -LiteralPath 'requirements.txt' -Algorithm SHA256).Hash
& .venv\Scripts\python.exe app.py $dependencyMarker = Join-Path $projectRoot '.venv\requirements.sha256'
$installedHash = if (Test-Path -LiteralPath $dependencyMarker) { (Get-Content -LiteralPath $dependencyMarker -Raw).Trim() } else { '' }
if ($installedHash -ne $dependencyHash) {
& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' }
Set-Content -LiteralPath $dependencyMarker -Value $dependencyHash
}
& .venv\Scripts\python.exe web_ui.py
+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.');
+29
View File
@@ -0,0 +1,29 @@
/* Synthetic Xbox input: no device, serial port, or browser is opened. */
const assert=require('node:assert/strict');
const input=require('../web/controller-input.js');
const pad=()=>({connected:true,mapping:'standard',axes:[0,0,0,0],buttons:Array.from({length:17},()=>({value:0,pressed:false}))});
const press=(pad,index,value=1)=>{pad.buttons[index]={value,pressed:value>.5};return pad};
const three=input.preset(),six=input.preset('sixaxis');
assert(input.validProfile(three));assert(input.validProfile(six));
assert.deepEqual(input.read(pad(),three),Object.fromEntries(input.names.map(name=>[name,0])));
assert.equal(input.read(press(pad(),1),three).vertical,1);
assert.equal(input.read(press(pad(),0),three).vertical,-1);
assert.equal(input.read(press(press(pad(),0),1),three).vertical,0);
assert.equal(input.read(press(pad(),7),three).forward,1);
assert.equal(input.read(press(pad(),6),three).forward,-1);
assert.equal(input.read(press(press(pad(),6),7),three).forward,0);
assert(input.read(press(pad(),7,.5),three).forward>0);
assert(input.read(press(pad(),7,.5),three).forward<.5);
let p=pad();p.axes[0]=1;p.axes[1]=-1;p.axes[2]=1;press(p,4);
assert.deepEqual(input.read(p,six),{forward:0,lateral:1,vertical:0,turn:1,pitch:-1,roll:-1});
let custom=structuredClone(six);custom.bindings.vertical={negative:'button:12',positive:'button:13',scale:.25};
assert.equal(input.read(press(pad(),13),custom).vertical,.25);
custom.bindings.turn.scale=0;assert.equal(input.read(p,custom).turn,0);
custom.bindings.pitch.negative='axis:90:-';assert.equal(input.read(p,custom),null);
assert.equal(input.read({...pad(),connected:false},three),null);
p=pad();p.axes[0]=NaN;assert.equal(input.read(p,three),null);
p=pad();p.buttons=[];assert.equal(input.read(p,three),null);
assert.equal(input.actions(press(pad(),2),three).lights,true);
assert.equal(input.migrate({turn:0,forward:1,vertical:3,invertForward:true,invertVertical:true,deadZone:.1}).bindings.forward.positive,'axis:1:-');
const duplicate=structuredClone(three);duplicate.bindings.forward.negative='button:7';assert.equal(input.validProfile(duplicate),false);
console.log('PASS: Xbox triggers, B/A, cancellation, six axes, custom buttons, limits, invalid devices, migration and actions.');
+165
View File
@@ -0,0 +1,165 @@
"""V2 protocol and persistence checks. Never opens a vehicle connection."""
import math
import queue
import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app import Axes, MavlinkWorker, normalize_endpoint
from rov_v2 import Telemetry, clean_station, clean_profile, decode_telemetry, save_json
from web_ui import ControlApi
class V2Tests(unittest.TestCase):
def worker(self, frame=0, version=(4, 5, 3)):
worker = MavlinkWorker(queue.Queue())
worker.connection = Mock()
worker.target_system = 1
worker.frame_type = frame
worker.firmware_version = version
worker.gain = .5
worker.last_control_at = time.monotonic() - 1
return worker
def test_six_axis_mapping_and_envelope(self):
worker = self.worker()
worker.set_axes(Axes(forward=1, lateral=1, vertical=1, yaw=1, pitch=1, roll=1))
worker._send_control()
args = worker.connection.mav.manual_control_send.call_args.args
self.assertEqual(args[6:8], (0, 3))
self.assertEqual(args[8:], (33, 33)) # S=pitch, T=roll; ArduSub 4.5 ±400.
self.assertEqual(args[1:5], (83, 83, 542, 83))
def test_modern_normalized_attitude(self):
worker = self.worker(version=(4, 8, 0))
worker.set_axes(Axes(pitch=.5, roll=-.5))
worker._send_control()
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args[8:], (250, -250))
def test_unsupported_frame_and_old_firmware(self):
worker = self.worker(frame=1)
worker.set_axes(Axes(pitch=1, roll=.2))
worker._send_control()
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args[8], 0)
worker.firmware_version = (4, 1, 0)
worker._send_control()
self.assertEqual(len(worker.connection.mav.manual_control_send.call_args.args), 6)
def test_watchdog_neutralizes_extended_axes(self):
worker = self.worker()
worker.require_input_lease = True
worker.armed = True
worker.last_heartbeat = time.monotonic()
worker.set_axes(Axes(pitch=1, roll=-1), 1)
worker.last_input_at -= 2
worker._send_control()
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args, (1, 0, 0, 500, 0, 0, 0, 3, 0, 0))
self.assertEqual(worker.axes, Axes())
def test_endpoints_preserve_configured_network_host(self):
for endpoint in ('udpin:0.0.0.0:14550', 'udpout:192.168.2.2:14550', 'tcp:192.168.2.2:5760'):
self.assertEqual(normalize_endpoint(endpoint), endpoint)
def test_sensor_units_and_staleness(self):
msg = SimpleNamespace(get_type=lambda: 'ATTITUDE', roll=math.pi/2, pitch=0, yaw=-math.pi/2, rollspeed=1, pitchspeed=0, yawspeed=0)
group, values = decode_telemetry(msg)
self.assertEqual(values['roll'], 90)
self.assertEqual(values['heading'], 270)
clock = [10]
store = Telemetry(lambda: clock[0])
store.update(group, values)
clock[0] = 14
self.assertTrue(store.snapshot()[group]['stale'])
msg = SimpleNamespace(get_type=lambda: 'GLOBAL_POSITION_INT', relative_alt=-1200, vz=25)
self.assertEqual(decode_telemetry(msg)[1], {'depth_m': 1.2, 'vertical_ms': .25})
def test_config_validation_and_atomic_roundtrip(self):
with self.assertRaises(ValueError):
clean_station({'video_url': 'javascript:alert(1)'})
with self.assertRaises(ValueError):
clean_station({'lights_relay': 1, 'camera_relay': 1})
with self.assertRaises(ValueError):
clean_profile({'version': 2, 'expo': float('nan')})
with tempfile.TemporaryDirectory() as folder:
target = Path(folder) / 'station.json'
save_json(target, clean_station({'frame': 2}))
import json
self.assertEqual(json.loads(target.read_text())['frame'], 2)
self.assertFalse(target.with_suffix('.json.tmp').exists())
def test_stale_current_is_not_refreshed_by_voltage(self):
api = ControlApi()
api.state['connected'] = True
with patch('web_ui.time.monotonic', return_value=10):
api._consume_battery({'current_a': 4, 'voltage': 16})
with patch('web_ui.time.monotonic', return_value=16):
api._consume_battery({'voltage': 15.9})
self.assertIsNone(api._snapshot()['battery']['current_a'])
with patch('web_ui.time.monotonic', return_value=17):
api._consume_battery({'current_a': None})
self.assertIsNone(api.state['battery']['current_a'])
def test_attitude_stream_is_never_disabled_when_requested(self):
worker = self.worker()
worker._configure_low_bandwidth_streams()
rates = [(name, interval) for name, _, interval in worker.stream_config]
self.assertIn(('ATTITUDE', 500000), rates)
self.assertNotIn(('ATTITUDE', -1), rates)
def test_network_control_omits_serial_priming_heartbeat(self):
worker = self.worker()
worker.network_link = True
worker._send_control()
worker.connection.mav.heartbeat_send.assert_not_called()
def test_profile_saves_atomically_and_cannot_change_while_armed(self):
api = ControlApi()
profile = {'version': 2, 'bindings': {}, 'actions': {}}
with tempfile.TemporaryDirectory() as folder:
api.profiles_path = Path(folder) / 'profiles.json'
api.save_controller_profile('$xbox', profile)
self.assertTrue(api.profiles_path.is_file())
before = api.profiles_path.read_bytes()
api.state.update(connected=True, armed=True)
with self.assertRaises(RuntimeError):
api.save_controller_profile('$xbox', profile)
self.assertEqual(before, api.profiles_path.read_bytes())
def test_native_recording_chunk_roundtrip_and_expired_token(self):
import base64
api = ControlApi()
with tempfile.TemporaryDirectory() as folder, patch('web_ui.__file__', str(Path(folder) / 'web_ui.py')):
token = api.recording_begin()
api.recording_chunk(token, base64.b64encode(b'video chunk').decode())
path = api.recording_end(token)
self.assertEqual(Path(path).read_bytes(), b'video chunk')
with self.assertRaises(ValueError):
api.recording_chunk(token, 'AA==')
def test_relay_action_waits_for_ack(self):
api = ControlApi()
api.station_settings['lights_relay'] = 0
worker = api.worker
worker.connection = Mock()
worker.target_system = worker.target_component = 1
worker.last_heartbeat = time.monotonic()
api.set_accessory('lights', True)
self.assertIsNone(api.state['accessories']['lights']['accepted'])
api._consume(('accessory_ack', 'lights', True, 2))
self.assertFalse(api.state['accessories']['lights']['accepted'])
self.assertEqual(api.state['accessories']['lights']['error'], 'Rejected')
def test_motor_names_survive_app_restart(self):
with tempfile.TemporaryDirectory() as folder, patch('web_ui.__file__', str(Path(folder) / 'web_ui.py')):
first = ControlApi()
settings = first.motor_settings
settings['motors']['1']['name'] = 'Port'
first.save_motor_settings(settings)
self.assertEqual(ControlApi().motor_settings['motors']['1']['name'], 'Port')
if __name__ == '__main__':
unittest.main()
+335
View File
@@ -0,0 +1,335 @@
"""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()
configured = {name: interval for name, _, interval in self.worker.stream_config}
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 1_000_000)
self.assertEqual(configured["ATTITUDE"], 500_000)
self.assertEqual(configured["GLOBAL_POSITION_INT"], 500_000)
self.assertNotIn(("ATTITUDE", 30, -1), self.worker.stream_config)
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}
+1013 -35
View File
File diff suppressed because it is too large Load Diff
+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 0.6 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);
+125
View File
@@ -0,0 +1,125 @@
/* 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(config?.version===2)return readProfile(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;
}
}
const names=['forward','lateral','vertical','turn','pitch','roll'];
const pair=(negative='off',positive='off')=>({negative,positive,scale:1});
function preset(name='triggers3'){
const bindings=Object.fromEntries(names.map(axis=>[axis,pair()]));
bindings.forward=pair('button:6','button:7');
bindings.vertical=pair('button:0','button:1');
bindings.turn=pair('axis:0:-','axis:0:+');
if(name==='sixaxis'){
bindings.lateral=pair('axis:2:-','axis:2:+');
bindings.pitch=pair('axis:1:-','axis:1:+');
bindings.roll=pair('button:4','button:5');
}else if(name==='classic'){
bindings.forward=pair('axis:1:+','axis:1:-');
bindings.vertical=pair('axis:3:+','axis:3:-');
}
return {version:2,preset:name,deadZone:.08,expo:.15,bindings,
actions:{lights:'button:2',stream:'button:3',record:'button:8'}};
}
function migrate(config,pad){
if(config?.version===2)return structuredClone(config);
if(!config)return preset(pad&&pad.mapping!=='standard'?'classic':'triggers3');
const result=preset('classic');
for(const axis of ['forward','vertical','turn']){
const index=config[axis],invert=config['invert'+axis[0].toUpperCase()+axis.slice(1)];
result.bindings[axis]=Number.isInteger(index)&&index>=0?
pair(`axis:${index}:${invert?'+':'-'}`,`axis:${index}:${invert?'-':'+'}`):pair();
}
result.deadZone=config.deadZone??.08;
result.actions={lights:'off',stream:'off',record:'off'};
return result;
}
function validProfile(config){
const source=value=>typeof value==='string'&&/^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$/.test(value);
return config?.version===2&&Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&
Number.isFinite(config.expo)&&config.expo>=0&&config.expo<=1&&names.every(axis=>{
const item=config.bindings?.[axis];return item&&source(item.negative)&&source(item.positive)&&
(item.negative==='off'||item.negative!==item.positive)&&Number.isFinite(item.scale)&&item.scale>=0&&item.scale<=1;
})&&['lights','stream','record'].every(action=>source(config.actions?.[action])&&!config.actions[action].startsWith('axis:'));
}
function inputValue(pad,source,zone){
if(source==='off')return 0;
const parts=source.split(':'),index=Number(parts[1]);
if(parts[0]==='button'){
const button=pad.buttons?.[index];if(!button||!Number.isFinite(button.value))return null;
return index===6||index===7?Math.max(0,deadZone(button.value,zone)):(button.pressed?1:0);
}
const value=pad.axes?.[index];
return Number.isFinite(value)?Math.max(0,deadZone(value,zone)*(parts[2]==='-'?-1:1)):null;
}
function readProfile(pad,config){
if(!pad?.connected||!validProfile(config))return null;
const result={};
for(const name of names){
const item=config.bindings[name],negative=inputValue(pad,item.negative,config.deadZone),positive=inputValue(pad,item.positive,config.deadZone);
if(negative===null||positive===null)return null;
const value=clamp(positive-negative);
result[name]=((1-config.expo)*value+config.expo*value**3)*item.scale;
}
return result;
}
function actions(pad,config){
return Object.fromEntries(Object.entries(config?.actions||{}).map(([name,source])=>[name,!!pad&&inputValue(pad,source,0)>.5]));
}
function sources(pad){
const choices=[['off','Off']],axes=['Left X','Left Y','Right X','Right Y'];
for(let index=0;index<Math.max(4,pad?.axes?.length||0);index++)for(const sign of ['-','+'])
choices.push([`axis:${index}:${sign}`,`${axes[index]||'Axis '+index} ${sign==='-'?'−':'+'}`]);
const buttons=['A','B','X','Y','LB','RB','LT','RT','View','Menu','Left click','Right click','D-pad up','D-pad down','D-pad left','D-pad right','Guide'];
for(let index=0;index<Math.max(17,pad?.buttons?.length||0);index++)choices.push([`button:${index}`,buttons[index]||'Button '+index]);
return choices;
}
root.ControllerInput={deadZone,defaults,read,Motion,names,preset,migrate,validProfile,actions,sources};
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()});
})();
+229
View File
@@ -0,0 +1,229 @@
/* Video stays in the browser's media pipeline; no frames cross the control bridge. */
(()=>{
const $=id=>document.getElementById(id);
const frames={0:['BlueROV1',6,['forward','lateral','vertical','turn','pitch','roll']],1:['Vectored',6,['forward','lateral','vertical','turn','roll']],2:['Full 6DOF',8,['forward','lateral','vertical','turn','pitch','roll']],3:['6DOF 90°',8,['forward','lateral','vertical','turn','pitch','roll']],4:['SimpleROV 3',3,['forward','vertical','turn']],5:['SimpleROV 4',4,['forward','vertical','turn','roll']],6:['SimpleROV 5',5,['forward','lateral','vertical','turn','roll']]};
let station={frame:4,video_mode:'whep',video_url:'',lights_relay:-1,camera_relay:-1};
try{station={...station,...JSON.parse(localStorage.getItem('sub-v2-station'))}}catch(_){}
let host,snapshot={},workspace='drive',streaming=false,connecting=false,generation=0,peer=null,sessionUrl=null,abort=null;
let recorder=null,recordToken=null,recordQueue=Promise.resolve(),recordChunks=[],recordBytes=0,ownedStream=null,recordError=null,saving=false;
let demoFrame=0,lastPaint=0,streamStarted=0,auxBusy=false,streamTimer=0,capabilitySignature='',demoLastClock=-1;
const notify=(message,error=false)=>host?.toast(message,error);
const call=(name,...args)=>host.api(name,...args);
const demo=()=>!!host?.demo();
const frame=()=>frames[(demo()||!snapshot.connected)?station.frame:snapshot.capabilities?.frame]||frames[4];
function supported(){
let axes=frame()[2];
const firmware=snapshot.capabilities?.firmware;
if(snapshot.connected&&!demo()&&!(firmware&&(firmware[0]>4||firmware[0]===4&&(firmware[1]===5||firmware[1]>=8))))axes=axes.filter(axis=>axis!=='pitch'&&axis!=='roll');
return axes;
}
function filterDemand(demand){return Object.fromEntries(Object.entries(demand).map(([name,value])=>[name,supported().includes(name)?value:0]))}
function view(name){
if(name===workspace)return;
host.reset();workspace=name;document.body.dataset.view=name;
$('dashboard').hidden=name!=='dashboard';document.querySelector('.primary-control-zone').hidden=name!=='drive';
$('view-drive').setAttribute('aria-pressed',String(name==='drive'));$('view-dashboard').setAttribute('aria-pressed',String(name==='dashboard'));
}
function fillStation(){
const form=$('station-form');
for(const [name,value] of Object.entries(station))if(form.elements[name])form.elements[name].value=value;
for(const field of form.querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
}
function state(text){$('camera-state').textContent=text;$('camera-empty').hidden=false}
function showStream(){
clearTimeout(streamTimer);connecting=false;
$('camera-empty').hidden=true;$('camera-record').disabled=station.video_mode==='mjpeg'&&!demo();
$('camera-badge').textContent=demo()?'Demo':'Stream';streamStarted=performance.now();demoLastClock=-1;$('stream-clock').textContent='00:00';
}
async function startStream(){
if(streaming||connecting)return;
if(!demo()&&!station.video_url){host.open('station-modal');return}
const current=++generation;connecting=true;state('Connecting');$('camera-toggle').textContent='Stop';
clearTimeout(streamTimer);streamTimer=setTimeout(()=>{if(current===generation)void failStream('Stream timeout')},10000);
try{
if(demo()){
$('camera-demo').hidden=false;streaming=true;showStream();demoFrame=requestAnimationFrame(drawDemo);
}else if(station.video_mode==='mjpeg'){
const img=$('camera-image');img.onload=()=>{if(current!==generation)return;streaming=true;img.hidden=false;showStream()};
img.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};img.src=station.video_url;
}else if(station.video_mode==='video'){
const video=$('camera-video');video.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};video.onended=()=>{if(current===generation)void stopStream()};video.src=station.video_url;video.hidden=false;await video.play();
if(current!==generation)return;streaming=true;showStream();
}else{
abort=new AbortController();const timeout=setTimeout(()=>abort?.abort(),10000);
try{
peer=new RTCPeerConnection({iceServers:[]});const connection=peer;
connection.addTransceiver('video',{direction:'recvonly'});
connection.ontrack=event=>{if(current!==generation)return;$('camera-video').srcObject=event.streams[0]||new MediaStream([event.track]);$('camera-video').hidden=false;void $('camera-video').play().catch(()=>{});streaming=true;showStream()};
connection.onconnectionstatechange=()=>{if(current===generation&&connection.connectionState==='failed')void failStream('Camera disconnected')};
await connection.setLocalDescription(await connection.createOffer());
if(connection.iceGatheringState!=='complete')await new Promise(resolve=>{
const timer=setTimeout(resolve,2000);connection.addEventListener('icegatheringstatechange',()=>{if(connection.iceGatheringState==='complete'){clearTimeout(timer);resolve()}},{once:true});
});
if(current!==generation)return;
const response=await fetch(station.video_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:connection.localDescription.sdp,signal:abort.signal});
if(!response.ok)throw new Error('WHEP '+response.status);
const location=response.headers.get('Location');let resource=location?new URL(location,station.video_url).href:null;
if(resource&&new URL(resource).origin!==new URL(station.video_url).origin)resource=null;
if(current!==generation){if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});return}
sessionUrl=resource;
const sdp=await response.text();if(current!==generation)return;
await connection.setRemoteDescription({type:'answer',sdp});
}finally{clearTimeout(timeout)}
}
}catch(error){if(current===generation){await stopStream();state('Unavailable');notify(error.message||'Stream unavailable',true)}}
finally{if(current===generation&&streaming)connecting=false}
}
async function failStream(message){await stopStream();state(message);notify(message,true)}
async function stopStream(){
++generation;connecting=false;streaming=false;abort?.abort();abort=null;
clearTimeout(streamTimer);
if(recorder&&recorder.state!=='inactive')recorder.stop();
peer?.close();peer=null;
if(sessionUrl){void fetch(sessionUrl,{method:'DELETE'}).catch(()=>{});sessionUrl=null}
cancelAnimationFrame(demoFrame);demoFrame=0;
const video=$('camera-video');video.onerror=video.onended=null;video.pause();video.srcObject=null;video.removeAttribute('src');video.load();video.hidden=true;
const img=$('camera-image');img.onload=img.onerror=null;img.removeAttribute('src');img.hidden=true;
$('camera-demo').hidden=true;$('camera-record').disabled=true;$('camera-toggle').textContent='Start';$('stream-clock').textContent='';state('Camera off');
}
function drawDemo(now){
if(!streaming||!demo())return;
demoFrame=requestAnimationFrame(drawDemo);
if(workspace!=='dashboard'||document.hidden||now-lastPaint<40)return;lastPaint=now;
const seconds=Math.floor((now-streamStarted)/1000);
if(seconds!==demoLastClock){demoLastClock=seconds;$('stream-clock').textContent=String(Math.floor(seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0')}
const canvas=$('camera-demo'),rect=canvas.getBoundingClientRect();
const width=Math.max(320,Math.round(rect.width)),height=Math.max(180,Math.round(rect.height));
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
const c=canvas.getContext('2d'),gradient=c.createLinearGradient(0,0,0,height);gradient.addColorStop(0,'#28444a');gradient.addColorStop(1,'#101e25');c.fillStyle=gradient;c.fillRect(0,0,width,height);
const light=c.createRadialGradient(width*.52,height*.62,4,width*.52,height*.62,width*.6);light.addColorStop(0,'#90aaa324');light.addColorStop(1,'#00000000');c.fillStyle=light;c.fillRect(0,0,width,height);
c.strokeStyle='#9eb2a92a';c.lineWidth=1;
for(let i=-4;i<8;i++){c.beginPath();c.moveTo(width*.5+i*16,height*.45);c.lineTo(width*.5+i*110,height);c.stroke()}
for(let i=0;i<8;i++){const y=height*.45+(i/8)**2*height*.55;c.beginPath();c.moveTo(0,y);c.lineTo(width,y);c.stroke()}
const pipe=c.createLinearGradient(0,height*.58,0,height*.76);pipe.addColorStop(0,'#a1afa0');pipe.addColorStop(.4,'#6e8980');pipe.addColorStop(1,'#263e3d');c.fillStyle=pipe;c.fillRect(width*.16,height*.58,width*.72,height*.18);
c.strokeStyle='#b3c2b166';c.strokeRect(width*.16,height*.58,width*.72,height*.18);
c.fillStyle='#c3d7c977';for(let i=0;i<32;i++){const x=(i*89.7+Math.sin(now/5000+i)*9)%width,y=(i*37.9+now*.004)%height;c.fillRect(x,y,1,1)}
c.strokeStyle='#e1f0e477';c.beginPath();c.moveTo(width/2-10,height/2);c.lineTo(width/2+10,height/2);c.moveTo(width/2,height/2-10);c.lineTo(width/2,height/2+10);c.stroke();
}
async function toggleRecord(){
if(saving)return;
if(recorder&&recorder.state!=='inactive'){recorder.stop();return}
if(!streaming)return;
try{
const stream=demo()?$('camera-demo').captureStream(24):$('camera-video').srcObject||$('camera-video').captureStream?.();
if(!stream?.getVideoTracks().length)throw new Error('Recording unavailable');
ownedStream=demo()?stream:null;recordToken=host.bridge()?await call('recording_begin'):null;recordQueue=Promise.resolve();recordChunks=[];recordBytes=0;recordError=null;
const mime=['video/webm;codecs=vp8','video/webm'].find(type=>MediaRecorder.isTypeSupported(type));
recorder=new MediaRecorder(stream,mime?{mimeType:mime,videoBitsPerSecond:4000000}:undefined);
recorder.ondataavailable=event=>{
if(!event.data.size)return;
if(recordToken){const token=recordToken;recordQueue=recordQueue.then(async()=>{
if(recordError)return;
const data=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=reject;reader.readAsDataURL(event.data)});
await call('recording_chunk',token,data);
}).catch(error=>{recordError=error;if(recorder?.state==='recording')recorder.stop()})}
else{recordChunks.push(event.data);recordBytes+=event.data.size;if(recordBytes>256*1024*1024&&recorder.state==='recording')recorder.stop()}
};
recorder.onstop=async()=>{
saving=true;$('camera-record').textContent='Saving';$('recording-badge').hidden=true;
try{
await recordQueue;
if(recordToken){const path=await call('recording_end',recordToken);notify(recordError?'Recording interrupted':'Video saved',!!recordError);if(path)$('camera-record').title=path}
else{const blob=new Blob(recordChunks,{type:recorder.mimeType}),url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download='argo-'+Date.now()+'.webm';link.click();setTimeout(()=>URL.revokeObjectURL(url),60000);notify('Video saved')}
}catch(error){notify(error.message||'Save failed',true)}
finally{ownedStream?.getTracks().forEach(track=>track.stop());ownedStream=null;recordToken=null;recordChunks=[];saving=false;$('camera-record').textContent='Record';$('camera-record').setAttribute('aria-pressed','false');recorder=null}
};
recorder.start(1000);$('camera-record').textContent='Stop record';$('camera-record').setAttribute('aria-pressed','true');$('recording-badge').hidden=false;
}catch(error){if(recordToken)await call('recording_end',recordToken).catch(()=>{});recordToken=null;notify(error.message||'Recording unavailable',true)}
}
async function accessory(name){
if(auxBusy||!snapshot.connected)return;
const relay=station[name+'_relay'];if(relay<0){host.open('station-modal');return}
auxBusy=true;
try{
const previous=snapshot.accessories?.[name],enabled=!(previous?.accepted&&previous.requested);
if(demo())host.demoAccessory(name,enabled);else await call('set_accessory',name,enabled);
if(name==='camera'&&!enabled)await stopStream();
}catch(error){notify(error.message||'Accessory unavailable',true)}finally{auxBusy=false}
}
function updateDemand(demand){
if(!host)return;
const axes=supported();$('extra-controls').hidden=!axes.some(axis=>['lateral','pitch','roll'].includes(axis));
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
const axis=label.dataset.motion;label.hidden=!axes.includes(axis);label.querySelector('input').value=Math.round((demand[axis]||0)*100);
label.querySelector('input').disabled=!host.touch();label.querySelector('output').value=Math.round((demand[axis]||0)*100);
}
}
function render(s){
snapshot=s;
if(!host)return;
const signature=String(!!s.connected)+':'+supported().join(',');
if(capabilitySignature&&signature!==capabilitySignature)host.reset();
capabilitySignature=signature;
host.scaling((demo()||!s.connected)?station.frame===4:s.capabilities?.frame===4);
if($('station-modal').open)for(const field of $('station-form').querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
const telemetry=demo()&&s.connected?{
attitude:{values:{heading:124,pitch:4.2,roll:-2.1},stale:false},depth:{values:{depth_m:1.8},stale:false},
imu:{values:{ax:.12,ay:-.08,az:9.78,gx:.01,gy:.02,gz:0,mx:.21,my:.04,mz:.43},stale:false},pressure:{values:{pressure_hpa:1190,temperature_c:18.6},stale:false}
}:s.telemetry||{};
const value=(group,key)=>s.connected&&!telemetry[group]?.stale?telemetry[group]?.values?.[key]:null;
const format=(value,unit,digits=1)=>Number.isFinite(value)?value.toFixed(digits)+unit:'—';
const show=(id,number,unit,digits)=>{$(id).textContent=format(number,unit,digits);$(id).parentElement.dataset.stale=String(!Number.isFinite(number))};
show('live-depth',value('depth','depth_m'),' m',2);show('live-heading',value('attitude','heading'),'°',0);show('live-pitch',value('attitude','pitch'),'°',1);show('live-roll',value('attitude','roll'),'°',1);
const battery=s.battery||{},fresh=s.connected&&!s.battery_stale;
const current=fresh&&!battery.current_stale?battery.current_a:null;
show('live-current',current,' A',1);show('live-power',fresh&&Number.isFinite(current)&&Number.isFinite(battery.voltage)?current*battery.voltage:null,' W',1);
show('live-charge',fresh?battery.consumed_mah:null,' mAh',0);
for(const [id,keys,unit] of [['live-accel',['ax','ay','az'],'m/s²'],['live-gyro',['gx','gy','gz'],'rad/s'],['live-mag',['mx','my','mz'],'G']]){
const values=keys.map(key=>value('imu',key));$(id).textContent=values.every(Number.isFinite)?values.map(value=>value.toFixed(2)).join(' / ')+' '+unit:'—';
}
show('live-temperature',value('pressure','temperature_c'),' °C',1);show('live-pressure',value('pressure','pressure_hpa'),' hPa',1);
$('sensor-state').textContent=demo()&&s.connected?'Demo':!s.connected?'Offline':Object.values(telemetry).some(group=>!group.stale)?'Receiving':'No data';
$('detected-frame').textContent=s.connected&&!demo()?(frames[s.capabilities?.frame]?.[0]||'Detecting'):demo()?'Demo':'Offline';
for(const [name,id] of [['lights','lights-toggle'],['camera','camera-power']]){
const item=s.accessories?.[name],pending=item&&item.accepted===null&&!item.error;
$(id).disabled=!s.connected||station[name+'_relay']<0||!!pending;$(id).setAttribute('aria-pressed',String(!!(item?.accepted&&item.requested)));
$(id).title=item?.error||(pending?'Awaiting ACK':station[name+'_relay']<0?'Configure relay':'Relay '+station[name+'_relay']);
}
if(streaming)$('stream-clock').textContent=new Date(performance.now()-streamStarted).toISOString().slice(14,19);
$('sensor-record').setAttribute('aria-pressed',String(!!s.sensor_recording));$('sensor-record').textContent=s.sensor_recording?'Stop log':'Log data';
$('sensor-record').disabled=!s.connected||demo();
updateDemand(host.demand());
}
function action(name){if(name==='stream')void(streaming||connecting?stopStream():startStream());else if(name==='record')void toggleRecord();else if(name==='lights')void accessory('lights')}
function init(callbacks){
host=callbacks;
$('view-drive').onclick=()=>view('drive');$('view-dashboard').onclick=()=>view('dashboard');
$('camera-toggle').onclick=()=>action('stream');$('camera-record').onclick=()=>action('record');$('lights-toggle').onclick=()=>accessory('lights');$('camera-power').onclick=()=>accessory('camera');
$('sensor-record').onclick=async()=>{try{if(demo())throw new Error('Live telemetry required');if(!host.bridge())throw new Error('Connect desktop');const path=await call('sensor_record',!snapshot.sensor_recording);if(path)$('sensor-record').title=path}catch(error){notify(error.message,true)}};
for(const name of ['lights_relay','camera_relay']){
const select=$('station-form').elements[name];select.add(new Option('Off','-1'));for(let index=0;index<16;index++)select.add(new Option('Relay '+index,String(index)));
}
const stationObserver=new MutationObserver(()=>{if($('station-modal').open)fillStation()});stationObserver.observe($('station-modal'),{attributes:true,attributeFilter:['open']});
document.querySelector('[data-open=station-modal]').addEventListener('click',fillStation);
$('station-form').onsubmit=async event=>{
event.preventDefault();const form=event.currentTarget,feedback=form.querySelector('.form-feedback');
if(host.locked()){feedback.textContent='Disengage first';feedback.hidden=false;return}
const config=Object.fromEntries(new FormData(form));for(const name of ['frame','lights_relay','camera_relay'])config[name]=Number(config[name]);
try{
if(config.video_url){const url=new URL(config.video_url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw new Error('Use an HTTP stream URL')}
if(config.lights_relay>=0&&config.lights_relay===config.camera_relay)throw new Error('Use separate relays');
if(streaming||connecting)await stopStream();
station=host.bridge()?await call('save_station_settings',config):config;localStorage.setItem('sub-v2-station',JSON.stringify(station));
host.previewFrame(station.frame,frames[station.frame][1]);host.reset();host.close($('station-modal'));notify('Vehicle saved');render(snapshot);
}catch(error){feedback.textContent=error.message||String(error);feedback.hidden=false}
};
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
const input=label.querySelector('input'),axis=label.dataset.motion;
input.oninput=()=>{if(host.touch())host.axis(axis,Number(input.value)/100)};
const release=()=>{if(host.touch())host.axis(axis,0);input.value=0};
input.onpointerup=release;input.onpointercancel=release;input.onblur=release;input.onkeyup=release;
}
for(const [name,text,value] of [['attitude_hz','Attitude · Hz',2],['depth_hz','Depth · Hz',2],['imu_hz','IMU · Hz',1],['pressure_hz','Pressure · Hz',1]]){
const label=document.createElement('label'),span=document.createElement('span'),select=document.createElement('select');span.textContent=text;select.name=name;
for(const rate of [0,1,2,5,10])select.add(new Option(rate===0?'Off':String(rate),String(rate)));select.value=String(value);label.append(span,select);$('link-form').querySelectorAll('.form-grid')[1].append(label);
}
addEventListener('pagehide',()=>void stopStream());document.body.dataset.view='drive';render(snapshot);
}
window.V2={init,render,filterDemand,action,updateDemand,station:()=>station,setStation:config=>{station={...station,...config};render(snapshot)}};
})();
+229 -83
View File
@@ -3,117 +3,263 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>SUB Control</title> <title>Argo V2</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css?v=20261022-v2">
<link rel="stylesheet" href="v2.css?v=20261022-v2">
</head> </head>
<body> <body>
<header class="window-bar">
<div class="window-drag pywebview-drag-region"><span class="mini-mark">S</span><strong>SUB Control</strong><i id="model-file-name">Built-in vehicle</i></div>
<div class="window-actions">
<button id="window-min" aria-label="Minimize">—</button>
<button id="window-max" aria-label="Maximize">□</button>
<button id="window-close" class="window-close" aria-label="Close">×</button>
</div>
</header>
<main class="app-shell"> <main class="app-shell">
<aside class="vehicle-rail">
<header class="brand"><span class="brand-mark">S</span><span>SUB CONTROL</span><small>03</small></header> <!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
<div class="eyebrow">LIVE VEHICLE</div> <aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
<div class="rov-stage" aria-label="Top view of three-thruster ROV"> <div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
<div class="water-ring r1"></div><div class="water-ring r2"></div> <!-- Connection Controls -->
<svg id="default-model" viewBox="0 0 260 310" role="img"> <div class="deck-connection">
<defs> <div class="port-row">
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1"> <select id="port" aria-label="Serial port">
<stop offset="0" stop-color="#566168"/><stop offset="1" stop-color="#22292d"/> <option value="demo" selected>Demo Mode (no hardware)</option>
</linearGradient> </select>
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="13" flood-opacity=".45"/></filter> <button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports">
</defs> <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>
<g filter="url(#shadow)"> </button>
<rect x="73" y="34" width="114" height="226" rx="55" fill="url(#body)" stroke="#707b81" stroke-width="2"/> <button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings">
<rect x="93" y="60" width="74" height="168" rx="35" fill="#30383c" stroke="#49545a"/> <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>
<circle cx="130" cy="145" r="28" fill="#7d898f" stroke="#a3adb1" stroke-width="3"/> </button>
<circle cx="130" cy="145" r="12" fill="#d9e0df"/> </div>
<path d="M78 98 48 80M182 98l30-18" stroke="#7e8a90" stroke-width="9" stroke-linecap="round"/> <button id="connect" class="button connect-button" aria-label="Toggle connection">
<g fill="#191f22" stroke="#7b878c" stroke-width="3"> <span class="btn-spinner" aria-hidden="true"></span>
<ellipse cx="38" cy="74" rx="27" ry="34"/><ellipse cx="222" cy="74" rx="27" ry="34"/> <span class="btn-label">Connect</span>
</g> </button>
<g stroke="#59656a" stroke-width="2"> </div>
<path d="M22 54l32 40M54 54 22 94M206 54l32 40M238 54l-32 40"/>
</g> <!-- Telemetry / Status Zone -->
</g> <section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
</svg> <div class="deck-card model-card">
<div class="rov-stage" aria-label="Vehicle model">
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas> <canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<div id="model-status" class="model-status">Initializing…</div>
<div class="model-tools"> <div class="model-tools">
<button id="load-model">Load STL</button> <button id="load-model">Load STL</button>
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden> <input id="model-input" type="file" accept=".stl,.STL" hidden>
</div> </div>
</div> </div>
<div class="vehicle-name"><small>VEHICLE</small><strong id="vehicle">No heartbeat</strong><span>SimpleROV-3 • differential drive</span></div> </div>
<div class="rail-bottom">
<button class="battery-card ghost" data-open="battery-modal"> <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><small>BATTERY</small><strong id="battery-title">—</strong></span><i id="battery-percent">Waiting</i> <span class="link-quality-dot" aria-hidden="true"></span>
<b><em id="battery-fill"></em></b> <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> </button>
<div class="link-state"><span id="link-dot"></span><strong id="link">DISCONNECTED</strong></div>
<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> </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> </aside>
<section class="workspace"> <!-- ── PRIMARY CONTROL ZONE ── -->
<header class="topbar"> <section class="controls-workspace" aria-label="Primary Vehicle Controls">
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div> <!-- Frameless Drag & Window Controls Header -->
<div class="connection"> <div class="workspace-topbar">
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select> <div class="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">Mapping</button></div>
<button id="refresh" class="icon-button" title="Refresh ports">↻</button> <div class="workspace-drag-area pywebview-drag-region"></div>
<button class="icon-button" data-open="link-modal" title="Link settings">•••</button> <div class="window-actions">
<button id="connect" class="button connect-button">Connect</button> <button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
<button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button>
<button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
</div>
</div> </div>
</header>
<section class="drive-grid"> <div class="workspace-nav"><div class="view-switch" role="group" aria-label="Workspace"><button id="view-drive" type="button" aria-pressed="true">Drive</button><button id="view-dashboard" type="button" aria-pressed="false">Dashboard</button></div><span class="version-mark">V2</span><button class="quiet-button" data-open="station-modal" type="button">Vehicle</button></div>
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
<section id="dashboard" class="dashboard" aria-label="Vehicle dashboard" hidden>
<div class="camera-stage">
<video id="camera-video" muted autoplay playsinline hidden></video><img id="camera-image" alt="Camera stream" hidden><canvas id="camera-demo" aria-label="Simulated inspection camera" hidden></canvas>
<div id="camera-empty" class="camera-empty"><svg viewBox="0 0 48 48" aria-hidden="true"><rect x="5" y="12" width="27" height="24" rx="6"/><path d="m32 20 11-6v20l-11-6"/></svg><span id="camera-state">Camera off</span></div>
<div class="camera-overlay"><span id="camera-badge">Preview</span><span id="recording-badge" hidden>● REC</span><span id="stream-clock"></span></div>
</div>
<div class="dashboard-actions"><button id="camera-toggle" type="button">Start</button><button id="camera-record" type="button" aria-pressed="false" disabled>Record</button><button id="lights-toggle" type="button" aria-pressed="false" disabled>Lights</button><button id="camera-power" type="button" aria-pressed="false" disabled>Camera power</button><button id="sensor-record" type="button" aria-pressed="false">Log data</button></div>
<div class="telemetry-strip" aria-label="Live telemetry"><div><span>Depth</span><strong id="live-depth">—</strong></div><div><span>Heading</span><strong id="live-heading">—</strong></div><div><span>Current</span><strong id="live-current">—</strong></div><div><span>Pitch</span><strong id="live-pitch">—</strong></div><div><span>Roll</span><strong id="live-roll">—</strong></div><div><span>Power</span><strong id="live-power">—</strong></div></div>
<details class="sensor-details"><summary>Sensors <span id="sensor-state">Offline</span></summary><div class="sensor-values"><div><span>Acceleration</span><strong id="live-accel">—</strong></div><div><span>Gyroscope</span><strong id="live-gyro">—</strong></div><div><span>Magnetometer</span><strong id="live-mag">—</strong></div><div><span>Temperature</span><strong id="live-temperature">—</strong></div><div><span>Pressure</span><strong id="live-pressure">—</strong></div><div><span>Charge used</span><strong id="live-charge">—</strong></div></div></details>
</section>
<section class="primary-control-zone" aria-label="Primary Control Zone">
<!-- Reserved Engage / Disarm row -->
<div class="engage-header-zone">
<button id="engage" class="button subtle engage-button" aria-label="Engage" aria-describedby="control-status" title="Engage (F2)">
<span class="engage-face">
<span id="engage-label" class="engage-label">engage</span>
<span class="engage-sheen-label" aria-hidden="true">engage</span>
<span class="engage-progress-label" aria-hidden="true">engage</span>
</span>
</button>
<p id="control-status" class="control-status" role="status" aria-live="polite">Connect to receive vehicle state.</p>
</div>
<!-- Main Joysticks & Thrust Control Grid -->
<div class="controls-grid">
<!-- Steering / Throttle Joystick -->
<article class="control-card steer-card"> <article class="control-card steer-card">
<header><div><small>STEER + THROTTLE</small><strong>Differential drive</strong></div><span class="key-hint">W A S D</span></header> <header>
<div title="Steer and throttle · W A S D"></div>
</header>
<div class="joystick-pad">
<div id="joystick" class="joystick" touch-action="none"> <div id="joystick" class="joystick" touch-action="none">
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div> <canvas id="joystick-canvas" title="Steer and throttle · W A S D" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
<span class="axis-label forward">FORWARD</span><span class="axis-label reverse">REVERSE</span>
<span class="axis-label left">TURN LEFT</span><span class="axis-label right">TURN RIGHT</span>
<div class="stick-shadow"></div><div id="stick" class="stick"><span></span></div>
</div> </div>
<footer><span><i class="dot surge"></i><b id="surge-readout">0</b>% surge</span><span><i class="dot yaw"></i><b id="yaw-readout">0</b>% turn</span></footer> </div>
<footer>
<span class="steer-readout-left">surge <b id="surge-readout">—</b></span>
<span class="steer-readout-right"><b id="yaw-readout">—</b> turn</span>
</footer>
</article> </article>
<!-- Vertical Thruster Slider -->
<article class="control-card vertical-card"> <article class="control-card vertical-card">
<header><div><small>VERTICAL</small><strong>Center thruster</strong></div><span class="key-hint">R / F</span></header> <header>
<div class="vertical-control"> <div title="Vertical thrust · R / F"></div>
<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>
<section class="command-bar"> </header>
<div class="commands"> <div class="heave-pad">
<button id="manual" class="button subtle">Manual</button> <div class="vertical-control">
<button id="arm" class="button arm">Arm</button> <span>rise</span>
<button id="disarm" class="button danger">Disarm</button> <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> </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> <span>dive</span>
<button class="button subtle" data-open="motors-modal">Motor setup</button> </div>
<button id="logs" class="icon-button" title="Open session logs">⌁</button> </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="Shared motor output limit" type="range" min="0" max="100" step="1" 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>
<div id="extra-controls" class="extra-controls" hidden><label data-motion="lateral"><span>Strafe</span><input type="range" min="-100" max="100" value="0" aria-label="Strafe demand"><output>0</output></label><label data-motion="pitch"><span>Pitch</span><input type="range" min="-100" max="100" value="0" aria-label="Pitch demand"><output>0</output></label><label data-motion="roll"><span>Roll</span><input type="range" min="-100" max="100" value="0" aria-label="Roll demand"><output>0</output></label></div>
</section> </section>
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
</section> </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> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<dialog id="arm-dialog" class="modal confirm-modal"><button class="modal-close" data-close>×</button><small>SAFETY CHECK</small><h2>Arm the vehicle?</h2><p>Remove propellers or securely restrain the ROV before enabling motor output.</p><div class="modal-actions"><button class="button subtle" data-close>Cancel</button><button id="confirm-arm" class="button arm">Confirm arm</button></div></dialog> <!-- Modals -->
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>CONNECTION</small><h2>Link settings</h2><div class="form-grid two"><label>Baud rate<select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label>Control rate<select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog> <dialog id="link-modal" class="modal" aria-labelledby="link-title">
<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> <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>
<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> <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> <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>
<script src="app.js"></script> <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>Battery detail · Hz</span><select name="battery_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</h2>
<section class="battery-live" aria-label="Live battery readings">
<div class="battery-live-grid">
<div><span>Pack voltage</span><strong id="battery-live-voltage">—</strong></div>
<div><span>Current draw</span><strong id="battery-live-current">—</strong></div>
<div><span>Charge used</span><strong id="battery-live-consumed">—</strong><small id="battery-live-consumed-source"></small></div>
<div><span>Capacity left</span><strong id="battery-live-remaining">—</strong></div>
</div>
<p id="battery-live-note" class="note">Waiting for battery telemetry.</p>
</section>
<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">Offline</p>
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
<div class="motor-scale-heading"><span>Output</span><label class="switch-control"><input id="motor-scale-independent" type="checkbox"><span class="switch-track" aria-hidden="true"></span><span>Independent</span></label></div>
<div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="controller-modal" class="modal controller-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">
<div class="form-grid two"><label><span>Controller</span><select id="controller-device"></select></label><label><span>Preset</span><select id="controller-preset"><option value="triggers3">Triggers · 3</option><option value="sixaxis">Six axes</option><option value="classic">Classic sticks</option><option value="custom">Custom</option></select></label></div>
<p id="controller-axes" class="note" hidden>Xbox · offline</p>
<div class="binding-head"><span>Motion</span><span>Negative</span><span>Positive</span><span></span><span>Limit %</span></div>
<div id="controller-bindings"></div>
<div class="form-grid two mapping-tuning"><label><span>Dead zone %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label><label><span>Expo %</span><input name="expo" type="number" min="0" max="100" step="1" required></label></div>
<details class="mapping-actions"><summary>Buttons</summary><div class="form-grid three"><label><span>Lights</span><select name="lights"></select></label><label><span>Stream</span><select name="stream"></select></label><label><span>Record</span><select name="record"></select></label></div></details>
<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>
<dialog id="station-modal" class="modal station-modal" aria-labelledby="station-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="station-title">Vehicle</h2><form id="station-form">
<div class="form-grid two"><label><span>Preview frame</span><select name="frame"><option value="4">SimpleROV · 3</option><option value="0">BlueROV1 · 6</option><option value="1">Vectored · 6</option><option value="2">Full 6DOF · 8</option><option value="3">6DOF 90° · 8</option><option value="5">SimpleROV · 4</option><option value="6">SimpleROV · 5</option></select></label><div class="detected-frame"><span>FC frame</span><strong id="detected-frame">Offline</strong></div></div>
<section class="settings-section"><h3>Camera</h3><label><span>Stream type</span><select name="video_mode"><option value="whep">WebRTC · WHEP</option><option value="video">HTTP video</option><option value="mjpeg">MJPEG</option></select></label><label><span>Stream URL</span><input name="video_url" type="url" placeholder="http://192.168.2.2:8889/cam/whep" autocomplete="off"></label></section>
<section class="settings-section"><h3>Accessories</h3><div class="form-grid two"><label><span>Lights relay</span><select name="lights_relay"></select></label><label><span>Camera relay</span><select name="camera_relay"></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">Save</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=20261022-v2"></script>
<script src="dashboard.js?v=20261022-v2"></script>
<script src="console-view.js?v=20261002-console-v2"></script>
<script src="app.js?v=20261022-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body> </body>
</html> </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}}};
})();
+491 -436
View File
File diff suppressed because it is too large Load Diff
+1521 -12
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
/* V2 uses the existing deck, type scale, and flat settings rows. */
:root { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
[hidden] { display:none !important; }
.workspace-nav { display:flex; align-items:center; gap:12px; margin:0 0 10px; min-height:32px; }
.view-switch { display:flex; gap:2px; padding:3px; background:#e1e1e1; border-radius:20px; }
.view-switch button,.quiet-button { border:0; background:transparent; color:#737575; border-radius:18px; padding:6px 14px; cursor:pointer; font-size:12px; }
.view-switch [aria-pressed=true] { background:#fafafa; color:#242929; box-shadow:0 1px 3px #0000000c; }
.workspace-nav .quiet-button { margin-left:auto; }
.version-mark { color:#9a9e9d; font-size:10px; letter-spacing:.08em; }
#controller-settings { display:block; }
.controller-modal { width:min(730px,calc(100vw - 28px)); max-height:calc(100dvh - 28px); overflow:auto; }
.binding-head,.binding-row { display:grid; grid-template-columns:80px minmax(100px,1fr) minmax(100px,1fr) 28px 54px; gap:8px; align-items:center; }
.binding-head { font-size:10px; color:#858b89; padding:8px 0; }
.binding-row { padding:9px 0; border-top:1px solid #dedfdd; }
.binding-row > span { font-size:12px; font-weight:500; }
.binding-row meter { display:block; width:50px; height:3px; margin-top:7px; accent-color:#6d9c8b; }
.binding-row select,.binding-row input { height:34px; min-height:34px; padding:0 8px; border-radius:8px; font-size:12px; min-width:0; }
.binding-row input { text-align:center; padding:0 2px; font-variant-numeric:tabular-nums; }
.binding-swap { background:transparent; border:0; height:32px; color:#6a7470; font-size:19px; cursor:pointer; border-radius:7px; }
.binding-swap:hover { background:#e0e5e1; }
.mapping-tuning { margin-top:14px; }
.mapping-actions { margin-top:16px; font-size:12px; }
.mapping-actions summary { padding:8px 0; cursor:pointer; color:#727b76; }
.form-grid.three { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
.dashboard { display:flex; flex:1; flex-direction:column; min-height:0; gap:0; overflow:auto; }
.camera-stage { flex:1 1 220px; min-height:150px; position:relative; border-radius:14px; overflow:hidden; background:#18282d; color:#f2f6f5; isolation:isolate; }
.camera-stage video,.camera-stage img,.camera-stage canvas { display:block; width:100%; height:100%; position:absolute; inset:0; object-fit:contain; }
.camera-empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:#8fa5aa; font-size:12px; }
.camera-empty svg { width:40px; fill:none; stroke:currentColor; stroke-width:1.4; opacity:.65; }
.camera-overlay { pointer-events:none; position:absolute; top:14px; left:16px; right:16px; display:flex; align-items:center; gap:10px; font-size:10px; text-shadow:0 1px 3px #000; color:#d7e5e4; }
#camera-badge { border:1px solid #a8c1c245; border-radius:10px; padding:3px 8px; background:#16282c55; }
#recording-badge { color:#ff9e93; font-weight:600; }
#stream-clock { margin-left:auto; font-variant-numeric:tabular-nums; }
.dashboard-actions { display:flex; align-items:center; gap:5px; padding:10px 0; flex-wrap:wrap; }
.dashboard-actions button { min-height:32px; padding:6px 12px; border-radius:18px; border:0; background:#e0e2df; color:#46524b; font-size:11px; cursor:pointer; white-space:nowrap; }
.dashboard-actions button[aria-pressed=true] { background:#d0ddd5; color:#345844; }
.dashboard-actions button:disabled { opacity:.4; cursor:default; }
.telemetry-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); row-gap:12px; border-top:1px solid #d9deda; padding:14px 0; }
.telemetry-strip > div { min-width:0; display:flex; flex-direction:column; gap:3px; }
.telemetry-strip span { font-size:10px; color:#7b8480; }
.telemetry-strip strong { font-weight:400; font-size:clamp(18px,2.2vw,26px); color:#36463d; font-variant-numeric:tabular-nums; letter-spacing:-.035em; white-space:nowrap; }
.telemetry-strip [data-stale=true] strong { color:#a4aaa5; }
.sensor-details { border-top:1px solid #d9deda; padding:10px 0 0; font-size:11px; }
.sensor-details summary { cursor:pointer; color:#58665d; }
.sensor-details summary span { float:right; color:#939c96; font-size:10px; }
.sensor-values { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; padding:14px 0; }
.sensor-values div { min-width:0; display:flex; flex-direction:column; gap:4px; }
.sensor-values span { color:#87908a; font-size:10px; }
.sensor-values strong { font-size:11px; font-weight:500; color:#4b5c50; font-variant-numeric:tabular-nums; }
.detected-frame { display:flex; flex-direction:column; gap:9px; color:#859086; font-size:12px; padding-top:4px; }
.detected-frame strong { color:#4b5c50; font-weight:500; }
.extra-controls { display:flex; gap:16px; margin-top:8px; padding:10px 0 0; border-top:1px solid #d9deda; }
.extra-controls label { display:grid; flex:1; min-width:0; grid-template-columns:1fr 24px; gap:4px; color:#7e8981; font-size:10px; }
.extra-controls label input { width:100%; min-width:0; grid-row:2; grid-column:1/-1; accent-color:#668c78; height:18px; }
.extra-controls output { text-align:right; font-variant-numeric:tabular-nums; }
.primary-control-zone { grid-template-rows:clamp(64px,11vh,90px) minmax(0,1fr) auto auto; }
.controls-grid { --joy-size:min(34vh,calc(100cqw - 146px),370px); }
.workspace-topbar { margin-bottom:8px; }
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):active { transform:scale(.97); }
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):focus-visible { outline:2px solid #6b9a84; outline-offset:2px; }
.station-modal { width:min(560px,calc(100vw - 28px)); }
body[data-view=dashboard] #controller-status { position:absolute; clip:rect(0,0,0,0); }
@media (max-width:760px) {
.binding-head,.binding-row { grid-template-columns:66px minmax(0,1fr) minmax(0,1fr) 24px 46px; gap:6px; }
.binding-row select { padding:0 4px; font-size:11px; }
.controller-modal { padding:22px 18px; }
.controls-workspace { padding:16px; }
.dashboard-actions { gap:4px; }.dashboard-actions button { padding:6px 9px; }
}
@media (max-width:600px) {
.app-shell { grid-template-columns:170px minmax(0,1fr); }
.sidebar-deck { padding:14px; }
.input-mode-switch button { padding:6px 8px; }
.controls-grid { --joy-size:min(30vh,calc(100cqw - 95px),300px); grid-template-columns:minmax(0,1fr) 70px; gap:6px; }
.control-toolbar { flex-wrap:wrap; gap:8px; }.control-toolbar .range-pill { width:100px; }
.control-toolbar .output-limit > span { width:auto; flex-basis:auto; }
.window-actions { gap:0; }.window-actions button { width:24px; }
.extra-controls { gap:8px; }.workspace-nav { gap:6px; }
.view-switch button { padding:6px 9px; }.workspace-nav .quiet-button { padding:6px; }
.version-mark { display:none; }
}
@media (prefers-reduced-motion:reduce) { :is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button) { transition:none; transform:none !important; } }
+736 -87
View File
File diff suppressed because it is too large Load Diff