Compare commits
7 Commits
10cd37eea7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a4d8f1b7af | |||
| 31f720a316 | |||
| 0093f3f4aa | |||
| 7c85397af0 | |||
| 73c20a0ff2 | |||
| 84ec93543b | |||
| 25bbefda71 |
@@ -3,4 +3,5 @@ __pycache__/
|
||||
*.py[cod]
|
||||
logs/
|
||||
model_settings.json
|
||||
link_settings.json
|
||||
runtime/
|
||||
|
||||
@@ -1,173 +1,34 @@
|
||||
# SUB Control
|
||||
|
||||
A deliberately small, low-bandwidth MAVLink controller for bench-testing an
|
||||
ArduSub vehicle over a USB serial connection. It sends no parameter-list
|
||||
request and no telemetry-stream request.
|
||||
A lightweight Windows controller for ArduSub ROVs over USB/MAVLink.
|
||||
|
||||

|
||||

|
||||
|
||||
The desktop window uses a local HTML/CSS/JavaScript interface rendered by the
|
||||
Windows WebView. Python remains the only launcher and owns the MAVLink worker,
|
||||
arming, logging, output limiting, and parameter writes. It does not run a web
|
||||
server, open a browser tab, or require an internet connection.
|
||||
## Quick start
|
||||
|
||||
The WebView is frameless and uses the compact title bar drawn by the interface.
|
||||
The vehicle rail includes a local STL viewer: choose **Load STL** to start with
|
||||
an orthographic top-view silhouette, then drag to orbit or use the mouse wheel
|
||||
to zoom. A cached STL copy is stored locally under the ignored `runtime/`
|
||||
directory so the model can be restored on the next launch.
|
||||
STEP/STP CAD solids must first be tessellated/exported as STL because the
|
||||
embedded renderer intentionally does not include a full CAD kernel.
|
||||
Binary and ASCII STL faces are preserved in full; large assemblies are not
|
||||
rendered by skipping triangles. Imports are limited to 30 MB to keep startup
|
||||
and interactive orbiting responsive.
|
||||
|
||||
## Safety first
|
||||
|
||||
- Remove propellers for initial tests and restrain the vehicle.
|
||||
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range,
|
||||
leak/failsafe, and emergency-stop arrangements before arming.
|
||||
- The application starts disarmed and limits pilot demand to 20% by default.
|
||||
Once armed, control movement is sent directly with no additional gate.
|
||||
- Closing or disconnecting sends neutral controls and a disarm request, but a
|
||||
physical power cutoff is still required.
|
||||
|
||||
The controls are pilot-demand axes, not direct PWM values. ArduSub performs
|
||||
the frame mixing and generates the configured ESC outputs. For ArduSub,
|
||||
`MANUAL_CONTROL.z = 500` is neutral vertical thrust.
|
||||
|
||||
## Run on Windows
|
||||
|
||||
1. Install Python 3.11 or newer. A virtual environment installs packages, but
|
||||
it does not install Python itself.
|
||||
2. Open PowerShell in this folder and run `./run.ps1` (or right-click it and
|
||||
choose **Run with PowerShell**).
|
||||
3. Select the autopilot's COM port, then click **Connect**. Baud and control-rate
|
||||
settings are under the small `•••` link button (normally 57600 baud / 20 Hz).
|
||||
4. Confirm `ArduSub` and a live heartbeat are shown.
|
||||
5. Click **Set MANUAL**, then **ARM** only when the bench is safe.
|
||||
6. Move the controls. Once armed, no additional enable button is needed.
|
||||
|
||||
`run.ps1` automatically creates a private `.venv` on the first run, installs
|
||||
the versions declared in `requirements.txt`, and starts SUB Control. Later runs
|
||||
reuse the same environment and update missing dependencies when necessary.
|
||||
The `.venv` directory is intentionally ignored by Git: virtual environments
|
||||
contain machine-specific executables and can be recreated from
|
||||
`requirements.txt`.
|
||||
|
||||
## Repository layout
|
||||
|
||||
The Gitea repository is named `argo`. `SUB Control` is a normal directory inside
|
||||
that repository containing this application. A Gitea **Project** is a planning
|
||||
board for issues and tasks; source code is not attached to a Project board.
|
||||
Git tracks the folder automatically because its files are committed on the
|
||||
repository's `main` branch.
|
||||
|
||||
Keyboard controls for SimpleROV-3: W/S forward/back, A/D yaw left/right,
|
||||
Q/E alternative yaw control, and R/F rise/dive.
|
||||
Releasing movement keys eases their controls back to neutral.
|
||||
|
||||
## Three-thruster limitation
|
||||
|
||||
The common ArduSub `SimpleROV-3` arrangement controls forward, yaw, and depth.
|
||||
It cannot independently control lateral, roll, or pitch. Accordingly, the main
|
||||
pad is mapped to forward/yaw, while the vertical slider controls depth thrust.
|
||||
|
||||
## Link behavior
|
||||
|
||||
- MAVLink GCS heartbeat: 1 Hz
|
||||
- `MANUAL_CONTROL`: selectable 10 or 20 Hz (default 20 Hz). Lower rates are
|
||||
intentionally excluded because they make short return ramps become abrupt
|
||||
steps at the flight controller.
|
||||
- No full parameter download
|
||||
- On connection, inherited bulk telemetry streams are stopped. Only
|
||||
`SYS_STATUS` at 1 Hz and `SERVO_OUTPUT_RAW` / `RC_CHANNELS` at 2 Hz are
|
||||
requested, in addition to the normal heartbeat and event-driven status text.
|
||||
- Arm/disarm uses `MAV_CMD_COMPONENT_ARM_DISARM` and displays `COMMAND_ACK`
|
||||
- Link health is based on received autopilot heartbeats
|
||||
- Battery voltage is read from `SYS_STATUS` at 1 Hz and shown as pack voltage,
|
||||
estimated per-cell voltage, and a voltage-only percentage. Cell count,
|
||||
capacity, C rating, full/empty cell voltages, and warning percentage are
|
||||
configurable and saved locally in `battery_settings.json`. Capacity consumed
|
||||
cannot be measured without a current sensor.
|
||||
- Servo configuration scans `SERVO1_FUNCTION` through `SERVO16_FUNCTION` and
|
||||
ArduSub's `MOT_1_DIRECTION` through `MOT_12_DIRECTION` one at a time, waiting
|
||||
for each response and retrying missed replies. A selected output can be
|
||||
assigned to Motor 1–12 or set unassigned. Motor direction is toggled with the
|
||||
corresponding `MOT_n_DIRECTION` value while disarmed; `SERVOx_REVERSED` is
|
||||
intentionally not used for Motor functions. It never downloads the full
|
||||
parameter list.
|
||||
The diagnostic scan also reads `SERVO1`–`SERVO3` MIN/TRIM/MAX and ArduSub's
|
||||
joystick gain values into the local session log; these values remain
|
||||
read-only in the application.
|
||||
- Every session creates a local text log under `logs/`, including operator
|
||||
actions, outgoing manual-control values and commands, acknowledgements,
|
||||
status text, and all received MAVLink messages. This does not use ArduPilot's
|
||||
onboard logger or add any link traffic.
|
||||
|
||||
The car-style virtual joystick maps only to forward/reverse and continuous
|
||||
differential turning; it never sends lateral motion. The joystick and vertical
|
||||
slider ease back to center over 400 ms after mouse/key release, so neither can
|
||||
be left commanding motion.
|
||||
Focus loss, stale telemetry, disconnect, and disarm clear commanded inputs
|
||||
without easing. This is a neutral-command request, **not proof that motors have
|
||||
stopped** when the communication link is unavailable.
|
||||
|
||||
In addition, transmitted pilot demands have a small 4% input deadband and a
|
||||
0.25-second full-range slew limit. This makes both acceleration and deceleration
|
||||
arrive at the flight controller as a sequence of stable intermediate values.
|
||||
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.
|
||||
|
||||
## Engage, confirmation, and interruption handling
|
||||
|
||||
The compact window opens at 1100 × 650. Empty sidebar areas drag the window;
|
||||
the STL viewer, buttons, and sliders are separate interactive regions. Output
|
||||
limit and Motor setup sit below the main controls. Recent activity retains the
|
||||
last three events, oldest at the top and newest emphasized at the bottom.
|
||||
Routine hold instructions are kept in tooltips;
|
||||
pending confirmations and safety warnings still appear above the controls.
|
||||
The full-width text control displays `engage` / `disengage`; disengage sends the
|
||||
same immediate disarm request described below.
|
||||
|
||||
- Hold **ENGAGE** for 1.4 seconds (or hold F2). Early release, leaving the
|
||||
button, interrupted touch, and focus loss cancel the hold without sending arm.
|
||||
- **ENGAGING…** means a request is pending. A send result or accepted command
|
||||
ACK alone does not prove that the vehicle armed: a fresh autopilot heartbeat
|
||||
reporting the requested state is the final confirmation. A heartbeat can
|
||||
confirm state even if the ACK packet was lost.
|
||||
- **DISARM** is one tap or F3, with no long hold. While confirmation is pending,
|
||||
**DISARMING…** stays red and driving is disabled. Disarm can also interrupt a
|
||||
pending Engage request. No forced-arm/disarm bypass is used.
|
||||
- Confirmation timeout: 5 seconds in the backend (6-second UI fallback).
|
||||
Rejection and timeout remain visible. Late heartbeat updates still determine
|
||||
the displayed state; the UI never substitutes an animation state for telemetry.
|
||||
- A heartbeat older than 3 seconds is stale. A missing bridge reply after
|
||||
1.5 seconds shows **STATE UNKNOWN**, preserving the last-known state without
|
||||
claiming disarm. Battery readings older than 5 seconds are marked stale.
|
||||
- The web frontend renews an input lease locally every 250 ms. If it stops for
|
||||
1 second, the worker clears stored demands and sends neutral at its configured
|
||||
control rate. This adds no MAVLink stream or parameter download. Focus loss
|
||||
clears both captured touch pointers and queued input; reconnect never replays
|
||||
the old joystick position. Normal release still eases back to center.
|
||||
- Link loss cannot deliver a stop over a broken tether. Verify the vehicle's
|
||||
own failsafe and use independent physical power isolation for bench tests.
|
||||
|
||||
### Regression checks (no hardware)
|
||||
|
||||
Run from this project folder after setting up the Python environment:
|
||||
Install Python 3.11+ with the Python launcher, then run:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python.exe -m unittest discover -s tests -v
|
||||
node --test tests/control_ui.test.cjs
|
||||
./run.ps1
|
||||
```
|
||||
|
||||
Node is needed only for the frontend regression tests, not to run the app.
|
||||
The tests use mocked transport and synthetic pointer events, including independent
|
||||
joystick/vertical pointers. Validate physical multi-touch on the intended screen
|
||||
and FC failsafes on a restrained, propeller-free setup before powered operation.
|
||||
The script creates a virtual environment and installs dependencies automatically.
|
||||
On first launch, it also adds a **SUB Control** desktop shortcut.
|
||||
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
|
||||
Connection settings are under **•••**.
|
||||
Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details.
|
||||
|
||||
## Controls
|
||||
|
||||
- Joystick: forward/back and turning. Vertical slider: rise/dive.
|
||||
- Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive.
|
||||
- Physical controller: select **Joystick** while disarmed. Left stick drives/turns; right stick controls rise/dive. **Setup** changes axes, inversion, and dead zone while disarmed.
|
||||
- Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
|
||||
- Movement controls ease back to neutral when released.
|
||||
- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle.
|
||||
|
||||
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
|
||||
|
||||
## Safety
|
||||
|
||||
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.
|
||||
|
||||
@@ -3,4 +3,5 @@ __pycache__/
|
||||
*.py[cod]
|
||||
logs/
|
||||
model_settings.json
|
||||
link_settings.json
|
||||
runtime/
|
||||
|
||||
+25
-164
@@ -1,173 +1,34 @@
|
||||
# SUB Control
|
||||
|
||||
A deliberately small, low-bandwidth MAVLink controller for bench-testing an
|
||||
ArduSub vehicle over a USB serial connection. It sends no parameter-list
|
||||
request and no telemetry-stream request.
|
||||
A lightweight Windows controller for ArduSub ROVs over USB/MAVLink.
|
||||
|
||||

|
||||

|
||||
|
||||
The desktop window uses a local HTML/CSS/JavaScript interface rendered by the
|
||||
Windows WebView. Python remains the only launcher and owns the MAVLink worker,
|
||||
arming, logging, output limiting, and parameter writes. It does not run a web
|
||||
server, open a browser tab, or require an internet connection.
|
||||
## Quick start
|
||||
|
||||
The WebView is frameless and uses the compact title bar drawn by the interface.
|
||||
The vehicle rail includes a local STL viewer: choose **Load STL** to start with
|
||||
an orthographic top-view silhouette, then drag to orbit or use the mouse wheel
|
||||
to zoom. A cached STL copy is stored locally under the ignored `runtime/`
|
||||
directory so the model can be restored on the next launch.
|
||||
STEP/STP CAD solids must first be tessellated/exported as STL because the
|
||||
embedded renderer intentionally does not include a full CAD kernel.
|
||||
Binary and ASCII STL faces are preserved in full; large assemblies are not
|
||||
rendered by skipping triangles. Imports are limited to 30 MB to keep startup
|
||||
and interactive orbiting responsive.
|
||||
|
||||
## Safety first
|
||||
|
||||
- Remove propellers for initial tests and restrain the vehicle.
|
||||
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range,
|
||||
leak/failsafe, and emergency-stop arrangements before arming.
|
||||
- The application starts disarmed and limits pilot demand to 20% by default.
|
||||
Once armed, control movement is sent directly with no additional gate.
|
||||
- Closing or disconnecting sends neutral controls and a disarm request, but a
|
||||
physical power cutoff is still required.
|
||||
|
||||
The controls are pilot-demand axes, not direct PWM values. ArduSub performs
|
||||
the frame mixing and generates the configured ESC outputs. For ArduSub,
|
||||
`MANUAL_CONTROL.z = 500` is neutral vertical thrust.
|
||||
|
||||
## Run on Windows
|
||||
|
||||
1. Install Python 3.11 or newer. A virtual environment installs packages, but
|
||||
it does not install Python itself.
|
||||
2. Open PowerShell in this folder and run `./run.ps1` (or right-click it and
|
||||
choose **Run with PowerShell**).
|
||||
3. Select the autopilot's COM port, then click **Connect**. Baud and control-rate
|
||||
settings are under the small `•••` link button (normally 57600 baud / 20 Hz).
|
||||
4. Confirm `ArduSub` and a live heartbeat are shown.
|
||||
5. Click **Set MANUAL**, then **ARM** only when the bench is safe.
|
||||
6. Move the controls. Once armed, no additional enable button is needed.
|
||||
|
||||
`run.ps1` automatically creates a private `.venv` on the first run, installs
|
||||
the versions declared in `requirements.txt`, and starts SUB Control. Later runs
|
||||
reuse the same environment and update missing dependencies when necessary.
|
||||
The `.venv` directory is intentionally ignored by Git: virtual environments
|
||||
contain machine-specific executables and can be recreated from
|
||||
`requirements.txt`.
|
||||
|
||||
## Repository layout
|
||||
|
||||
The Gitea repository is named `argo`. `SUB Control` is a normal directory inside
|
||||
that repository containing this application. A Gitea **Project** is a planning
|
||||
board for issues and tasks; source code is not attached to a Project board.
|
||||
Git tracks the folder automatically because its files are committed on the
|
||||
repository's `main` branch.
|
||||
|
||||
Keyboard controls for SimpleROV-3: W/S forward/back, A/D yaw left/right,
|
||||
Q/E alternative yaw control, and R/F rise/dive.
|
||||
Releasing movement keys eases their controls back to neutral.
|
||||
|
||||
## Three-thruster limitation
|
||||
|
||||
The common ArduSub `SimpleROV-3` arrangement controls forward, yaw, and depth.
|
||||
It cannot independently control lateral, roll, or pitch. Accordingly, the main
|
||||
pad is mapped to forward/yaw, while the vertical slider controls depth thrust.
|
||||
|
||||
## Link behavior
|
||||
|
||||
- MAVLink GCS heartbeat: 1 Hz
|
||||
- `MANUAL_CONTROL`: selectable 10 or 20 Hz (default 20 Hz). Lower rates are
|
||||
intentionally excluded because they make short return ramps become abrupt
|
||||
steps at the flight controller.
|
||||
- No full parameter download
|
||||
- On connection, inherited bulk telemetry streams are stopped. Only
|
||||
`SYS_STATUS` at 1 Hz and `SERVO_OUTPUT_RAW` / `RC_CHANNELS` at 2 Hz are
|
||||
requested, in addition to the normal heartbeat and event-driven status text.
|
||||
- Arm/disarm uses `MAV_CMD_COMPONENT_ARM_DISARM` and displays `COMMAND_ACK`
|
||||
- Link health is based on received autopilot heartbeats
|
||||
- Battery voltage is read from `SYS_STATUS` at 1 Hz and shown as pack voltage,
|
||||
estimated per-cell voltage, and a voltage-only percentage. Cell count,
|
||||
capacity, C rating, full/empty cell voltages, and warning percentage are
|
||||
configurable and saved locally in `battery_settings.json`. Capacity consumed
|
||||
cannot be measured without a current sensor.
|
||||
- Servo configuration scans `SERVO1_FUNCTION` through `SERVO16_FUNCTION` and
|
||||
ArduSub's `MOT_1_DIRECTION` through `MOT_12_DIRECTION` one at a time, waiting
|
||||
for each response and retrying missed replies. A selected output can be
|
||||
assigned to Motor 1–12 or set unassigned. Motor direction is toggled with the
|
||||
corresponding `MOT_n_DIRECTION` value while disarmed; `SERVOx_REVERSED` is
|
||||
intentionally not used for Motor functions. It never downloads the full
|
||||
parameter list.
|
||||
The diagnostic scan also reads `SERVO1`–`SERVO3` MIN/TRIM/MAX and ArduSub's
|
||||
joystick gain values into the local session log; these values remain
|
||||
read-only in the application.
|
||||
- Every session creates a local text log under `logs/`, including operator
|
||||
actions, outgoing manual-control values and commands, acknowledgements,
|
||||
status text, and all received MAVLink messages. This does not use ArduPilot's
|
||||
onboard logger or add any link traffic.
|
||||
|
||||
The car-style virtual joystick maps only to forward/reverse and continuous
|
||||
differential turning; it never sends lateral motion. The joystick and vertical
|
||||
slider ease back to center over 400 ms after mouse/key release, so neither can
|
||||
be left commanding motion.
|
||||
Focus loss, stale telemetry, disconnect, and disarm clear commanded inputs
|
||||
without easing. This is a neutral-command request, **not proof that motors have
|
||||
stopped** when the communication link is unavailable.
|
||||
|
||||
In addition, transmitted pilot demands have a small 4% input deadband and a
|
||||
0.25-second full-range slew limit. This makes both acceleration and deceleration
|
||||
arrive at the flight controller as a sequence of stable intermediate values.
|
||||
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.
|
||||
|
||||
## Engage, confirmation, and interruption handling
|
||||
|
||||
The compact window opens at 1100 × 650. Empty sidebar areas drag the window;
|
||||
the STL viewer, buttons, and sliders are separate interactive regions. Output
|
||||
limit and Motor setup sit below the main controls. Recent activity retains the
|
||||
last three events, oldest at the top and newest emphasized at the bottom.
|
||||
Routine hold instructions are kept in tooltips;
|
||||
pending confirmations and safety warnings still appear above the controls.
|
||||
The full-width text control displays `engage` / `disengage`; disengage sends the
|
||||
same immediate disarm request described below.
|
||||
|
||||
- Hold **ENGAGE** for 1.4 seconds (or hold F2). Early release, leaving the
|
||||
button, interrupted touch, and focus loss cancel the hold without sending arm.
|
||||
- **ENGAGING…** means a request is pending. A send result or accepted command
|
||||
ACK alone does not prove that the vehicle armed: a fresh autopilot heartbeat
|
||||
reporting the requested state is the final confirmation. A heartbeat can
|
||||
confirm state even if the ACK packet was lost.
|
||||
- **DISARM** is one tap or F3, with no long hold. While confirmation is pending,
|
||||
**DISARMING…** stays red and driving is disabled. Disarm can also interrupt a
|
||||
pending Engage request. No forced-arm/disarm bypass is used.
|
||||
- Confirmation timeout: 5 seconds in the backend (6-second UI fallback).
|
||||
Rejection and timeout remain visible. Late heartbeat updates still determine
|
||||
the displayed state; the UI never substitutes an animation state for telemetry.
|
||||
- A heartbeat older than 3 seconds is stale. A missing bridge reply after
|
||||
1.5 seconds shows **STATE UNKNOWN**, preserving the last-known state without
|
||||
claiming disarm. Battery readings older than 5 seconds are marked stale.
|
||||
- The web frontend renews an input lease locally every 250 ms. If it stops for
|
||||
1 second, the worker clears stored demands and sends neutral at its configured
|
||||
control rate. This adds no MAVLink stream or parameter download. Focus loss
|
||||
clears both captured touch pointers and queued input; reconnect never replays
|
||||
the old joystick position. Normal release still eases back to center.
|
||||
- Link loss cannot deliver a stop over a broken tether. Verify the vehicle's
|
||||
own failsafe and use independent physical power isolation for bench tests.
|
||||
|
||||
### Regression checks (no hardware)
|
||||
|
||||
Run from this project folder after setting up the Python environment:
|
||||
Install Python 3.11+ with the Python launcher, then run:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python.exe -m unittest discover -s tests -v
|
||||
node --test tests/control_ui.test.cjs
|
||||
./run.ps1
|
||||
```
|
||||
|
||||
Node is needed only for the frontend regression tests, not to run the app.
|
||||
The tests use mocked transport and synthetic pointer events, including independent
|
||||
joystick/vertical pointers. Validate physical multi-touch on the intended screen
|
||||
and FC failsafes on a restrained, propeller-free setup before powered operation.
|
||||
The script creates a virtual environment and installs dependencies automatically.
|
||||
On first launch, it also adds a **SUB Control** desktop shortcut.
|
||||
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
|
||||
Connection settings are under **•••**.
|
||||
Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details.
|
||||
|
||||
## Controls
|
||||
|
||||
- Joystick: forward/back and turning. Vertical slider: rise/dive.
|
||||
- Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive.
|
||||
- Physical controller: select **Joystick** while disarmed. Left stick drives/turns; right stick controls rise/dive. **Setup** changes axes, inversion, and dead zone while disarmed.
|
||||
- Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
|
||||
- Movement controls ease back to neutral when released.
|
||||
- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle.
|
||||
|
||||
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
|
||||
|
||||
## Safety
|
||||
|
||||
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.
|
||||
|
||||
+447
-32
@@ -13,6 +13,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
# ESC_TELEMETRY_1_TO_4 uses a MAVLink 2 message ID. Select the v2 dialect
|
||||
# before importing pymavlink so received ESC packets can be decoded/logged.
|
||||
os.environ["MAVLINK20"] = "1"
|
||||
from pymavlink import mavutil
|
||||
from serial.tools import list_ports
|
||||
|
||||
@@ -67,6 +70,10 @@ class MavlinkWorker:
|
||||
self.connection = None
|
||||
self.stop_event = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
# pymavlink writes are not thread-safe. The web/API thread can issue
|
||||
# commands while this worker sends heartbeats and controls, so serialize
|
||||
# every packet before it reaches a low-bandwidth serial link.
|
||||
self.tx_lock = threading.Lock()
|
||||
self.thread = None
|
||||
self.axes = Axes()
|
||||
self.output_axes = Axes()
|
||||
@@ -87,6 +94,34 @@ class MavlinkWorker:
|
||||
self.last_rx = 0.0
|
||||
self.armed = False
|
||||
self.config_paused = False
|
||||
# Keep configuration traffic off the link during an arm/disarm
|
||||
# transition, including the wait for the vehicle's confirmation.
|
||||
self.command_quiet_until = 0.0
|
||||
self.disarm_requested_until = 0.0
|
||||
self.pending_arm_state = None
|
||||
self.next_arm_retry_at = 0.0
|
||||
self.arm_retry_deadline = 0.0
|
||||
self.arm_confirmation = 0
|
||||
self.stream_config = deque()
|
||||
self.next_stream_config_at = 0.0
|
||||
# Low-rate TIMESYNC echoes measure PC↔FC round trips with minimal traffic.
|
||||
self.ping_rate_hz = 1
|
||||
self.heavy_quality_test = None
|
||||
self.heavy_pending_pings = {}
|
||||
self.heavy_quality_latest = None
|
||||
self.telemetry_rates_hz = {
|
||||
"SYS_STATUS": 1, "SERVO_OUTPUT_RAW": 2,
|
||||
"RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1,
|
||||
}
|
||||
self.ping_sequence = 0
|
||||
self.next_ping_at = 0.0
|
||||
self.pending_pings = {}
|
||||
self.ping_results = deque(maxlen=300)
|
||||
self.bad_frame_times = deque()
|
||||
self.bad_frame_bytes = deque()
|
||||
self.rx_sequence_by_source = {}
|
||||
self.rx_delivery_results = deque(maxlen=1024)
|
||||
self.next_quality_publish_at = 0.0
|
||||
self.param_scan = deque()
|
||||
self.param_waiting = None
|
||||
self.param_deadline = 0.0
|
||||
@@ -96,6 +131,9 @@ class MavlinkWorker:
|
||||
self.log_lock = threading.Lock()
|
||||
self.log_file = None
|
||||
self.log_path = None
|
||||
self.console_lines = deque(maxlen=500)
|
||||
self.console_rx_lines = deque(maxlen=300)
|
||||
self.console_tx_lines = deque(maxlen=300)
|
||||
|
||||
def connect(self, port: str, baud: int):
|
||||
print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}")
|
||||
@@ -109,8 +147,12 @@ class MavlinkWorker:
|
||||
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
|
||||
normalized = normalize_endpoint(port)
|
||||
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
|
||||
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} control_hz={self.rate_hz}")
|
||||
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} "
|
||||
f"control_hz={self.rate_hz} rx=MAVLink2 tx=MAVLink2")
|
||||
self.events.put(("log_path", str(self.log_path)))
|
||||
self.last_heartbeat = 0.0
|
||||
self.last_rx = 0.0
|
||||
self.next_quality_publish_at = 0.0
|
||||
self.thread = threading.Thread(
|
||||
target=self._run, args=(normalized, baud), daemon=True
|
||||
)
|
||||
@@ -137,7 +179,7 @@ class MavlinkWorker:
|
||||
|
||||
# Send initial GCS heartbeat immediately
|
||||
self.log("TX_HEARTBEAT", "GCS initial")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
@@ -149,17 +191,32 @@ class MavlinkWorker:
|
||||
now = time.monotonic()
|
||||
if now >= next_gcs_hb:
|
||||
self.log("TX_HEARTBEAT", "GCS")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
next_gcs_hb = now + 1.0
|
||||
if self.target_system and now >= next_control:
|
||||
# ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a
|
||||
# 20 Hz neutral stream alive here only competes with ARM and
|
||||
# its acknowledgement on constrained links.
|
||||
if self.target_system and self.armed and now >= next_control:
|
||||
self._send_control()
|
||||
next_control = now + (1.0 / max(1, self.rate_hz))
|
||||
# The first disarm packet is preceded by a neutral command.
|
||||
# Until disarm is confirmed, keep sending neutral at a
|
||||
# lower rate instead of consuming 20 Hz on the weak link.
|
||||
control_hz = min(self.rate_hz, 5) if now < self.disarm_requested_until else self.rate_hz
|
||||
next_control = now + (1.0 / max(1, control_hz))
|
||||
elif not self.armed:
|
||||
next_control = now
|
||||
if self.target_system:
|
||||
self._tick_arm_retry(now)
|
||||
self._tick_link_probe(now)
|
||||
self._tick_stream_config(now)
|
||||
self._tick_param_scan(now)
|
||||
if now >= self.next_quality_publish_at:
|
||||
self._publish_link_quality()
|
||||
self.next_quality_publish_at = now + 1.0
|
||||
|
||||
msg = conn.recv_match(blocking=False)
|
||||
if msg:
|
||||
@@ -170,6 +227,8 @@ class MavlinkWorker:
|
||||
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
|
||||
self.events.put(("error", f"Connection error: {exc}"))
|
||||
finally:
|
||||
if self.heavy_quality_test is not None:
|
||||
self.cancel_heavy_quality_test()
|
||||
self._safe_stop()
|
||||
if self.connection:
|
||||
try:
|
||||
@@ -180,6 +239,19 @@ class MavlinkWorker:
|
||||
self.target_system = 0
|
||||
self.armed = False
|
||||
self.config_paused = False
|
||||
self.command_quiet_until = 0.0
|
||||
self.disarm_requested_until = 0.0
|
||||
self.pending_arm_state = None
|
||||
self.next_arm_retry_at = 0.0
|
||||
self.arm_retry_deadline = 0.0
|
||||
self.arm_confirmation = 0
|
||||
self.stream_config.clear()
|
||||
self.pending_pings.clear()
|
||||
self.ping_results.clear()
|
||||
self.bad_frame_times.clear()
|
||||
self.bad_frame_bytes.clear()
|
||||
self.rx_sequence_by_source.clear()
|
||||
self.rx_delivery_results.clear()
|
||||
self.log("SESSION", "disconnected")
|
||||
with self.log_lock:
|
||||
if self.log_file:
|
||||
@@ -189,22 +261,71 @@ class MavlinkWorker:
|
||||
|
||||
def log(self, category: str, message: str):
|
||||
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
line = f"{timestamp} [{category}] {message}"
|
||||
with self.log_lock:
|
||||
self.console_lines.append(line[:2000])
|
||||
if category.startswith("RX") or category == "ONBOARD_LOG_ERROR":
|
||||
self.console_rx_lines.append(line[:2000])
|
||||
elif category.startswith("TX") or category == "OPERATOR":
|
||||
self.console_tx_lines.append(line[:2000])
|
||||
if self.log_file:
|
||||
self.log_file.write(f"{timestamp} [{category}] {message}\n")
|
||||
self.log_file.write(line + "\n")
|
||||
|
||||
def read_console(self, limit=150):
|
||||
with self.log_lock:
|
||||
limit = max(1, min(300, int(limit)))
|
||||
return {
|
||||
"rx": list(self.console_rx_lines)[-limit:],
|
||||
"tx": list(self.console_tx_lines)[-limit:],
|
||||
}
|
||||
|
||||
def _send_mav(self, connection, method: str, *args):
|
||||
"""Serialize MAVLink 2 writes and protect the start of each burst."""
|
||||
with self.tx_lock:
|
||||
# This full-duplex module pair loses the opening bytes of some
|
||||
# PC-to-FC bursts after the line has been idle. Put a valid,
|
||||
# expendable GCS heartbeat first so the following packet remains
|
||||
# intact and the FC also sees a healthy GCS heartbeat stream.
|
||||
if method != "heartbeat_send" and hasattr(connection.mav, "heartbeat_send"):
|
||||
connection.mav.heartbeat_send(
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
getattr(connection.mav, method)(*args)
|
||||
|
||||
def _handle_message(self, msg):
|
||||
msg_type = msg.get_type()
|
||||
if msg_type == "BAD_DATA":
|
||||
now = time.monotonic()
|
||||
self.bad_frame_times.append(now)
|
||||
self.bad_frame_bytes.append((now, len(getattr(msg, "data", b""))))
|
||||
self._trim_bad_frames(now)
|
||||
self.log("RX_BAD_DATA", repr(getattr(msg, "data", b"")))
|
||||
return
|
||||
self.last_rx = time.monotonic()
|
||||
self._track_rx_delivery(msg)
|
||||
try:
|
||||
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
|
||||
except Exception:
|
||||
payload = str(msg)
|
||||
self.log("RX", payload)
|
||||
if msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
|
||||
if msg_type == "TIMESYNC" and int(getattr(msg, "tc1", 0)) != 0:
|
||||
token = int(getattr(msg, "ts1", -1))
|
||||
heavy_probe = self.heavy_pending_pings.pop(token, None)
|
||||
if heavy_probe is not None:
|
||||
stage_index, sent_at = heavy_probe
|
||||
self._record_heavy_probe(stage_index, (time.monotonic() - sent_at) * 1000.0)
|
||||
self._publish_heavy_quality()
|
||||
sent_at = None
|
||||
else:
|
||||
sent_at = self.pending_pings.pop(token, None)
|
||||
if sent_at is not None:
|
||||
rtt_ms = (time.monotonic() - sent_at) * 1000.0
|
||||
self.ping_results.append((time.monotonic(), rtt_ms))
|
||||
self.log("RX_LINK_PROBE", f"timesync={token} rtt_ms={rtt_ms:.1f}")
|
||||
self._publish_link_quality()
|
||||
elif msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
|
||||
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
|
||||
return
|
||||
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||
@@ -216,6 +337,10 @@ class MavlinkWorker:
|
||||
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
|
||||
self.armed = armed
|
||||
self.config_paused = armed
|
||||
if self.pending_arm_state is not None and armed == self.pending_arm_state:
|
||||
self.pending_arm_state = None
|
||||
if not armed:
|
||||
self.disarm_requested_until = 0.0
|
||||
try:
|
||||
mode = mavutil.mode_string_v10(msg)
|
||||
except Exception:
|
||||
@@ -224,6 +349,7 @@ class MavlinkWorker:
|
||||
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
|
||||
if first:
|
||||
self.next_ping_at = time.monotonic()
|
||||
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
||||
self._configure_low_bandwidth_streams()
|
||||
self._start_servo_scan()
|
||||
@@ -232,12 +358,21 @@ class MavlinkWorker:
|
||||
return
|
||||
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
|
||||
result_name = result.name if result else str(msg.result)
|
||||
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
|
||||
self.log("RX_STREAM_CONFIG_ACK", result_name)
|
||||
return
|
||||
if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
|
||||
and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")):
|
||||
self.pending_arm_state = None
|
||||
self.events.put(("ack", int(msg.command), result_name, self.last_rx))
|
||||
elif msg_type == "STATUSTEXT":
|
||||
text = msg.text
|
||||
if isinstance(text, bytes):
|
||||
text = text.decode(errors="replace")
|
||||
self.events.put(("statustext", text.rstrip("\x00")))
|
||||
text = text.rstrip("\x00")
|
||||
if "/APM/LOGS" in text and "Failed" in text:
|
||||
self.log("ONBOARD_LOG_ERROR", text)
|
||||
self.events.put(("statustext", text))
|
||||
elif msg_type == "SYS_STATUS":
|
||||
voltage_mv = int(msg.voltage_battery)
|
||||
if 0 < voltage_mv < 65535:
|
||||
@@ -264,6 +399,238 @@ class MavlinkWorker:
|
||||
self.events.put(("motor_direction", int(parts[1]),
|
||||
int(round(msg.param_value))))
|
||||
|
||||
def _trim_bad_frames(self, now):
|
||||
while self.bad_frame_times and now - self.bad_frame_times[0] > 20.0:
|
||||
self.bad_frame_times.popleft()
|
||||
|
||||
def _track_rx_delivery(self, msg):
|
||||
"""Estimate downlink loss from MAVLink's per-source packet sequence."""
|
||||
try:
|
||||
source = (int(msg.get_srcSystem()), int(msg.get_srcComponent()))
|
||||
sequence = int(msg.get_seq())
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return
|
||||
if self.target_system and source != (self.target_system, self.target_component):
|
||||
return
|
||||
previous = self.rx_sequence_by_source.get(source)
|
||||
self.rx_sequence_by_source[source] = sequence
|
||||
if previous is None:
|
||||
return
|
||||
missing = (sequence - previous - 1) & 0xFF
|
||||
# A large backwards jump is a reset/reordered packet, not 200 losses.
|
||||
if missing > 127:
|
||||
return
|
||||
timestamp = time.monotonic()
|
||||
self.rx_delivery_results.extend((timestamp, False) for _ in range(missing))
|
||||
self.rx_delivery_results.append((timestamp, True))
|
||||
|
||||
def _publish_link_quality(self):
|
||||
now = time.monotonic()
|
||||
self._trim_bad_frames(now)
|
||||
while self.ping_results and now - self.ping_results[0][0] > 30.0:
|
||||
self.ping_results.popleft()
|
||||
while self.rx_delivery_results and now - self.rx_delivery_results[0][0] > 30.0:
|
||||
self.rx_delivery_results.popleft()
|
||||
while self.bad_frame_bytes and now - self.bad_frame_bytes[0][0] > 20.0:
|
||||
self.bad_frame_bytes.popleft()
|
||||
samples = [sample for _, sample in self.ping_results]
|
||||
received = [sample for sample in samples if sample is not None]
|
||||
total = len(samples)
|
||||
loss_percent = 100.0 * (total - len(received)) / total if total else None
|
||||
ordered_rtt = sorted(received)
|
||||
latency_ms = (sum(ordered_rtt) / len(ordered_rtt)) if ordered_rtt else None
|
||||
median_rtt_ms = ordered_rtt[len(ordered_rtt) // 2] if ordered_rtt else None
|
||||
p95_rtt_ms = ordered_rtt[min(len(ordered_rtt) - 1, (len(ordered_rtt) * 95 + 99) // 100 - 1)] if ordered_rtt else None
|
||||
rx_received = sum(1 for _, delivered in self.rx_delivery_results if delivered)
|
||||
rx_missing = sum(1 for _, delivered in self.rx_delivery_results if not delivered)
|
||||
rx_loss_percent = 100.0 * rx_missing / (rx_received + rx_missing) if rx_received else None
|
||||
rx_corrupt_bytes = sum(byte_count for _, byte_count in self.bad_frame_bytes)
|
||||
heartbeat_age = (now - self.last_heartbeat) if self.last_heartbeat else None
|
||||
if total < 10 or rx_received < 10 or heartbeat_age is None:
|
||||
grade = "testing"
|
||||
elif (heartbeat_age > 3 or loss_percent > 15
|
||||
or (rx_loss_percent is not None and rx_loss_percent > 5)
|
||||
or len(self.bad_frame_bytes) >= 5):
|
||||
grade = "poor"
|
||||
elif (loss_percent > 3 or (rx_loss_percent is not None and rx_loss_percent > 1)
|
||||
or (p95_rtt_ms is not None and p95_rtt_ms > 500) or self.bad_frame_bytes):
|
||||
grade = "fair"
|
||||
elif (loss_percent > 0.5 or (rx_loss_percent is not None and rx_loss_percent > 0.2)
|
||||
or (p95_rtt_ms is not None and p95_rtt_ms > 200)):
|
||||
grade = "good"
|
||||
else:
|
||||
grade = "excellent"
|
||||
self.events.put(("link_quality", {
|
||||
"grade": grade,
|
||||
"latency_ms": round(latency_ms, 1) if latency_ms is not None else None,
|
||||
"rtt_median_ms": round(median_rtt_ms, 1) if median_rtt_ms is not None else None,
|
||||
"rtt_p95_ms": round(p95_rtt_ms, 1) if p95_rtt_ms is not None else None,
|
||||
"loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
|
||||
"probe_loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
|
||||
"probe_samples": total,
|
||||
"probe_replies": len(received),
|
||||
"rx_packets_30s": rx_received,
|
||||
"rx_sequence_loss_percent": round(rx_loss_percent, 1) if rx_loss_percent is not None else None,
|
||||
"rx_corrupt_frames_20s": len(self.bad_frame_bytes),
|
||||
"rx_corrupt_bytes_20s": rx_corrupt_bytes,
|
||||
"samples": total,
|
||||
"corrupt_frames_20s": len(self.bad_frame_times),
|
||||
"heartbeat_age_s": round(heartbeat_age, 1) if heartbeat_age is not None else None,
|
||||
"probe_supported": total > 0,
|
||||
}))
|
||||
|
||||
def _tick_link_probe(self, now):
|
||||
if self.heavy_quality_test is not None:
|
||||
self._tick_heavy_quality(now)
|
||||
return
|
||||
expired = [sequence for sequence, sent_at in self.pending_pings.items()
|
||||
if now - sent_at >= 2.5]
|
||||
for sequence in expired:
|
||||
self.pending_pings.pop(sequence, None)
|
||||
self.ping_results.append((now, None))
|
||||
self.log("LINK_PROBE_TIMEOUT", f"timesync={sequence}")
|
||||
if expired:
|
||||
self._publish_link_quality()
|
||||
if (now < self.next_ping_at or now < self.command_quiet_until
|
||||
or not self.connection or not self.target_system):
|
||||
return
|
||||
token = time.monotonic_ns()
|
||||
self.pending_pings[token] = now
|
||||
self.log("TX_LINK_PROBE", f"timesync={token}")
|
||||
# ArduPilot implements TIMESYNC even on builds that omit PING. A
|
||||
# request has tc1=0; the response mirrors our unique ts1 token.
|
||||
self._send_mav(self.connection, "timesync_send", 0, token)
|
||||
self.next_ping_at = now + (1.0 / max(1, self.ping_rate_hz))
|
||||
|
||||
def start_heavy_quality_test(self):
|
||||
if not self.connection or not self.target_system:
|
||||
raise RuntimeError("Connect to the flight controller before testing the link")
|
||||
if self.armed:
|
||||
raise RuntimeError("The heavy link test is available while the vehicle is disarmed")
|
||||
if self.pending_arm_state is not None or time.monotonic() < self.command_quiet_until:
|
||||
raise RuntimeError("Wait for the current vehicle command to finish")
|
||||
if self.heavy_quality_test is not None:
|
||||
raise RuntimeError("A heavy link test is already running")
|
||||
self.pending_pings.clear()
|
||||
self.heavy_pending_pings.clear()
|
||||
self.heavy_quality_latest = {
|
||||
"running": True, "stage": 1, "stages": 3, "rate_hz": 1,
|
||||
"status": "Running", "results": [],
|
||||
}
|
||||
self.heavy_quality_test = {
|
||||
"rates": (1, 5, 10), "duration_s": 10.0, "stage_index": 0,
|
||||
"stage_started": time.monotonic(), "next_probe_at": time.monotonic(),
|
||||
"stages": [{"rate_hz": rate, "sent": 0, "replies": 0,
|
||||
"timeouts": 0, "rtts": []} for rate in (1, 5, 10)],
|
||||
}
|
||||
self.log("TX_HEAVY_TEST", "started rates=1,5,10Hz stage_seconds=10")
|
||||
self._publish_heavy_quality()
|
||||
return self.heavy_quality_latest
|
||||
|
||||
def cancel_heavy_quality_test(self):
|
||||
if self.heavy_quality_test is None:
|
||||
return self.heavy_quality_latest
|
||||
self.heavy_pending_pings.clear()
|
||||
test = self.heavy_quality_test
|
||||
self.heavy_quality_test = None
|
||||
self.heavy_quality_latest = {
|
||||
"running": False, "stage": 0, "stages": len(test["rates"]),
|
||||
"rate_hz": None, "status": "Cancelled", "results": self._heavy_results(test),
|
||||
}
|
||||
self.next_ping_at = time.monotonic()
|
||||
self.log("TX_HEAVY_TEST", "cancelled")
|
||||
self.events.put(("heavy_quality", self.heavy_quality_latest))
|
||||
return self.heavy_quality_latest
|
||||
|
||||
@staticmethod
|
||||
def _heavy_results(test):
|
||||
results = []
|
||||
for stage in test["stages"]:
|
||||
rtts = sorted(stage["rtts"])
|
||||
replies = stage["replies"]
|
||||
resolved = replies + stage["timeouts"]
|
||||
results.append({
|
||||
"rate_hz": stage["rate_hz"], "sent": stage["sent"], "replies": replies,
|
||||
"loss_percent": round(100.0 * stage["timeouts"] / resolved, 1) if resolved else None,
|
||||
"median_rtt_ms": round(rtts[len(rtts) // 2], 1) if rtts else None,
|
||||
"p95_rtt_ms": round(rtts[min(len(rtts) - 1, (len(rtts) * 95 + 99) // 100 - 1)], 1) if rtts else None,
|
||||
})
|
||||
return results
|
||||
|
||||
def _record_heavy_probe(self, stage_index, rtt_ms=None):
|
||||
test = self.heavy_quality_test
|
||||
if test is None or not 0 <= stage_index < len(test["stages"]):
|
||||
return
|
||||
stage = test["stages"][stage_index]
|
||||
if rtt_ms is None:
|
||||
stage["timeouts"] += 1
|
||||
else:
|
||||
stage["replies"] += 1
|
||||
stage["rtts"].append(rtt_ms)
|
||||
|
||||
def _publish_heavy_quality(self):
|
||||
test = self.heavy_quality_test
|
||||
if test is None:
|
||||
return
|
||||
index = test["stage_index"]
|
||||
stage = test["stages"][index]
|
||||
self.heavy_quality_latest = {
|
||||
"running": True, "stage": index + 1, "stages": len(test["rates"]),
|
||||
"rate_hz": stage["rate_hz"], "sent": stage["sent"],
|
||||
"replies": stage["replies"], "timeouts": stage["timeouts"],
|
||||
"status": f"Stage {index + 1} of {len(test['rates'])}: {stage['rate_hz']} Hz",
|
||||
"results": self._heavy_results(test),
|
||||
}
|
||||
self.events.put(("heavy_quality", self.heavy_quality_latest))
|
||||
|
||||
def _tick_heavy_quality(self, now):
|
||||
test = self.heavy_quality_test
|
||||
if test is None:
|
||||
return
|
||||
for token, (stage_index, sent_at) in list(self.heavy_pending_pings.items()):
|
||||
if now - sent_at >= 2.5:
|
||||
self.heavy_pending_pings.pop(token, None)
|
||||
self._record_heavy_probe(stage_index)
|
||||
self.log("HEAVY_PROBE_TIMEOUT", f"rate={test['rates'][stage_index]}Hz timesync={token}")
|
||||
index = test["stage_index"]
|
||||
stage_pending = any(stage_index == index for stage_index, _ in self.heavy_pending_pings.values())
|
||||
if now - test["stage_started"] >= test["duration_s"] and not stage_pending:
|
||||
index += 1
|
||||
if index >= len(test["rates"]):
|
||||
results = self._heavy_results(test)
|
||||
self.heavy_quality_test = None
|
||||
self.heavy_quality_latest = {
|
||||
"running": False, "stage": 3, "stages": 3, "rate_hz": None,
|
||||
"status": "Complete", "results": results,
|
||||
}
|
||||
self.next_ping_at = now
|
||||
self.log("TX_HEAVY_TEST", "complete")
|
||||
self.events.put(("heavy_quality", self.heavy_quality_latest))
|
||||
return
|
||||
test["stage_index"] = index
|
||||
test["stage_started"] = now
|
||||
test["next_probe_at"] = now
|
||||
self._publish_heavy_quality()
|
||||
index = test["stage_index"]
|
||||
stage = test["stages"][index]
|
||||
if now >= test["next_probe_at"] and now - test["stage_started"] < test["duration_s"]:
|
||||
token = time.monotonic_ns()
|
||||
self.heavy_pending_pings[token] = (index, now)
|
||||
stage["sent"] += 1
|
||||
self.log("TX_HEAVY_PROBE", f"rate={stage['rate_hz']}Hz timesync={token}")
|
||||
self._send_mav(self.connection, "timesync_send", 0, token)
|
||||
test["next_probe_at"] = now + 1.0 / stage["rate_hz"]
|
||||
if now >= self.next_quality_publish_at:
|
||||
self._publish_heavy_quality()
|
||||
self.next_quality_publish_at = now + 1.0
|
||||
|
||||
def set_link_rates(self, ping_hz, telemetry_rates):
|
||||
self.ping_rate_hz = max(1, min(10, int(ping_hz)))
|
||||
for name in self.telemetry_rates_hz:
|
||||
if name in telemetry_rates:
|
||||
self.telemetry_rates_hz[name] = max(0, min(10, int(telemetry_rates[name])))
|
||||
if self.connection and self.target_system:
|
||||
self._configure_low_bandwidth_streams()
|
||||
def _start_servo_scan(self):
|
||||
names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)]
|
||||
# ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal,
|
||||
@@ -284,31 +651,52 @@ class MavlinkWorker:
|
||||
self.param_retries = 0
|
||||
|
||||
def _configure_low_bandwidth_streams(self):
|
||||
"""Stop inherited high-rate streams and request only diagnostic essentials."""
|
||||
"""Pace optional telemetry changes; never burst them at arm time."""
|
||||
if not self.connection:
|
||||
return
|
||||
self.log("TX_STREAM_CONFIG", "stop MAV_DATA_STREAM_ALL")
|
||||
self.connection.mav.request_data_stream_send(
|
||||
self._send_mav(self.connection, "request_data_stream_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0,
|
||||
)
|
||||
# Heartbeat and STATUSTEXT do not need to be requested. Keep only
|
||||
# output/input evidence needed to diagnose controls, at 2 Hz each.
|
||||
for message_id, interval_us, name in (
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS, 1_000_000, "SYS_STATUS"),
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW, 500_000, "SERVO_OUTPUT_RAW"),
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_RC_CHANNELS, 500_000, "RC_CHANNELS"),
|
||||
):
|
||||
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
|
||||
self.connection.mav.command_long_send(
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
|
||||
0, message_id, interval_us, 0, 0, 0, 0, 0,
|
||||
)
|
||||
self.events.put(("status", "Low-bandwidth telemetry configured; reading servo setup…"))
|
||||
# ATTITUDE and VFR_HUD interval commands were accepted by this FC.
|
||||
# AHRS2 is also unneeded, but an acknowledgement has not been seen.
|
||||
# NAMED_VALUE_FLOAT explicitly returned DENIED, so do not retry it.
|
||||
# A single interval command is sometimes lost, so send two paced
|
||||
# passes. This does not change persistent parameters or onboard logs.
|
||||
unwanted = (
|
||||
"ATTITUDE", "AHRS2", "VFR_HUD",
|
||||
)
|
||||
self.stream_config = deque(
|
||||
(name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), -1)
|
||||
for _ in range(2) for name in unwanted
|
||||
)
|
||||
for name, rate_hz in self.telemetry_rates_hz.items():
|
||||
interval_us = -1 if rate_hz == 0 else round(1_000_000 / rate_hz)
|
||||
self.stream_config.append((
|
||||
name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), interval_us,
|
||||
))
|
||||
self.next_stream_config_at = time.monotonic()
|
||||
self.events.put(("status", "Applying low-bandwidth telemetry rates…"))
|
||||
|
||||
def _tick_stream_config(self, now):
|
||||
if (not self.connection or not self.target_system or self.armed
|
||||
or now < self.command_quiet_until or now < self.next_stream_config_at
|
||||
or not self.stream_config):
|
||||
return
|
||||
name, message_id, interval_us = self.stream_config.popleft()
|
||||
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
|
||||
self._send_mav(self.connection, "command_long_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
|
||||
0, message_id, interval_us, 0, 0, 0, 0, 0,
|
||||
)
|
||||
self.next_stream_config_at = now + 0.5
|
||||
|
||||
def _tick_param_scan(self, now):
|
||||
if not self.connection or self.config_paused or now < self.param_deadline:
|
||||
if (not self.connection or self.config_paused
|
||||
or self.stream_config or now < self.next_stream_config_at
|
||||
or now < self.command_quiet_until or now < self.param_deadline):
|
||||
return
|
||||
if self.param_waiting is not None:
|
||||
if self.param_retries < 2:
|
||||
@@ -329,7 +717,7 @@ class MavlinkWorker:
|
||||
|
||||
def _request_param(self, name: str):
|
||||
self.log("TX_PARAM_REQUEST", name)
|
||||
self.connection.mav.param_request_read_send(
|
||||
self._send_mav(self.connection, "param_request_read_send",
|
||||
self.target_system, self.target_component, name.encode("ascii"), -1
|
||||
)
|
||||
|
||||
@@ -343,7 +731,7 @@ class MavlinkWorker:
|
||||
raise RuntimeError(f"{name} has not been read from the autopilot")
|
||||
self.pending_sets[name] = int(value)
|
||||
self.log("TX_PARAM_SET", f"{name}={value} type={param_type}")
|
||||
self.connection.mav.param_set_send(
|
||||
self._send_mav(self.connection, "param_set_send",
|
||||
self.target_system, self.target_component, name.encode("ascii"),
|
||||
float(value), param_type,
|
||||
)
|
||||
@@ -396,7 +784,7 @@ class MavlinkWorker:
|
||||
f"x={x} y={y} z={z} r={r} active={active} "
|
||||
f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})",
|
||||
)
|
||||
conn.mav.manual_control_send(self.target_system, x, y, z, r, 0)
|
||||
self._send_mav(conn, "manual_control_send", self.target_system, x, y, z, r, 0)
|
||||
|
||||
@staticmethod
|
||||
def _approach(current: float, target: float, max_change: float) -> float:
|
||||
@@ -409,18 +797,45 @@ class MavlinkWorker:
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
raise RuntimeError("No autopilot heartbeat")
|
||||
if arm and self.heavy_quality_test is not None:
|
||||
self.cancel_heavy_quality_test()
|
||||
now = time.monotonic()
|
||||
self.command_quiet_until = now + 6.0
|
||||
if not arm:
|
||||
self.disarm_requested_until = now + 6.0
|
||||
self._send_control(force_neutral=True)
|
||||
else:
|
||||
# Give the ARM request priority over the low-rate parameter scan.
|
||||
self.config_paused = True
|
||||
self.log("TX_COMMAND", "ARM" if arm else "DISARM")
|
||||
conn.mav.command_long_send(
|
||||
self.pending_arm_state = bool(arm)
|
||||
self.next_arm_retry_at = now + 0.25
|
||||
self.arm_retry_deadline = now + 5.0
|
||||
self.arm_confirmation = 0
|
||||
self._send_arm_packet(arm)
|
||||
|
||||
def _send_arm_packet(self, arm: bool):
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
return
|
||||
self._send_mav(conn, "command_long_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
|
||||
0, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
|
||||
self.arm_confirmation, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
|
||||
)
|
||||
|
||||
def _tick_arm_retry(self, now):
|
||||
if self.pending_arm_state is None or now < self.next_arm_retry_at:
|
||||
return
|
||||
if now >= self.arm_retry_deadline:
|
||||
self.log("TX_COMMAND_TIMEOUT", "ARM" if self.pending_arm_state else "DISARM")
|
||||
self.pending_arm_state = None
|
||||
return
|
||||
self.log("TX_COMMAND_RETRY", "ARM" if self.pending_arm_state else "DISARM")
|
||||
self.arm_confirmation = min(255, self.arm_confirmation + 1)
|
||||
self._send_arm_packet(self.pending_arm_state)
|
||||
self.next_arm_retry_at = now + 0.25
|
||||
|
||||
def set_manual_mode(self):
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
@@ -430,7 +845,7 @@ class MavlinkWorker:
|
||||
if manual is None:
|
||||
raise RuntimeError("MANUAL mode is not advertised by this firmware")
|
||||
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
|
||||
conn.mav.set_mode_send(
|
||||
self._send_mav(conn, "set_mode_send",
|
||||
self.target_system,
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
|
||||
manual,
|
||||
@@ -467,7 +882,7 @@ class MavlinkWorker:
|
||||
if self.connection and self.target_system:
|
||||
try:
|
||||
self._send_control(force_neutral=True)
|
||||
self.connection.mav.command_long_send(
|
||||
self._send_mav(self.connection, "command_long_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -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)
|
||||
@@ -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: $_"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Best-effort Windows first-run shortcut, including direct Python launches."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
def ensure_desktop_shortcut():
|
||||
if os.name != "nt":
|
||||
return
|
||||
root = Path(__file__).resolve().parent
|
||||
if (root / "runtime" / "desktop-shortcut-created.txt").exists():
|
||||
return
|
||||
powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(powershell), "-NoProfile", "-NonInteractive", "-File", str(root / "create_shortcut.ps1")],
|
||||
cwd=root, timeout=10, check=True, capture_output=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
print(f"Desktop shortcut setup skipped: {error}")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -3,6 +3,8 @@ $ErrorActionPreference = "Stop"
|
||||
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location -LiteralPath $projectRoot
|
||||
|
||||
& (Join-Path $projectRoot 'create_shortcut.ps1')
|
||||
|
||||
if (-not (Get-Command py -ErrorAction SilentlyContinue)) {
|
||||
throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again."
|
||||
}
|
||||
|
||||
@@ -5,6 +5,99 @@ 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.
|
||||
|
||||
@@ -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.');
|
||||
@@ -1,6 +1,9 @@
|
||||
"""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
|
||||
@@ -100,9 +103,18 @@ class WorkerWatchdogTests(unittest.TestCase):
|
||||
|
||||
def assertNeutral(self):
|
||||
self.worker._send_control()
|
||||
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args,
|
||||
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)
|
||||
@@ -112,6 +124,15 @@ class WorkerWatchdogTests(unittest.TestCase):
|
||||
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)
|
||||
@@ -122,6 +143,160 @@ class WorkerWatchdogTests(unittest.TestCase):
|
||||
self.worker.set_axes(Axes(forward=1), 19)
|
||||
self.assertEqual(self.worker.axes, Axes())
|
||||
|
||||
def test_mavlink_transmissions_are_serialized(self):
|
||||
first_entered = threading.Event()
|
||||
release_first = threading.Event()
|
||||
second_entered = threading.Event()
|
||||
|
||||
class Mav:
|
||||
def first_send(self, **_):
|
||||
first_entered.set()
|
||||
release_first.wait(1)
|
||||
|
||||
def second_send(self, **_):
|
||||
second_entered.set()
|
||||
|
||||
connection = SimpleNamespace(mav=Mav())
|
||||
first = threading.Thread(target=self.worker._send_mav,
|
||||
args=(connection, "first_send"))
|
||||
second = threading.Thread(target=self.worker._send_mav,
|
||||
args=(connection, "second_send"))
|
||||
first.start()
|
||||
self.assertTrue(first_entered.wait(.5))
|
||||
second.start()
|
||||
time.sleep(.05)
|
||||
self.assertFalse(second_entered.is_set())
|
||||
release_first.set()
|
||||
first.join(1)
|
||||
second.join(1)
|
||||
self.assertTrue(second_entered.is_set())
|
||||
|
||||
def test_live_console_is_read_only_bounded_and_keeps_newest_lines(self):
|
||||
for index in range(310):
|
||||
self.worker.log("RX_TEST", f"vehicle {index}")
|
||||
self.worker.log("TX_TEST", f"computer {index}")
|
||||
sides = self.worker.read_console(2)
|
||||
self.assertEqual(set(sides), {"rx", "tx"})
|
||||
self.assertTrue(sides["rx"][0].endswith("[RX_TEST] vehicle 308"))
|
||||
self.assertTrue(sides["rx"][1].endswith("[RX_TEST] vehicle 309"))
|
||||
self.assertTrue(sides["tx"][1].endswith("[TX_TEST] computer 309"))
|
||||
self.assertEqual(len(self.worker.console_rx_lines), 300)
|
||||
self.assertEqual(len(self.worker.console_tx_lines), 300)
|
||||
self.assertEqual(len(self.worker.console_lines), 500)
|
||||
|
||||
def test_arm_request_pauses_parameter_traffic(self):
|
||||
self.worker.armed = False
|
||||
self.worker.param_scan.append("SERVO1_FUNCTION")
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(True)
|
||||
self.worker.config_paused = False # A disarmed heartbeat arrived.
|
||||
self.worker._tick_param_scan(10.1)
|
||||
self.assertEqual(self.worker.command_quiet_until, 16)
|
||||
self.worker.connection.mav.param_request_read_send.assert_not_called()
|
||||
self.worker.connection.mav.command_long_send.assert_called_once()
|
||||
|
||||
def test_arm_request_retries_until_state_confirmation(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(True)
|
||||
self.worker._tick_arm_retry(10.24)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_arm_retry(10.25)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
self.worker.pending_arm_state = None
|
||||
self.worker._tick_arm_retry(10.50)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
|
||||
def test_outbound_command_has_valid_heartbeat_lead_in(self):
|
||||
self.worker._send_mav(self.worker.connection, "timesync_send", 0, 123)
|
||||
names = [call[0] for call in self.worker.connection.mav.method_calls]
|
||||
self.assertEqual(names[-2:], ["heartbeat_send", "timesync_send"])
|
||||
|
||||
def test_disarm_sends_neutral_and_pauses_parameter_traffic(self):
|
||||
self.worker.param_scan.append("SERVO1_FUNCTION")
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(False)
|
||||
self.worker.config_paused = False
|
||||
self.worker._tick_param_scan(10.1)
|
||||
self.assertEqual(self.worker.disarm_requested_until, 16)
|
||||
self.assertEqual(self.control_values(),
|
||||
(1, 0, 0, NEUTRAL_Z, 0, 0))
|
||||
self.worker.connection.mav.param_request_read_send.assert_not_called()
|
||||
|
||||
def test_telemetry_config_is_paced_and_includes_low_rate_esc(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._configure_low_bandwidth_streams()
|
||||
self.assertEqual(len(self.worker.stream_config), 10)
|
||||
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
|
||||
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
|
||||
self.worker.connection.mav.command_long_send.assert_not_called()
|
||||
self.worker._tick_stream_config(10)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_stream_config(10.1)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker.command_quiet_until = 16
|
||||
self.worker._tick_stream_config(11)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_stream_config(16)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
|
||||
def test_link_probe_uses_configured_rate_and_measures_exact_echo(self):
|
||||
self.worker.armed = False
|
||||
self.worker.ping_rate_hz = 5
|
||||
with patch("app.time.monotonic", return_value=10), \
|
||||
patch("app.time.monotonic_ns", return_value=10_000_000_000):
|
||||
self.worker._tick_link_probe(10)
|
||||
self.worker.connection.mav.timesync_send.assert_called_once_with(
|
||||
0, 10_000_000_000)
|
||||
self.assertEqual(self.worker.pending_pings, {10_000_000_000: 10})
|
||||
self.assertEqual(self.worker.next_ping_at, 10.2)
|
||||
message = SimpleNamespace(
|
||||
tc1=1, ts1=10_000_000_000, get_type=lambda: "TIMESYNC",
|
||||
to_dict=lambda: {"tc1": 1, "ts1": 10_000_000_000})
|
||||
with patch("app.time.monotonic", return_value=10.04):
|
||||
self.worker._handle_message(message)
|
||||
quality = self.worker.events.get_nowait()
|
||||
self.assertEqual(quality[0], "link_quality")
|
||||
self.assertEqual(quality[1]["latency_ms"], 40.0)
|
||||
self.assertEqual(quality[1]["loss_percent"], 0.0)
|
||||
|
||||
def test_link_rates_rebuild_requested_telemetry_intervals(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.set_link_rates(10, {
|
||||
"SYS_STATUS": 5, "SERVO_OUTPUT_RAW": 10,
|
||||
"RC_CHANNELS": 0, "ESC_TELEMETRY_1_TO_4": 2,
|
||||
})
|
||||
self.assertEqual(self.worker.ping_rate_hz, 10)
|
||||
configured = {name: interval for name, _, interval in self.worker.stream_config}
|
||||
self.assertEqual(configured["SYS_STATUS"], 200_000)
|
||||
self.assertEqual(configured["SERVO_OUTPUT_RAW"], 100_000)
|
||||
self.assertEqual(configured["RC_CHANNELS"], -1)
|
||||
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 500_000)
|
||||
|
||||
def test_link_probe_counts_timeout_and_pauses_for_commands(self):
|
||||
self.worker.pending_pings[4] = 5
|
||||
self.worker.rx_delivery_results.extend(((10, True), (10, False)))
|
||||
self.worker.next_ping_at = 0
|
||||
self.worker.command_quiet_until = 20
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._tick_link_probe(10)
|
||||
self.worker.connection.mav.timesync_send.assert_not_called()
|
||||
self.assertEqual(list(self.worker.ping_results), [(10, None)])
|
||||
quality = self.worker.events.get_nowait()[1]
|
||||
self.assertEqual(quality["loss_percent"], 100.0)
|
||||
self.assertEqual(quality["grade"], "testing")
|
||||
|
||||
def test_clean_downlink_cannot_hide_failed_round_trips(self):
|
||||
self.worker.ping_results.extend([(10, None)] * 20)
|
||||
self.worker.rx_delivery_results.extend([(10, True)] * 200)
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._publish_link_quality()
|
||||
quality = self.worker.events.get_nowait()[1]
|
||||
self.assertEqual(quality["loss_percent"], 100.0)
|
||||
self.assertEqual(quality["grade"], "poor")
|
||||
|
||||
|
||||
class BridgeTests(unittest.TestCase):
|
||||
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
|
||||
|
||||
@@ -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});
|
||||
+385
-28
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
|
||||
link_degraded: 'Signal lost — check tether',
|
||||
};
|
||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||
const activity=[];
|
||||
let lastReportedStatus='';
|
||||
@@ -14,26 +14,147 @@ function recordActivity(message){
|
||||
if(!message||activity[0]===message)return;
|
||||
activity.unshift(String(message));activity.length=Math.min(activity.length,3);
|
||||
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
|
||||
const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]}
|
||||
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&&performance.now()-lastSafetyTick<750;
|
||||
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
|
||||
|
||||
window.canDrive=canDrive;
|
||||
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
|
||||
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
|
||||
const controllerMotion=new ControllerInput.Motion();
|
||||
const controllerId=pad=>pad.index+':'+pad.id;
|
||||
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
|
||||
function loadControllerConfig(pad){
|
||||
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
|
||||
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
|
||||
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
|
||||
}
|
||||
function selectController(pad){
|
||||
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
|
||||
}
|
||||
function setInputMode(mode){
|
||||
if(mode===inputMode||inputConfigurationLocked())return false;
|
||||
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
|
||||
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
|
||||
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
|
||||
$('#controller-settings').hidden=mode!=='joystick';
|
||||
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys');
|
||||
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
|
||||
return true;
|
||||
}
|
||||
function inputConfigurationLocked(){
|
||||
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
|
||||
}
|
||||
function syncInputModeControls(){
|
||||
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
||||
$('.input-mode-switch').dataset.locked=String(locked);
|
||||
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
|
||||
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
|
||||
}
|
||||
$('#controller-settings').disabled=locked||!pads.length;
|
||||
$('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup';
|
||||
if($('#controller-modal').open){
|
||||
const form=$('#controller-form');
|
||||
for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad;
|
||||
$('#controller-device').disabled=locked||!pads.length;
|
||||
const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.';
|
||||
if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback);
|
||||
}
|
||||
}
|
||||
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
|
||||
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
|
||||
$('#input-joystick').onclick=()=>setInputMode('joystick');
|
||||
function fillControllerForm(){
|
||||
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
|
||||
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
|
||||
if(!pads.length)device.add(new Option('No controller detected',''));
|
||||
device.value=controllerKey||'';
|
||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
||||
const config=controllerConfig||ControllerInput.defaults(pad);
|
||||
for(const name of ['turn','forward','vertical']){
|
||||
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
|
||||
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
|
||||
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
|
||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);
|
||||
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
|
||||
}
|
||||
form.elements.deadZone.value=Math.round(config.deadZone*100);
|
||||
device.disabled=inputConfigurationLocked()||!pads.length;
|
||||
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
|
||||
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
|
||||
}
|
||||
$('#controller-device').onchange=e=>{
|
||||
if(inputConfigurationLocked())return;
|
||||
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
|
||||
if(pad){selectController(pad);fillControllerForm()}
|
||||
};
|
||||
$('#controller-form').onsubmit=e=>{
|
||||
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
|
||||
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
|
||||
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
|
||||
const config={deadZone:Number(form.elements.deadZone.value)/100};
|
||||
for(const name of ['turn','forward','vertical']){
|
||||
config[name]=Number(form.elements[name].value);
|
||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
|
||||
}
|
||||
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
|
||||
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
|
||||
}
|
||||
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
|
||||
resetInputs();closeModal($('#controller-modal'));
|
||||
};
|
||||
function pollController(now){
|
||||
try{
|
||||
if(inputMode==='joystick'){
|
||||
const pads=getControllers();
|
||||
if(controllerKey===null&&pads.length)selectController(pads[0]);
|
||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
|
||||
let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id;
|
||||
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
|
||||
if(!target||!canDrive()){
|
||||
if(controllerWasDriving)resetInputs();
|
||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
||||
}else{
|
||||
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
|
||||
if(!controllerNeedCenter){
|
||||
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
|
||||
control.turn=demand.turn;control.forward=demand.forward;
|
||||
window.joystick3D?.setVisual(demand.turn,-demand.forward);
|
||||
setHeave(demand.vertical,false);updateReadouts();sendAxes();
|
||||
}
|
||||
}
|
||||
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
|
||||
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
|
||||
if($('#controller-modal').open){
|
||||
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
|
||||
if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes;
|
||||
if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm();
|
||||
}
|
||||
}else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'}
|
||||
}catch(error){
|
||||
if(controllerWasDriving)resetInputs();
|
||||
$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard';
|
||||
}
|
||||
requestAnimationFrame(pollController);
|
||||
}
|
||||
document.body.dataset.inputMode=inputMode;
|
||||
|
||||
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
|
||||
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
||||
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||
window.showToast=toast;
|
||||
const manualPorts=new Map();
|
||||
function populatePorts(ports){
|
||||
const p=$('#port');
|
||||
if(!p)return;
|
||||
const old=p.value||'';
|
||||
p.innerHTML='';
|
||||
|
||||
|
||||
const allList=[];
|
||||
const seen=new Set();
|
||||
|
||||
@@ -50,6 +171,7 @@ function populatePorts(ports){
|
||||
};
|
||||
|
||||
if(Array.isArray(ports)) ports.forEach(addPort);
|
||||
manualPorts.forEach(addPort);
|
||||
addPort({device:'demo', label:'Demo Mode (no hardware)'});
|
||||
|
||||
let matched=false;
|
||||
@@ -82,20 +204,21 @@ function sendAxes(){
|
||||
setInterval(()=>{if(canDrive())sendAxes()},250);
|
||||
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||
|
||||
window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||
window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||
|
||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
|
||||
function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();}
|
||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
|
||||
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
|
||||
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||
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(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else easeHeave()}}
|
||||
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||
function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
|
||||
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||
function resetInputs(){
|
||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
||||
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
|
||||
window.joystick3D?.reset();
|
||||
@@ -111,28 +234,64 @@ addEventListener('focus',()=>focusChanged(!document.hidden));
|
||||
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
|
||||
addEventListener('pagehide',()=>focusChanged(false));
|
||||
|
||||
function setFeedback(el,message=''){
|
||||
if(!el)return;
|
||||
el.textContent=message;el.hidden=!message;
|
||||
}
|
||||
function openModal(id){
|
||||
const el=typeof id==='string'?document.getElementById(id):id;
|
||||
if(!el)return;
|
||||
if(!el||el.open)return;
|
||||
if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return}
|
||||
resetInputs();
|
||||
el._returnFocus=document.activeElement;
|
||||
if(el.id==='battery-modal')fillBatteryForm();
|
||||
if(el.id==='controller-modal')fillControllerForm();
|
||||
if(el.id==='link-modal'){
|
||||
fillLinkForm();
|
||||
$('#custom-port-status').textContent='';
|
||||
$('#custom-port-input').value='';
|
||||
}
|
||||
el.querySelectorAll('.form-feedback').forEach(feedback=>setFeedback(feedback));
|
||||
el.classList.remove('closing');
|
||||
if(!el.open)el.showModal();
|
||||
el.showModal();
|
||||
}
|
||||
function closeModal(dialog){
|
||||
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
|
||||
if(!dialog||!dialog.open||dialog.classList.contains('closing')||dialog.querySelector('[data-busy="true"]'))return;
|
||||
dialog.classList.add('closing');
|
||||
let done=false;
|
||||
const finish=()=>{
|
||||
if(done)return;
|
||||
done=true;
|
||||
dialog.removeEventListener('animationend',finish);
|
||||
dialog.removeEventListener('animationend',onEnd);
|
||||
dialog.classList.remove('closing');
|
||||
dialog.close();
|
||||
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
|
||||
};
|
||||
dialog.addEventListener('animationend',finish,{once:true});
|
||||
setTimeout(finish,300);
|
||||
const onEnd=e=>{if(e.target===dialog)finish()};
|
||||
dialog.addEventListener('animationend',onEnd);
|
||||
setTimeout(finish,180);
|
||||
}
|
||||
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
|
||||
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
|
||||
$('#link-quality').onclick=()=>openModal('link-quality-modal');
|
||||
let heavyActionBusy=false;
|
||||
async function requestHeavyTest(action){
|
||||
if(heavyActionBusy)return;
|
||||
heavyActionBusy=true;
|
||||
setFeedback($('#heavy-quality-error'));
|
||||
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
try{
|
||||
const test=await api(action);
|
||||
vehicleSnapshot.heavy_quality=test;
|
||||
renderHeavyQuality(test,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
}catch(e){setFeedback($('#heavy-quality-error'),e.message||'Link test unavailable. Try again.')}
|
||||
finally{
|
||||
heavyActionBusy=false;
|
||||
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
}
|
||||
}
|
||||
$('#heavy-quality-run').onclick=()=>requestHeavyTest('start_heavy_quality_test');
|
||||
$('#heavy-quality-cancel').onclick=()=>requestHeavyTest('cancel_heavy_quality_test');
|
||||
$$('dialog').forEach(d=>{
|
||||
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
|
||||
d.addEventListener('click',e=>{
|
||||
@@ -255,7 +414,7 @@ $('#connect').onclick=async()=>{
|
||||
recordActivity(`Connecting to ${port}…`);
|
||||
toast(`Connecting to ${port}…`);
|
||||
try{
|
||||
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
|
||||
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){
|
||||
@@ -270,7 +429,9 @@ const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input
|
||||
if(customPortBtn&&customPortInput){
|
||||
const applyCustomPort=()=>{
|
||||
const val=customPortInput.value.trim();
|
||||
if(!val)return;
|
||||
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){
|
||||
@@ -280,8 +441,7 @@ if(customPortBtn&&customPortInput){
|
||||
p.add(new Option(`Custom (${val})`, val), 0);
|
||||
p.value=val;
|
||||
}
|
||||
closeModal($('#link-modal'));
|
||||
toast(`Selected port: ${val}`);
|
||||
$('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
|
||||
};
|
||||
customPortBtn.onclick=applyCustomPort;
|
||||
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
||||
@@ -356,12 +516,14 @@ function startEngageHold(e){
|
||||
if(view.action!=='arm'||!inputFocused||engageStart)return;
|
||||
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
|
||||
syncInputModeControls();
|
||||
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
|
||||
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||
}
|
||||
function cancelEngageHold(showHint=true){
|
||||
if(!engageStart)return;
|
||||
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
|
||||
syncInputModeControls();
|
||||
}
|
||||
|
||||
if(engageButton){
|
||||
@@ -372,7 +534,7 @@ if(engageButton){
|
||||
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');
|
||||
@@ -380,6 +542,56 @@ if(engageButton){
|
||||
}
|
||||
|
||||
$('#logs').onclick=()=>api('open_logs');
|
||||
const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
|
||||
let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0;
|
||||
const consolePulseTimers={};
|
||||
function pulseConsole(side){
|
||||
const dot=$('.'+side+'-dot');
|
||||
clearTimeout(consolePulseTimers[side]);dot.classList.add('lit');
|
||||
consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260);
|
||||
}
|
||||
function renderConsole(){
|
||||
const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache);
|
||||
if(signature===consoleSignature)return;
|
||||
consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter);
|
||||
}
|
||||
function setConsoleOpen(open){
|
||||
consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open);
|
||||
if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')}
|
||||
else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180);
|
||||
$('#console').setAttribute('aria-expanded',String(open));
|
||||
if(open)refreshConsole();else $('#console').focus({preventScroll:true});
|
||||
}
|
||||
$('#console').setAttribute('aria-controls','console-dock');
|
||||
$('#console').setAttribute('aria-expanded','false');
|
||||
$('#console').onclick=()=>setConsoleOpen(!consoleOpen);
|
||||
$('#console-close').onclick=()=>setConsoleOpen(false);
|
||||
$('#console-filter').onchange=renderConsole;
|
||||
$('#console-pause').onclick=()=>{
|
||||
consolePaused=!consolePaused;
|
||||
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
|
||||
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
|
||||
$('#console-state').textContent=consolePaused?'Paused':'';
|
||||
$('#console-state').hidden=!consolePaused;
|
||||
if(!consolePaused)refreshConsole();
|
||||
};
|
||||
async function refreshConsole(){
|
||||
if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
|
||||
consoleRefreshBusy=true;
|
||||
try{
|
||||
const sides=await withTimeout(api('read_console',150),1500);
|
||||
if(consolePaused||!consoleOpen)return;
|
||||
const filter=$('#console-filter').value;
|
||||
for(const side of ['rx','tx']){
|
||||
const previous=new Set(consoleCache[side]||[]);
|
||||
if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side);
|
||||
}
|
||||
consoleCache=sides;renderConsole();
|
||||
$('#console-state').textContent='';$('#console-state').hidden=true;
|
||||
}catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}}
|
||||
finally{consoleRefreshBusy=false}
|
||||
}
|
||||
setInterval(refreshConsole,750);
|
||||
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||
// Explicit pointer ownership makes the full range rail a reliable touch target
|
||||
@@ -391,21 +603,124 @@ gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)
|
||||
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
|
||||
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
|
||||
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
|
||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||
async function saveSettings(form,method,commit,message){
|
||||
if(form.dataset.busy==='true'||!form.reportValidity())return;
|
||||
const data=Object.fromEntries(new FormData(form));
|
||||
const button=form.querySelector('[type="submit"]'),label=button.textContent;
|
||||
const disabled=new Map([...form.querySelectorAll('input,select,button')].map(el=>[el,el.disabled]));
|
||||
form.dataset.busy='true';disabled.forEach((_,el)=>el.disabled=true);button.textContent='Saving…';
|
||||
setFeedback(form.querySelector('.form-feedback'));
|
||||
let saved=false;
|
||||
try{commit(await api(method,data));saved=true;toast(message)}
|
||||
catch(error){setFeedback(form.querySelector('.form-feedback'),error.message||'Could not save settings. Try again.')}
|
||||
finally{
|
||||
delete form.dataset.busy;disabled.forEach((value,el)=>el.disabled=value);button.textContent=label;
|
||||
if(saved)closeModal(form.closest('dialog'));
|
||||
}
|
||||
}
|
||||
$('#battery-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_battery',value=>batteryConfig=value,'Battery settings saved')};
|
||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
|
||||
$('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
|
||||
function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
|
||||
const motorEdits=new Map();
|
||||
async function requestMotorEdit(servo,kind,value){
|
||||
if(motorEdits.has(servo))return;
|
||||
const s=vehicleSnapshot.servos?.[String(servo)]||{};
|
||||
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
|
||||
setFeedback($('#motor-feedback'));
|
||||
if(demoActive){
|
||||
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
|
||||
else s.direction=expected;
|
||||
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
|
||||
}
|
||||
motorEdits.set(servo,{kind,expected,started:performance.now()});
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
try{
|
||||
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
|
||||
}catch(error){
|
||||
motorEdits.delete(servo);
|
||||
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
}
|
||||
}
|
||||
function renderMotors(servos){
|
||||
for(const [servo,edit] of motorEdits){
|
||||
const s=servos[String(servo)]||{};
|
||||
if((edit.kind==='function'?s.function:s.direction)===edit.expected){
|
||||
motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`);
|
||||
}else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){
|
||||
motorEdits.delete(servo);
|
||||
setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`);
|
||||
}
|
||||
}
|
||||
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
|
||||
const assigned=Object.values(servos).filter(s=>s.motor).length;
|
||||
$('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…';
|
||||
const signature=JSON.stringify([servos,editable,[...motorEdits]]);
|
||||
if(signature===servoSignature)return;
|
||||
servoSignature=signature;
|
||||
const list=$('#motor-list'),expanded=list.querySelector('details')?.open;
|
||||
list.replaceChildren();
|
||||
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded;
|
||||
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary);
|
||||
for(let i=1;i<=16;i++){
|
||||
const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row';
|
||||
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
|
||||
const select=row.querySelector('select');
|
||||
select.add(new Option(s.function==null?'Reading…':'Unassigned','0'));
|
||||
for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor)));
|
||||
select.value=String(s.motor||0);
|
||||
select.disabled=!editable||!!pending||s.function==null;
|
||||
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
|
||||
row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal';
|
||||
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null;
|
||||
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`);
|
||||
reverse.onclick=()=>requestMotorEdit(i,'direction');
|
||||
(s.motor?list:other).append(row);
|
||||
}
|
||||
if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)}
|
||||
if(assigned<16)list.append(other);
|
||||
}
|
||||
function renderHeavyQuality(test,connected,armed){
|
||||
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
|
||||
if(!status||!results||!run||!stop)return;
|
||||
const quality=demoActive?null:vehicleSnapshot.link_quality;
|
||||
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
|
||||
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
|
||||
const running=!!test?.running;
|
||||
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
|
||||
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
|
||||
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
|
||||
stop.hidden=!running;
|
||||
stop.disabled=heavyActionBusy;
|
||||
results.replaceChildren();
|
||||
for(const row of test?.results||[]){
|
||||
const line=document.createElement('div');line.className='heavy-result-row';
|
||||
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
|
||||
const stats=document.createElement('span');
|
||||
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
|
||||
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
|
||||
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
|
||||
line.append(rate,stats);results.append(line);
|
||||
}
|
||||
}
|
||||
function render(s,confirmed=false){
|
||||
vehicleSnapshot=s;
|
||||
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
|
||||
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
|
||||
const previous=view;
|
||||
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||
syncInputModeControls();
|
||||
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
||||
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
|
||||
document.body.dataset.appState=appState;
|
||||
document.body.dataset.controlsEnabled=String(view.drive);
|
||||
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||
$('#port').disabled=!!s.connected;
|
||||
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||
const connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
@@ -437,7 +752,7 @@ function render(s,confirmed=false){
|
||||
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
|
||||
const motorBtn=$('[data-open="motors-modal"]');
|
||||
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||
|
||||
|
||||
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
|
||||
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
|
||||
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
|
||||
@@ -448,13 +763,50 @@ function render(s,confirmed=false){
|
||||
const b=s.battery;
|
||||
if(b){
|
||||
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}%`;
|
||||
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
|
||||
$('.battery-card').title=`Estimated charge from voltage · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
|
||||
$('#battery-fill').style.width=`${b.percent}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
|
||||
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
|
||||
$('.battery-card').dataset.low=String(!!b.low);
|
||||
}else{
|
||||
$('#battery-title').textContent='—';
|
||||
$('#battery-percent').textContent='waiting';
|
||||
$('#battery-title').textContent='No voltage';
|
||||
$('#battery-percent').textContent='—';
|
||||
$('.battery-card').dataset.stale='false';
|
||||
$('.battery-card').title='Waiting for battery voltage · Battery settings';
|
||||
$('#battery-fill').style.width='0';
|
||||
$('.battery-card').dataset.low='false';
|
||||
}
|
||||
const quality=s.link_quality,qualityCard=$('#link-quality');
|
||||
if(qualityCard){
|
||||
if(demoActive){
|
||||
qualityCard.dataset.grade='offline';
|
||||
$('#link-quality-grade').textContent='Demo';
|
||||
$('#link-quality-roundtrip').textContent='no hardware link test';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
renderMotors(s.servos||{});
|
||||
return;
|
||||
}
|
||||
const grade=quality?.grade||(s.connected?'testing':'offline');
|
||||
qualityCard.dataset.grade=grade;
|
||||
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
|
||||
if(quality&&quality.probe_samples){
|
||||
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
|
||||
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
|
||||
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
|
||||
const downLoss=percent(quality.rx_sequence_loss_percent);
|
||||
const badFrames=quality.rx_corrupt_frames_20s??0;
|
||||
const badBytes=quality.rx_corrupt_bytes_20s??0;
|
||||
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
|
||||
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
|
||||
if(quality.probe_samples<10||quality.rx_packets_30s<10){
|
||||
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
|
||||
}
|
||||
}else{
|
||||
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
}
|
||||
}
|
||||
renderMotors(s.servos||{});
|
||||
}
|
||||
@@ -564,8 +916,10 @@ async function boot(){
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
const init=await withTimeout(api('initial_state'),4000);
|
||||
batteryConfig=init.battery_config||{};
|
||||
linkConfig=init.link_config||{};
|
||||
populatePorts(init.ports);
|
||||
fillBatteryForm();
|
||||
fillLinkForm();
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
@@ -600,3 +954,6 @@ if(document.readyState==='loading'){
|
||||
loadDefaultModel();
|
||||
waitForBridge();
|
||||
}
|
||||
|
||||
|
||||
requestAnimationFrame(pollController);
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Controller conditioning is independent of drawing and MAVLink transport. */
|
||||
((root)=>{
|
||||
const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0));
|
||||
function deadZone(value,zone=.08){
|
||||
value=clamp(value);zone=Math.max(0,Math.min(.4,zone));
|
||||
return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone);
|
||||
}
|
||||
function defaults(pad){
|
||||
return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1,
|
||||
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
|
||||
}
|
||||
function read(pad,config){
|
||||
if(!pad?.connected||!pad.axes)return null;
|
||||
const axis=(index,invert)=>{
|
||||
if(index===-1)return 0;
|
||||
if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null;
|
||||
return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1);
|
||||
};
|
||||
let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical);
|
||||
if(turn===null||forward===null||vertical===null)return null;
|
||||
const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length}
|
||||
return {turn,forward,vertical};
|
||||
}
|
||||
class Smoother{
|
||||
constructor(){this.reset()}
|
||||
reset(){this.value=0;this.last=null;this.release=null}
|
||||
step(target,now){
|
||||
target=clamp(target);
|
||||
const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now;
|
||||
if(target===0){
|
||||
if(!this.release)this.release={at:now,start:this.value};
|
||||
const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t);
|
||||
this.value=this.release.start*(1-ease);
|
||||
}else{
|
||||
this.release=null;
|
||||
this.value+=(target-this.value)*(1-Math.exp(-dt/.1));
|
||||
if(Math.abs(target-this.value)<.001)this.value=target;
|
||||
}
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
class Motion{
|
||||
constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}}
|
||||
reset(){Object.values(this.axes).forEach(axis=>axis.reset())}
|
||||
step(target,now){
|
||||
const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)]));
|
||||
const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length}
|
||||
return demand;
|
||||
}
|
||||
}
|
||||
root.ControllerInput={deadZone,defaults,read,Motion};
|
||||
if(typeof module!=='undefined')module.exports=root.ControllerInput;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
@@ -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()});
|
||||
})();
|
||||
+96
-20
@@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sub Control</title>
|
||||
<link rel="stylesheet" href="styles.css?v=20260827-bridge-v5">
|
||||
<link rel="stylesheet" href="styles.css?v=20261002-spacing-motion-v2">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
|
||||
|
||||
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
|
||||
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
|
||||
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
|
||||
@@ -44,20 +44,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Battery Status -->
|
||||
<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 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>
|
||||
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header>
|
||||
<div 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>
|
||||
@@ -66,6 +71,7 @@
|
||||
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
|
||||
<!-- Frameless Drag & Window Controls Header -->
|
||||
<div class="workspace-topbar">
|
||||
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
|
||||
<div class="workspace-drag-area pywebview-drag-region"></div>
|
||||
<div class="window-actions">
|
||||
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
||||
@@ -74,6 +80,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
|
||||
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
||||
<!-- Reserved Engage / Disarm row -->
|
||||
<div class="engage-header-zone">
|
||||
@@ -92,12 +99,12 @@
|
||||
<!-- Steering / Throttle Joystick -->
|
||||
<article class="control-card steer-card">
|
||||
<header>
|
||||
<div><small>steer + throttle</small></div>
|
||||
<span class="key-hint">w a s d</span>
|
||||
<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" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
|
||||
<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>
|
||||
@@ -109,13 +116,13 @@
|
||||
<!-- Vertical Thruster Slider -->
|
||||
<article class="control-card vertical-card">
|
||||
<header>
|
||||
<div><small>vertical</small></div>
|
||||
<span class="key-hint">r / f</span>
|
||||
<div title="Vertical thrust · R / F"></div>
|
||||
|
||||
</header>
|
||||
<div class="heave-pad">
|
||||
<div class="vertical-control">
|
||||
<span>rise</span>
|
||||
<div id="heave" class="heave-track" touch-action="none">
|
||||
<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>
|
||||
@@ -132,27 +139,96 @@
|
||||
<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>
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
|
||||
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
|
||||
<header class="console-dock-header"><div><h2 id="console-title" title="Local session messages. Select a message to expand its details.">Console</h2></div><button id="console-close" type="button" class="console-close" aria-label="Hide console" title="Hide console"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
|
||||
<div class="console-toolbar"><label><span class="visually-hidden">Console filter</span><select id="console-filter"><option value="useful">Useful traffic</option><option value="all">All traffic</option></select></label><button id="console-pause" type="button" aria-pressed="false">Pause</button></div>
|
||||
<p id="console-state" class="console-state" role="status" title="Pausing the viewer does not stop session logging." hidden></p>
|
||||
<div class="console-streams"><section class="console-side"><header><i class="rx-dot" aria-hidden="true" title="Vehicle to PC · new displayed message"></i><span>RX</span></header><div id="console-rx-output" class="console-output" tabindex="0" aria-label="Received messages"></div></section><section class="console-side"><header><i class="tx-dot" aria-hidden="true" title="PC to vehicle · new displayed message"></i><span>TX</span></header><div id="console-tx-output" class="console-output" tabindex="0" aria-label="Sent messages"></div></section></div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Modals -->
|
||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><div style="margin-top:14px;"><label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:11px;font-weight:500;color:var(--muted)">Custom Port / Endpoint</span><div style="display:flex;gap:8px;"><input id="custom-port-input" placeholder="e.g. COM3 or tcp:127.0.0.1:5760" style="flex:1;height:32px;padding:0 12px;font-size:11px;"><button type="button" id="custom-port-btn" class="button subtle" style="white-space:nowrap;">Use Port</button></div></label></div><p class="note" style="margin-top:14px;">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="link-title">Link settings</h2>
|
||||
<form id="link-form" title="Baud changes apply on the next connection.">
|
||||
<section class="settings-section"><h3>Serial connection</h3>
|
||||
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
|
||||
|
||||
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
|
||||
<p id="custom-port-status" class="note" role="status"></p>
|
||||
</section>
|
||||
<section class="settings-section"><h3 title="Lower rates leave more bandwidth for controls. Rates reset after a reboot.">Telemetry rates</h3>
|
||||
<div class="form-grid two">
|
||||
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="quality-title">Link quality</h2>
|
||||
|
||||
<div class="quality-summary" title="Round-trip replies, incoming packet loss, and heartbeat freshness."><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
|
||||
<section class="heavy-test-panel"><h3 title="Tests 1, 5 and 10 Hz for about 35 seconds while disarmed. The live check resumes afterward.">Extended test</h3>
|
||||
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
|
||||
<div id="heavy-quality-results" class="heavy-quality-results"></div>
|
||||
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button id="heavy-quality-run" type="button" class="button modal-primary">Run extended test</button><button id="heavy-quality-cancel" type="button" class="button subtle" hidden>Stop test</button></div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery settings</h2>
|
||||
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
|
||||
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
|
||||
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save battery</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
|
||||
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
|
||||
</dialog>
|
||||
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
|
||||
<form id="controller-form" title="Center sticks after changing input mode or reconnecting before movement resumes.">
|
||||
<label><span>Controller</span><select id="controller-device"></select></label>
|
||||
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
|
||||
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
|
||||
<div class="controller-mapping-row"><span>Turn</span><select name="turn" aria-label="Turn axis"></select><input name="invertTurn" type="checkbox" aria-label="Invert Turn"></div>
|
||||
<div class="controller-mapping-row"><span>Forward / back</span><select name="forward" aria-label="Forward / back axis"></select><input name="invertForward" type="checkbox" aria-label="Invert Forward / back"></div>
|
||||
<div class="controller-mapping-row"><span>Rise / dive</span><select name="vertical" aria-label="Rise / dive axis"></select><input name="invertVertical" type="checkbox" aria-label="Invert Rise / dive"></div>
|
||||
<label class="controller-deadzone"><span>Dead zone · %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label>
|
||||
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save mapping</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="model-viewer.js?v=20260826-controls-v2"></script>
|
||||
<script src="joystick-3d.js?v=20260827-polish-v3"></script>
|
||||
<script src="joystick-3d.js?v=20261002-spacing-motion-v2"></script>
|
||||
<script src="control-state.js?v=20260827-state-v1"></script>
|
||||
<script src="app.js?v=20260827-bridge-v6"></script>
|
||||
<script src="controller-input.js?v=20261001-controller-v1"></script>
|
||||
<script src="console-view.js?v=20261002-console-v2"></script>
|
||||
<script src="app.js?v=20261002-spacing-motion-v2"></script>
|
||||
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -23,20 +23,29 @@
|
||||
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
|
||||
|
||||
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore();
|
||||
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 returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
|
||||
function 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.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
||||
canvas.addEventListener('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);resize();
|
||||
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
|
||||
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}}};
|
||||
})();
|
||||
|
||||
+369
-12
@@ -1,5 +1,19 @@
|
||||
/* SUB Control UI – Desktop Layout */
|
||||
|
||||
/* Isolated pointer lighting: no layout or input interception. */
|
||||
.controls-workspace { position:relative; isolation:isolate; }
|
||||
.workspace-light { position:absolute; inset:0; z-index:-1; pointer-events:none; border-radius:inherit; opacity:0; background:radial-gradient(600px circle at var(--light-x) var(--light-y),rgba(255,255,255,.35),transparent 75%); transition:opacity .35s ease-out; }
|
||||
.workspace-light.lit { opacity:1; }
|
||||
.light-surface { position:relative; }
|
||||
.cursor-light { position:absolute; inset:0; z-index:3; pointer-events:none; border-radius:inherit; }
|
||||
.cursor-light::before,.cursor-light::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; opacity:0; transition:opacity .35s ease-out; }
|
||||
.cursor-light.pill-light::before { background:radial-gradient(240px circle at var(--light-x) var(--light-y),rgba(110,190,235,.08),transparent 80%); }
|
||||
.cursor-light::after { padding:1px; background:linear-gradient(var(--light-angle,315deg),transparent 30%,rgba(255,255,255,.13) 65%,rgba(255,255,255,.50) 100%); -webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0); -webkit-mask-composite:xor; mask-composite:exclude; }
|
||||
.cursor-light.lit::before,.cursor-light.near::after { opacity:1; }
|
||||
.joystick > .cursor-light { inset:7%; border-radius:50%; }
|
||||
#engage > .cursor-light::after { display:none; }
|
||||
@media (prefers-reduced-motion:reduce) { .workspace-light,.cursor-light::before,.cursor-light::after { transition:none; } }
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0c1012;
|
||||
@@ -14,7 +28,7 @@
|
||||
--amber: #f2c675;
|
||||
--red: #f0958b;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||
font-family: Inter, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@@ -525,6 +539,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
margin-left: auto;
|
||||
}
|
||||
.logs-link:hover { color: #f1f4f2; }
|
||||
.activity-actions { display:flex; align-items:center; gap:12px; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
PRIMARY CONTROL ZONE (Workspace)
|
||||
@@ -540,6 +555,59 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Active MAVLink link test ── */
|
||||
.link-quality {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: 3px 0;
|
||||
color: #eef2f0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 9px;
|
||||
transition: background-color .18s ease;
|
||||
}
|
||||
.link-quality:hover,.link-quality:focus-visible { background:rgba(255,255,255,.055); outline:none; }
|
||||
.link-quality-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #667074;
|
||||
}
|
||||
.link-quality span:not(.link-quality-dot) { display:flex; flex-direction:column; gap:1px; min-width:0; }
|
||||
.link-quality small { color:#8d979a; font-size:10px; font-weight:400; }
|
||||
.link-quality strong { font-size:14px; font-weight:500; line-height:1.15; }
|
||||
.link-quality i {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
color: #aab2b4;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.link-quality i span { display:block; }
|
||||
.link-quality[data-grade="excellent"] .link-quality-dot { background:#bce96c; box-shadow:0 0 8px rgba(188,233,108,.35); }
|
||||
.link-quality[data-grade="good"] .link-quality-dot { background:#85c7e8; }
|
||||
.link-quality[data-grade="fair"] .link-quality-dot { background:#e7bf69; }
|
||||
.link-quality[data-grade="poor"] .link-quality-dot { background:#f07f78; box-shadow:0 0 8px rgba(240,127,120,.3); }
|
||||
.link-quality[data-grade="testing"] .link-quality-dot { background:#aab3b6; }
|
||||
.quality-modal { width:min(520px,calc(100vw - 32px)); }
|
||||
.heavy-test-panel { margin-top:18px; padding-top:14px; border-top:1px solid rgba(128,145,145,.2); }
|
||||
.heavy-quality-status { font-size:14px; font-weight:500; }
|
||||
.heavy-quality-results { display:grid; gap:6px; margin-top:12px; }
|
||||
.heavy-result-row { display:flex; justify-content:space-between; gap:12px; align-items:baseline; padding:7px 10px; border-radius:10px; background:rgba(120,140,140,.08); font-size:12px; }
|
||||
.heavy-result-row strong { flex:none; }
|
||||
.heavy-result-row span { text-align:right; color:var(--muted); }
|
||||
.primary-control-zone {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -688,7 +756,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
|
||||
background: #4e7140;
|
||||
border-color: #78ad5a;
|
||||
}
|
||||
.danger {
|
||||
.danger {
|
||||
--engage-progress: 0%;
|
||||
background: #ad3f38;
|
||||
color: #ffffff;
|
||||
@@ -879,7 +947,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
|
||||
border-radius: 24px;
|
||||
background: rgba(247, 248, 245, 0.96);
|
||||
color: #1a1e1f;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 30px 80px -12px rgba(0, 0, 0, 0.42),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.06),
|
||||
0 10px 24px -6px rgba(0, 0, 0, 0.16);
|
||||
@@ -1011,6 +1079,37 @@ body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.console-modal {
|
||||
width: min(1040px, calc(100vw - 64px));
|
||||
max-width: 1040px;
|
||||
}
|
||||
.console-split { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; min-width:0; }
|
||||
.console-split section { min-width:0; }
|
||||
.console-split header { display:flex; align-items:center; gap:7px; margin:0 2px 7px; }
|
||||
.console-split header i { width:7px; height:7px; border-radius:50%; flex:0 0 auto; }
|
||||
.console-split header .rx-dot { background:#79c8eb; }
|
||||
.console-split header .tx-dot { background:#bce96c; }
|
||||
.console-split header span { color:#303836; font-size:12px; font-weight:600; }
|
||||
.console-split header small { margin-left:auto; color:#7d8683; font-size:10px; }
|
||||
.console-split pre {
|
||||
height: min(55vh, 480px);
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
border: 1px solid #d9ddda;
|
||||
border-radius: 12px;
|
||||
background: #171b1d;
|
||||
color: #dce4e1;
|
||||
font: 11px/1.45 Consolas, "SFMono-Regular", monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.console-split { grid-template-columns:1fr; }
|
||||
.console-split pre { height:220px; }
|
||||
}
|
||||
|
||||
/* A pending or unknown armed state must stay legible, even without a link. */
|
||||
#engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -1039,7 +1138,7 @@ body #connect, body #connect:hover, body #connect.is-connected, body #connect.is
|
||||
}
|
||||
body #connect .btn-label { animation:none; }
|
||||
.telemetry-zone { gap:20px; }
|
||||
.rov-stage { height:clamp(180px,32vh,250px); min-height:180px; touch-action:none; }
|
||||
.rov-stage { flex:1; height:auto; min-height:0; width:100%; touch-action:none; border-radius:31px; overflow:hidden; background:transparent; }
|
||||
.model-card { justify-content:flex-start; }
|
||||
.rov-stage::before { display:none; }
|
||||
.model-tools button { font-size:12px; padding:7px 11px; }
|
||||
@@ -1052,24 +1151,25 @@ body #connect .btn-label { animation:none; }
|
||||
.activity-history li { font-size:12px; font-weight:300; line-height:1.25; color:#999; overflow-wrap:anywhere; }
|
||||
.activity-history #status-text { color:#fff; font-size:12px; font-weight:300; }
|
||||
.activity-history li[hidden] { display:none; }
|
||||
.controls-workspace { padding:8px 20px 16px; background:#f6f6f6; }
|
||||
.controls-workspace { padding:8px 20px 16px; background:#ededed; }
|
||||
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) 52px; }
|
||||
.engage-header-zone { display:block; position:relative; height:120px; min-height:0; margin:0; padding:0; }
|
||||
body #engage { width:calc(100% + 40px); margin-left:-20px; height:104px; padding:0 20px; border:0; border-radius:0; background:transparent; box-shadow:none; }
|
||||
body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
|
||||
#engage::before, #engage::after { display:none; }
|
||||
body #engage { align-items:flex-end; padding-bottom:12px; }
|
||||
.engage-face > span { font-family:"Segoe UI Variable Display","Segoe UI",Arial,sans-serif; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
|
||||
.engage-face > span { font-family:inherit; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
|
||||
#engage { --engage-green:#4cce73; --engage-glow:drop-shadow(0 0 2px rgba(76,206,115,.3)); }
|
||||
#engage .engage-label { color:#777; background:none; animation:none; }
|
||||
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; }
|
||||
#engage:is(.arm,.danger):not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-sheen-label { opacity:1; animation:engage-sweep 2.2s linear infinite; }
|
||||
@keyframes engage-sweep { from {mask-position:100% 0;-webkit-mask-position:100% 0} to {mask-position:0% 0;-webkit-mask-position:0% 0} }
|
||||
.engage-progress-label { color:#4cce73; clip-path:none; filter:drop-shadow(0 0 2px rgba(76,206,115,.3)); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
|
||||
.engage-progress-label { color:var(--engage-green); clip-path:none; filter:var(--engage-glow); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
|
||||
#engage.charged .engage-progress-label { mask-image:none; -webkit-mask-image:none; }
|
||||
#engage.pending .engage-label { color:#5a8753; background:none; }
|
||||
#engage.danger .engage-label { color:#171717; }
|
||||
#engage.pending .engage-label { color:var(--engage-green); filter:var(--engage-glow); background:none; }
|
||||
#engage.danger .engage-label { color:#777; }
|
||||
#engage:focus-visible { outline:2px solid #8d9991; outline-offset:-6px; }
|
||||
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:#f6f6f6; }
|
||||
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:transparent; }
|
||||
.control-status[hidden] { display:none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.engage-sheen-label { display:none; }
|
||||
@@ -1094,10 +1194,11 @@ body #engage { align-items:flex-end; padding-bottom:12px; }
|
||||
.heave-fill { left:22px; right:22px; background:#999; }
|
||||
.control-toolbar { display:flex; justify-content:center; align-items:center; gap:16px; padding-top:12px; }
|
||||
.control-toolbar .output-limit { width:auto; flex-direction:row; align-items:center; font-size:13px; gap:12px; color:#777; }
|
||||
.control-toolbar .output-limit span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
|
||||
.control-toolbar .output-limit > span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
|
||||
.control-toolbar .output-limit b { display:inline-block; flex:0 0 4ch; width:4ch; text-align:right; color:#333; font-variant-numeric:tabular-nums; }
|
||||
.control-toolbar .range-pill { width:175px; grid-template-columns:28px 1fr 28px; height:40px; background:#e7e7e7; border:0; }
|
||||
.control-toolbar .range-pill button { width:28px; height:40px; color:#666; font-size:22px; }
|
||||
.control-toolbar .range-pill button { width:28px; height:40px; padding:0; display:grid; place-items:center; color:#666; line-height:1; }
|
||||
.control-toolbar .range-pill button svg { width:24px; height:24px; display:block; }
|
||||
.control-toolbar .range-pill input[type="range"] { background:#c4c4c4; height:20px; background-clip:content-box; padding:8px 0; }
|
||||
.control-toolbar .range-pill input[type="range"]::-webkit-slider-thumb { width:18px; height:18px; background:#858585; }
|
||||
.control-toolbar .config-btn { width:auto; height:40px; padding:0 18px; font-size:13px; background:#e7e7e7; color:#444; border:0; border-radius:999px; }
|
||||
@@ -1115,3 +1216,259 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
|
||||
.telemetry-zone { gap:12px; }
|
||||
.activity-history { gap:4px; }
|
||||
}
|
||||
|
||||
/* Settings windows: readable fields, predictable spacing, one action hierarchy. */
|
||||
.modal {
|
||||
color-scheme: light;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid #dedfdf;
|
||||
border-radius: 20px;
|
||||
background: #f6f6f6;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,.24);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.modal[open] { animation: dialog-in .16s ease-out; }
|
||||
.modal.closing { animation: dialog-out .12s ease-in forwards; }
|
||||
.modal::backdrop { background: rgba(15,19,22,.4); backdrop-filter: blur(3px); }
|
||||
.modal > small { display:block; color:#73787b; font-size:12px; font-weight:400; margin:0 42px 6px 0; }
|
||||
.modal h2 { margin:0 40px 18px 0; font-size:28px; letter-spacing:-.025em; font-weight:400; }
|
||||
.modal h3 { margin:0 0 10px; font-size:14px; font-weight:500; color:#454b4e; }
|
||||
.modal .note { font-size:13px; line-height:1.5; margin:8px 0 0; color:#676e71; }
|
||||
.modal-close { top:16px; right:16px; width:36px; height:36px; display:grid; place-items:center; border-radius:50%; background:#e9ebec; color:#555c60; font-size:25px; line-height:1; }
|
||||
.modal-close:hover { background:#dfe3e5; }
|
||||
.settings-section { margin-top:22px; }
|
||||
.settings-section + .settings-section { border-top:1px solid #e0e3e4; padding-top:20px; }
|
||||
.modal .form-grid { gap:16px; margin-top:10px; }
|
||||
.modal .form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.modal label { min-width:0; font-size:13px; line-height:1.35; font-weight:400; color:#50585c; }
|
||||
.modal label > span { display:block; margin-bottom:7px; }
|
||||
.modal input,.modal select { width:100%; min-width:0; height:42px; padding:0 13px; border:1px solid #d7dcde; border-radius:999px; background:#fff; color:#272c2f; font-size:14px; font-weight:400; }
|
||||
.modal select { padding-right:28px; }
|
||||
.modal :is(button,input,select,summary,pre):focus-visible { outline:2px solid #6598b5; outline-offset:3px; }
|
||||
.modal .number-pill { height:42px; grid-template-columns:36px minmax(0,1fr) 36px; background:#fff; border:1px solid #d7dcde; }
|
||||
.modal .number-pill button { width:36px; height:40px; font-size:24px; color:#5b666b; }
|
||||
.modal .number-pill input { border:0; padding:0; height:40px; appearance:textfield; -moz-appearance:textfield; }
|
||||
.modal .number-pill input::-webkit-inner-spin-button { appearance:none; }
|
||||
.modal .number-pill:focus-within { border-color:#6598b5; }
|
||||
.custom-port-label { display:block; margin-top:16px; }
|
||||
.custom-port-row { display:flex; gap:8px; }
|
||||
.custom-port-row input { flex:1; }
|
||||
.modal .button { min-height:42px; height:auto; padding:10px 18px; font-size:14px; font-weight:400; border-radius:999px; white-space:normal; }
|
||||
.modal .button.subtle { background:#e9ebec; border:0; color:#424b50; }
|
||||
.modal .modal-primary { background:#333e44; color:#fff; border:0; }
|
||||
.modal .modal-primary:hover { background:#455660; }
|
||||
.modal .modal-actions { margin-top:24px; gap:10px; align-items:center; }
|
||||
.modal .form-feedback { margin:16px 0 0; padding:10px 12px; border-radius:12px; font-size:13px; line-height:1.5; color:#9b392e; background:#f5e6e3; }
|
||||
.modal [hidden] { display:none !important; }
|
||||
.modal [data-busy="true"] { cursor:progress; }
|
||||
.motor-modal { width:min(650px,calc(100vw - 32px)); }
|
||||
.modal .motor-head,.modal .motor-row { grid-template-columns:78px minmax(100px,1fr) 94px 86px; font-size:13px; gap:10px; padding:10px 0; }
|
||||
.modal .motor-head { margin-top:18px; color:#727b80; font-size:12px; font-weight:400; }
|
||||
.motor-row strong { font-size:12px; font-weight:500; }
|
||||
.motor-row > span { font-size:12px; color:#727b80; }
|
||||
.modal .motor-row .button { min-height:36px; font-size:12px; padding:8px 12px; }
|
||||
.motor-other { margin-top:16px; }
|
||||
.motor-other summary { cursor:pointer; padding:12px 0; color:#697278; font-size:13px; }
|
||||
.quality-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin-top:18px; }
|
||||
.quality-summary div { border-radius:14px; background:#e9ebec; padding:14px 12px; min-width:0; }
|
||||
.quality-summary span { display:block; font-size:12px; color:#697278; margin-bottom:8px; }
|
||||
.quality-summary strong { font-size:20px; font-weight:400; font-variant-numeric:tabular-nums; }
|
||||
.modal .heavy-quality-status { padding:12px; background:#e9ebec; border-radius:12px; font-size:14px; margin-top:14px; }
|
||||
.modal .heavy-result-row { font-size:13px; padding:10px 12px; font-variant-numeric:tabular-nums; }
|
||||
.console-modal { width:min(1020px,calc(100vw - 32px)); }
|
||||
.console-toolbar { display:flex; justify-content:space-between; align-items:center; gap:12px; }
|
||||
.console-toolbar .note { margin:0; }
|
||||
.modal .console-split header small { font-size:12px; }
|
||||
.modal .console-split pre { height:clamp(180px,48vh,430px); padding:14px; font-size:12px; line-height:1.55; border-radius:12px; }
|
||||
@keyframes dialog-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
|
||||
@keyframes dialog-out { to { opacity:0; transform:translateY(5px); } }
|
||||
@media (max-width:520px) {
|
||||
.modal { padding:22px 18px; }
|
||||
.modal .form-grid.two { grid-template-columns:1fr; }
|
||||
.modal .motor-head,.modal .motor-row { grid-template-columns:56px minmax(80px,1fr) 74px 66px; gap:5px; font-size:12px; }
|
||||
.modal .motor-row .button { padding:8px 6px; }
|
||||
.quality-summary { gap:6px; }
|
||||
.quality-summary div { padding:12px 8px; }
|
||||
.quality-summary strong { font-size:17px; }
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce) {
|
||||
.modal[open],.modal.closing { animation:none; }
|
||||
}
|
||||
|
||||
/* Input selection stays separate from vehicle arming and command ownership. */
|
||||
#engage:focus,#engage:focus-visible { outline:none!important; box-shadow:none!important; }
|
||||
.controls-workspace { container-type:inline-size; }
|
||||
.input-mode-controls { display:flex; align-items:center; gap:6px; flex-shrink:0; -webkit-app-region:no-drag; }
|
||||
.input-mode-switch { display:flex; padding:3px; gap:2px; background:#dedede; border-radius:999px; }
|
||||
.input-mode-switch button,.controller-settings { border:0; border-radius:999px; background:transparent; color:#686868; padding:7px 11px; font-size:12px; cursor:pointer; }
|
||||
.input-mode-switch button[aria-pressed="true"] { background:#fafafa; color:#252525; box-shadow:0 1px 3px #0000000c; }
|
||||
.controller-settings { background:#dedede; }
|
||||
.controller-status { margin:0; height:24px; flex-shrink:0; font-size:12px; line-height:20px; color:#6d7478; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
body[data-input-mode="joystick"] .key-hint { visibility:hidden; }
|
||||
body[data-input-mode="joystick"] :is(#joystick-canvas,#heave) { pointer-events:none; cursor:default; }
|
||||
.controls-grid { --joy-size:min(52vh,calc(100cqw - 136px),460px); }
|
||||
.control-toolbar { flex-wrap:wrap; gap:8px 14px; }
|
||||
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) auto; }
|
||||
.control-toolbar { min-height:52px; }
|
||||
.engage-face > span { font-size:clamp(52px,11cqw,90px); }
|
||||
.controller-mapping-head,.controller-mapping-row { display:grid; grid-template-columns:1fr 1.25fr 50px; gap:14px; align-items:center; }
|
||||
.controller-mapping-head { color:#777; font-size:12px; margin-bottom:8px; }
|
||||
.controller-mapping-row { margin:8px 0; }
|
||||
.modal .controller-mapping-row input[type="checkbox"] { width:22px; height:22px; min-height:0; margin:auto; padding:0; accent-color:#527f9b; }
|
||||
.controller-mapping-row label { display:flex; align-items:center; }
|
||||
#controller-axes { min-height:22px; margin:12px 0; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; color:#65737d; font-size:12px; }
|
||||
#controller-form > label { margin-top:18px; }
|
||||
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
|
||||
/* Read-only local console, not a modal: driving stays available while it is open. */
|
||||
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr) 340px; }
|
||||
.console-dock[hidden] { display:none!important; }
|
||||
.console-dock { display:flex; flex-direction:column; min-width:0; min-height:0; padding:18px 16px; background:#f5f5f5; border-left:1px solid #d8dcde; color:#29343b; color-scheme:light; }
|
||||
.console-dock-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:14px; }
|
||||
.console-dock-header small { font-size:11px; color:#81888d; }
|
||||
.console-dock-header h2 { font-size:22px; font-weight:400; margin:4px 0 0; letter-spacing:-.02em; }
|
||||
.console-close { width:38px; height:38px; flex-shrink:0; border:0; border-radius:999px; background:#e7e8e9; color:#48525a; font-size:25px; cursor:pointer; }
|
||||
.console-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.console-toolbar label { flex:1; min-width:0; }
|
||||
.console-toolbar select,.console-toolbar button { height:34px; border:0; border-radius:999px; background:#e7e9ea; color:#42525e; font-size:12px; padding:0 12px; }
|
||||
.console-toolbar select { width:100%; }
|
||||
.console-toolbar button { cursor:pointer; }
|
||||
.console-state { min-height:28px; margin:10px 0 8px; font-size:11px; color:#7e868b; line-height:1.4; }
|
||||
.console-streams { display:grid; grid-template-rows:1fr 1fr; gap:14px; min-height:0; flex:1; }
|
||||
.console-side { display:flex; flex-direction:column; min-height:0; }
|
||||
.console-side > header { display:flex; align-items:center; gap:6px; padding:0 4px 8px; font-size:12px; font-weight:500; color:#435966; }
|
||||
.console-side > header small { margin-left:auto; font-size:11px; font-weight:300; color:#899198; }
|
||||
.console-side > header i { width:6px; height:6px; border-radius:50%; background:#5888a3; }
|
||||
.console-side > header .tx-dot { background:#659287; }
|
||||
.console-output { min-height:0; flex:1; overflow:auto; overscroll-behavior:contain; padding:0 8px; border:1px solid #e0e3e5; border-radius:12px; background:#fafbfc; color:#343d43; font:12px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif; }
|
||||
.console-row { border-bottom:1px solid #e9edef; }
|
||||
.console-row:last-child { border-bottom:0; }
|
||||
.console-row summary { display:flex; gap:8px; cursor:pointer; padding:10px 0; list-style:none; }
|
||||
.console-row summary::-webkit-details-marker { display:none; }
|
||||
.console-row time { flex-shrink:0; color:#929ba1; font:10px/1.8 ui-monospace,Consolas,monospace; font-variant-numeric:tabular-nums; }
|
||||
.console-message { min-width:0; display:block; overflow-wrap:anywhere; }
|
||||
.console-message strong { display:block; font-size:11px; font-weight:500; color:#517c97; letter-spacing:.025em; }
|
||||
.console-row[data-tone="command"] strong { color:#467a69; }
|
||||
.console-row[data-tone="warning"] strong { color:#aa664b; }
|
||||
.console-fields { display:flex; flex-wrap:wrap; gap:2px 8px; font-size:11px; }
|
||||
.console-fields i { color:#929a9f; font-style:normal; }
|
||||
.console-fields b { font-weight:400; color:#45535d; }
|
||||
.console-row pre { margin:0 0 10px; padding:10px; border-radius:8px; background:#eef1f3; color:#52616c; font:11px/1.5 ui-monospace,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.console-empty { color:#959da3; font-size:12px; line-height:1.6; margin:14px 0; }
|
||||
@media (max-height:610px) { .primary-control-zone { grid-template-rows:100px minmax(0,1fr) auto; } .controls-grid { --joy-size:min(46vh,calc(100cqw - 136px),420px); } }
|
||||
@media (max-width:1000px) {
|
||||
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr); }
|
||||
.console-dock { position:absolute; top:0; right:0; bottom:0; width:min(340px,calc(100vw - 32px)); z-index:12; box-shadow:-8px 0 24px #00000012; border-radius:12px 0 0 12px; }
|
||||
}
|
||||
|
||||
/* Three shared sizes: compact 32px, standard 40px, primary 62px. */
|
||||
:root {
|
||||
--pill-sm:32px; --pill-md:40px; --pill-lg:62px; --pill-radius:999px;
|
||||
--battery-charge:#63cb85; --battery-low:#e98a7b;
|
||||
}
|
||||
.port-row select,.icon-button { height:var(--pill-sm); }
|
||||
.icon-button { width:var(--pill-sm); }
|
||||
body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); }
|
||||
.input-mode-switch { height:var(--pill-md); padding:4px; }
|
||||
.input-mode-switch button { height:var(--pill-sm); padding:0 12px; }
|
||||
.controller-settings { height:var(--pill-md); padding:0 14px; }
|
||||
.input-mode-switch button:disabled { opacity:.65!important; }
|
||||
.input-mode-switch button[aria-pressed="true"]:disabled { opacity:1!important; }
|
||||
.control-toolbar .range-pill,.control-toolbar .config-btn { height:var(--pill-md); border-radius:var(--pill-radius); }
|
||||
.control-toolbar .range-pill button { height:var(--pill-md); }
|
||||
.window-actions button { width:var(--pill-md); height:var(--pill-md); }
|
||||
.console-toolbar select,.console-toolbar button { height:var(--pill-sm); border-radius:var(--pill-radius); }
|
||||
.modal input,.modal select,.modal .number-pill { height:var(--pill-md); border-radius:var(--pill-radius); }
|
||||
.modal .number-pill input,.modal .number-pill button { height:calc(var(--pill-md) - 2px); }
|
||||
.modal .button { min-height:var(--pill-md); padding:8px 18px; border-radius:var(--pill-radius); }
|
||||
.modal .motor-row .button { min-height:var(--pill-sm); padding:6px 12px; }
|
||||
.modal .controller-mapping-row input[type="checkbox"] { height:22px; border-radius:4px; }
|
||||
|
||||
/* Fixed SVG geometry avoids font baselines shifting the close icon. */
|
||||
.modal-close,.modal-close:hover,.console-close,.console-close:hover,.window-actions .window-close,.window-actions .window-close:hover {
|
||||
display:grid; place-items:center; width:var(--pill-md); height:var(--pill-md);
|
||||
padding:0; border:0; border-radius:0; background:transparent; box-shadow:none;
|
||||
}
|
||||
.modal-close svg,.console-close svg { display:block; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; }
|
||||
.modal-close:hover,.console-close:hover { color:#222; }
|
||||
.heave-knob { height:var(--pill-md); background:linear-gradient(145deg,#737373,#454545); }
|
||||
|
||||
/* Activity has a heading, compact event history, then explicit actions. */
|
||||
.telemetry-zone { gap:16px; }
|
||||
.deck-status-bar { flex-shrink:0; margin-top:0; padding-top:12px; }
|
||||
.activity-heading { display:block; margin:0 0 8px; color:#eee; font-size:14px; font-weight:650; }
|
||||
.activity-history { gap:4px; margin:0 0 10px; }
|
||||
.activity-actions { display:flex; gap:8px; }
|
||||
.activity-actions .logs-link {
|
||||
display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box;
|
||||
height:var(--pill-sm); min-width:76px; padding:0 14px; margin:0;
|
||||
border:0; border-radius:var(--pill-radius); background:#414141; color:#eee;
|
||||
font-size:12px; font-weight:400; letter-spacing:0; line-height:1; cursor:pointer;
|
||||
}
|
||||
.activity-actions .logs-link:hover { background:#4b4b4b; }
|
||||
.activity-actions .logs-link:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
|
||||
.activity-actions .logs-link[aria-expanded="true"] { background:#555; }
|
||||
|
||||
/* Battery is the final deck item: text floats above the charge fill. */
|
||||
.battery-card {
|
||||
position:relative; display:block; flex-shrink:0; width:100%; height:var(--pill-lg);
|
||||
min-height:var(--pill-lg); margin:0; padding:0; overflow:hidden;
|
||||
border:0; border-radius:var(--pill-radius); background:#314b3a;
|
||||
color:#f2f5f2; isolation:isolate;
|
||||
}
|
||||
.battery-meter { position:absolute; inset:0; z-index:0; overflow:hidden; border-radius:inherit; pointer-events:none; }
|
||||
.battery-meter #battery-fill { display:block; height:100%; width:0; border-radius:inherit; background:var(--battery-charge); opacity:.55; transition:width .35s ease,background .2s ease,opacity .18s ease-out; }
|
||||
.battery-card .battery-copy { position:absolute; inset:0; z-index:1; display:flex; flex-direction:row; align-items:center; justify-content:space-between; gap:8px; padding:0 18px; pointer-events:none; }
|
||||
.battery-summary { display:flex; flex-direction:column; gap:3px; flex-shrink:0; }
|
||||
.battery-card small { color:#d7e8dc; font-size:12px; line-height:1; font-variant-numeric:tabular-nums; }
|
||||
.battery-card strong { font-size:26px; font-weight:400; line-height:1.15; font-variant-numeric:tabular-nums; }
|
||||
.battery-card i { align-self:auto; color:#d9e0da; text-align:right; font-size:12px; line-height:1.35; font-style:normal; }
|
||||
.battery-card[data-low="true"] { background:#4a3734; }
|
||||
.battery-card[data-stale="true"] #battery-fill { opacity:.25; }
|
||||
.battery-card:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
|
||||
|
||||
/* The same slender, rounded scrollbar is used by every scrollable panel. */
|
||||
*::-webkit-scrollbar { width:8px; height:8px; }
|
||||
*::-webkit-scrollbar-track,*::-webkit-scrollbar-corner { background:transparent; }
|
||||
*::-webkit-scrollbar-thumb { min-height:28px; border:2px solid transparent; border-radius:var(--pill-radius); background:#aeb6ba; background-clip:padding-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background:#87959e; background-clip:padding-box; }
|
||||
*::-webkit-scrollbar-button { display:none; width:0; height:0; }
|
||||
.sidebar-deck::-webkit-scrollbar-thumb { background:#666; background-clip:padding-box; }
|
||||
.modal { scrollbar-gutter:stable; }
|
||||
@supports not selector(::-webkit-scrollbar) { * { scrollbar-width:thin; scrollbar-color:#aeb6ba transparent; } }
|
||||
@media (max-height:610px) { .telemetry-zone { gap:12px; } }
|
||||
|
||||
/* Shared panel geometry and quiet, presentational motion. Never ease command data. */
|
||||
:root { --panel-inset:20px; --ui-gap:12px; --ui-duration:180ms; --ui-ease:cubic-bezier(.2,0,.2,1); }
|
||||
.sidebar-deck,.controls-workspace,.console-dock { padding:var(--panel-inset); }
|
||||
.sidebar-deck,.telemetry-zone,.deck-connection,.port-row,.activity-actions,.input-mode-controls,.console-toolbar,.console-streams { gap:var(--ui-gap); }
|
||||
.deck-connection { padding-bottom:var(--ui-gap); }
|
||||
.workspace-topbar,.console-dock-header { min-height:var(--pill-md); height:var(--pill-md); margin-bottom:var(--ui-gap); }
|
||||
.port-row select,.port-row .icon-button { height:var(--pill-md); }
|
||||
.port-row .icon-button { width:var(--pill-md); }
|
||||
.activity-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.activity-actions .logs-link { min-width:0; width:100%; }
|
||||
.console-dock-header h2 { margin:0; }
|
||||
.console-toolbar { margin-bottom:var(--ui-gap); }
|
||||
.console-state { min-height:0; margin:0 0 var(--ui-gap); font-size:12px; }
|
||||
.console-side > header { padding-left:0; padding-right:0; }
|
||||
.console-side > header i { background:#b4bdc2; transition:background-color var(--ui-duration) ease-out,box-shadow var(--ui-duration) ease-out; }
|
||||
.console-side > header .rx-dot.lit { background:#4fa4cf; box-shadow:0 0 5px #4fa4cf66; }
|
||||
.console-side > header .tx-dot.lit { background:#56ae8a; box-shadow:0 0 5px #56ae8a66; }
|
||||
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::before,
|
||||
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::after,
|
||||
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card) {
|
||||
transition-property:background-color,border-color,color,opacity,box-shadow,filter;
|
||||
transition-duration:var(--ui-duration); transition-timing-function:var(--ui-ease);
|
||||
}
|
||||
/* Keep pointer positions immediate; the existing conditioning owns motor easing. */
|
||||
.modal[open],.console-dock:not([hidden]),.form-feedback:not([hidden]),.console-state:not([hidden]),.console-row pre { animation:ui-fade-in var(--ui-duration) var(--ui-ease); }
|
||||
.modal.closing,.console-dock.closing { animation:ui-fade-out var(--ui-duration) ease-out forwards; pointer-events:none; }
|
||||
@keyframes ui-fade-in { from { opacity:0; } to { opacity:1; } }
|
||||
@keyframes ui-fade-out { from { opacity:1; } to { opacity:0; } }
|
||||
.controller-status { transition:opacity var(--ui-duration) ease-out; }
|
||||
.controller-status[data-quiet="true"] { opacity:0; pointer-events:none; }
|
||||
|
||||
+72
-4
@@ -40,12 +40,15 @@ class ControlApi:
|
||||
"app_state": "disconnected",
|
||||
"status": "Ready. Select a USB port to begin.",
|
||||
"battery": None,
|
||||
"link_quality": None,
|
||||
"heavy_quality": None,
|
||||
"log_path": None,
|
||||
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
|
||||
}
|
||||
self.servo_functions: dict[int, int] = {}
|
||||
self.motor_directions: dict[int, int] = {}
|
||||
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
|
||||
self.link_path = Path(__file__).resolve().parent / "link_settings.json"
|
||||
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
|
||||
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
|
||||
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
|
||||
@@ -62,11 +65,22 @@ class ControlApi:
|
||||
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
self.link_config = {
|
||||
"baud": 57600, "control_hz": 20,
|
||||
"ping_hz": 1, "system_hz": 1, "servo_hz": 2,
|
||||
"rc_hz": 2, "esc_hz": 1,
|
||||
}
|
||||
try:
|
||||
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
self._apply_link_config()
|
||||
|
||||
def initial_state(self):
|
||||
return {
|
||||
"ports": self.refresh_ports(),
|
||||
"battery_config": self.battery_config,
|
||||
"link_config": self.link_config,
|
||||
"state": self._snapshot(),
|
||||
"model_info": self.get_saved_model_info(),
|
||||
}
|
||||
@@ -233,6 +247,9 @@ class ControlApi:
|
||||
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},
|
||||
)
|
||||
@@ -268,15 +285,26 @@ class ControlApi:
|
||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||
|
||||
def disconnect(self):
|
||||
if self.worker.heavy_quality_test is not None:
|
||||
self.worker.cancel_heavy_quality_test()
|
||||
if self.demo_mode:
|
||||
self.demo_mode = False
|
||||
with self.lock:
|
||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||
armed=False, mode=None, battery=None, status="Demo mode disconnected")
|
||||
armed=False, mode=None, battery=None, link_quality=None,
|
||||
status="Demo mode disconnected")
|
||||
return True
|
||||
self.worker.disconnect()
|
||||
return True
|
||||
|
||||
def start_heavy_quality_test(self):
|
||||
if self.demo_mode:
|
||||
raise RuntimeError("The heavy link test requires a real flight-controller connection")
|
||||
return self.worker.start_heavy_quality_test()
|
||||
|
||||
def cancel_heavy_quality_test(self):
|
||||
return self.worker.cancel_heavy_quality_test()
|
||||
|
||||
def set_axes(self, forward, turn, vertical, sequence=None):
|
||||
if self.demo_mode:
|
||||
with self.lock:
|
||||
@@ -389,8 +417,33 @@ class ControlApi:
|
||||
raise ValueError("Full voltage per cell must be greater than empty voltage")
|
||||
if not 0 <= clean["warning_percent"] <= 100:
|
||||
raise ValueError("Warning percentage must be between 0 and 100")
|
||||
self.battery_config = clean
|
||||
self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||
self.battery_config = clean
|
||||
return clean
|
||||
|
||||
def _apply_link_config(self):
|
||||
self.worker.rate_hz = self.link_config["control_hz"]
|
||||
self.worker.set_link_rates(self.link_config["ping_hz"], {
|
||||
"SYS_STATUS": self.link_config["system_hz"],
|
||||
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
|
||||
"RC_CHANNELS": self.link_config["rc_hz"],
|
||||
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
|
||||
})
|
||||
|
||||
def save_link_settings(self, config):
|
||||
allowed = {0, 1, 2, 5, 10}
|
||||
clean = {key: int(config.get(key, value)) for key, value in self.link_config.items()}
|
||||
if clean["baud"] not in {9600, 19200, 38400, 57600, 115200}:
|
||||
raise ValueError("Choose a supported baud rate")
|
||||
if clean["control_hz"] not in {10, 20}:
|
||||
raise ValueError("Control rate must be 10 or 20 Hz")
|
||||
if clean["ping_hz"] not in {1, 2, 5, 10}:
|
||||
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
|
||||
if any(clean[key] not in allowed for key in ("system_hz", "servo_hz", "rc_hz", "esc_hz")):
|
||||
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
|
||||
self.link_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||
self.link_config = clean
|
||||
self._apply_link_config()
|
||||
return clean
|
||||
|
||||
def open_logs(self):
|
||||
@@ -399,6 +452,10 @@ class ControlApi:
|
||||
os.startfile(path)
|
||||
return True
|
||||
|
||||
def read_console(self, limit=150):
|
||||
"""Return a bounded, read-only view of the local session console."""
|
||||
return self.worker.read_console(limit)
|
||||
|
||||
def poll(self):
|
||||
# Bound a poll even if telemetry arrives continuously.
|
||||
with self._poll_lock:
|
||||
@@ -468,6 +525,10 @@ class ControlApi:
|
||||
"voltage": voltage, "per_cell": per_cell, "percent": percent,
|
||||
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
|
||||
}
|
||||
elif kind == "link_quality":
|
||||
self.state["link_quality"] = event[1]
|
||||
elif kind == "heavy_quality":
|
||||
self.state["heavy_quality"] = event[1]
|
||||
elif kind == "servo_param":
|
||||
_, param_id, value = event
|
||||
servo = int(param_id[5:param_id.index("_")])
|
||||
@@ -490,7 +551,7 @@ class ControlApi:
|
||||
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",
|
||||
mode=None, status=status_msg)
|
||||
mode=None, link_quality=None, status=status_msg)
|
||||
self.state["last_error"] = None
|
||||
|
||||
def _update_servo(self, servo):
|
||||
@@ -585,6 +646,12 @@ def start_local_server(directory: Path) -> int:
|
||||
|
||||
|
||||
def main():
|
||||
if os.name == "nt":
|
||||
# Keep the running window separate from generic Python taskbar groups.
|
||||
import ctypes
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("SUBControl.Desktop")
|
||||
from desktop_shortcut import ensure_desktop_shortcut
|
||||
ensure_desktop_shortcut()
|
||||
api = ControlApi()
|
||||
api._migrate_legacy_model()
|
||||
project_root = Path(__file__).resolve().parent
|
||||
@@ -602,7 +669,8 @@ def main():
|
||||
window.events.maximized += lambda: setattr(api, "maximized", True)
|
||||
window.events.restored += lambda: setattr(api, "maximized", False)
|
||||
try:
|
||||
webview.start(debug=True)
|
||||
icon_path = project_root / "assets" / "sub-control.ico"
|
||||
webview.start(debug=True, icon=str(icon_path) if icon_path.is_file() else None)
|
||||
finally:
|
||||
api.close()
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
# ESC_TELEMETRY_1_TO_4 uses a MAVLink 2 message ID. Select the v2 dialect
|
||||
# before importing pymavlink so received ESC packets can be decoded/logged.
|
||||
os.environ["MAVLINK20"] = "1"
|
||||
from pymavlink import mavutil
|
||||
from serial.tools import list_ports
|
||||
|
||||
@@ -67,6 +70,10 @@ class MavlinkWorker:
|
||||
self.connection = None
|
||||
self.stop_event = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
# pymavlink writes are not thread-safe. The web/API thread can issue
|
||||
# commands while this worker sends heartbeats and controls, so serialize
|
||||
# every packet before it reaches a low-bandwidth serial link.
|
||||
self.tx_lock = threading.Lock()
|
||||
self.thread = None
|
||||
self.axes = Axes()
|
||||
self.output_axes = Axes()
|
||||
@@ -87,6 +94,34 @@ class MavlinkWorker:
|
||||
self.last_rx = 0.0
|
||||
self.armed = False
|
||||
self.config_paused = False
|
||||
# Keep configuration traffic off the link during an arm/disarm
|
||||
# transition, including the wait for the vehicle's confirmation.
|
||||
self.command_quiet_until = 0.0
|
||||
self.disarm_requested_until = 0.0
|
||||
self.pending_arm_state = None
|
||||
self.next_arm_retry_at = 0.0
|
||||
self.arm_retry_deadline = 0.0
|
||||
self.arm_confirmation = 0
|
||||
self.stream_config = deque()
|
||||
self.next_stream_config_at = 0.0
|
||||
# Low-rate TIMESYNC echoes measure PC↔FC round trips with minimal traffic.
|
||||
self.ping_rate_hz = 1
|
||||
self.heavy_quality_test = None
|
||||
self.heavy_pending_pings = {}
|
||||
self.heavy_quality_latest = None
|
||||
self.telemetry_rates_hz = {
|
||||
"SYS_STATUS": 1, "SERVO_OUTPUT_RAW": 2,
|
||||
"RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1,
|
||||
}
|
||||
self.ping_sequence = 0
|
||||
self.next_ping_at = 0.0
|
||||
self.pending_pings = {}
|
||||
self.ping_results = deque(maxlen=300)
|
||||
self.bad_frame_times = deque()
|
||||
self.bad_frame_bytes = deque()
|
||||
self.rx_sequence_by_source = {}
|
||||
self.rx_delivery_results = deque(maxlen=1024)
|
||||
self.next_quality_publish_at = 0.0
|
||||
self.param_scan = deque()
|
||||
self.param_waiting = None
|
||||
self.param_deadline = 0.0
|
||||
@@ -96,6 +131,9 @@ class MavlinkWorker:
|
||||
self.log_lock = threading.Lock()
|
||||
self.log_file = None
|
||||
self.log_path = None
|
||||
self.console_lines = deque(maxlen=500)
|
||||
self.console_rx_lines = deque(maxlen=300)
|
||||
self.console_tx_lines = deque(maxlen=300)
|
||||
|
||||
def connect(self, port: str, baud: int):
|
||||
print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}")
|
||||
@@ -109,8 +147,12 @@ class MavlinkWorker:
|
||||
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
|
||||
normalized = normalize_endpoint(port)
|
||||
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
|
||||
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} control_hz={self.rate_hz}")
|
||||
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} "
|
||||
f"control_hz={self.rate_hz} rx=MAVLink2 tx=MAVLink2")
|
||||
self.events.put(("log_path", str(self.log_path)))
|
||||
self.last_heartbeat = 0.0
|
||||
self.last_rx = 0.0
|
||||
self.next_quality_publish_at = 0.0
|
||||
self.thread = threading.Thread(
|
||||
target=self._run, args=(normalized, baud), daemon=True
|
||||
)
|
||||
@@ -137,7 +179,7 @@ class MavlinkWorker:
|
||||
|
||||
# Send initial GCS heartbeat immediately
|
||||
self.log("TX_HEARTBEAT", "GCS initial")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
@@ -149,17 +191,32 @@ class MavlinkWorker:
|
||||
now = time.monotonic()
|
||||
if now >= next_gcs_hb:
|
||||
self.log("TX_HEARTBEAT", "GCS")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
next_gcs_hb = now + 1.0
|
||||
if self.target_system and now >= next_control:
|
||||
# ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a
|
||||
# 20 Hz neutral stream alive here only competes with ARM and
|
||||
# its acknowledgement on constrained links.
|
||||
if self.target_system and self.armed and now >= next_control:
|
||||
self._send_control()
|
||||
next_control = now + (1.0 / max(1, self.rate_hz))
|
||||
# The first disarm packet is preceded by a neutral command.
|
||||
# Until disarm is confirmed, keep sending neutral at a
|
||||
# lower rate instead of consuming 20 Hz on the weak link.
|
||||
control_hz = min(self.rate_hz, 5) if now < self.disarm_requested_until else self.rate_hz
|
||||
next_control = now + (1.0 / max(1, control_hz))
|
||||
elif not self.armed:
|
||||
next_control = now
|
||||
if self.target_system:
|
||||
self._tick_arm_retry(now)
|
||||
self._tick_link_probe(now)
|
||||
self._tick_stream_config(now)
|
||||
self._tick_param_scan(now)
|
||||
if now >= self.next_quality_publish_at:
|
||||
self._publish_link_quality()
|
||||
self.next_quality_publish_at = now + 1.0
|
||||
|
||||
msg = conn.recv_match(blocking=False)
|
||||
if msg:
|
||||
@@ -170,6 +227,8 @@ class MavlinkWorker:
|
||||
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
|
||||
self.events.put(("error", f"Connection error: {exc}"))
|
||||
finally:
|
||||
if self.heavy_quality_test is not None:
|
||||
self.cancel_heavy_quality_test()
|
||||
self._safe_stop()
|
||||
if self.connection:
|
||||
try:
|
||||
@@ -180,6 +239,19 @@ class MavlinkWorker:
|
||||
self.target_system = 0
|
||||
self.armed = False
|
||||
self.config_paused = False
|
||||
self.command_quiet_until = 0.0
|
||||
self.disarm_requested_until = 0.0
|
||||
self.pending_arm_state = None
|
||||
self.next_arm_retry_at = 0.0
|
||||
self.arm_retry_deadline = 0.0
|
||||
self.arm_confirmation = 0
|
||||
self.stream_config.clear()
|
||||
self.pending_pings.clear()
|
||||
self.ping_results.clear()
|
||||
self.bad_frame_times.clear()
|
||||
self.bad_frame_bytes.clear()
|
||||
self.rx_sequence_by_source.clear()
|
||||
self.rx_delivery_results.clear()
|
||||
self.log("SESSION", "disconnected")
|
||||
with self.log_lock:
|
||||
if self.log_file:
|
||||
@@ -189,22 +261,71 @@ class MavlinkWorker:
|
||||
|
||||
def log(self, category: str, message: str):
|
||||
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
line = f"{timestamp} [{category}] {message}"
|
||||
with self.log_lock:
|
||||
self.console_lines.append(line[:2000])
|
||||
if category.startswith("RX") or category == "ONBOARD_LOG_ERROR":
|
||||
self.console_rx_lines.append(line[:2000])
|
||||
elif category.startswith("TX") or category == "OPERATOR":
|
||||
self.console_tx_lines.append(line[:2000])
|
||||
if self.log_file:
|
||||
self.log_file.write(f"{timestamp} [{category}] {message}\n")
|
||||
self.log_file.write(line + "\n")
|
||||
|
||||
def read_console(self, limit=150):
|
||||
with self.log_lock:
|
||||
limit = max(1, min(300, int(limit)))
|
||||
return {
|
||||
"rx": list(self.console_rx_lines)[-limit:],
|
||||
"tx": list(self.console_tx_lines)[-limit:],
|
||||
}
|
||||
|
||||
def _send_mav(self, connection, method: str, *args):
|
||||
"""Serialize MAVLink 2 writes and protect the start of each burst."""
|
||||
with self.tx_lock:
|
||||
# This full-duplex module pair loses the opening bytes of some
|
||||
# PC-to-FC bursts after the line has been idle. Put a valid,
|
||||
# expendable GCS heartbeat first so the following packet remains
|
||||
# intact and the FC also sees a healthy GCS heartbeat stream.
|
||||
if method != "heartbeat_send" and hasattr(connection.mav, "heartbeat_send"):
|
||||
connection.mav.heartbeat_send(
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
getattr(connection.mav, method)(*args)
|
||||
|
||||
def _handle_message(self, msg):
|
||||
msg_type = msg.get_type()
|
||||
if msg_type == "BAD_DATA":
|
||||
now = time.monotonic()
|
||||
self.bad_frame_times.append(now)
|
||||
self.bad_frame_bytes.append((now, len(getattr(msg, "data", b""))))
|
||||
self._trim_bad_frames(now)
|
||||
self.log("RX_BAD_DATA", repr(getattr(msg, "data", b"")))
|
||||
return
|
||||
self.last_rx = time.monotonic()
|
||||
self._track_rx_delivery(msg)
|
||||
try:
|
||||
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
|
||||
except Exception:
|
||||
payload = str(msg)
|
||||
self.log("RX", payload)
|
||||
if msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
|
||||
if msg_type == "TIMESYNC" and int(getattr(msg, "tc1", 0)) != 0:
|
||||
token = int(getattr(msg, "ts1", -1))
|
||||
heavy_probe = self.heavy_pending_pings.pop(token, None)
|
||||
if heavy_probe is not None:
|
||||
stage_index, sent_at = heavy_probe
|
||||
self._record_heavy_probe(stage_index, (time.monotonic() - sent_at) * 1000.0)
|
||||
self._publish_heavy_quality()
|
||||
sent_at = None
|
||||
else:
|
||||
sent_at = self.pending_pings.pop(token, None)
|
||||
if sent_at is not None:
|
||||
rtt_ms = (time.monotonic() - sent_at) * 1000.0
|
||||
self.ping_results.append((time.monotonic(), rtt_ms))
|
||||
self.log("RX_LINK_PROBE", f"timesync={token} rtt_ms={rtt_ms:.1f}")
|
||||
self._publish_link_quality()
|
||||
elif msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
|
||||
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
|
||||
return
|
||||
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||
@@ -216,6 +337,10 @@ class MavlinkWorker:
|
||||
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
|
||||
self.armed = armed
|
||||
self.config_paused = armed
|
||||
if self.pending_arm_state is not None and armed == self.pending_arm_state:
|
||||
self.pending_arm_state = None
|
||||
if not armed:
|
||||
self.disarm_requested_until = 0.0
|
||||
try:
|
||||
mode = mavutil.mode_string_v10(msg)
|
||||
except Exception:
|
||||
@@ -224,6 +349,7 @@ class MavlinkWorker:
|
||||
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
|
||||
if first:
|
||||
self.next_ping_at = time.monotonic()
|
||||
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
||||
self._configure_low_bandwidth_streams()
|
||||
self._start_servo_scan()
|
||||
@@ -232,12 +358,21 @@ class MavlinkWorker:
|
||||
return
|
||||
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
|
||||
result_name = result.name if result else str(msg.result)
|
||||
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
|
||||
self.log("RX_STREAM_CONFIG_ACK", result_name)
|
||||
return
|
||||
if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
|
||||
and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")):
|
||||
self.pending_arm_state = None
|
||||
self.events.put(("ack", int(msg.command), result_name, self.last_rx))
|
||||
elif msg_type == "STATUSTEXT":
|
||||
text = msg.text
|
||||
if isinstance(text, bytes):
|
||||
text = text.decode(errors="replace")
|
||||
self.events.put(("statustext", text.rstrip("\x00")))
|
||||
text = text.rstrip("\x00")
|
||||
if "/APM/LOGS" in text and "Failed" in text:
|
||||
self.log("ONBOARD_LOG_ERROR", text)
|
||||
self.events.put(("statustext", text))
|
||||
elif msg_type == "SYS_STATUS":
|
||||
voltage_mv = int(msg.voltage_battery)
|
||||
if 0 < voltage_mv < 65535:
|
||||
@@ -264,6 +399,239 @@ class MavlinkWorker:
|
||||
self.events.put(("motor_direction", int(parts[1]),
|
||||
int(round(msg.param_value))))
|
||||
|
||||
def _trim_bad_frames(self, now):
|
||||
while self.bad_frame_times and now - self.bad_frame_times[0] > 20.0:
|
||||
self.bad_frame_times.popleft()
|
||||
|
||||
def _track_rx_delivery(self, msg):
|
||||
"""Estimate downlink loss from MAVLink's per-source packet sequence."""
|
||||
try:
|
||||
source = (int(msg.get_srcSystem()), int(msg.get_srcComponent()))
|
||||
sequence = int(msg.get_seq())
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return
|
||||
if self.target_system and source != (self.target_system, self.target_component):
|
||||
return
|
||||
previous = self.rx_sequence_by_source.get(source)
|
||||
self.rx_sequence_by_source[source] = sequence
|
||||
if previous is None:
|
||||
return
|
||||
missing = (sequence - previous - 1) & 0xFF
|
||||
# A large backwards jump is a reset/reordered packet, not 200 losses.
|
||||
if missing > 127:
|
||||
return
|
||||
timestamp = time.monotonic()
|
||||
self.rx_delivery_results.extend((timestamp, False) for _ in range(missing))
|
||||
self.rx_delivery_results.append((timestamp, True))
|
||||
|
||||
def _publish_link_quality(self):
|
||||
now = time.monotonic()
|
||||
self._trim_bad_frames(now)
|
||||
while self.ping_results and now - self.ping_results[0][0] > 30.0:
|
||||
self.ping_results.popleft()
|
||||
while self.rx_delivery_results and now - self.rx_delivery_results[0][0] > 30.0:
|
||||
self.rx_delivery_results.popleft()
|
||||
while self.bad_frame_bytes and now - self.bad_frame_bytes[0][0] > 20.0:
|
||||
self.bad_frame_bytes.popleft()
|
||||
samples = [sample for _, sample in self.ping_results]
|
||||
received = [sample for sample in samples if sample is not None]
|
||||
total = len(samples)
|
||||
loss_percent = 100.0 * (total - len(received)) / total if total else None
|
||||
ordered_rtt = sorted(received)
|
||||
latency_ms = (sum(ordered_rtt) / len(ordered_rtt)) if ordered_rtt else None
|
||||
median_rtt_ms = ordered_rtt[len(ordered_rtt) // 2] if ordered_rtt else None
|
||||
p95_rtt_ms = ordered_rtt[min(len(ordered_rtt) - 1, (len(ordered_rtt) * 95 + 99) // 100 - 1)] if ordered_rtt else None
|
||||
rx_received = sum(1 for _, delivered in self.rx_delivery_results if delivered)
|
||||
rx_missing = sum(1 for _, delivered in self.rx_delivery_results if not delivered)
|
||||
rx_loss_percent = 100.0 * rx_missing / (rx_received + rx_missing) if rx_received else None
|
||||
rx_corrupt_bytes = sum(byte_count for _, byte_count in self.bad_frame_bytes)
|
||||
heartbeat_age = (now - self.last_heartbeat) if self.last_heartbeat else None
|
||||
if total < 10 or rx_received < 10 or heartbeat_age is None:
|
||||
grade = "testing"
|
||||
elif (heartbeat_age > 3 or loss_percent > 15
|
||||
or (rx_loss_percent is not None and rx_loss_percent > 5)
|
||||
or len(self.bad_frame_bytes) >= 5):
|
||||
grade = "poor"
|
||||
elif (loss_percent > 3 or (rx_loss_percent is not None and rx_loss_percent > 1)
|
||||
or (p95_rtt_ms is not None and p95_rtt_ms > 500) or self.bad_frame_bytes):
|
||||
grade = "fair"
|
||||
elif (loss_percent > 0.5 or (rx_loss_percent is not None and rx_loss_percent > 0.2)
|
||||
or (p95_rtt_ms is not None and p95_rtt_ms > 200)):
|
||||
grade = "good"
|
||||
else:
|
||||
grade = "excellent"
|
||||
self.events.put(("link_quality", {
|
||||
"grade": grade,
|
||||
"latency_ms": round(latency_ms, 1) if latency_ms is not None else None,
|
||||
"rtt_median_ms": round(median_rtt_ms, 1) if median_rtt_ms is not None else None,
|
||||
"rtt_p95_ms": round(p95_rtt_ms, 1) if p95_rtt_ms is not None else None,
|
||||
"loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
|
||||
"probe_loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
|
||||
"probe_samples": total,
|
||||
"probe_replies": len(received),
|
||||
"rx_packets_30s": rx_received,
|
||||
"rx_sequence_loss_percent": round(rx_loss_percent, 1) if rx_loss_percent is not None else None,
|
||||
"rx_corrupt_frames_20s": len(self.bad_frame_bytes),
|
||||
"rx_corrupt_bytes_20s": rx_corrupt_bytes,
|
||||
"samples": total,
|
||||
"corrupt_frames_20s": len(self.bad_frame_times),
|
||||
"heartbeat_age_s": round(heartbeat_age, 1) if heartbeat_age is not None else None,
|
||||
"probe_supported": total > 0,
|
||||
}))
|
||||
|
||||
def _tick_link_probe(self, now):
|
||||
if self.heavy_quality_test is not None:
|
||||
self._tick_heavy_quality(now)
|
||||
return
|
||||
expired = [sequence for sequence, sent_at in self.pending_pings.items()
|
||||
if now - sent_at >= 2.5]
|
||||
for sequence in expired:
|
||||
self.pending_pings.pop(sequence, None)
|
||||
self.ping_results.append((now, None))
|
||||
self.log("LINK_PROBE_TIMEOUT", f"timesync={sequence}")
|
||||
if expired:
|
||||
self._publish_link_quality()
|
||||
if (now < self.next_ping_at or now < self.command_quiet_until
|
||||
or not self.connection or not self.target_system):
|
||||
return
|
||||
token = time.monotonic_ns()
|
||||
self.pending_pings[token] = now
|
||||
self.log("TX_LINK_PROBE", f"timesync={token}")
|
||||
# ArduPilot implements TIMESYNC even on builds that omit PING. A
|
||||
# request has tc1=0; the response mirrors our unique ts1 token.
|
||||
self._send_mav(self.connection, "timesync_send", 0, token)
|
||||
self.next_ping_at = now + (1.0 / max(1, self.ping_rate_hz))
|
||||
|
||||
def start_heavy_quality_test(self):
|
||||
if not self.connection or not self.target_system:
|
||||
raise RuntimeError("Connect to the flight controller before testing the link")
|
||||
if self.armed:
|
||||
raise RuntimeError("The heavy link test is available while the vehicle is disarmed")
|
||||
if self.pending_arm_state is not None or time.monotonic() < self.command_quiet_until:
|
||||
raise RuntimeError("Wait for the current vehicle command to finish")
|
||||
if self.heavy_quality_test is not None:
|
||||
raise RuntimeError("A heavy link test is already running")
|
||||
self.pending_pings.clear()
|
||||
self.heavy_pending_pings.clear()
|
||||
self.heavy_quality_latest = {
|
||||
"running": True, "stage": 1, "stages": 3, "rate_hz": 1,
|
||||
"status": "Running", "results": [],
|
||||
}
|
||||
self.heavy_quality_test = {
|
||||
"rates": (1, 5, 10), "duration_s": 10.0, "stage_index": 0,
|
||||
"stage_started": time.monotonic(), "next_probe_at": time.monotonic(),
|
||||
"stages": [{"rate_hz": rate, "sent": 0, "replies": 0,
|
||||
"timeouts": 0, "rtts": []} for rate in (1, 5, 10)],
|
||||
}
|
||||
self.log("TX_HEAVY_TEST", "started rates=1,5,10Hz stage_seconds=10")
|
||||
self._publish_heavy_quality()
|
||||
return self.heavy_quality_latest
|
||||
|
||||
def cancel_heavy_quality_test(self):
|
||||
if self.heavy_quality_test is None:
|
||||
return self.heavy_quality_latest
|
||||
self.heavy_pending_pings.clear()
|
||||
test = self.heavy_quality_test
|
||||
self.heavy_quality_test = None
|
||||
self.heavy_quality_latest = {
|
||||
"running": False, "stage": 0, "stages": len(test["rates"]),
|
||||
"rate_hz": None, "status": "Cancelled", "results": self._heavy_results(test),
|
||||
}
|
||||
self.next_ping_at = time.monotonic()
|
||||
self.log("TX_HEAVY_TEST", "cancelled")
|
||||
self.events.put(("heavy_quality", self.heavy_quality_latest))
|
||||
return self.heavy_quality_latest
|
||||
|
||||
@staticmethod
|
||||
def _heavy_results(test):
|
||||
results = []
|
||||
for stage in test["stages"]:
|
||||
rtts = sorted(stage["rtts"])
|
||||
sent = stage["sent"]
|
||||
replies = stage["replies"]
|
||||
resolved = replies + stage["timeouts"]
|
||||
results.append({
|
||||
"rate_hz": stage["rate_hz"], "sent": sent, "replies": replies,
|
||||
"loss_percent": round(100.0 * stage["timeouts"] / resolved, 1) if resolved else None,
|
||||
"median_rtt_ms": round(rtts[len(rtts) // 2], 1) if rtts else None,
|
||||
"p95_rtt_ms": round(rtts[min(len(rtts) - 1, (len(rtts) * 95 + 99) // 100 - 1)], 1) if rtts else None,
|
||||
})
|
||||
return results
|
||||
|
||||
def _record_heavy_probe(self, stage_index, rtt_ms=None):
|
||||
test = self.heavy_quality_test
|
||||
if test is None or not 0 <= stage_index < len(test["stages"]):
|
||||
return
|
||||
stage = test["stages"][stage_index]
|
||||
if rtt_ms is None:
|
||||
stage["timeouts"] += 1
|
||||
else:
|
||||
stage["replies"] += 1
|
||||
stage["rtts"].append(rtt_ms)
|
||||
|
||||
def _publish_heavy_quality(self):
|
||||
test = self.heavy_quality_test
|
||||
if test is None:
|
||||
return
|
||||
index = test["stage_index"]
|
||||
stage = test["stages"][index]
|
||||
self.heavy_quality_latest = {
|
||||
"running": True, "stage": index + 1, "stages": len(test["rates"]),
|
||||
"rate_hz": stage["rate_hz"], "sent": stage["sent"],
|
||||
"replies": stage["replies"], "timeouts": stage["timeouts"],
|
||||
"status": f"Stage {index + 1} of {len(test['rates'])}: {stage['rate_hz']} Hz",
|
||||
"results": self._heavy_results(test),
|
||||
}
|
||||
self.events.put(("heavy_quality", self.heavy_quality_latest))
|
||||
|
||||
def _tick_heavy_quality(self, now):
|
||||
test = self.heavy_quality_test
|
||||
if test is None:
|
||||
return
|
||||
for token, (stage_index, sent_at) in list(self.heavy_pending_pings.items()):
|
||||
if now - sent_at >= 2.5:
|
||||
self.heavy_pending_pings.pop(token, None)
|
||||
self._record_heavy_probe(stage_index)
|
||||
self.log("HEAVY_PROBE_TIMEOUT", f"rate={test['rates'][stage_index]}Hz timesync={token}")
|
||||
index = test["stage_index"]
|
||||
stage_pending = any(stage_index == index for stage_index, _ in self.heavy_pending_pings.values())
|
||||
if now - test["stage_started"] >= test["duration_s"] and not stage_pending:
|
||||
index += 1
|
||||
if index >= len(test["rates"]):
|
||||
results = self._heavy_results(test)
|
||||
self.heavy_quality_test = None
|
||||
self.heavy_quality_latest = {
|
||||
"running": False, "stage": 3, "stages": 3, "rate_hz": None,
|
||||
"status": "Complete", "results": results,
|
||||
}
|
||||
self.next_ping_at = now
|
||||
self.log("TX_HEAVY_TEST", "complete")
|
||||
self.events.put(("heavy_quality", self.heavy_quality_latest))
|
||||
return
|
||||
test["stage_index"] = index
|
||||
test["stage_started"] = now
|
||||
test["next_probe_at"] = now
|
||||
self._publish_heavy_quality()
|
||||
index = test["stage_index"]
|
||||
stage = test["stages"][index]
|
||||
if now >= test["next_probe_at"] and now - test["stage_started"] < test["duration_s"]:
|
||||
token = time.monotonic_ns()
|
||||
self.heavy_pending_pings[token] = (index, now)
|
||||
stage["sent"] += 1
|
||||
self.log("TX_HEAVY_PROBE", f"rate={stage['rate_hz']}Hz timesync={token}")
|
||||
self._send_mav(self.connection, "timesync_send", 0, token)
|
||||
test["next_probe_at"] = now + 1.0 / stage["rate_hz"]
|
||||
if now >= self.next_quality_publish_at:
|
||||
self._publish_heavy_quality()
|
||||
self.next_quality_publish_at = now + 1.0
|
||||
|
||||
def set_link_rates(self, ping_hz, telemetry_rates):
|
||||
self.ping_rate_hz = max(1, min(10, int(ping_hz)))
|
||||
for name in self.telemetry_rates_hz:
|
||||
if name in telemetry_rates:
|
||||
self.telemetry_rates_hz[name] = max(0, min(10, int(telemetry_rates[name])))
|
||||
if self.connection and self.target_system:
|
||||
self._configure_low_bandwidth_streams()
|
||||
def _start_servo_scan(self):
|
||||
names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)]
|
||||
# ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal,
|
||||
@@ -284,31 +652,52 @@ class MavlinkWorker:
|
||||
self.param_retries = 0
|
||||
|
||||
def _configure_low_bandwidth_streams(self):
|
||||
"""Stop inherited high-rate streams and request only diagnostic essentials."""
|
||||
"""Pace optional telemetry changes; never burst them at arm time."""
|
||||
if not self.connection:
|
||||
return
|
||||
self.log("TX_STREAM_CONFIG", "stop MAV_DATA_STREAM_ALL")
|
||||
self.connection.mav.request_data_stream_send(
|
||||
self._send_mav(self.connection, "request_data_stream_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0,
|
||||
)
|
||||
# Heartbeat and STATUSTEXT do not need to be requested. Keep only
|
||||
# output/input evidence needed to diagnose controls, at 2 Hz each.
|
||||
for message_id, interval_us, name in (
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS, 1_000_000, "SYS_STATUS"),
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW, 500_000, "SERVO_OUTPUT_RAW"),
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_RC_CHANNELS, 500_000, "RC_CHANNELS"),
|
||||
):
|
||||
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
|
||||
self.connection.mav.command_long_send(
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
|
||||
0, message_id, interval_us, 0, 0, 0, 0, 0,
|
||||
)
|
||||
self.events.put(("status", "Low-bandwidth telemetry configured; reading servo setup…"))
|
||||
# ATTITUDE and VFR_HUD interval commands were accepted by this FC.
|
||||
# AHRS2 is also unneeded, but an acknowledgement has not been seen.
|
||||
# NAMED_VALUE_FLOAT explicitly returned DENIED, so do not retry it.
|
||||
# A single interval command is sometimes lost, so send two paced
|
||||
# passes. This does not change persistent parameters or onboard logs.
|
||||
unwanted = (
|
||||
"ATTITUDE", "AHRS2", "VFR_HUD",
|
||||
)
|
||||
self.stream_config = deque(
|
||||
(name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), -1)
|
||||
for _ in range(2) for name in unwanted
|
||||
)
|
||||
for name, rate_hz in self.telemetry_rates_hz.items():
|
||||
interval_us = -1 if rate_hz == 0 else round(1_000_000 / rate_hz)
|
||||
self.stream_config.append((
|
||||
name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), interval_us,
|
||||
))
|
||||
self.next_stream_config_at = time.monotonic()
|
||||
self.events.put(("status", "Applying low-bandwidth telemetry rates…"))
|
||||
|
||||
def _tick_stream_config(self, now):
|
||||
if (not self.connection or not self.target_system or self.armed
|
||||
or now < self.command_quiet_until or now < self.next_stream_config_at
|
||||
or not self.stream_config):
|
||||
return
|
||||
name, message_id, interval_us = self.stream_config.popleft()
|
||||
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
|
||||
self._send_mav(self.connection, "command_long_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
|
||||
0, message_id, interval_us, 0, 0, 0, 0, 0,
|
||||
)
|
||||
self.next_stream_config_at = now + 0.5
|
||||
|
||||
def _tick_param_scan(self, now):
|
||||
if not self.connection or self.config_paused or now < self.param_deadline:
|
||||
if (not self.connection or self.config_paused
|
||||
or self.stream_config or now < self.next_stream_config_at
|
||||
or now < self.command_quiet_until or now < self.param_deadline):
|
||||
return
|
||||
if self.param_waiting is not None:
|
||||
if self.param_retries < 2:
|
||||
@@ -329,7 +718,7 @@ class MavlinkWorker:
|
||||
|
||||
def _request_param(self, name: str):
|
||||
self.log("TX_PARAM_REQUEST", name)
|
||||
self.connection.mav.param_request_read_send(
|
||||
self._send_mav(self.connection, "param_request_read_send",
|
||||
self.target_system, self.target_component, name.encode("ascii"), -1
|
||||
)
|
||||
|
||||
@@ -343,7 +732,7 @@ class MavlinkWorker:
|
||||
raise RuntimeError(f"{name} has not been read from the autopilot")
|
||||
self.pending_sets[name] = int(value)
|
||||
self.log("TX_PARAM_SET", f"{name}={value} type={param_type}")
|
||||
self.connection.mav.param_set_send(
|
||||
self._send_mav(self.connection, "param_set_send",
|
||||
self.target_system, self.target_component, name.encode("ascii"),
|
||||
float(value), param_type,
|
||||
)
|
||||
@@ -396,7 +785,7 @@ class MavlinkWorker:
|
||||
f"x={x} y={y} z={z} r={r} active={active} "
|
||||
f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})",
|
||||
)
|
||||
conn.mav.manual_control_send(self.target_system, x, y, z, r, 0)
|
||||
self._send_mav(conn, "manual_control_send", self.target_system, x, y, z, r, 0)
|
||||
|
||||
@staticmethod
|
||||
def _approach(current: float, target: float, max_change: float) -> float:
|
||||
@@ -409,18 +798,45 @@ class MavlinkWorker:
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
raise RuntimeError("No autopilot heartbeat")
|
||||
if arm and self.heavy_quality_test is not None:
|
||||
self.cancel_heavy_quality_test()
|
||||
now = time.monotonic()
|
||||
self.command_quiet_until = now + 6.0
|
||||
if not arm:
|
||||
self.disarm_requested_until = now + 6.0
|
||||
self._send_control(force_neutral=True)
|
||||
else:
|
||||
# Give the ARM request priority over the low-rate parameter scan.
|
||||
self.config_paused = True
|
||||
self.log("TX_COMMAND", "ARM" if arm else "DISARM")
|
||||
conn.mav.command_long_send(
|
||||
self.pending_arm_state = bool(arm)
|
||||
self.next_arm_retry_at = now + 0.25
|
||||
self.arm_retry_deadline = now + 5.0
|
||||
self.arm_confirmation = 0
|
||||
self._send_arm_packet(arm)
|
||||
|
||||
def _send_arm_packet(self, arm: bool):
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
return
|
||||
self._send_mav(conn, "command_long_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
|
||||
0, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
|
||||
self.arm_confirmation, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
|
||||
)
|
||||
|
||||
def _tick_arm_retry(self, now):
|
||||
if self.pending_arm_state is None or now < self.next_arm_retry_at:
|
||||
return
|
||||
if now >= self.arm_retry_deadline:
|
||||
self.log("TX_COMMAND_TIMEOUT", "ARM" if self.pending_arm_state else "DISARM")
|
||||
self.pending_arm_state = None
|
||||
return
|
||||
self.log("TX_COMMAND_RETRY", "ARM" if self.pending_arm_state else "DISARM")
|
||||
self.arm_confirmation = min(255, self.arm_confirmation + 1)
|
||||
self._send_arm_packet(self.pending_arm_state)
|
||||
self.next_arm_retry_at = now + 0.25
|
||||
|
||||
def set_manual_mode(self):
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
@@ -430,7 +846,7 @@ class MavlinkWorker:
|
||||
if manual is None:
|
||||
raise RuntimeError("MANUAL mode is not advertised by this firmware")
|
||||
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
|
||||
conn.mav.set_mode_send(
|
||||
self._send_mav(conn, "set_mode_send",
|
||||
self.target_system,
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
|
||||
manual,
|
||||
@@ -467,7 +883,7 @@ class MavlinkWorker:
|
||||
if self.connection and self.target_system:
|
||||
try:
|
||||
self._send_control(force_neutral=True)
|
||||
self.connection.mav.command_long_send(
|
||||
self._send_mav(self.connection, "command_long_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -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)
|
||||
@@ -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: $_"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Best-effort Windows first-run shortcut, including direct Python launches."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
def ensure_desktop_shortcut():
|
||||
if os.name != "nt":
|
||||
return
|
||||
root = Path(__file__).resolve().parent
|
||||
if (root / "runtime" / "desktop-shortcut-created.txt").exists():
|
||||
return
|
||||
powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(powershell), "-NoProfile", "-NonInteractive", "-File", str(root / "create_shortcut.ps1")],
|
||||
cwd=root, timeout=10, check=True, capture_output=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
print(f"Desktop shortcut setup skipped: {error}")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -3,6 +3,8 @@ $ErrorActionPreference = "Stop"
|
||||
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location -LiteralPath $projectRoot
|
||||
|
||||
& (Join-Path $projectRoot 'create_shortcut.ps1')
|
||||
|
||||
if (-not (Get-Command py -ErrorAction SilentlyContinue)) {
|
||||
throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again."
|
||||
}
|
||||
|
||||
@@ -5,6 +5,99 @@ 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.
|
||||
|
||||
@@ -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.');
|
||||
+176
-1
@@ -1,6 +1,9 @@
|
||||
"""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
|
||||
@@ -100,9 +103,18 @@ class WorkerWatchdogTests(unittest.TestCase):
|
||||
|
||||
def assertNeutral(self):
|
||||
self.worker._send_control()
|
||||
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args,
|
||||
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)
|
||||
@@ -112,6 +124,15 @@ class WorkerWatchdogTests(unittest.TestCase):
|
||||
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)
|
||||
@@ -122,6 +143,160 @@ class WorkerWatchdogTests(unittest.TestCase):
|
||||
self.worker.set_axes(Axes(forward=1), 19)
|
||||
self.assertEqual(self.worker.axes, Axes())
|
||||
|
||||
def test_mavlink_transmissions_are_serialized(self):
|
||||
first_entered = threading.Event()
|
||||
release_first = threading.Event()
|
||||
second_entered = threading.Event()
|
||||
|
||||
class Mav:
|
||||
def first_send(self, **_):
|
||||
first_entered.set()
|
||||
release_first.wait(1)
|
||||
|
||||
def second_send(self, **_):
|
||||
second_entered.set()
|
||||
|
||||
connection = SimpleNamespace(mav=Mav())
|
||||
first = threading.Thread(target=self.worker._send_mav,
|
||||
args=(connection, "first_send"))
|
||||
second = threading.Thread(target=self.worker._send_mav,
|
||||
args=(connection, "second_send"))
|
||||
first.start()
|
||||
self.assertTrue(first_entered.wait(.5))
|
||||
second.start()
|
||||
time.sleep(.05)
|
||||
self.assertFalse(second_entered.is_set())
|
||||
release_first.set()
|
||||
first.join(1)
|
||||
second.join(1)
|
||||
self.assertTrue(second_entered.is_set())
|
||||
|
||||
def test_live_console_is_read_only_bounded_and_keeps_newest_lines(self):
|
||||
for index in range(310):
|
||||
self.worker.log("RX_TEST", f"vehicle {index}")
|
||||
self.worker.log("TX_TEST", f"computer {index}")
|
||||
sides = self.worker.read_console(2)
|
||||
self.assertEqual(set(sides), {"rx", "tx"})
|
||||
self.assertTrue(sides["rx"][0].endswith("[RX_TEST] vehicle 308"))
|
||||
self.assertTrue(sides["rx"][1].endswith("[RX_TEST] vehicle 309"))
|
||||
self.assertTrue(sides["tx"][1].endswith("[TX_TEST] computer 309"))
|
||||
self.assertEqual(len(self.worker.console_rx_lines), 300)
|
||||
self.assertEqual(len(self.worker.console_tx_lines), 300)
|
||||
self.assertEqual(len(self.worker.console_lines), 500)
|
||||
|
||||
def test_arm_request_pauses_parameter_traffic(self):
|
||||
self.worker.armed = False
|
||||
self.worker.param_scan.append("SERVO1_FUNCTION")
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(True)
|
||||
self.worker.config_paused = False # A disarmed heartbeat arrived.
|
||||
self.worker._tick_param_scan(10.1)
|
||||
self.assertEqual(self.worker.command_quiet_until, 16)
|
||||
self.worker.connection.mav.param_request_read_send.assert_not_called()
|
||||
self.worker.connection.mav.command_long_send.assert_called_once()
|
||||
|
||||
def test_arm_request_retries_until_state_confirmation(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(True)
|
||||
self.worker._tick_arm_retry(10.24)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_arm_retry(10.25)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
self.worker.pending_arm_state = None
|
||||
self.worker._tick_arm_retry(10.50)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
|
||||
def test_outbound_command_has_valid_heartbeat_lead_in(self):
|
||||
self.worker._send_mav(self.worker.connection, "timesync_send", 0, 123)
|
||||
names = [call[0] for call in self.worker.connection.mav.method_calls]
|
||||
self.assertEqual(names[-2:], ["heartbeat_send", "timesync_send"])
|
||||
|
||||
def test_disarm_sends_neutral_and_pauses_parameter_traffic(self):
|
||||
self.worker.param_scan.append("SERVO1_FUNCTION")
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(False)
|
||||
self.worker.config_paused = False
|
||||
self.worker._tick_param_scan(10.1)
|
||||
self.assertEqual(self.worker.disarm_requested_until, 16)
|
||||
self.assertEqual(self.control_values(),
|
||||
(1, 0, 0, NEUTRAL_Z, 0, 0))
|
||||
self.worker.connection.mav.param_request_read_send.assert_not_called()
|
||||
|
||||
def test_telemetry_config_is_paced_and_includes_low_rate_esc(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._configure_low_bandwidth_streams()
|
||||
self.assertEqual(len(self.worker.stream_config), 10)
|
||||
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
|
||||
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
|
||||
self.worker.connection.mav.command_long_send.assert_not_called()
|
||||
self.worker._tick_stream_config(10)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_stream_config(10.1)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker.command_quiet_until = 16
|
||||
self.worker._tick_stream_config(11)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_stream_config(16)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
|
||||
def test_link_probe_uses_configured_rate_and_measures_exact_echo(self):
|
||||
self.worker.armed = False
|
||||
self.worker.ping_rate_hz = 5
|
||||
with patch("app.time.monotonic", return_value=10), \
|
||||
patch("app.time.monotonic_ns", return_value=10_000_000_000):
|
||||
self.worker._tick_link_probe(10)
|
||||
self.worker.connection.mav.timesync_send.assert_called_once_with(
|
||||
0, 10_000_000_000)
|
||||
self.assertEqual(self.worker.pending_pings, {10_000_000_000: 10})
|
||||
self.assertEqual(self.worker.next_ping_at, 10.2)
|
||||
message = SimpleNamespace(
|
||||
tc1=1, ts1=10_000_000_000, get_type=lambda: "TIMESYNC",
|
||||
to_dict=lambda: {"tc1": 1, "ts1": 10_000_000_000})
|
||||
with patch("app.time.monotonic", return_value=10.04):
|
||||
self.worker._handle_message(message)
|
||||
quality = self.worker.events.get_nowait()
|
||||
self.assertEqual(quality[0], "link_quality")
|
||||
self.assertEqual(quality[1]["latency_ms"], 40.0)
|
||||
self.assertEqual(quality[1]["loss_percent"], 0.0)
|
||||
|
||||
def test_link_rates_rebuild_requested_telemetry_intervals(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.set_link_rates(10, {
|
||||
"SYS_STATUS": 5, "SERVO_OUTPUT_RAW": 10,
|
||||
"RC_CHANNELS": 0, "ESC_TELEMETRY_1_TO_4": 2,
|
||||
})
|
||||
self.assertEqual(self.worker.ping_rate_hz, 10)
|
||||
configured = {name: interval for name, _, interval in self.worker.stream_config}
|
||||
self.assertEqual(configured["SYS_STATUS"], 200_000)
|
||||
self.assertEqual(configured["SERVO_OUTPUT_RAW"], 100_000)
|
||||
self.assertEqual(configured["RC_CHANNELS"], -1)
|
||||
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 500_000)
|
||||
|
||||
def test_link_probe_counts_timeout_and_pauses_for_commands(self):
|
||||
self.worker.pending_pings[4] = 5
|
||||
self.worker.rx_delivery_results.extend(((10, True), (10, False)))
|
||||
self.worker.next_ping_at = 0
|
||||
self.worker.command_quiet_until = 20
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._tick_link_probe(10)
|
||||
self.worker.connection.mav.timesync_send.assert_not_called()
|
||||
self.assertEqual(list(self.worker.ping_results), [(10, None)])
|
||||
quality = self.worker.events.get_nowait()[1]
|
||||
self.assertEqual(quality["loss_percent"], 100.0)
|
||||
self.assertEqual(quality["grade"], "testing")
|
||||
|
||||
def test_clean_downlink_cannot_hide_failed_round_trips(self):
|
||||
self.worker.ping_results.extend([(10, None)] * 20)
|
||||
self.worker.rx_delivery_results.extend([(10, True)] * 200)
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._publish_link_quality()
|
||||
quality = self.worker.events.get_nowait()[1]
|
||||
self.assertEqual(quality["loss_percent"], 100.0)
|
||||
self.assertEqual(quality["grade"], "poor")
|
||||
|
||||
|
||||
class BridgeTests(unittest.TestCase):
|
||||
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
|
||||
|
||||
@@ -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});
|
||||
+385
-28
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
|
||||
link_degraded: 'Signal lost — check tether',
|
||||
};
|
||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||
const activity=[];
|
||||
let lastReportedStatus='';
|
||||
@@ -14,26 +14,147 @@ function recordActivity(message){
|
||||
if(!message||activity[0]===message)return;
|
||||
activity.unshift(String(message));activity.length=Math.min(activity.length,3);
|
||||
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
|
||||
const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]}
|
||||
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&&performance.now()-lastSafetyTick<750;
|
||||
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
|
||||
|
||||
window.canDrive=canDrive;
|
||||
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
|
||||
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
|
||||
const controllerMotion=new ControllerInput.Motion();
|
||||
const controllerId=pad=>pad.index+':'+pad.id;
|
||||
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
|
||||
function loadControllerConfig(pad){
|
||||
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
|
||||
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
|
||||
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
|
||||
}
|
||||
function selectController(pad){
|
||||
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
|
||||
}
|
||||
function setInputMode(mode){
|
||||
if(mode===inputMode||inputConfigurationLocked())return false;
|
||||
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
|
||||
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
|
||||
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
|
||||
$('#controller-settings').hidden=mode!=='joystick';
|
||||
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys');
|
||||
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
|
||||
return true;
|
||||
}
|
||||
function inputConfigurationLocked(){
|
||||
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
|
||||
}
|
||||
function syncInputModeControls(){
|
||||
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
||||
$('.input-mode-switch').dataset.locked=String(locked);
|
||||
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
|
||||
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
|
||||
}
|
||||
$('#controller-settings').disabled=locked||!pads.length;
|
||||
$('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup';
|
||||
if($('#controller-modal').open){
|
||||
const form=$('#controller-form');
|
||||
for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad;
|
||||
$('#controller-device').disabled=locked||!pads.length;
|
||||
const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.';
|
||||
if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback);
|
||||
}
|
||||
}
|
||||
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
|
||||
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
|
||||
$('#input-joystick').onclick=()=>setInputMode('joystick');
|
||||
function fillControllerForm(){
|
||||
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
|
||||
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
|
||||
if(!pads.length)device.add(new Option('No controller detected',''));
|
||||
device.value=controllerKey||'';
|
||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
||||
const config=controllerConfig||ControllerInput.defaults(pad);
|
||||
for(const name of ['turn','forward','vertical']){
|
||||
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
|
||||
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
|
||||
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
|
||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);
|
||||
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
|
||||
}
|
||||
form.elements.deadZone.value=Math.round(config.deadZone*100);
|
||||
device.disabled=inputConfigurationLocked()||!pads.length;
|
||||
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
|
||||
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
|
||||
}
|
||||
$('#controller-device').onchange=e=>{
|
||||
if(inputConfigurationLocked())return;
|
||||
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
|
||||
if(pad){selectController(pad);fillControllerForm()}
|
||||
};
|
||||
$('#controller-form').onsubmit=e=>{
|
||||
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
|
||||
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
|
||||
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
|
||||
const config={deadZone:Number(form.elements.deadZone.value)/100};
|
||||
for(const name of ['turn','forward','vertical']){
|
||||
config[name]=Number(form.elements[name].value);
|
||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
|
||||
}
|
||||
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
|
||||
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
|
||||
}
|
||||
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
|
||||
resetInputs();closeModal($('#controller-modal'));
|
||||
};
|
||||
function pollController(now){
|
||||
try{
|
||||
if(inputMode==='joystick'){
|
||||
const pads=getControllers();
|
||||
if(controllerKey===null&&pads.length)selectController(pads[0]);
|
||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
|
||||
let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id;
|
||||
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
|
||||
if(!target||!canDrive()){
|
||||
if(controllerWasDriving)resetInputs();
|
||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
||||
}else{
|
||||
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
|
||||
if(!controllerNeedCenter){
|
||||
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
|
||||
control.turn=demand.turn;control.forward=demand.forward;
|
||||
window.joystick3D?.setVisual(demand.turn,-demand.forward);
|
||||
setHeave(demand.vertical,false);updateReadouts();sendAxes();
|
||||
}
|
||||
}
|
||||
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
|
||||
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
|
||||
if($('#controller-modal').open){
|
||||
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
|
||||
if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes;
|
||||
if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm();
|
||||
}
|
||||
}else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'}
|
||||
}catch(error){
|
||||
if(controllerWasDriving)resetInputs();
|
||||
$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard';
|
||||
}
|
||||
requestAnimationFrame(pollController);
|
||||
}
|
||||
document.body.dataset.inputMode=inputMode;
|
||||
|
||||
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
|
||||
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
||||
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||
window.showToast=toast;
|
||||
const manualPorts=new Map();
|
||||
function populatePorts(ports){
|
||||
const p=$('#port');
|
||||
if(!p)return;
|
||||
const old=p.value||'';
|
||||
p.innerHTML='';
|
||||
|
||||
|
||||
const allList=[];
|
||||
const seen=new Set();
|
||||
|
||||
@@ -50,6 +171,7 @@ function populatePorts(ports){
|
||||
};
|
||||
|
||||
if(Array.isArray(ports)) ports.forEach(addPort);
|
||||
manualPorts.forEach(addPort);
|
||||
addPort({device:'demo', label:'Demo Mode (no hardware)'});
|
||||
|
||||
let matched=false;
|
||||
@@ -82,20 +204,21 @@ function sendAxes(){
|
||||
setInterval(()=>{if(canDrive())sendAxes()},250);
|
||||
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||
|
||||
window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||
window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||
|
||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
|
||||
function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();}
|
||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
|
||||
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
|
||||
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||
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(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else easeHeave()}}
|
||||
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||
function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
|
||||
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||
function resetInputs(){
|
||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
||||
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
|
||||
window.joystick3D?.reset();
|
||||
@@ -111,28 +234,64 @@ addEventListener('focus',()=>focusChanged(!document.hidden));
|
||||
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
|
||||
addEventListener('pagehide',()=>focusChanged(false));
|
||||
|
||||
function setFeedback(el,message=''){
|
||||
if(!el)return;
|
||||
el.textContent=message;el.hidden=!message;
|
||||
}
|
||||
function openModal(id){
|
||||
const el=typeof id==='string'?document.getElementById(id):id;
|
||||
if(!el)return;
|
||||
if(!el||el.open)return;
|
||||
if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return}
|
||||
resetInputs();
|
||||
el._returnFocus=document.activeElement;
|
||||
if(el.id==='battery-modal')fillBatteryForm();
|
||||
if(el.id==='controller-modal')fillControllerForm();
|
||||
if(el.id==='link-modal'){
|
||||
fillLinkForm();
|
||||
$('#custom-port-status').textContent='';
|
||||
$('#custom-port-input').value='';
|
||||
}
|
||||
el.querySelectorAll('.form-feedback').forEach(feedback=>setFeedback(feedback));
|
||||
el.classList.remove('closing');
|
||||
if(!el.open)el.showModal();
|
||||
el.showModal();
|
||||
}
|
||||
function closeModal(dialog){
|
||||
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
|
||||
if(!dialog||!dialog.open||dialog.classList.contains('closing')||dialog.querySelector('[data-busy="true"]'))return;
|
||||
dialog.classList.add('closing');
|
||||
let done=false;
|
||||
const finish=()=>{
|
||||
if(done)return;
|
||||
done=true;
|
||||
dialog.removeEventListener('animationend',finish);
|
||||
dialog.removeEventListener('animationend',onEnd);
|
||||
dialog.classList.remove('closing');
|
||||
dialog.close();
|
||||
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
|
||||
};
|
||||
dialog.addEventListener('animationend',finish,{once:true});
|
||||
setTimeout(finish,300);
|
||||
const onEnd=e=>{if(e.target===dialog)finish()};
|
||||
dialog.addEventListener('animationend',onEnd);
|
||||
setTimeout(finish,180);
|
||||
}
|
||||
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
|
||||
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
|
||||
$('#link-quality').onclick=()=>openModal('link-quality-modal');
|
||||
let heavyActionBusy=false;
|
||||
async function requestHeavyTest(action){
|
||||
if(heavyActionBusy)return;
|
||||
heavyActionBusy=true;
|
||||
setFeedback($('#heavy-quality-error'));
|
||||
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
try{
|
||||
const test=await api(action);
|
||||
vehicleSnapshot.heavy_quality=test;
|
||||
renderHeavyQuality(test,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
}catch(e){setFeedback($('#heavy-quality-error'),e.message||'Link test unavailable. Try again.')}
|
||||
finally{
|
||||
heavyActionBusy=false;
|
||||
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
}
|
||||
}
|
||||
$('#heavy-quality-run').onclick=()=>requestHeavyTest('start_heavy_quality_test');
|
||||
$('#heavy-quality-cancel').onclick=()=>requestHeavyTest('cancel_heavy_quality_test');
|
||||
$$('dialog').forEach(d=>{
|
||||
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
|
||||
d.addEventListener('click',e=>{
|
||||
@@ -255,7 +414,7 @@ $('#connect').onclick=async()=>{
|
||||
recordActivity(`Connecting to ${port}…`);
|
||||
toast(`Connecting to ${port}…`);
|
||||
try{
|
||||
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
|
||||
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){
|
||||
@@ -270,7 +429,9 @@ const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input
|
||||
if(customPortBtn&&customPortInput){
|
||||
const applyCustomPort=()=>{
|
||||
const val=customPortInput.value.trim();
|
||||
if(!val)return;
|
||||
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){
|
||||
@@ -280,8 +441,7 @@ if(customPortBtn&&customPortInput){
|
||||
p.add(new Option(`Custom (${val})`, val), 0);
|
||||
p.value=val;
|
||||
}
|
||||
closeModal($('#link-modal'));
|
||||
toast(`Selected port: ${val}`);
|
||||
$('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
|
||||
};
|
||||
customPortBtn.onclick=applyCustomPort;
|
||||
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
||||
@@ -356,12 +516,14 @@ function startEngageHold(e){
|
||||
if(view.action!=='arm'||!inputFocused||engageStart)return;
|
||||
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
|
||||
syncInputModeControls();
|
||||
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
|
||||
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||
}
|
||||
function cancelEngageHold(showHint=true){
|
||||
if(!engageStart)return;
|
||||
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
|
||||
syncInputModeControls();
|
||||
}
|
||||
|
||||
if(engageButton){
|
||||
@@ -372,7 +534,7 @@ if(engageButton){
|
||||
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');
|
||||
@@ -380,6 +542,56 @@ if(engageButton){
|
||||
}
|
||||
|
||||
$('#logs').onclick=()=>api('open_logs');
|
||||
const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
|
||||
let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0;
|
||||
const consolePulseTimers={};
|
||||
function pulseConsole(side){
|
||||
const dot=$('.'+side+'-dot');
|
||||
clearTimeout(consolePulseTimers[side]);dot.classList.add('lit');
|
||||
consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260);
|
||||
}
|
||||
function renderConsole(){
|
||||
const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache);
|
||||
if(signature===consoleSignature)return;
|
||||
consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter);
|
||||
}
|
||||
function setConsoleOpen(open){
|
||||
consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open);
|
||||
if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')}
|
||||
else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180);
|
||||
$('#console').setAttribute('aria-expanded',String(open));
|
||||
if(open)refreshConsole();else $('#console').focus({preventScroll:true});
|
||||
}
|
||||
$('#console').setAttribute('aria-controls','console-dock');
|
||||
$('#console').setAttribute('aria-expanded','false');
|
||||
$('#console').onclick=()=>setConsoleOpen(!consoleOpen);
|
||||
$('#console-close').onclick=()=>setConsoleOpen(false);
|
||||
$('#console-filter').onchange=renderConsole;
|
||||
$('#console-pause').onclick=()=>{
|
||||
consolePaused=!consolePaused;
|
||||
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
|
||||
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
|
||||
$('#console-state').textContent=consolePaused?'Paused':'';
|
||||
$('#console-state').hidden=!consolePaused;
|
||||
if(!consolePaused)refreshConsole();
|
||||
};
|
||||
async function refreshConsole(){
|
||||
if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
|
||||
consoleRefreshBusy=true;
|
||||
try{
|
||||
const sides=await withTimeout(api('read_console',150),1500);
|
||||
if(consolePaused||!consoleOpen)return;
|
||||
const filter=$('#console-filter').value;
|
||||
for(const side of ['rx','tx']){
|
||||
const previous=new Set(consoleCache[side]||[]);
|
||||
if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side);
|
||||
}
|
||||
consoleCache=sides;renderConsole();
|
||||
$('#console-state').textContent='';$('#console-state').hidden=true;
|
||||
}catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}}
|
||||
finally{consoleRefreshBusy=false}
|
||||
}
|
||||
setInterval(refreshConsole,750);
|
||||
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||
// Explicit pointer ownership makes the full range rail a reliable touch target
|
||||
@@ -391,21 +603,124 @@ gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)
|
||||
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
|
||||
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
|
||||
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
|
||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||
async function saveSettings(form,method,commit,message){
|
||||
if(form.dataset.busy==='true'||!form.reportValidity())return;
|
||||
const data=Object.fromEntries(new FormData(form));
|
||||
const button=form.querySelector('[type="submit"]'),label=button.textContent;
|
||||
const disabled=new Map([...form.querySelectorAll('input,select,button')].map(el=>[el,el.disabled]));
|
||||
form.dataset.busy='true';disabled.forEach((_,el)=>el.disabled=true);button.textContent='Saving…';
|
||||
setFeedback(form.querySelector('.form-feedback'));
|
||||
let saved=false;
|
||||
try{commit(await api(method,data));saved=true;toast(message)}
|
||||
catch(error){setFeedback(form.querySelector('.form-feedback'),error.message||'Could not save settings. Try again.')}
|
||||
finally{
|
||||
delete form.dataset.busy;disabled.forEach((value,el)=>el.disabled=value);button.textContent=label;
|
||||
if(saved)closeModal(form.closest('dialog'));
|
||||
}
|
||||
}
|
||||
$('#battery-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_battery',value=>batteryConfig=value,'Battery settings saved')};
|
||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
|
||||
$('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
|
||||
function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
|
||||
const motorEdits=new Map();
|
||||
async function requestMotorEdit(servo,kind,value){
|
||||
if(motorEdits.has(servo))return;
|
||||
const s=vehicleSnapshot.servos?.[String(servo)]||{};
|
||||
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
|
||||
setFeedback($('#motor-feedback'));
|
||||
if(demoActive){
|
||||
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
|
||||
else s.direction=expected;
|
||||
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
|
||||
}
|
||||
motorEdits.set(servo,{kind,expected,started:performance.now()});
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
try{
|
||||
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
|
||||
}catch(error){
|
||||
motorEdits.delete(servo);
|
||||
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
}
|
||||
}
|
||||
function renderMotors(servos){
|
||||
for(const [servo,edit] of motorEdits){
|
||||
const s=servos[String(servo)]||{};
|
||||
if((edit.kind==='function'?s.function:s.direction)===edit.expected){
|
||||
motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`);
|
||||
}else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){
|
||||
motorEdits.delete(servo);
|
||||
setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`);
|
||||
}
|
||||
}
|
||||
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
|
||||
const assigned=Object.values(servos).filter(s=>s.motor).length;
|
||||
$('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…';
|
||||
const signature=JSON.stringify([servos,editable,[...motorEdits]]);
|
||||
if(signature===servoSignature)return;
|
||||
servoSignature=signature;
|
||||
const list=$('#motor-list'),expanded=list.querySelector('details')?.open;
|
||||
list.replaceChildren();
|
||||
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded;
|
||||
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary);
|
||||
for(let i=1;i<=16;i++){
|
||||
const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row';
|
||||
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
|
||||
const select=row.querySelector('select');
|
||||
select.add(new Option(s.function==null?'Reading…':'Unassigned','0'));
|
||||
for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor)));
|
||||
select.value=String(s.motor||0);
|
||||
select.disabled=!editable||!!pending||s.function==null;
|
||||
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
|
||||
row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal';
|
||||
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null;
|
||||
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`);
|
||||
reverse.onclick=()=>requestMotorEdit(i,'direction');
|
||||
(s.motor?list:other).append(row);
|
||||
}
|
||||
if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)}
|
||||
if(assigned<16)list.append(other);
|
||||
}
|
||||
function renderHeavyQuality(test,connected,armed){
|
||||
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
|
||||
if(!status||!results||!run||!stop)return;
|
||||
const quality=demoActive?null:vehicleSnapshot.link_quality;
|
||||
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
|
||||
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
|
||||
const running=!!test?.running;
|
||||
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
|
||||
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
|
||||
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
|
||||
stop.hidden=!running;
|
||||
stop.disabled=heavyActionBusy;
|
||||
results.replaceChildren();
|
||||
for(const row of test?.results||[]){
|
||||
const line=document.createElement('div');line.className='heavy-result-row';
|
||||
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
|
||||
const stats=document.createElement('span');
|
||||
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
|
||||
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
|
||||
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
|
||||
line.append(rate,stats);results.append(line);
|
||||
}
|
||||
}
|
||||
function render(s,confirmed=false){
|
||||
vehicleSnapshot=s;
|
||||
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
|
||||
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
|
||||
const previous=view;
|
||||
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||
syncInputModeControls();
|
||||
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
||||
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
|
||||
document.body.dataset.appState=appState;
|
||||
document.body.dataset.controlsEnabled=String(view.drive);
|
||||
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||
$('#port').disabled=!!s.connected;
|
||||
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||
const connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
@@ -437,7 +752,7 @@ function render(s,confirmed=false){
|
||||
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
|
||||
const motorBtn=$('[data-open="motors-modal"]');
|
||||
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||
|
||||
|
||||
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
|
||||
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
|
||||
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
|
||||
@@ -448,13 +763,50 @@ function render(s,confirmed=false){
|
||||
const b=s.battery;
|
||||
if(b){
|
||||
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}%`;
|
||||
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
|
||||
$('.battery-card').title=`Estimated charge from voltage · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
|
||||
$('#battery-fill').style.width=`${b.percent}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
|
||||
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
|
||||
$('.battery-card').dataset.low=String(!!b.low);
|
||||
}else{
|
||||
$('#battery-title').textContent='—';
|
||||
$('#battery-percent').textContent='waiting';
|
||||
$('#battery-title').textContent='No voltage';
|
||||
$('#battery-percent').textContent='—';
|
||||
$('.battery-card').dataset.stale='false';
|
||||
$('.battery-card').title='Waiting for battery voltage · Battery settings';
|
||||
$('#battery-fill').style.width='0';
|
||||
$('.battery-card').dataset.low='false';
|
||||
}
|
||||
const quality=s.link_quality,qualityCard=$('#link-quality');
|
||||
if(qualityCard){
|
||||
if(demoActive){
|
||||
qualityCard.dataset.grade='offline';
|
||||
$('#link-quality-grade').textContent='Demo';
|
||||
$('#link-quality-roundtrip').textContent='no hardware link test';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
renderMotors(s.servos||{});
|
||||
return;
|
||||
}
|
||||
const grade=quality?.grade||(s.connected?'testing':'offline');
|
||||
qualityCard.dataset.grade=grade;
|
||||
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
|
||||
if(quality&&quality.probe_samples){
|
||||
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
|
||||
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
|
||||
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
|
||||
const downLoss=percent(quality.rx_sequence_loss_percent);
|
||||
const badFrames=quality.rx_corrupt_frames_20s??0;
|
||||
const badBytes=quality.rx_corrupt_bytes_20s??0;
|
||||
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
|
||||
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
|
||||
if(quality.probe_samples<10||quality.rx_packets_30s<10){
|
||||
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
|
||||
}
|
||||
}else{
|
||||
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
}
|
||||
}
|
||||
renderMotors(s.servos||{});
|
||||
}
|
||||
@@ -564,8 +916,10 @@ async function boot(){
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
const init=await withTimeout(api('initial_state'),4000);
|
||||
batteryConfig=init.battery_config||{};
|
||||
linkConfig=init.link_config||{};
|
||||
populatePorts(init.ports);
|
||||
fillBatteryForm();
|
||||
fillLinkForm();
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
@@ -600,3 +954,6 @@ if(document.readyState==='loading'){
|
||||
loadDefaultModel();
|
||||
waitForBridge();
|
||||
}
|
||||
|
||||
|
||||
requestAnimationFrame(pollController);
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Controller conditioning is independent of drawing and MAVLink transport. */
|
||||
((root)=>{
|
||||
const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0));
|
||||
function deadZone(value,zone=.08){
|
||||
value=clamp(value);zone=Math.max(0,Math.min(.4,zone));
|
||||
return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone);
|
||||
}
|
||||
function defaults(pad){
|
||||
return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1,
|
||||
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
|
||||
}
|
||||
function read(pad,config){
|
||||
if(!pad?.connected||!pad.axes)return null;
|
||||
const axis=(index,invert)=>{
|
||||
if(index===-1)return 0;
|
||||
if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null;
|
||||
return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1);
|
||||
};
|
||||
let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical);
|
||||
if(turn===null||forward===null||vertical===null)return null;
|
||||
const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length}
|
||||
return {turn,forward,vertical};
|
||||
}
|
||||
class Smoother{
|
||||
constructor(){this.reset()}
|
||||
reset(){this.value=0;this.last=null;this.release=null}
|
||||
step(target,now){
|
||||
target=clamp(target);
|
||||
const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now;
|
||||
if(target===0){
|
||||
if(!this.release)this.release={at:now,start:this.value};
|
||||
const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t);
|
||||
this.value=this.release.start*(1-ease);
|
||||
}else{
|
||||
this.release=null;
|
||||
this.value+=(target-this.value)*(1-Math.exp(-dt/.1));
|
||||
if(Math.abs(target-this.value)<.001)this.value=target;
|
||||
}
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
class Motion{
|
||||
constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}}
|
||||
reset(){Object.values(this.axes).forEach(axis=>axis.reset())}
|
||||
step(target,now){
|
||||
const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)]));
|
||||
const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length}
|
||||
return demand;
|
||||
}
|
||||
}
|
||||
root.ControllerInput={deadZone,defaults,read,Motion};
|
||||
if(typeof module!=='undefined')module.exports=root.ControllerInput;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
@@ -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()});
|
||||
})();
|
||||
+96
-20
@@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sub Control</title>
|
||||
<link rel="stylesheet" href="styles.css?v=20260827-bridge-v5">
|
||||
<link rel="stylesheet" href="styles.css?v=20261002-spacing-motion-v2">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
|
||||
|
||||
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
|
||||
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
|
||||
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
|
||||
@@ -44,20 +44,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Battery Status -->
|
||||
<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 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>
|
||||
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header>
|
||||
<div 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>
|
||||
@@ -66,6 +71,7 @@
|
||||
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
|
||||
<!-- Frameless Drag & Window Controls Header -->
|
||||
<div class="workspace-topbar">
|
||||
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
|
||||
<div class="workspace-drag-area pywebview-drag-region"></div>
|
||||
<div class="window-actions">
|
||||
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
||||
@@ -74,6 +80,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
|
||||
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
||||
<!-- Reserved Engage / Disarm row -->
|
||||
<div class="engage-header-zone">
|
||||
@@ -92,12 +99,12 @@
|
||||
<!-- Steering / Throttle Joystick -->
|
||||
<article class="control-card steer-card">
|
||||
<header>
|
||||
<div><small>steer + throttle</small></div>
|
||||
<span class="key-hint">w a s d</span>
|
||||
<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" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
|
||||
<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>
|
||||
@@ -109,13 +116,13 @@
|
||||
<!-- Vertical Thruster Slider -->
|
||||
<article class="control-card vertical-card">
|
||||
<header>
|
||||
<div><small>vertical</small></div>
|
||||
<span class="key-hint">r / f</span>
|
||||
<div title="Vertical thrust · R / F"></div>
|
||||
|
||||
</header>
|
||||
<div class="heave-pad">
|
||||
<div class="vertical-control">
|
||||
<span>rise</span>
|
||||
<div id="heave" class="heave-track" touch-action="none">
|
||||
<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>
|
||||
@@ -132,27 +139,96 @@
|
||||
<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>
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
|
||||
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
|
||||
<header class="console-dock-header"><div><h2 id="console-title" title="Local session messages. Select a message to expand its details.">Console</h2></div><button id="console-close" type="button" class="console-close" aria-label="Hide console" title="Hide console"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
|
||||
<div class="console-toolbar"><label><span class="visually-hidden">Console filter</span><select id="console-filter"><option value="useful">Useful traffic</option><option value="all">All traffic</option></select></label><button id="console-pause" type="button" aria-pressed="false">Pause</button></div>
|
||||
<p id="console-state" class="console-state" role="status" title="Pausing the viewer does not stop session logging." hidden></p>
|
||||
<div class="console-streams"><section class="console-side"><header><i class="rx-dot" aria-hidden="true" title="Vehicle to PC · new displayed message"></i><span>RX</span></header><div id="console-rx-output" class="console-output" tabindex="0" aria-label="Received messages"></div></section><section class="console-side"><header><i class="tx-dot" aria-hidden="true" title="PC to vehicle · new displayed message"></i><span>TX</span></header><div id="console-tx-output" class="console-output" tabindex="0" aria-label="Sent messages"></div></section></div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Modals -->
|
||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><div style="margin-top:14px;"><label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:11px;font-weight:500;color:var(--muted)">Custom Port / Endpoint</span><div style="display:flex;gap:8px;"><input id="custom-port-input" placeholder="e.g. COM3 or tcp:127.0.0.1:5760" style="flex:1;height:32px;padding:0 12px;font-size:11px;"><button type="button" id="custom-port-btn" class="button subtle" style="white-space:nowrap;">Use Port</button></div></label></div><p class="note" style="margin-top:14px;">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="link-title">Link settings</h2>
|
||||
<form id="link-form" title="Baud changes apply on the next connection.">
|
||||
<section class="settings-section"><h3>Serial connection</h3>
|
||||
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
|
||||
|
||||
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
|
||||
<p id="custom-port-status" class="note" role="status"></p>
|
||||
</section>
|
||||
<section class="settings-section"><h3 title="Lower rates leave more bandwidth for controls. Rates reset after a reboot.">Telemetry rates</h3>
|
||||
<div class="form-grid two">
|
||||
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="quality-title">Link quality</h2>
|
||||
|
||||
<div class="quality-summary" title="Round-trip replies, incoming packet loss, and heartbeat freshness."><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
|
||||
<section class="heavy-test-panel"><h3 title="Tests 1, 5 and 10 Hz for about 35 seconds while disarmed. The live check resumes afterward.">Extended test</h3>
|
||||
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
|
||||
<div id="heavy-quality-results" class="heavy-quality-results"></div>
|
||||
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button id="heavy-quality-run" type="button" class="button modal-primary">Run extended test</button><button id="heavy-quality-cancel" type="button" class="button subtle" hidden>Stop test</button></div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery settings</h2>
|
||||
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
|
||||
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
|
||||
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save battery</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
|
||||
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
|
||||
</dialog>
|
||||
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
|
||||
<form id="controller-form" title="Center sticks after changing input mode or reconnecting before movement resumes.">
|
||||
<label><span>Controller</span><select id="controller-device"></select></label>
|
||||
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
|
||||
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
|
||||
<div class="controller-mapping-row"><span>Turn</span><select name="turn" aria-label="Turn axis"></select><input name="invertTurn" type="checkbox" aria-label="Invert Turn"></div>
|
||||
<div class="controller-mapping-row"><span>Forward / back</span><select name="forward" aria-label="Forward / back axis"></select><input name="invertForward" type="checkbox" aria-label="Invert Forward / back"></div>
|
||||
<div class="controller-mapping-row"><span>Rise / dive</span><select name="vertical" aria-label="Rise / dive axis"></select><input name="invertVertical" type="checkbox" aria-label="Invert Rise / dive"></div>
|
||||
<label class="controller-deadzone"><span>Dead zone · %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label>
|
||||
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save mapping</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="model-viewer.js?v=20260826-controls-v2"></script>
|
||||
<script src="joystick-3d.js?v=20260827-polish-v3"></script>
|
||||
<script src="joystick-3d.js?v=20261002-spacing-motion-v2"></script>
|
||||
<script src="control-state.js?v=20260827-state-v1"></script>
|
||||
<script src="app.js?v=20260827-bridge-v6"></script>
|
||||
<script src="controller-input.js?v=20261001-controller-v1"></script>
|
||||
<script src="console-view.js?v=20261002-console-v2"></script>
|
||||
<script src="app.js?v=20261002-spacing-motion-v2"></script>
|
||||
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+14
-5
@@ -23,20 +23,29 @@
|
||||
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
|
||||
|
||||
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore();
|
||||
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 returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
|
||||
function 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.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
||||
canvas.addEventListener('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);resize();
|
||||
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
|
||||
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}}};
|
||||
})();
|
||||
|
||||
+369
-12
@@ -1,5 +1,19 @@
|
||||
/* SUB Control UI – Desktop Layout */
|
||||
|
||||
/* Isolated pointer lighting: no layout or input interception. */
|
||||
.controls-workspace { position:relative; isolation:isolate; }
|
||||
.workspace-light { position:absolute; inset:0; z-index:-1; pointer-events:none; border-radius:inherit; opacity:0; background:radial-gradient(600px circle at var(--light-x) var(--light-y),rgba(255,255,255,.35),transparent 75%); transition:opacity .35s ease-out; }
|
||||
.workspace-light.lit { opacity:1; }
|
||||
.light-surface { position:relative; }
|
||||
.cursor-light { position:absolute; inset:0; z-index:3; pointer-events:none; border-radius:inherit; }
|
||||
.cursor-light::before,.cursor-light::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; opacity:0; transition:opacity .35s ease-out; }
|
||||
.cursor-light.pill-light::before { background:radial-gradient(240px circle at var(--light-x) var(--light-y),rgba(110,190,235,.08),transparent 80%); }
|
||||
.cursor-light::after { padding:1px; background:linear-gradient(var(--light-angle,315deg),transparent 30%,rgba(255,255,255,.13) 65%,rgba(255,255,255,.50) 100%); -webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0); -webkit-mask-composite:xor; mask-composite:exclude; }
|
||||
.cursor-light.lit::before,.cursor-light.near::after { opacity:1; }
|
||||
.joystick > .cursor-light { inset:7%; border-radius:50%; }
|
||||
#engage > .cursor-light::after { display:none; }
|
||||
@media (prefers-reduced-motion:reduce) { .workspace-light,.cursor-light::before,.cursor-light::after { transition:none; } }
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0c1012;
|
||||
@@ -14,7 +28,7 @@
|
||||
--amber: #f2c675;
|
||||
--red: #f0958b;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||
font-family: Inter, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@@ -525,6 +539,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
margin-left: auto;
|
||||
}
|
||||
.logs-link:hover { color: #f1f4f2; }
|
||||
.activity-actions { display:flex; align-items:center; gap:12px; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
PRIMARY CONTROL ZONE (Workspace)
|
||||
@@ -540,6 +555,59 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Active MAVLink link test ── */
|
||||
.link-quality {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: 3px 0;
|
||||
color: #eef2f0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 9px;
|
||||
transition: background-color .18s ease;
|
||||
}
|
||||
.link-quality:hover,.link-quality:focus-visible { background:rgba(255,255,255,.055); outline:none; }
|
||||
.link-quality-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #667074;
|
||||
}
|
||||
.link-quality span:not(.link-quality-dot) { display:flex; flex-direction:column; gap:1px; min-width:0; }
|
||||
.link-quality small { color:#8d979a; font-size:10px; font-weight:400; }
|
||||
.link-quality strong { font-size:14px; font-weight:500; line-height:1.15; }
|
||||
.link-quality i {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
color: #aab2b4;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.link-quality i span { display:block; }
|
||||
.link-quality[data-grade="excellent"] .link-quality-dot { background:#bce96c; box-shadow:0 0 8px rgba(188,233,108,.35); }
|
||||
.link-quality[data-grade="good"] .link-quality-dot { background:#85c7e8; }
|
||||
.link-quality[data-grade="fair"] .link-quality-dot { background:#e7bf69; }
|
||||
.link-quality[data-grade="poor"] .link-quality-dot { background:#f07f78; box-shadow:0 0 8px rgba(240,127,120,.3); }
|
||||
.link-quality[data-grade="testing"] .link-quality-dot { background:#aab3b6; }
|
||||
.quality-modal { width:min(520px,calc(100vw - 32px)); }
|
||||
.heavy-test-panel { margin-top:18px; padding-top:14px; border-top:1px solid rgba(128,145,145,.2); }
|
||||
.heavy-quality-status { font-size:14px; font-weight:500; }
|
||||
.heavy-quality-results { display:grid; gap:6px; margin-top:12px; }
|
||||
.heavy-result-row { display:flex; justify-content:space-between; gap:12px; align-items:baseline; padding:7px 10px; border-radius:10px; background:rgba(120,140,140,.08); font-size:12px; }
|
||||
.heavy-result-row strong { flex:none; }
|
||||
.heavy-result-row span { text-align:right; color:var(--muted); }
|
||||
.primary-control-zone {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -688,7 +756,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
|
||||
background: #4e7140;
|
||||
border-color: #78ad5a;
|
||||
}
|
||||
.danger {
|
||||
.danger {
|
||||
--engage-progress: 0%;
|
||||
background: #ad3f38;
|
||||
color: #ffffff;
|
||||
@@ -879,7 +947,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
|
||||
border-radius: 24px;
|
||||
background: rgba(247, 248, 245, 0.96);
|
||||
color: #1a1e1f;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 30px 80px -12px rgba(0, 0, 0, 0.42),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.06),
|
||||
0 10px 24px -6px rgba(0, 0, 0, 0.16);
|
||||
@@ -1011,6 +1079,37 @@ body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.console-modal {
|
||||
width: min(1040px, calc(100vw - 64px));
|
||||
max-width: 1040px;
|
||||
}
|
||||
.console-split { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; min-width:0; }
|
||||
.console-split section { min-width:0; }
|
||||
.console-split header { display:flex; align-items:center; gap:7px; margin:0 2px 7px; }
|
||||
.console-split header i { width:7px; height:7px; border-radius:50%; flex:0 0 auto; }
|
||||
.console-split header .rx-dot { background:#79c8eb; }
|
||||
.console-split header .tx-dot { background:#bce96c; }
|
||||
.console-split header span { color:#303836; font-size:12px; font-weight:600; }
|
||||
.console-split header small { margin-left:auto; color:#7d8683; font-size:10px; }
|
||||
.console-split pre {
|
||||
height: min(55vh, 480px);
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
border: 1px solid #d9ddda;
|
||||
border-radius: 12px;
|
||||
background: #171b1d;
|
||||
color: #dce4e1;
|
||||
font: 11px/1.45 Consolas, "SFMono-Regular", monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.console-split { grid-template-columns:1fr; }
|
||||
.console-split pre { height:220px; }
|
||||
}
|
||||
|
||||
/* A pending or unknown armed state must stay legible, even without a link. */
|
||||
#engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -1039,7 +1138,7 @@ body #connect, body #connect:hover, body #connect.is-connected, body #connect.is
|
||||
}
|
||||
body #connect .btn-label { animation:none; }
|
||||
.telemetry-zone { gap:20px; }
|
||||
.rov-stage { height:clamp(180px,32vh,250px); min-height:180px; touch-action:none; }
|
||||
.rov-stage { flex:1; height:auto; min-height:0; width:100%; touch-action:none; border-radius:31px; overflow:hidden; background:transparent; }
|
||||
.model-card { justify-content:flex-start; }
|
||||
.rov-stage::before { display:none; }
|
||||
.model-tools button { font-size:12px; padding:7px 11px; }
|
||||
@@ -1052,24 +1151,25 @@ body #connect .btn-label { animation:none; }
|
||||
.activity-history li { font-size:12px; font-weight:300; line-height:1.25; color:#999; overflow-wrap:anywhere; }
|
||||
.activity-history #status-text { color:#fff; font-size:12px; font-weight:300; }
|
||||
.activity-history li[hidden] { display:none; }
|
||||
.controls-workspace { padding:8px 20px 16px; background:#f6f6f6; }
|
||||
.controls-workspace { padding:8px 20px 16px; background:#ededed; }
|
||||
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) 52px; }
|
||||
.engage-header-zone { display:block; position:relative; height:120px; min-height:0; margin:0; padding:0; }
|
||||
body #engage { width:calc(100% + 40px); margin-left:-20px; height:104px; padding:0 20px; border:0; border-radius:0; background:transparent; box-shadow:none; }
|
||||
body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
|
||||
#engage::before, #engage::after { display:none; }
|
||||
body #engage { align-items:flex-end; padding-bottom:12px; }
|
||||
.engage-face > span { font-family:"Segoe UI Variable Display","Segoe UI",Arial,sans-serif; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
|
||||
.engage-face > span { font-family:inherit; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
|
||||
#engage { --engage-green:#4cce73; --engage-glow:drop-shadow(0 0 2px rgba(76,206,115,.3)); }
|
||||
#engage .engage-label { color:#777; background:none; animation:none; }
|
||||
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; }
|
||||
#engage:is(.arm,.danger):not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-sheen-label { opacity:1; animation:engage-sweep 2.2s linear infinite; }
|
||||
@keyframes engage-sweep { from {mask-position:100% 0;-webkit-mask-position:100% 0} to {mask-position:0% 0;-webkit-mask-position:0% 0} }
|
||||
.engage-progress-label { color:#4cce73; clip-path:none; filter:drop-shadow(0 0 2px rgba(76,206,115,.3)); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
|
||||
.engage-progress-label { color:var(--engage-green); clip-path:none; filter:var(--engage-glow); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
|
||||
#engage.charged .engage-progress-label { mask-image:none; -webkit-mask-image:none; }
|
||||
#engage.pending .engage-label { color:#5a8753; background:none; }
|
||||
#engage.danger .engage-label { color:#171717; }
|
||||
#engage.pending .engage-label { color:var(--engage-green); filter:var(--engage-glow); background:none; }
|
||||
#engage.danger .engage-label { color:#777; }
|
||||
#engage:focus-visible { outline:2px solid #8d9991; outline-offset:-6px; }
|
||||
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:#f6f6f6; }
|
||||
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:transparent; }
|
||||
.control-status[hidden] { display:none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.engage-sheen-label { display:none; }
|
||||
@@ -1094,10 +1194,11 @@ body #engage { align-items:flex-end; padding-bottom:12px; }
|
||||
.heave-fill { left:22px; right:22px; background:#999; }
|
||||
.control-toolbar { display:flex; justify-content:center; align-items:center; gap:16px; padding-top:12px; }
|
||||
.control-toolbar .output-limit { width:auto; flex-direction:row; align-items:center; font-size:13px; gap:12px; color:#777; }
|
||||
.control-toolbar .output-limit span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
|
||||
.control-toolbar .output-limit > span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
|
||||
.control-toolbar .output-limit b { display:inline-block; flex:0 0 4ch; width:4ch; text-align:right; color:#333; font-variant-numeric:tabular-nums; }
|
||||
.control-toolbar .range-pill { width:175px; grid-template-columns:28px 1fr 28px; height:40px; background:#e7e7e7; border:0; }
|
||||
.control-toolbar .range-pill button { width:28px; height:40px; color:#666; font-size:22px; }
|
||||
.control-toolbar .range-pill button { width:28px; height:40px; padding:0; display:grid; place-items:center; color:#666; line-height:1; }
|
||||
.control-toolbar .range-pill button svg { width:24px; height:24px; display:block; }
|
||||
.control-toolbar .range-pill input[type="range"] { background:#c4c4c4; height:20px; background-clip:content-box; padding:8px 0; }
|
||||
.control-toolbar .range-pill input[type="range"]::-webkit-slider-thumb { width:18px; height:18px; background:#858585; }
|
||||
.control-toolbar .config-btn { width:auto; height:40px; padding:0 18px; font-size:13px; background:#e7e7e7; color:#444; border:0; border-radius:999px; }
|
||||
@@ -1115,3 +1216,259 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
|
||||
.telemetry-zone { gap:12px; }
|
||||
.activity-history { gap:4px; }
|
||||
}
|
||||
|
||||
/* Settings windows: readable fields, predictable spacing, one action hierarchy. */
|
||||
.modal {
|
||||
color-scheme: light;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid #dedfdf;
|
||||
border-radius: 20px;
|
||||
background: #f6f6f6;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,.24);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.modal[open] { animation: dialog-in .16s ease-out; }
|
||||
.modal.closing { animation: dialog-out .12s ease-in forwards; }
|
||||
.modal::backdrop { background: rgba(15,19,22,.4); backdrop-filter: blur(3px); }
|
||||
.modal > small { display:block; color:#73787b; font-size:12px; font-weight:400; margin:0 42px 6px 0; }
|
||||
.modal h2 { margin:0 40px 18px 0; font-size:28px; letter-spacing:-.025em; font-weight:400; }
|
||||
.modal h3 { margin:0 0 10px; font-size:14px; font-weight:500; color:#454b4e; }
|
||||
.modal .note { font-size:13px; line-height:1.5; margin:8px 0 0; color:#676e71; }
|
||||
.modal-close { top:16px; right:16px; width:36px; height:36px; display:grid; place-items:center; border-radius:50%; background:#e9ebec; color:#555c60; font-size:25px; line-height:1; }
|
||||
.modal-close:hover { background:#dfe3e5; }
|
||||
.settings-section { margin-top:22px; }
|
||||
.settings-section + .settings-section { border-top:1px solid #e0e3e4; padding-top:20px; }
|
||||
.modal .form-grid { gap:16px; margin-top:10px; }
|
||||
.modal .form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.modal label { min-width:0; font-size:13px; line-height:1.35; font-weight:400; color:#50585c; }
|
||||
.modal label > span { display:block; margin-bottom:7px; }
|
||||
.modal input,.modal select { width:100%; min-width:0; height:42px; padding:0 13px; border:1px solid #d7dcde; border-radius:999px; background:#fff; color:#272c2f; font-size:14px; font-weight:400; }
|
||||
.modal select { padding-right:28px; }
|
||||
.modal :is(button,input,select,summary,pre):focus-visible { outline:2px solid #6598b5; outline-offset:3px; }
|
||||
.modal .number-pill { height:42px; grid-template-columns:36px minmax(0,1fr) 36px; background:#fff; border:1px solid #d7dcde; }
|
||||
.modal .number-pill button { width:36px; height:40px; font-size:24px; color:#5b666b; }
|
||||
.modal .number-pill input { border:0; padding:0; height:40px; appearance:textfield; -moz-appearance:textfield; }
|
||||
.modal .number-pill input::-webkit-inner-spin-button { appearance:none; }
|
||||
.modal .number-pill:focus-within { border-color:#6598b5; }
|
||||
.custom-port-label { display:block; margin-top:16px; }
|
||||
.custom-port-row { display:flex; gap:8px; }
|
||||
.custom-port-row input { flex:1; }
|
||||
.modal .button { min-height:42px; height:auto; padding:10px 18px; font-size:14px; font-weight:400; border-radius:999px; white-space:normal; }
|
||||
.modal .button.subtle { background:#e9ebec; border:0; color:#424b50; }
|
||||
.modal .modal-primary { background:#333e44; color:#fff; border:0; }
|
||||
.modal .modal-primary:hover { background:#455660; }
|
||||
.modal .modal-actions { margin-top:24px; gap:10px; align-items:center; }
|
||||
.modal .form-feedback { margin:16px 0 0; padding:10px 12px; border-radius:12px; font-size:13px; line-height:1.5; color:#9b392e; background:#f5e6e3; }
|
||||
.modal [hidden] { display:none !important; }
|
||||
.modal [data-busy="true"] { cursor:progress; }
|
||||
.motor-modal { width:min(650px,calc(100vw - 32px)); }
|
||||
.modal .motor-head,.modal .motor-row { grid-template-columns:78px minmax(100px,1fr) 94px 86px; font-size:13px; gap:10px; padding:10px 0; }
|
||||
.modal .motor-head { margin-top:18px; color:#727b80; font-size:12px; font-weight:400; }
|
||||
.motor-row strong { font-size:12px; font-weight:500; }
|
||||
.motor-row > span { font-size:12px; color:#727b80; }
|
||||
.modal .motor-row .button { min-height:36px; font-size:12px; padding:8px 12px; }
|
||||
.motor-other { margin-top:16px; }
|
||||
.motor-other summary { cursor:pointer; padding:12px 0; color:#697278; font-size:13px; }
|
||||
.quality-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin-top:18px; }
|
||||
.quality-summary div { border-radius:14px; background:#e9ebec; padding:14px 12px; min-width:0; }
|
||||
.quality-summary span { display:block; font-size:12px; color:#697278; margin-bottom:8px; }
|
||||
.quality-summary strong { font-size:20px; font-weight:400; font-variant-numeric:tabular-nums; }
|
||||
.modal .heavy-quality-status { padding:12px; background:#e9ebec; border-radius:12px; font-size:14px; margin-top:14px; }
|
||||
.modal .heavy-result-row { font-size:13px; padding:10px 12px; font-variant-numeric:tabular-nums; }
|
||||
.console-modal { width:min(1020px,calc(100vw - 32px)); }
|
||||
.console-toolbar { display:flex; justify-content:space-between; align-items:center; gap:12px; }
|
||||
.console-toolbar .note { margin:0; }
|
||||
.modal .console-split header small { font-size:12px; }
|
||||
.modal .console-split pre { height:clamp(180px,48vh,430px); padding:14px; font-size:12px; line-height:1.55; border-radius:12px; }
|
||||
@keyframes dialog-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
|
||||
@keyframes dialog-out { to { opacity:0; transform:translateY(5px); } }
|
||||
@media (max-width:520px) {
|
||||
.modal { padding:22px 18px; }
|
||||
.modal .form-grid.two { grid-template-columns:1fr; }
|
||||
.modal .motor-head,.modal .motor-row { grid-template-columns:56px minmax(80px,1fr) 74px 66px; gap:5px; font-size:12px; }
|
||||
.modal .motor-row .button { padding:8px 6px; }
|
||||
.quality-summary { gap:6px; }
|
||||
.quality-summary div { padding:12px 8px; }
|
||||
.quality-summary strong { font-size:17px; }
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce) {
|
||||
.modal[open],.modal.closing { animation:none; }
|
||||
}
|
||||
|
||||
/* Input selection stays separate from vehicle arming and command ownership. */
|
||||
#engage:focus,#engage:focus-visible { outline:none!important; box-shadow:none!important; }
|
||||
.controls-workspace { container-type:inline-size; }
|
||||
.input-mode-controls { display:flex; align-items:center; gap:6px; flex-shrink:0; -webkit-app-region:no-drag; }
|
||||
.input-mode-switch { display:flex; padding:3px; gap:2px; background:#dedede; border-radius:999px; }
|
||||
.input-mode-switch button,.controller-settings { border:0; border-radius:999px; background:transparent; color:#686868; padding:7px 11px; font-size:12px; cursor:pointer; }
|
||||
.input-mode-switch button[aria-pressed="true"] { background:#fafafa; color:#252525; box-shadow:0 1px 3px #0000000c; }
|
||||
.controller-settings { background:#dedede; }
|
||||
.controller-status { margin:0; height:24px; flex-shrink:0; font-size:12px; line-height:20px; color:#6d7478; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
body[data-input-mode="joystick"] .key-hint { visibility:hidden; }
|
||||
body[data-input-mode="joystick"] :is(#joystick-canvas,#heave) { pointer-events:none; cursor:default; }
|
||||
.controls-grid { --joy-size:min(52vh,calc(100cqw - 136px),460px); }
|
||||
.control-toolbar { flex-wrap:wrap; gap:8px 14px; }
|
||||
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) auto; }
|
||||
.control-toolbar { min-height:52px; }
|
||||
.engage-face > span { font-size:clamp(52px,11cqw,90px); }
|
||||
.controller-mapping-head,.controller-mapping-row { display:grid; grid-template-columns:1fr 1.25fr 50px; gap:14px; align-items:center; }
|
||||
.controller-mapping-head { color:#777; font-size:12px; margin-bottom:8px; }
|
||||
.controller-mapping-row { margin:8px 0; }
|
||||
.modal .controller-mapping-row input[type="checkbox"] { width:22px; height:22px; min-height:0; margin:auto; padding:0; accent-color:#527f9b; }
|
||||
.controller-mapping-row label { display:flex; align-items:center; }
|
||||
#controller-axes { min-height:22px; margin:12px 0; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; color:#65737d; font-size:12px; }
|
||||
#controller-form > label { margin-top:18px; }
|
||||
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
|
||||
/* Read-only local console, not a modal: driving stays available while it is open. */
|
||||
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr) 340px; }
|
||||
.console-dock[hidden] { display:none!important; }
|
||||
.console-dock { display:flex; flex-direction:column; min-width:0; min-height:0; padding:18px 16px; background:#f5f5f5; border-left:1px solid #d8dcde; color:#29343b; color-scheme:light; }
|
||||
.console-dock-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:14px; }
|
||||
.console-dock-header small { font-size:11px; color:#81888d; }
|
||||
.console-dock-header h2 { font-size:22px; font-weight:400; margin:4px 0 0; letter-spacing:-.02em; }
|
||||
.console-close { width:38px; height:38px; flex-shrink:0; border:0; border-radius:999px; background:#e7e8e9; color:#48525a; font-size:25px; cursor:pointer; }
|
||||
.console-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.console-toolbar label { flex:1; min-width:0; }
|
||||
.console-toolbar select,.console-toolbar button { height:34px; border:0; border-radius:999px; background:#e7e9ea; color:#42525e; font-size:12px; padding:0 12px; }
|
||||
.console-toolbar select { width:100%; }
|
||||
.console-toolbar button { cursor:pointer; }
|
||||
.console-state { min-height:28px; margin:10px 0 8px; font-size:11px; color:#7e868b; line-height:1.4; }
|
||||
.console-streams { display:grid; grid-template-rows:1fr 1fr; gap:14px; min-height:0; flex:1; }
|
||||
.console-side { display:flex; flex-direction:column; min-height:0; }
|
||||
.console-side > header { display:flex; align-items:center; gap:6px; padding:0 4px 8px; font-size:12px; font-weight:500; color:#435966; }
|
||||
.console-side > header small { margin-left:auto; font-size:11px; font-weight:300; color:#899198; }
|
||||
.console-side > header i { width:6px; height:6px; border-radius:50%; background:#5888a3; }
|
||||
.console-side > header .tx-dot { background:#659287; }
|
||||
.console-output { min-height:0; flex:1; overflow:auto; overscroll-behavior:contain; padding:0 8px; border:1px solid #e0e3e5; border-radius:12px; background:#fafbfc; color:#343d43; font:12px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif; }
|
||||
.console-row { border-bottom:1px solid #e9edef; }
|
||||
.console-row:last-child { border-bottom:0; }
|
||||
.console-row summary { display:flex; gap:8px; cursor:pointer; padding:10px 0; list-style:none; }
|
||||
.console-row summary::-webkit-details-marker { display:none; }
|
||||
.console-row time { flex-shrink:0; color:#929ba1; font:10px/1.8 ui-monospace,Consolas,monospace; font-variant-numeric:tabular-nums; }
|
||||
.console-message { min-width:0; display:block; overflow-wrap:anywhere; }
|
||||
.console-message strong { display:block; font-size:11px; font-weight:500; color:#517c97; letter-spacing:.025em; }
|
||||
.console-row[data-tone="command"] strong { color:#467a69; }
|
||||
.console-row[data-tone="warning"] strong { color:#aa664b; }
|
||||
.console-fields { display:flex; flex-wrap:wrap; gap:2px 8px; font-size:11px; }
|
||||
.console-fields i { color:#929a9f; font-style:normal; }
|
||||
.console-fields b { font-weight:400; color:#45535d; }
|
||||
.console-row pre { margin:0 0 10px; padding:10px; border-radius:8px; background:#eef1f3; color:#52616c; font:11px/1.5 ui-monospace,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.console-empty { color:#959da3; font-size:12px; line-height:1.6; margin:14px 0; }
|
||||
@media (max-height:610px) { .primary-control-zone { grid-template-rows:100px minmax(0,1fr) auto; } .controls-grid { --joy-size:min(46vh,calc(100cqw - 136px),420px); } }
|
||||
@media (max-width:1000px) {
|
||||
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr); }
|
||||
.console-dock { position:absolute; top:0; right:0; bottom:0; width:min(340px,calc(100vw - 32px)); z-index:12; box-shadow:-8px 0 24px #00000012; border-radius:12px 0 0 12px; }
|
||||
}
|
||||
|
||||
/* Three shared sizes: compact 32px, standard 40px, primary 62px. */
|
||||
:root {
|
||||
--pill-sm:32px; --pill-md:40px; --pill-lg:62px; --pill-radius:999px;
|
||||
--battery-charge:#63cb85; --battery-low:#e98a7b;
|
||||
}
|
||||
.port-row select,.icon-button { height:var(--pill-sm); }
|
||||
.icon-button { width:var(--pill-sm); }
|
||||
body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); }
|
||||
.input-mode-switch { height:var(--pill-md); padding:4px; }
|
||||
.input-mode-switch button { height:var(--pill-sm); padding:0 12px; }
|
||||
.controller-settings { height:var(--pill-md); padding:0 14px; }
|
||||
.input-mode-switch button:disabled { opacity:.65!important; }
|
||||
.input-mode-switch button[aria-pressed="true"]:disabled { opacity:1!important; }
|
||||
.control-toolbar .range-pill,.control-toolbar .config-btn { height:var(--pill-md); border-radius:var(--pill-radius); }
|
||||
.control-toolbar .range-pill button { height:var(--pill-md); }
|
||||
.window-actions button { width:var(--pill-md); height:var(--pill-md); }
|
||||
.console-toolbar select,.console-toolbar button { height:var(--pill-sm); border-radius:var(--pill-radius); }
|
||||
.modal input,.modal select,.modal .number-pill { height:var(--pill-md); border-radius:var(--pill-radius); }
|
||||
.modal .number-pill input,.modal .number-pill button { height:calc(var(--pill-md) - 2px); }
|
||||
.modal .button { min-height:var(--pill-md); padding:8px 18px; border-radius:var(--pill-radius); }
|
||||
.modal .motor-row .button { min-height:var(--pill-sm); padding:6px 12px; }
|
||||
.modal .controller-mapping-row input[type="checkbox"] { height:22px; border-radius:4px; }
|
||||
|
||||
/* Fixed SVG geometry avoids font baselines shifting the close icon. */
|
||||
.modal-close,.modal-close:hover,.console-close,.console-close:hover,.window-actions .window-close,.window-actions .window-close:hover {
|
||||
display:grid; place-items:center; width:var(--pill-md); height:var(--pill-md);
|
||||
padding:0; border:0; border-radius:0; background:transparent; box-shadow:none;
|
||||
}
|
||||
.modal-close svg,.console-close svg { display:block; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; }
|
||||
.modal-close:hover,.console-close:hover { color:#222; }
|
||||
.heave-knob { height:var(--pill-md); background:linear-gradient(145deg,#737373,#454545); }
|
||||
|
||||
/* Activity has a heading, compact event history, then explicit actions. */
|
||||
.telemetry-zone { gap:16px; }
|
||||
.deck-status-bar { flex-shrink:0; margin-top:0; padding-top:12px; }
|
||||
.activity-heading { display:block; margin:0 0 8px; color:#eee; font-size:14px; font-weight:650; }
|
||||
.activity-history { gap:4px; margin:0 0 10px; }
|
||||
.activity-actions { display:flex; gap:8px; }
|
||||
.activity-actions .logs-link {
|
||||
display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box;
|
||||
height:var(--pill-sm); min-width:76px; padding:0 14px; margin:0;
|
||||
border:0; border-radius:var(--pill-radius); background:#414141; color:#eee;
|
||||
font-size:12px; font-weight:400; letter-spacing:0; line-height:1; cursor:pointer;
|
||||
}
|
||||
.activity-actions .logs-link:hover { background:#4b4b4b; }
|
||||
.activity-actions .logs-link:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
|
||||
.activity-actions .logs-link[aria-expanded="true"] { background:#555; }
|
||||
|
||||
/* Battery is the final deck item: text floats above the charge fill. */
|
||||
.battery-card {
|
||||
position:relative; display:block; flex-shrink:0; width:100%; height:var(--pill-lg);
|
||||
min-height:var(--pill-lg); margin:0; padding:0; overflow:hidden;
|
||||
border:0; border-radius:var(--pill-radius); background:#314b3a;
|
||||
color:#f2f5f2; isolation:isolate;
|
||||
}
|
||||
.battery-meter { position:absolute; inset:0; z-index:0; overflow:hidden; border-radius:inherit; pointer-events:none; }
|
||||
.battery-meter #battery-fill { display:block; height:100%; width:0; border-radius:inherit; background:var(--battery-charge); opacity:.55; transition:width .35s ease,background .2s ease,opacity .18s ease-out; }
|
||||
.battery-card .battery-copy { position:absolute; inset:0; z-index:1; display:flex; flex-direction:row; align-items:center; justify-content:space-between; gap:8px; padding:0 18px; pointer-events:none; }
|
||||
.battery-summary { display:flex; flex-direction:column; gap:3px; flex-shrink:0; }
|
||||
.battery-card small { color:#d7e8dc; font-size:12px; line-height:1; font-variant-numeric:tabular-nums; }
|
||||
.battery-card strong { font-size:26px; font-weight:400; line-height:1.15; font-variant-numeric:tabular-nums; }
|
||||
.battery-card i { align-self:auto; color:#d9e0da; text-align:right; font-size:12px; line-height:1.35; font-style:normal; }
|
||||
.battery-card[data-low="true"] { background:#4a3734; }
|
||||
.battery-card[data-stale="true"] #battery-fill { opacity:.25; }
|
||||
.battery-card:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
|
||||
|
||||
/* The same slender, rounded scrollbar is used by every scrollable panel. */
|
||||
*::-webkit-scrollbar { width:8px; height:8px; }
|
||||
*::-webkit-scrollbar-track,*::-webkit-scrollbar-corner { background:transparent; }
|
||||
*::-webkit-scrollbar-thumb { min-height:28px; border:2px solid transparent; border-radius:var(--pill-radius); background:#aeb6ba; background-clip:padding-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background:#87959e; background-clip:padding-box; }
|
||||
*::-webkit-scrollbar-button { display:none; width:0; height:0; }
|
||||
.sidebar-deck::-webkit-scrollbar-thumb { background:#666; background-clip:padding-box; }
|
||||
.modal { scrollbar-gutter:stable; }
|
||||
@supports not selector(::-webkit-scrollbar) { * { scrollbar-width:thin; scrollbar-color:#aeb6ba transparent; } }
|
||||
@media (max-height:610px) { .telemetry-zone { gap:12px; } }
|
||||
|
||||
/* Shared panel geometry and quiet, presentational motion. Never ease command data. */
|
||||
:root { --panel-inset:20px; --ui-gap:12px; --ui-duration:180ms; --ui-ease:cubic-bezier(.2,0,.2,1); }
|
||||
.sidebar-deck,.controls-workspace,.console-dock { padding:var(--panel-inset); }
|
||||
.sidebar-deck,.telemetry-zone,.deck-connection,.port-row,.activity-actions,.input-mode-controls,.console-toolbar,.console-streams { gap:var(--ui-gap); }
|
||||
.deck-connection { padding-bottom:var(--ui-gap); }
|
||||
.workspace-topbar,.console-dock-header { min-height:var(--pill-md); height:var(--pill-md); margin-bottom:var(--ui-gap); }
|
||||
.port-row select,.port-row .icon-button { height:var(--pill-md); }
|
||||
.port-row .icon-button { width:var(--pill-md); }
|
||||
.activity-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.activity-actions .logs-link { min-width:0; width:100%; }
|
||||
.console-dock-header h2 { margin:0; }
|
||||
.console-toolbar { margin-bottom:var(--ui-gap); }
|
||||
.console-state { min-height:0; margin:0 0 var(--ui-gap); font-size:12px; }
|
||||
.console-side > header { padding-left:0; padding-right:0; }
|
||||
.console-side > header i { background:#b4bdc2; transition:background-color var(--ui-duration) ease-out,box-shadow var(--ui-duration) ease-out; }
|
||||
.console-side > header .rx-dot.lit { background:#4fa4cf; box-shadow:0 0 5px #4fa4cf66; }
|
||||
.console-side > header .tx-dot.lit { background:#56ae8a; box-shadow:0 0 5px #56ae8a66; }
|
||||
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::before,
|
||||
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::after,
|
||||
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card) {
|
||||
transition-property:background-color,border-color,color,opacity,box-shadow,filter;
|
||||
transition-duration:var(--ui-duration); transition-timing-function:var(--ui-ease);
|
||||
}
|
||||
/* Keep pointer positions immediate; the existing conditioning owns motor easing. */
|
||||
.modal[open],.console-dock:not([hidden]),.form-feedback:not([hidden]),.console-state:not([hidden]),.console-row pre { animation:ui-fade-in var(--ui-duration) var(--ui-ease); }
|
||||
.modal.closing,.console-dock.closing { animation:ui-fade-out var(--ui-duration) ease-out forwards; pointer-events:none; }
|
||||
@keyframes ui-fade-in { from { opacity:0; } to { opacity:1; } }
|
||||
@keyframes ui-fade-out { from { opacity:1; } to { opacity:0; } }
|
||||
.controller-status { transition:opacity var(--ui-duration) ease-out; }
|
||||
.controller-status[data-quiet="true"] { opacity:0; pointer-events:none; }
|
||||
|
||||
@@ -40,12 +40,15 @@ class ControlApi:
|
||||
"app_state": "disconnected",
|
||||
"status": "Ready. Select a USB port to begin.",
|
||||
"battery": None,
|
||||
"link_quality": None,
|
||||
"heavy_quality": None,
|
||||
"log_path": None,
|
||||
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
|
||||
}
|
||||
self.servo_functions: dict[int, int] = {}
|
||||
self.motor_directions: dict[int, int] = {}
|
||||
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
|
||||
self.link_path = Path(__file__).resolve().parent / "link_settings.json"
|
||||
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
|
||||
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
|
||||
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
|
||||
@@ -62,11 +65,22 @@ class ControlApi:
|
||||
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
self.link_config = {
|
||||
"baud": 57600, "control_hz": 20,
|
||||
"ping_hz": 1, "system_hz": 1, "servo_hz": 2,
|
||||
"rc_hz": 2, "esc_hz": 1,
|
||||
}
|
||||
try:
|
||||
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
self._apply_link_config()
|
||||
|
||||
def initial_state(self):
|
||||
return {
|
||||
"ports": self.refresh_ports(),
|
||||
"battery_config": self.battery_config,
|
||||
"link_config": self.link_config,
|
||||
"state": self._snapshot(),
|
||||
"model_info": self.get_saved_model_info(),
|
||||
}
|
||||
@@ -233,6 +247,9 @@ class ControlApi:
|
||||
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},
|
||||
)
|
||||
@@ -268,15 +285,26 @@ class ControlApi:
|
||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||
|
||||
def disconnect(self):
|
||||
if self.worker.heavy_quality_test is not None:
|
||||
self.worker.cancel_heavy_quality_test()
|
||||
if self.demo_mode:
|
||||
self.demo_mode = False
|
||||
with self.lock:
|
||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||
armed=False, mode=None, battery=None, status="Demo mode disconnected")
|
||||
armed=False, mode=None, battery=None, link_quality=None,
|
||||
status="Demo mode disconnected")
|
||||
return True
|
||||
self.worker.disconnect()
|
||||
return True
|
||||
|
||||
def start_heavy_quality_test(self):
|
||||
if self.demo_mode:
|
||||
raise RuntimeError("The heavy link test requires a real flight-controller connection")
|
||||
return self.worker.start_heavy_quality_test()
|
||||
|
||||
def cancel_heavy_quality_test(self):
|
||||
return self.worker.cancel_heavy_quality_test()
|
||||
|
||||
def set_axes(self, forward, turn, vertical, sequence=None):
|
||||
if self.demo_mode:
|
||||
with self.lock:
|
||||
@@ -389,8 +417,33 @@ class ControlApi:
|
||||
raise ValueError("Full voltage per cell must be greater than empty voltage")
|
||||
if not 0 <= clean["warning_percent"] <= 100:
|
||||
raise ValueError("Warning percentage must be between 0 and 100")
|
||||
self.battery_config = clean
|
||||
self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||
self.battery_config = clean
|
||||
return clean
|
||||
|
||||
def _apply_link_config(self):
|
||||
self.worker.rate_hz = self.link_config["control_hz"]
|
||||
self.worker.set_link_rates(self.link_config["ping_hz"], {
|
||||
"SYS_STATUS": self.link_config["system_hz"],
|
||||
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
|
||||
"RC_CHANNELS": self.link_config["rc_hz"],
|
||||
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
|
||||
})
|
||||
|
||||
def save_link_settings(self, config):
|
||||
allowed = {0, 1, 2, 5, 10}
|
||||
clean = {key: int(config.get(key, value)) for key, value in self.link_config.items()}
|
||||
if clean["baud"] not in {9600, 19200, 38400, 57600, 115200}:
|
||||
raise ValueError("Choose a supported baud rate")
|
||||
if clean["control_hz"] not in {10, 20}:
|
||||
raise ValueError("Control rate must be 10 or 20 Hz")
|
||||
if clean["ping_hz"] not in {1, 2, 5, 10}:
|
||||
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
|
||||
if any(clean[key] not in allowed for key in ("system_hz", "servo_hz", "rc_hz", "esc_hz")):
|
||||
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
|
||||
self.link_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||
self.link_config = clean
|
||||
self._apply_link_config()
|
||||
return clean
|
||||
|
||||
def open_logs(self):
|
||||
@@ -399,6 +452,10 @@ class ControlApi:
|
||||
os.startfile(path)
|
||||
return True
|
||||
|
||||
def read_console(self, limit=150):
|
||||
"""Return a bounded, read-only view of the local session console."""
|
||||
return self.worker.read_console(limit)
|
||||
|
||||
def poll(self):
|
||||
# Bound a poll even if telemetry arrives continuously.
|
||||
with self._poll_lock:
|
||||
@@ -468,6 +525,10 @@ class ControlApi:
|
||||
"voltage": voltage, "per_cell": per_cell, "percent": percent,
|
||||
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
|
||||
}
|
||||
elif kind == "link_quality":
|
||||
self.state["link_quality"] = event[1]
|
||||
elif kind == "heavy_quality":
|
||||
self.state["heavy_quality"] = event[1]
|
||||
elif kind == "servo_param":
|
||||
_, param_id, value = event
|
||||
servo = int(param_id[5:param_id.index("_")])
|
||||
@@ -490,7 +551,7 @@ class ControlApi:
|
||||
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",
|
||||
mode=None, status=status_msg)
|
||||
mode=None, link_quality=None, status=status_msg)
|
||||
self.state["last_error"] = None
|
||||
|
||||
def _update_servo(self, servo):
|
||||
@@ -585,6 +646,12 @@ def start_local_server(directory: Path) -> int:
|
||||
|
||||
|
||||
def main():
|
||||
if os.name == "nt":
|
||||
# Keep the running window separate from generic Python taskbar groups.
|
||||
import ctypes
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("SUBControl.Desktop")
|
||||
from desktop_shortcut import ensure_desktop_shortcut
|
||||
ensure_desktop_shortcut()
|
||||
api = ControlApi()
|
||||
api._migrate_legacy_model()
|
||||
project_root = Path(__file__).resolve().parent
|
||||
@@ -602,7 +669,8 @@ def main():
|
||||
window.events.maximized += lambda: setattr(api, "maximized", True)
|
||||
window.events.restored += lambda: setattr(api, "maximized", False)
|
||||
try:
|
||||
webview.start(debug=True)
|
||||
icon_path = project_root / "assets" / "sub-control.ico"
|
||||
webview.start(debug=True, icon=str(icon_path) if icon_path.is_file() else None)
|
||||
finally:
|
||||
api.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user