Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e91c28f1e | |||
| a4d8f1b7af | |||
| 31f720a316 | |||
| 0093f3f4aa | |||
| 7c85397af0 | |||
| 73c20a0ff2 | |||
| 84ec93543b | |||
| 25bbefda71 |
@@ -3,4 +3,6 @@ __pycache__/
|
||||
*.py[cod]
|
||||
logs/
|
||||
model_settings.json
|
||||
link_settings.json
|
||||
runtime/
|
||||
motor_settings.json
|
||||
|
||||
@@ -1,173 +1,38 @@
|
||||
# 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.
|
||||
|
||||
## UI design rule
|
||||
|
||||
Keep UI changes minimal and consistent with the existing app. Do not add explanatory text. Use labels of at most two words, prefer flat rows over nested containers, and keep controls from overlapping.
|
||||
|
||||
## 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/
|
||||
|
||||
+29
-164
@@ -1,173 +1,38 @@
|
||||
# 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.
|
||||
|
||||
## UI design rule
|
||||
|
||||
Keep UI changes minimal and consistent with the existing app. Do not add explanatory text. Use labels of at most two words, prefer flat rows over nested containers, and keep controls from overlapping.
|
||||
|
||||
## 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.
|
||||
|
||||
+600
-39
@@ -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()
|
||||
@@ -80,6 +87,8 @@ class MavlinkWorker:
|
||||
# At 20 Hz this produces five small steps from neutral to full command.
|
||||
self.ramp_seconds = 0.25
|
||||
self.gain = 0.20
|
||||
self.independent_motor_scaling = False
|
||||
self.motor_gains = {motor: 20 for motor in range(1, 13)}
|
||||
self.rate_hz = 20
|
||||
self.target_system = 0
|
||||
self.target_component = 0
|
||||
@@ -87,15 +96,51 @@ 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, "BATTERY_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
|
||||
self.param_retries = 0
|
||||
self.param_types = {}
|
||||
self.pending_sets = {}
|
||||
self.motor_test_lock = threading.Lock()
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
self.motor_test_acknowledged = False
|
||||
self.motor_test_last_motor = None
|
||||
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 +154,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 +186,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 +198,34 @@ 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:
|
||||
self._send_control()
|
||||
next_control = now + (1.0 / max(1, self.rate_hz))
|
||||
# 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.
|
||||
motor_test_mode = self._tick_motor_test(now)
|
||||
if self.target_system and self.armed and now >= next_control:
|
||||
if not motor_test_mode:
|
||||
self._send_control()
|
||||
# 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 +236,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 +248,23 @@ 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
|
||||
with self.motor_test_lock:
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
self.motor_test_last_motor = None
|
||||
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 +274,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 +350,17 @@ class MavlinkWorker:
|
||||
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
|
||||
self.armed = armed
|
||||
self.config_paused = armed
|
||||
if not armed:
|
||||
with self.motor_test_lock:
|
||||
was_testing = self.motor_test_mode
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
if was_testing:
|
||||
self.events.put(("motor_test_stopped", "Vehicle disarmed"))
|
||||
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 +369,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,16 +378,62 @@ 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_DO_MOTOR_TEST:
|
||||
self.log("RX_MOTOR_TEST_ACK", result_name)
|
||||
if result_name in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"):
|
||||
if not self.motor_test_acknowledged:
|
||||
self.motor_test_acknowledged = True
|
||||
self.events.put(("motor_test_ack", result_name))
|
||||
else:
|
||||
with self.motor_test_lock:
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
self.events.put(("motor_test_rejected", 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:
|
||||
self.events.put(("battery", voltage_mv / 1000.0))
|
||||
current_ca = int(msg.current_battery)
|
||||
remaining = int(msg.battery_remaining)
|
||||
self.events.put(("battery", {
|
||||
"voltage": voltage_mv / 1000.0 if 0 < voltage_mv < 65535 else None,
|
||||
"current_a": current_ca / 100.0 if current_ca >= 0 else None,
|
||||
"remaining_percent": remaining if 0 <= remaining <= 100 else None,
|
||||
}))
|
||||
elif msg_type == "BATTERY_STATUS":
|
||||
voltages = [int(value) for value in getattr(msg, "voltages", [])]
|
||||
valid_voltages = [value for value in voltages if 0 < value < 65535]
|
||||
voltage = sum(valid_voltages) / 1000.0 if valid_voltages else None
|
||||
current_ca = int(msg.current_battery)
|
||||
consumed = int(msg.current_consumed)
|
||||
remaining = int(msg.battery_remaining)
|
||||
self.events.put(("battery", {
|
||||
"voltage": voltage,
|
||||
"current_a": current_ca / 100.0 if current_ca >= 0 else None,
|
||||
"consumed_mah": consumed if consumed >= 0 else None,
|
||||
"remaining_percent": remaining if 0 <= remaining <= 100 else None,
|
||||
}))
|
||||
elif msg_type == "SERVO_OUTPUT_RAW":
|
||||
base = int(getattr(msg, "port", 0)) * 8 + 1
|
||||
outputs = {}
|
||||
for index in range(1, 9):
|
||||
raw = int(getattr(msg, f"servo{index}_raw", 0))
|
||||
outputs[str(base + index - 1)] = raw if 0 < raw < 65535 else None
|
||||
self.events.put(("servo_output", outputs))
|
||||
elif msg_type == "PARAM_VALUE":
|
||||
param_id = msg.param_id
|
||||
if isinstance(param_id, bytes):
|
||||
@@ -264,6 +456,299 @@ 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 _send_motor_test(self, motor, throttle_percent):
|
||||
if not self.connection or not self.target_system:
|
||||
raise RuntimeError("No autopilot heartbeat")
|
||||
self.log("TX_MOTOR_TEST", f"motor={motor} throttle={throttle_percent}%")
|
||||
self._send_mav(self.connection, "command_int_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_FRAME_GLOBAL,
|
||||
mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, 0, 0,
|
||||
float(motor), 0.0, float(throttle_percent), 1.0, 1, 0, 0,
|
||||
)
|
||||
|
||||
def start_motor_test(self, motor, throttle_percent):
|
||||
motor, throttle_percent = int(motor), int(throttle_percent)
|
||||
if not 1 <= motor <= 12 or not 0 <= throttle_percent <= 100:
|
||||
raise ValueError("Choose a motor and a test throttle from 0% to 100%")
|
||||
if not self.connection or not self.target_system:
|
||||
raise RuntimeError("Connect to the vehicle before testing a motor")
|
||||
if not self.armed or time.monotonic() - self.last_heartbeat > 2.0:
|
||||
raise RuntimeError("Arm the vehicle and confirm fresh telemetry before motor testing")
|
||||
with self.motor_test_lock:
|
||||
if self.motor_test_mode:
|
||||
raise RuntimeError("Finish the current motor test before starting another")
|
||||
self.motor_test_request = {"motor": motor, "throttle": throttle_percent, "next_at": 0.0}
|
||||
self.motor_test_last_motor = motor
|
||||
self.motor_test_mode = True
|
||||
self.motor_test_acknowledged = False
|
||||
self.set_axes(Axes())
|
||||
self.log("OPERATOR", f"held motor test for motor {motor} at {throttle_percent}%")
|
||||
return True
|
||||
|
||||
def stop_motor_test(self):
|
||||
with self.motor_test_lock:
|
||||
request = self.motor_test_request
|
||||
if request is not None:
|
||||
self.motor_test_last_motor = request["motor"]
|
||||
motor = self.motor_test_last_motor
|
||||
was_testing = self.motor_test_mode
|
||||
self.motor_test_request = None
|
||||
if was_testing and motor and self.connection and self.target_system:
|
||||
self._send_motor_test(motor, 0)
|
||||
return True
|
||||
|
||||
def _tick_motor_test(self, now):
|
||||
motor = throttle = None
|
||||
with self.motor_test_lock:
|
||||
request = self.motor_test_request
|
||||
active = self.motor_test_mode
|
||||
if request is not None and not self.armed:
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
active = False
|
||||
self.events.put(("motor_test_stopped", "Vehicle disarmed"))
|
||||
elif request is not None and now >= request["next_at"]:
|
||||
request["next_at"] = now + 0.25
|
||||
motor, throttle = request["motor"], request["throttle"]
|
||||
else:
|
||||
motor = throttle = None
|
||||
if motor is not None:
|
||||
self._send_motor_test(motor, throttle)
|
||||
return active
|
||||
|
||||
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 +769,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 +835,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 +849,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,
|
||||
)
|
||||
@@ -365,6 +871,8 @@ class MavlinkWorker:
|
||||
active = not (force_neutral or expired)
|
||||
target = self.axes if active else Axes()
|
||||
gain = self.gain
|
||||
independent_motor_scaling = self.independent_motor_scaling
|
||||
motor_gains = dict(self.motor_gains)
|
||||
if force_neutral:
|
||||
self.output_axes = Axes()
|
||||
else:
|
||||
@@ -387,16 +895,31 @@ class MavlinkWorker:
|
||||
)
|
||||
limited_forward = axes.forward / horizontal_mix_peak
|
||||
limited_yaw = axes.yaw / horizontal_mix_peak
|
||||
x = round(limited_forward * gain * 1000)
|
||||
y = round(axes.lateral * gain * 1000)
|
||||
r = round(limited_yaw * gain * 1000)
|
||||
z = round(NEUTRAL_Z + axes.vertical * gain * 500)
|
||||
if independent_motor_scaling:
|
||||
# The desktop controller's SimpleROV-3 profile maps motors 1/2
|
||||
# to surge/turn and motor 3 to vertical thrust. Convert the two
|
||||
# independently scaled horizontal demands back into MANUAL_CONTROL
|
||||
# axes so ArduSub's configured mixer remains in charge.
|
||||
motor_1 = (limited_forward + limited_yaw) * motor_gains.get(1, 100) / 100.0
|
||||
motor_2 = (limited_forward - limited_yaw) * motor_gains.get(2, 100) / 100.0
|
||||
limited_forward = (motor_1 + motor_2) / 2.0
|
||||
limited_yaw = (motor_1 - motor_2) / 2.0
|
||||
vertical_gain = motor_gains.get(3, 100) / 100.0
|
||||
x = round(limited_forward * 1000)
|
||||
y = round(axes.lateral * 1000)
|
||||
r = round(limited_yaw * 1000)
|
||||
z = round(NEUTRAL_Z + axes.vertical * vertical_gain * 500)
|
||||
else:
|
||||
x = round(limited_forward * gain * 1000)
|
||||
y = round(axes.lateral * gain * 1000)
|
||||
r = round(limited_yaw * gain * 1000)
|
||||
z = round(NEUTRAL_Z + axes.vertical * gain * 500)
|
||||
self.log(
|
||||
"TX_MANUAL_CONTROL",
|
||||
f"x={x} y={y} z={z} r={r} active={active} "
|
||||
f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})",
|
||||
)
|
||||
conn.mav.manual_control_send(self.target_system, x, y, z, r, 0)
|
||||
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 +932,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,12 +980,23 @@ class MavlinkWorker:
|
||||
if manual is None:
|
||||
raise RuntimeError("MANUAL mode is not advertised by this firmware")
|
||||
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
|
||||
conn.mav.set_mode_send(
|
||||
self._send_mav(conn, "set_mode_send",
|
||||
self.target_system,
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
|
||||
manual,
|
||||
)
|
||||
|
||||
def set_motor_scaling(self, independent, gains):
|
||||
clean = {motor: max(0, min(100, int(gains.get(motor, 100)))) for motor in range(1, 13)}
|
||||
with self.lock:
|
||||
self.independent_motor_scaling = bool(independent)
|
||||
self.motor_gains = clean
|
||||
|
||||
def motor_output_scale(self, motor):
|
||||
with self.lock:
|
||||
return (self.motor_gains.get(int(motor), 100) / 100.0
|
||||
if self.independent_motor_scaling else self.gain)
|
||||
|
||||
def set_axes(self, axes: Axes, sequence=None):
|
||||
def conditioned(value):
|
||||
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
|
||||
@@ -467,7 +1028,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});
|
||||
+454
-48
@@ -6,7 +6,8 @@ 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,battery_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
|
||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||
const activity=[];
|
||||
let lastReportedStatus='';
|
||||
@@ -14,26 +15,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 +172,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 +205,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 +235,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 +415,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 +430,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,39 +442,57 @@ 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();}};
|
||||
}
|
||||
const engageButton=$('#engage');
|
||||
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
|
||||
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
|
||||
const ENGAGE_HOLD_MS=1400;
|
||||
let engageLabelValue=$('#engage-label')?.textContent||'engage';
|
||||
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null,engageAppendFrame=null;
|
||||
const ENGAGE_HOLD_MS=600;
|
||||
|
||||
function applyEngageVisual(next){
|
||||
if(!engageButton)return;
|
||||
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||
// Interaction updates immediately; the short fade never gates emergency access.
|
||||
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging','DISARMING…':'disengaging','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||
// Update the state label without delaying the requested control action.
|
||||
engageButton.disabled=!next.action;
|
||||
engageButton.setAttribute('aria-busy',String(next.pending));
|
||||
engageButton.setAttribute('aria-label',label);
|
||||
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message;
|
||||
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 0.6 seconds to engage (F2)':next.message;
|
||||
const key=`${next.label}/${next.tone}`;
|
||||
if(engageVisualState===key)return;
|
||||
const previous=engageVisualState;engageVisualState=key;
|
||||
if(engageAppendFrame)cancelAnimationFrame(engageAppendFrame);
|
||||
clearTimeout(engageTransitionTimer);
|
||||
engageButton.classList.remove('arm','danger','subtle','charged','pending');
|
||||
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
|
||||
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%');
|
||||
$('#engage-label').textContent=label;
|
||||
const mainLabel=$('#engage-label'),previousLabel=engageLabelValue;
|
||||
const appended=previousLabel==='engage'&&label==='engaging'||previousLabel==='disengage'&&label==='disengaging';
|
||||
let suffix=null;
|
||||
if(appended){
|
||||
const previousWidth=mainLabel.getBoundingClientRect().width;
|
||||
const stem=previousLabel.slice(0,-1);
|
||||
mainLabel.replaceChildren(document.createTextNode(stem));
|
||||
suffix=document.createElement('span');suffix.className='engage-suffix';suffix.textContent=label.slice(stem.length);mainLabel.append(suffix);
|
||||
const appendedWidth=mainLabel.getBoundingClientRect().width;
|
||||
mainLabel.style.setProperty('--engage-append-shift',`${(appendedWidth-previousWidth)/2}px`);
|
||||
mainLabel.classList.add('is-appending');
|
||||
}else{
|
||||
mainLabel.classList.remove('is-appending');
|
||||
mainLabel.style.removeProperty('--engage-append-shift');
|
||||
mainLabel.textContent=label;
|
||||
}
|
||||
engageLabelValue=label;
|
||||
if(engageProgressLabel)engageProgressLabel.textContent=label;
|
||||
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
|
||||
if(previous){
|
||||
engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
|
||||
engageButton.classList.add('state-transition');
|
||||
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240);
|
||||
if(suffix)engageAppendFrame=requestAnimationFrame(()=>suffix.isConnected&&suffix.classList.add('is-visible'));
|
||||
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),200);
|
||||
}
|
||||
}
|
||||
function completeEngage(){
|
||||
@@ -356,12 +536,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 +554,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,7 +562,72 @@ if(engageButton){
|
||||
}
|
||||
|
||||
$('#logs').onclick=()=>api('open_logs');
|
||||
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||
const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
|
||||
let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0;
|
||||
const consolePulseTimers={};
|
||||
function pulseConsole(side){
|
||||
const dot=$('.'+side+'-dot');
|
||||
clearTimeout(consolePulseTimers[side]);dot.classList.add('lit');
|
||||
consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260);
|
||||
}
|
||||
function renderConsole(){
|
||||
const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache);
|
||||
if(signature===consoleSignature)return;
|
||||
consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter);
|
||||
}
|
||||
function setConsoleOpen(open){
|
||||
consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open);
|
||||
if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')}
|
||||
else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180);
|
||||
$('#console').setAttribute('aria-expanded',String(open));
|
||||
if(open)refreshConsole();else $('#console').focus({preventScroll:true});
|
||||
}
|
||||
$('#console').setAttribute('aria-controls','console-dock');
|
||||
$('#console').setAttribute('aria-expanded','false');
|
||||
$('#console').onclick=()=>setConsoleOpen(!consoleOpen);
|
||||
$('#console-close').onclick=()=>setConsoleOpen(false);
|
||||
$('#console-filter').onchange=renderConsole;
|
||||
$('#console-pause').onclick=()=>{
|
||||
consolePaused=!consolePaused;
|
||||
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
|
||||
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
|
||||
$('#console-state').textContent=consolePaused?'Paused':'';
|
||||
$('#console-state').hidden=!consolePaused;
|
||||
if(!consolePaused)refreshConsole();
|
||||
};
|
||||
async function refreshConsole(){
|
||||
if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
|
||||
consoleRefreshBusy=true;
|
||||
try{
|
||||
const sides=await withTimeout(api('read_console',150),1500);
|
||||
if(consolePaused||!consoleOpen)return;
|
||||
const filter=$('#console-filter').value;
|
||||
for(const side of ['rx','tx']){
|
||||
const previous=new Set(consoleCache[side]||[]);
|
||||
if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side);
|
||||
}
|
||||
consoleCache=sides;renderConsole();
|
||||
$('#console-state').textContent='';$('#console-state').hidden=true;
|
||||
}catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}}
|
||||
finally{consoleRefreshBusy=false}
|
||||
}
|
||||
setInterval(refreshConsole,750);
|
||||
function syncMotorScalingUI(){
|
||||
const independent=!!motorSettings.independent;
|
||||
document.body.dataset.motorScaling=independent?'independent':'shared';
|
||||
const toggle=$('#motor-scale-independent');if(toggle)toggle.checked=independent;
|
||||
const gain=$('#gain'),down=$('#gain-down'),up=$('#gain-up');
|
||||
if(gain){gain.disabled=independent;gain.title=independent?'Set motor scales in Motor setup':'Shared limit for every motor';}
|
||||
if(down)down.disabled=independent;if(up)up.disabled=independent;
|
||||
}
|
||||
function pushMotorSettings(persist=true){
|
||||
if(!bridgeReady)return Promise.resolve(motorSettings);
|
||||
const payload=JSON.parse(JSON.stringify(motorSettings));
|
||||
motorSettingsQueue=motorSettingsQueue.catch(()=>{}).then(()=>api('save_motor_settings',payload,persist)).then(saved=>{motorSettings=saved;syncMotorScalingUI();if(persist){servoSignature='';renderMotors(vehicleSnapshot.servos||{});toast('Motor settings saved')}return saved}).catch(error=>{setFeedback($('#motor-feedback'),error.message||'Could not save motor settings.');throw error});
|
||||
return motorSettingsQueue;
|
||||
}
|
||||
function motorScale(motor){return motorSettings.independent?(motorSettings.motors[String(motor)]?.scale??100):+$('#gain').value}
|
||||
function setGain(value){if(motorSettings.independent)return;const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;servoSignature='';renderMotors(vehicleSnapshot.servos||{});if(bridgeReady)api('set_gain',+gain.value)}
|
||||
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||
// Explicit pointer ownership makes the full range rail a reliable touch target
|
||||
// in the embedded window, without depending on native range drag defaults.
|
||||
@@ -391,21 +638,128 @@ 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();
|
||||
$('#motor-scale-independent').onchange=e=>{
|
||||
if(e.target.checked&&!motorSettings.customized){for(let motor=1;motor<=12;motor++)motorSettings.motors[String(motor)].scale=+$('#gain').value;motorSettings.customized=true}
|
||||
motorSettings={...motorSettings,independent:e.target.checked};syncMotorScalingUI();servoSignature='';renderMotors(vehicleSnapshot.servos||{});pushMotorSettings(true).catch(()=>{});
|
||||
};
|
||||
async function requestMotorEdit(servo,kind,value){
|
||||
if(motorEdits.has(servo))return;
|
||||
const s=vehicleSnapshot.servos?.[String(servo)]||{};
|
||||
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
|
||||
setFeedback($('#motor-feedback'));
|
||||
if(demoActive){
|
||||
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
|
||||
else s.direction=expected;
|
||||
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
|
||||
}
|
||||
motorEdits.set(servo,{kind,expected,started:performance.now()});
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
try{
|
||||
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
|
||||
}catch(error){
|
||||
motorEdits.delete(servo);
|
||||
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
}
|
||||
}
|
||||
function renderMotors(servos){
|
||||
for(const [servo,edit] of motorEdits){
|
||||
const state=servos[String(servo)]||{};
|
||||
if((edit.kind==='function'?state.function:state.direction)===edit.expected){motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`)}
|
||||
else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){motorEdits.delete(servo);setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received.`)}
|
||||
}
|
||||
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
|
||||
const assignments=Object.entries(servos).filter(([,state])=>state.motor).sort((a,b)=>a[1].motor-b[1].motor);
|
||||
const servoByMotor=new Map(assignments.map(([servo,state])=>[String(state.motor),{servo,...state}]));
|
||||
$('#motor-status').textContent=!vehicleSnapshot.connected?'Offline':vehicleSnapshot.armed?'Armed':`${assignments.length} outputs`;
|
||||
const shownMotors=[...new Set(assignments.map(([,state])=>state.motor))].sort((a,b)=>a-b);
|
||||
const structure=JSON.stringify([assignments.map(([port,state])=>[port,state.function,state.motor,state.direction]),editable,[...motorEdits],motorSettings.independent,shownMotors,Object.values(motorSettings.motors).map(m=>m.name)]);
|
||||
if(structure!==servoSignature){
|
||||
servoSignature=structure;const list=$('#motor-list');list.replaceChildren();
|
||||
for(const motor of shownMotors){
|
||||
const row=document.createElement('article');row.className='motor-row';row.dataset.motor=String(motor);
|
||||
row.innerHTML='<input class="motor-name" type="text" maxlength="24" autocomplete="off" aria-label="Motor name"><input class="motor-scale" type="range" min="0" max="100" step="1" aria-label="Motor output scale"><output class="motor-scale-value"></output><button type="button" class="button subtle motor-reverse">Reverse</button><span class="motor-channel"></span>';
|
||||
const name=row.querySelector('.motor-name');name.onchange=()=>{motorSettings.motors[String(motor)].name=name.value.trim().slice(0,24)||`Motor ${motor}`;name.value=motorSettings.motors[String(motor)].name;pushMotorSettings(true).catch(()=>{})};
|
||||
const scale=row.querySelector('.motor-scale');scale.oninput=()=>{motorSettings.customized=true;motorSettings.motors[String(motor)].scale=+scale.value;row.querySelector('output').textContent=`${scale.value}%`;pushMotorSettings(false).catch(()=>{})};scale.onchange=()=>pushMotorSettings(true).catch(()=>{});
|
||||
row.querySelector('.motor-reverse').onclick=()=>{const state=servoByMotor.get(String(motor));if(state?.servo)requestMotorEdit(state.servo,'direction')};list.append(row);
|
||||
}
|
||||
const details=document.createElement('details');details.className='motor-mapping';const summary=document.createElement('summary');summary.textContent='Assignments';details.append(summary);
|
||||
for(let i=1;i<=16;i++){
|
||||
const state=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-map-row';row.innerHTML='<span class="motor-map-port"></span><select aria-label="Servo output motor assignment"></select><span class="motor-map-direction"></span>';
|
||||
row.querySelector('.motor-map-port').textContent=`SERVO${i}`;const select=row.querySelector('select');select.add(new Option(state.function==null?'Reading…':'Unassigned','0'));for(let motor=1;motor<=12;motor++)select.add(new Option(motorSettings.motors[String(motor)]?.name||`Motor ${motor}`,String(motor)));select.value=String(state.motor||0);select.disabled=!editable||!!pending||state.function==null;select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
|
||||
row.querySelector('.motor-map-direction').textContent=pending?'Saving…':state.direction==null?'—':state.direction===-1?'Reversed':'Normal';details.append(row);
|
||||
}
|
||||
list.append(details);
|
||||
}
|
||||
for(const motor of shownMotors){
|
||||
const row=$(`#motor-list .motor-row[data-motor="${motor}"]`),state=servoByMotor.get(String(motor))||{},settings=motorSettings.motors[String(motor)]||{name:`Motor ${motor}`,scale:20};if(!row)continue;
|
||||
const servo=state.servo||null,name=row.querySelector('.motor-name');if(document.activeElement!==name)name.value=settings.name;
|
||||
const scale=row.querySelector('.motor-scale');scale.disabled=!motorSettings.independent;if(document.activeElement!==scale)scale.value=motorScale(motor);
|
||||
row.querySelector('.motor-scale-value').textContent=`${motorScale(motor)}%`;row.querySelector('.motor-channel').textContent=servo?`SERVO${servo} · ${state.output_us||'—'} µs`:'Unassigned';
|
||||
const reverse=row.querySelector('.motor-reverse');reverse.disabled=!editable||!servo||state.direction==null||motorEdits.has(servo);reverse.textContent=state.direction===-1?'Restore':'Reverse';reverse.setAttribute('aria-label',`${reverse.textContent} ${settings.name}`);
|
||||
}
|
||||
}
|
||||
function renderHeavyQuality(test,connected,armed){
|
||||
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
|
||||
if(!status||!results||!run||!stop)return;
|
||||
const quality=demoActive?null:vehicleSnapshot.link_quality;
|
||||
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
|
||||
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
|
||||
const running=!!test?.running;
|
||||
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
|
||||
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
|
||||
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
|
||||
stop.hidden=!running;
|
||||
stop.disabled=heavyActionBusy;
|
||||
results.replaceChildren();
|
||||
for(const row of test?.results||[]){
|
||||
const line=document.createElement('div');line.className='heavy-result-row';
|
||||
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
|
||||
const stats=document.createElement('span');
|
||||
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
|
||||
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
|
||||
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
|
||||
line.append(rate,stats);results.append(line);
|
||||
}
|
||||
}
|
||||
function render(s,confirmed=false){
|
||||
vehicleSnapshot=s;
|
||||
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
|
||||
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
|
||||
const previous=view;
|
||||
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||
syncInputModeControls();
|
||||
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);
|
||||
const driveEnabled=view.drive;
|
||||
document.body.dataset.controlsEnabled=String(driveEnabled);
|
||||
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||
$('#port').disabled=!!s.connected;
|
||||
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||
const connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
@@ -436,8 +790,8 @@ function render(s,confirmed=false){
|
||||
controlStatus.classList.toggle('is-error',view.error);
|
||||
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
|
||||
const motorBtn=$('[data-open="motors-modal"]');
|
||||
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||
|
||||
if(motorBtn)motorBtn.disabled=false;
|
||||
|
||||
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)}
|
||||
@@ -447,14 +801,60 @@ function render(s,confirmed=false){
|
||||
updateReadouts();
|
||||
const b=s.battery;
|
||||
if(b){
|
||||
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
|
||||
$('#battery-fill').style.width=`${b.percent}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
|
||||
$('#battery-title').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'Voltage unavailable';
|
||||
$('#battery-percent').textContent=Number.isFinite(b.percent)?`${Math.round(b.percent)}%`:'—';
|
||||
$('#battery-live-voltage').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'—';
|
||||
$('#battery-live-current').textContent=Number.isFinite(b.current_a)?`${b.current_a.toFixed(1)} A`:'Unavailable';
|
||||
$('#battery-live-consumed').textContent=Number.isFinite(b.consumed_mah)?`${Math.round(b.consumed_mah).toLocaleString()} mAh`:'Unavailable';
|
||||
$('#battery-live-consumed-source').textContent=b.consumed_source||'';
|
||||
$('#battery-live-remaining').textContent=Number.isFinite(b.remaining_mah)?`${Math.round(b.remaining_mah).toLocaleString()} mAh`:Number.isFinite(b.remaining_percent)?`${Math.round(b.remaining_percent)}%`:'Unavailable';
|
||||
$('#battery-live-note').textContent=s.battery_stale?'Battery telemetry is stale. Check the telemetry rate and battery monitor.':b.charge_source==='Voltage estimate'?'Charge percentage is estimated from voltage. Add a calibrated current sensor for consumption readings.':'Live values come from the flight controller; accuracy depends on its battery monitor setup.';
|
||||
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
|
||||
$('.battery-card').title=`${b.charge_source||'Battery status'} · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
|
||||
$('#battery-fill').style.width=`${Number.isFinite(b.percent)?b.percent:0}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
|
||||
$('.battery-card').dataset.low=String(!!b.low);
|
||||
}else{
|
||||
$('#battery-title').textContent='—';
|
||||
$('#battery-percent').textContent='waiting';
|
||||
$('#battery-title').textContent='No voltage';
|
||||
$('#battery-percent').textContent='—';
|
||||
$('#battery-live-voltage').textContent='—';$('#battery-live-current').textContent='—';
|
||||
$('#battery-live-consumed').textContent='—';$('#battery-live-consumed-source').textContent='';
|
||||
$('#battery-live-remaining').textContent='—';$('#battery-live-note').textContent='Waiting for battery telemetry.';
|
||||
$('.battery-card').dataset.stale='false';
|
||||
$('.battery-card').title='Waiting for battery voltage · Battery settings';
|
||||
$('#battery-fill').style.width='0';
|
||||
$('.battery-card').dataset.low='false';
|
||||
}
|
||||
const quality=s.link_quality,qualityCard=$('#link-quality');
|
||||
if(qualityCard){
|
||||
if(demoActive){
|
||||
qualityCard.dataset.grade='offline';
|
||||
$('#link-quality-grade').textContent='Demo';
|
||||
$('#link-quality-roundtrip').textContent='no hardware link test';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
renderMotors(s.servos||{});
|
||||
return;
|
||||
}
|
||||
const grade=quality?.grade||(s.connected?'testing':'offline');
|
||||
qualityCard.dataset.grade=grade;
|
||||
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
|
||||
if(quality&&quality.probe_samples){
|
||||
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
|
||||
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
|
||||
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
|
||||
const downLoss=percent(quality.rx_sequence_loss_percent);
|
||||
const badFrames=quality.rx_corrupt_frames_20s??0;
|
||||
const badBytes=quality.rx_corrupt_bytes_20s??0;
|
||||
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
|
||||
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
|
||||
if(quality.probe_samples<10||quality.rx_packets_30s<10){
|
||||
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
|
||||
}
|
||||
}else{
|
||||
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
}
|
||||
}
|
||||
renderMotors(s.servos||{});
|
||||
}
|
||||
@@ -560,18 +960,22 @@ async function boot(){
|
||||
if(booted||booting||!hasDesktopApi())return;
|
||||
booting=true;clearTimeout(bootRetry);
|
||||
try{
|
||||
// OS port discovery must not depend on model/config initialization succeeding.
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
const init=await withTimeout(api('initial_state'),4000);
|
||||
batteryConfig=init.battery_config||{};
|
||||
populatePorts(init.ports);
|
||||
linkConfig=init.link_config||{};
|
||||
motorSettings=init.motor_settings||motorSettings;
|
||||
syncMotorScalingUI();
|
||||
fillBatteryForm();
|
||||
fillLinkForm();
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
// Populate the serial selector before starting the optional model load.
|
||||
// A large cached mesh must never delay an operator seeing USB COM ports.
|
||||
loadDefaultModel();
|
||||
// COM discovery can block on Windows drivers; finish the first paint first.
|
||||
setTimeout(()=>withTimeout(api('refresh_ports'),4000).then(populatePorts).catch(()=>{}),0);
|
||||
// Let the controls paint before fetching and parsing the large STL mesh.
|
||||
const startModelLoad=()=>{void loadDefaultModel()};
|
||||
if('requestIdleCallback' in window)requestIdleCallback(startModelLoad,{timeout:2500});
|
||||
else setTimeout(startModelLoad,1500);
|
||||
if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
|
||||
if(appState==='disconnected'||demoActive){
|
||||
try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
|
||||
@@ -595,8 +999,10 @@ function waitForBridge(){
|
||||
addEventListener('pywebviewready',boot);
|
||||
render(vehicleSnapshot);
|
||||
if(document.readyState==='loading'){
|
||||
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
|
||||
addEventListener('DOMContentLoaded',waitForBridge);
|
||||
}else{
|
||||
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);
|
||||
@@ -18,7 +18,7 @@
|
||||
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
|
||||
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
|
||||
} else if (known && online) {
|
||||
action=focused?'arm':null; message='Disarmed · hold 1.4 seconds to engage.';
|
||||
action=focused?'arm':null; message='Disarmed · hold 0.6 seconds to engage.';
|
||||
}
|
||||
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
|
||||
const failure=error || state.command_error;
|
||||
|
||||
@@ -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()});
|
||||
})();
|
||||
+108
-21
@@ -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=20261017-engage-center-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,107 @@
|
||||
<label class="output-limit">
|
||||
<span>Output limit <b id="gain-value">20%</b></span>
|
||||
<div class="range-pill">
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
|
||||
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
|
||||
<input id="gain" aria-label="Shared motor output limit" type="range" min="0" max="100" step="1" value="20">
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
||||
</div>
|
||||
</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>Battery detail · Hz</span><select name="battery_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="quality-title">Link quality</h2>
|
||||
|
||||
<div class="quality-summary" title="Round-trip replies, incoming packet loss, and heartbeat freshness."><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
|
||||
<section class="heavy-test-panel"><h3 title="Tests 1, 5 and 10 Hz for about 35 seconds while disarmed. The live check resumes afterward.">Extended test</h3>
|
||||
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
|
||||
<div id="heavy-quality-results" class="heavy-quality-results"></div>
|
||||
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button id="heavy-quality-run" type="button" class="button modal-primary">Run extended test</button><button id="heavy-quality-cancel" type="button" class="button subtle" hidden>Stop test</button></div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery</h2>
|
||||
<section class="battery-live" aria-label="Live battery readings">
|
||||
<div class="battery-live-grid">
|
||||
<div><span>Pack voltage</span><strong id="battery-live-voltage">—</strong></div>
|
||||
<div><span>Current draw</span><strong id="battery-live-current">—</strong></div>
|
||||
<div><span>Charge used</span><strong id="battery-live-consumed">—</strong><small id="battery-live-consumed-source"></small></div>
|
||||
<div><span>Capacity left</span><strong id="battery-live-remaining">—</strong></div>
|
||||
</div>
|
||||
<p id="battery-live-note" class="note">Waiting for battery telemetry.</p>
|
||||
</section>
|
||||
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
|
||||
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
|
||||
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save battery</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Offline</p>
|
||||
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="motor-scale-heading"><span>Output</span><label class="switch-control"><input id="motor-scale-independent" type="checkbox"><span class="switch-track" aria-hidden="true"></span><span>Independent</span></label></div>
|
||||
<div id="motor-list" class="motor-list"></div>
|
||||
</dialog>
|
||||
<dialog id="controller-modal" class="modal" 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=20261017-engage-center-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}}};
|
||||
})();
|
||||
|
||||
+435
-31
@@ -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);
|
||||
@@ -645,23 +713,27 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
pointer-events: none;
|
||||
}
|
||||
.engage-button.charging { transition: background-color .24s ease, border-color .24s ease, color .2s ease; }
|
||||
.engage-face { position: relative; display: grid; place-items: center; min-width: 74px; }
|
||||
.engage-face > span { grid-area: 1/1; font-size: 11px; font-weight: 650; letter-spacing: .12em; }
|
||||
.engage-face { position:relative; display:grid; place-items:center; width:7.5em; height:1em; min-width:0; text-align:center; font-size:clamp(52px,11cqw,90px); }
|
||||
.engage-face > span { grid-area:1/1; justify-self:center; align-self:start; font-size:11px; font-weight:650; letter-spacing:.12em; white-space:nowrap; }
|
||||
.engage-label {
|
||||
position:relative;
|
||||
color: transparent;
|
||||
background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%);
|
||||
background-size: 250% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
text-align:center;
|
||||
transition:transform 180ms cubic-bezier(.77,0,.175,1);
|
||||
}
|
||||
#engage .engage-label.is-appending { transform:translateX(var(--engage-append-shift,0px)); }
|
||||
.arm:not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-label { animation: engage-shimmer 4.8s linear infinite; }
|
||||
.engage-suffix { display:inline-block; opacity:0; clip-path:inset(0 100% 0 0); transform:translateX(-.08em); transition:clip-path 180ms cubic-bezier(.77,0,.175,1),opacity 120ms ease-out,transform 180ms cubic-bezier(.77,0,.175,1); }
|
||||
.state-transition .engage-suffix,.engage-suffix.is-visible { opacity:1; clip-path:inset(0); transform:translateX(0); }
|
||||
.engage-progress-label {
|
||||
color: #a9ed72;
|
||||
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
|
||||
transition: opacity .18s ease;
|
||||
}
|
||||
.state-transition .engage-face { animation: state-label .24s ease-out; }
|
||||
@keyframes state-label { from {opacity:.35} to {opacity:1} }
|
||||
@keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} }
|
||||
.engage-button:hover { transform:none; }
|
||||
.engage-button.pending { background:#405346; border-color:#516e56; }
|
||||
@@ -688,7 +760,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 +951,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);
|
||||
@@ -947,21 +1019,14 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
|
||||
color: #7a8280;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.motor-modal { width: min(600px, calc(100vw - 40px)); }
|
||||
.motor-head, .motor-row {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 80px 75px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #e0e4df;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.motor-head {
|
||||
font-weight: 500;
|
||||
color: #727a7c;
|
||||
border-bottom: 1.5px solid #c8cec6;
|
||||
}
|
||||
.motor-modal { width: min(860px, calc(100vw - 40px)); }
|
||||
.battery-live { margin:14px 0 20px; padding:14px; border-radius:16px; background:#f0f3ef; }
|
||||
.battery-live-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
|
||||
.battery-live-grid > div { min-width:0; padding:10px 11px; border-radius:12px; background:#fff; }
|
||||
.battery-live-grid span,.battery-live-grid small { display:block; color:#727b77; font-size:11px; }
|
||||
.battery-live-grid strong { display:block; margin-top:7px; color:#303936; font-size:18px; font-weight:500; font-variant-numeric:tabular-nums; }
|
||||
.battery-live-grid small { margin-top:4px; }
|
||||
.battery-live .note { margin:10px 0 0; line-height:1.5; font-size:12px; }
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast {
|
||||
@@ -1011,6 +1076,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 +1135,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,27 +1148,31 @@ 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(52px,11cqw,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; }
|
||||
#engage .engage-label.is-appending { transition:none; transform:none; }
|
||||
.engage-suffix { transition:opacity var(--ui-duration,180ms) ease-out; }
|
||||
.state-transition .engage-suffix { clip-path:inset(0); transform:none; }
|
||||
}
|
||||
.controls-grid {
|
||||
--joy-size:min(54vh,calc(100vw - 480px),460px);
|
||||
@@ -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,306 @@ 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(860px,calc(100vw - 32px)); }
|
||||
@media (max-width:650px) { .battery-live-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
.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; }
|
||||
.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 .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; }
|
||||
|
||||
/* Motor scaling and control-plane fit: keep interaction areas inside their rows. */
|
||||
.primary-control-zone { grid-template-rows:clamp(76px,14vh,108px) minmax(0,1fr) auto; }
|
||||
.engage-header-zone { height:auto; min-height:0; align-self:stretch; }
|
||||
body #engage { height:100%; min-height:0; }
|
||||
.controls-grid { --joy-size:min(40vh,calc(100cqw - 168px),380px); min-height:0; align-content:center; }
|
||||
.joystick { min-width:0; min-height:0; max-width:100%; max-height:100%; }
|
||||
.joystick-pad,.heave-pad { min-width:0; min-height:0; flex:1 1 auto; overflow:hidden; }
|
||||
.control-card { min-width:0; min-height:0; overflow:hidden; }
|
||||
.control-toolbar { flex-wrap:nowrap; min-height:48px; padding:5px 0 0; gap:clamp(8px,1.5vw,18px); }
|
||||
.control-toolbar .output-limit { min-width:0; }
|
||||
.control-toolbar .output-limit > span { flex:0 1 120px; width:120px; }
|
||||
.control-toolbar .range-pill { width:clamp(112px,20cqw,190px); flex:0 1 190px; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
|
||||
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
|
||||
@media (max-height:610px) {
|
||||
.primary-control-zone { grid-template-rows:clamp(64px,12vh,82px) minmax(0,1fr) auto; }
|
||||
.controls-grid { --joy-size:min(34vh,calc(100cqw - 168px),300px); }
|
||||
}
|
||||
.motor-modal { width:min(720px,calc(100vw - 36px)); max-height:calc(100dvh - 28px); padding:clamp(18px,3vw,28px); overflow:auto; }
|
||||
.motor-modal > h2 { margin:0 0 2px; }
|
||||
.motor-scale-heading { display:flex; align-items:center; justify-content:space-between; padding:12px 0 7px; border-bottom:1px solid #e7eae7; color:#56635b; font-size:12px; }
|
||||
.switch-control { display:flex; align-items:center; gap:9px; cursor:pointer; color:#34423a; font-size:12px; }
|
||||
.switch-control input { position:absolute; opacity:0; width:1px; height:1px; }
|
||||
.switch-track { position:relative; display:block; width:34px; height:20px; border-radius:999px; background:#d8ddda; transition:background-color 160ms ease-out; }
|
||||
.switch-track::after { content:""; position:absolute; width:14px; height:14px; left:3px; top:3px; border-radius:50%; background:#fff; box-shadow:0 1px 2px #0002; transition:transform 160ms ease-out; }
|
||||
.switch-control input:checked + .switch-track { background:#4d8767; }
|
||||
.switch-control input:checked + .switch-track::after { transform:translateX(14px); }
|
||||
.switch-control input:focus-visible + .switch-track { outline:2px solid #6598b5; outline-offset:3px; }
|
||||
.motor-list { display:grid; }
|
||||
.motor-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(100px,1.5fr) 42px 76px; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid #e7eae7; }
|
||||
.motor-name { min-width:0; height:34px; padding:0 8px; border:1px solid transparent; border-radius:6px; background:transparent; font-size:13px; }
|
||||
.motor-name:hover,.motor-name:focus { border-color:#d8dfda; background:#fff; }
|
||||
.motor-row .motor-scale { width:100%; min-width:0; height:22px; padding:0; border:0; background:transparent; accent-color:#4d8767; }
|
||||
.motor-scale-value { color:#344b3c; font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; text-align:right; }
|
||||
.motor-reverse { min-height:30px; padding:5px 9px; font-size:11px; }
|
||||
.motor-reverse:disabled { opacity:.42; cursor:not-allowed; }
|
||||
.motor-channel { grid-column:1/-1; margin-top:-8px; color:#87918a; font-size:10px; }
|
||||
.motor-mapping { margin-top:8px; }
|
||||
.motor-mapping summary { padding:9px 0; color:#657168; font-size:12px; cursor:pointer; }
|
||||
.motor-map-row { display:grid; grid-template-columns:65px minmax(0,1fr) 72px; align-items:center; gap:9px; padding:6px 0; border-bottom:1px solid #edf0ed; }
|
||||
.motor-map-port { color:#4b5a50; font:11px ui-monospace,Consolas,monospace; }
|
||||
.motor-map-row select { height:32px; font-size:12px; }
|
||||
.motor-map-direction { color:#77827b; font-size:11px; text-align:right; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
|
||||
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
|
||||
.controls-grid { align-content:start; }
|
||||
.joystick-pad,.heave-pad { padding-top:0; }
|
||||
.joystick { transform:translateY(-7px); }
|
||||
@media (max-width:640px) { .motor-row { grid-template-columns:minmax(0,1fr) minmax(75px,1.3fr) 38px 70px; gap:7px; } .motor-map-row { grid-template-columns:54px minmax(0,1fr) 64px; gap:6px; } }
|
||||
@media (max-width:520px) { .motor-modal { padding:18px 14px; } .motor-row { grid-template-columns:minmax(0,1fr) minmax(64px,1fr) 36px 66px; gap:5px; } .motor-name { font-size:12px; } .motor-reverse { padding:5px 6px; } }
|
||||
@media (prefers-reduced-motion:reduce) { .switch-track,.switch-track::after { transition:none; } }
|
||||
|
||||
+233
-20
@@ -28,6 +28,9 @@ class ControlApi:
|
||||
self.worker.require_input_lease = True
|
||||
self.vehicle_state = VehicleState()
|
||||
self.battery_received_at = None
|
||||
self.battery_last_current = None
|
||||
self.battery_last_current_at = None
|
||||
self.battery_session_consumed_mah = 0.0
|
||||
self.demo_mode = False
|
||||
self.lock = threading.Lock()
|
||||
self._poll_lock = threading.Lock()
|
||||
@@ -40,12 +43,31 @@ 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)},
|
||||
"motor_test_active": False,
|
||||
"servos": {str(i): {"function": None, "direction": None, "output_us": None} for i in range(1, 17)},
|
||||
}
|
||||
self.servo_functions: dict[int, int] = {}
|
||||
self.motor_directions: dict[int, int] = {}
|
||||
self.motor_settings_path = Path(__file__).resolve().parent / "motor_settings.json"
|
||||
self.motor_settings = {
|
||||
"independent": False,
|
||||
"customized": False,
|
||||
"motors": {str(i): {"name": f"Motor {i}", "scale": 20} for i in range(1, 13)},
|
||||
}
|
||||
try:
|
||||
saved_motor_settings = json.loads(self.motor_settings_path.read_text(encoding="utf-8"))
|
||||
self.motor_settings = self._clean_motor_settings(saved_motor_settings)
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
self.worker.set_motor_scaling(
|
||||
self.motor_settings["independent"],
|
||||
{int(i): entry["scale"] for i, entry in self.motor_settings["motors"].items()},
|
||||
)
|
||||
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
|
||||
self.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,13 +84,23 @@ 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, "battery_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,
|
||||
"motor_settings": self.motor_settings,
|
||||
"state": self._snapshot(),
|
||||
"model_info": self.get_saved_model_info(),
|
||||
}
|
||||
|
||||
def refresh_ports(self):
|
||||
@@ -233,6 +265,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},
|
||||
)
|
||||
@@ -242,8 +277,12 @@ class ControlApi:
|
||||
self.state["servos"][str(servo)] = {
|
||||
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
|
||||
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
|
||||
"output_us": None,
|
||||
}
|
||||
return self._snapshot()
|
||||
self.battery_last_current = None
|
||||
self.battery_last_current_at = None
|
||||
self.battery_session_consumed_mah = 0.0
|
||||
self.worker.rate_hz = int(rate)
|
||||
with self.lock:
|
||||
self.vehicle_state.disconnect()
|
||||
@@ -268,15 +307,57 @@ class ControlApi:
|
||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||
|
||||
def disconnect(self):
|
||||
self.worker.stop_motor_test()
|
||||
with self.lock:
|
||||
self.state["motor_test_active"] = False
|
||||
if self.worker.heavy_quality_test is not None:
|
||||
self.worker.cancel_heavy_quality_test()
|
||||
if self.demo_mode:
|
||||
self.demo_mode = False
|
||||
with self.lock:
|
||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||
armed=False, mode=None, battery=None, 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 start_motor_test(self, motor, throttle_percent):
|
||||
motor, throttle_percent = int(motor), int(throttle_percent)
|
||||
if self.demo_mode:
|
||||
raise RuntimeError("Motor tests require a real flight-controller connection")
|
||||
with self.lock:
|
||||
if not self.state.get("connected") or not self.state.get("armed"):
|
||||
raise RuntimeError("Connect and arm the vehicle before motor testing")
|
||||
assignment = next((item for item in self.state["servos"].values() if item.get("motor") == motor), None)
|
||||
if assignment is None:
|
||||
raise ValueError(f"Motor {motor} is not assigned to a servo output")
|
||||
if self.state.get("motor_test_active"):
|
||||
raise RuntimeError("A motor test is already active")
|
||||
effective_percent = round(throttle_percent * self.worker.motor_output_scale(motor))
|
||||
self.state["motor_test_active"] = True
|
||||
self.state["status"] = f"Motor {motor} test active at {effective_percent}% output"
|
||||
try:
|
||||
return self.worker.start_motor_test(motor, effective_percent)
|
||||
except Exception:
|
||||
with self.lock:
|
||||
self.state["motor_test_active"] = False
|
||||
raise
|
||||
|
||||
def stop_motor_test(self):
|
||||
self.worker.stop_motor_test()
|
||||
with self.lock:
|
||||
self.state["status"] = "Motor test released; waiting for flight-controller stop"
|
||||
return True
|
||||
|
||||
def set_axes(self, forward, turn, vertical, sequence=None):
|
||||
if self.demo_mode:
|
||||
with self.lock:
|
||||
@@ -302,10 +383,44 @@ class ControlApi:
|
||||
return True
|
||||
|
||||
def set_gain(self, value):
|
||||
value = max(5, min(50, int(value)))
|
||||
self.worker.gain = value / 100.0
|
||||
value = max(0, min(100, int(value)))
|
||||
if self.motor_settings["independent"]:
|
||||
raise RuntimeError("Use per-motor scales while independent scaling is enabled")
|
||||
with self.worker.lock:
|
||||
self.worker.gain = value / 100.0
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _clean_motor_settings(settings):
|
||||
if not isinstance(settings, dict):
|
||||
raise ValueError("Motor settings must be an object")
|
||||
requested = settings.get("motors", {})
|
||||
if not isinstance(requested, dict):
|
||||
raise ValueError("Motor settings are invalid")
|
||||
motors = {}
|
||||
for motor in range(1, 13):
|
||||
entry = requested.get(str(motor), {})
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
name = str(entry.get("name", f"Motor {motor}")).strip()[:24] or f"Motor {motor}"
|
||||
try:
|
||||
scale = max(0, min(100, int(entry.get("scale", 20))))
|
||||
except (TypeError, ValueError):
|
||||
scale = 20
|
||||
motors[str(motor)] = {"name": name, "scale": scale}
|
||||
return {"independent": bool(settings.get("independent", False)), "customized": bool(settings.get("customized", False)), "motors": motors}
|
||||
|
||||
def save_motor_settings(self, settings, persist=True):
|
||||
clean = self._clean_motor_settings(settings)
|
||||
if persist:
|
||||
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||
self.motor_settings = clean
|
||||
self.worker.set_motor_scaling(
|
||||
clean["independent"],
|
||||
{int(i): entry["scale"] for i, entry in clean["motors"].items()},
|
||||
)
|
||||
return clean
|
||||
|
||||
def command(self, name, sequence=None, expires_at=None):
|
||||
if self.demo_mode:
|
||||
if name not in ("manual", "arm", "disarm"):
|
||||
@@ -389,8 +504,34 @@ 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"],
|
||||
"BATTERY_STATUS": self.link_config["battery_hz"],
|
||||
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
|
||||
"RC_CHANNELS": self.link_config["rc_hz"],
|
||||
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
|
||||
})
|
||||
|
||||
def save_link_settings(self, config):
|
||||
allowed = {0, 1, 2, 5, 10}
|
||||
clean = {key: int(config.get(key, value)) for key, value in self.link_config.items()}
|
||||
if clean["baud"] not in {9600, 19200, 38400, 57600, 115200}:
|
||||
raise ValueError("Choose a supported baud rate")
|
||||
if clean["control_hz"] not in {10, 20}:
|
||||
raise ValueError("Control rate must be 10 or 20 Hz")
|
||||
if clean["ping_hz"] not in {1, 2, 5, 10}:
|
||||
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
|
||||
if any(clean[key] not in allowed for key in ("system_hz", "battery_hz", "servo_hz", "rc_hz", "esc_hz")):
|
||||
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 +540,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:
|
||||
@@ -431,6 +576,7 @@ class ControlApi:
|
||||
def _snapshot(self):
|
||||
with self.lock:
|
||||
snap = json.loads(json.dumps(self.state))
|
||||
snap["motor_settings"] = self.motor_settings
|
||||
snap["app_state"] = self._derive_app_state()
|
||||
if not self.demo_mode:
|
||||
snap.update(self.vehicle_state.snapshot())
|
||||
@@ -446,6 +592,9 @@ class ControlApi:
|
||||
if kind == "heartbeat":
|
||||
_, system, autopilot, mode, armed = event[:5]
|
||||
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
|
||||
if not armed and self.state.get("motor_test_active"):
|
||||
self.state["motor_test_active"] = False
|
||||
self.state["status"] = "Flight controller reports disarmed after motor test"
|
||||
self.state.update(
|
||||
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
|
||||
armed=bool(armed),
|
||||
@@ -458,16 +607,23 @@ class ControlApi:
|
||||
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
|
||||
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
||||
elif kind == "battery":
|
||||
self.battery_received_at = time.monotonic()
|
||||
voltage = float(event[1])
|
||||
cells = self.battery_config["cells"]
|
||||
per_cell = voltage / cells
|
||||
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
|
||||
percent = max(0, min(100, (per_cell - low) * 100 / (high - low)))
|
||||
self.state["battery"] = {
|
||||
"voltage": voltage, "per_cell": per_cell, "percent": percent,
|
||||
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
|
||||
}
|
||||
self._consume_battery(event[1])
|
||||
elif kind == "servo_output":
|
||||
for servo, output_us in event[1].items():
|
||||
if servo in self.state["servos"]:
|
||||
self.state["servos"][servo]["output_us"] = output_us
|
||||
elif kind == "motor_test_ack":
|
||||
self.state["status"] = "Motor test accepted by flight controller"
|
||||
elif kind == "motor_test_rejected":
|
||||
self.state["motor_test_active"] = False
|
||||
self.state["status"] = f"Motor test rejected: {event[1]}"
|
||||
elif kind == "motor_test_stopped":
|
||||
self.state["motor_test_active"] = False
|
||||
self.state["status"] = f"Motor test ended: {event[1]}"
|
||||
elif kind == "link_quality":
|
||||
self.state["link_quality"] = event[1]
|
||||
elif kind == "heavy_quality":
|
||||
self.state["heavy_quality"] = event[1]
|
||||
elif kind == "servo_param":
|
||||
_, param_id, value = event
|
||||
servo = int(param_id[5:param_id.index("_")])
|
||||
@@ -490,9 +646,59 @@ 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, motor_test_active=False)
|
||||
self.state["last_error"] = None
|
||||
|
||||
def _consume_battery(self, data):
|
||||
self.battery_received_at = time.monotonic()
|
||||
now = self.battery_received_at
|
||||
previous = self.state.get("battery") or {}
|
||||
battery = dict(previous)
|
||||
battery["cells"] = self.battery_config["cells"]
|
||||
voltage = data.get("voltage")
|
||||
if voltage is not None and float(voltage) > 0:
|
||||
battery["voltage"] = float(voltage)
|
||||
battery["per_cell"] = float(voltage) / self.battery_config["cells"]
|
||||
current = data.get("current_a")
|
||||
if current is not None and float(current) >= 0:
|
||||
current = float(current)
|
||||
if self.battery_last_current is not None and self.battery_last_current_at is not None:
|
||||
elapsed = now - self.battery_last_current_at
|
||||
if 0 < elapsed <= 10:
|
||||
average = (current + self.battery_last_current) / 2
|
||||
self.battery_session_consumed_mah += average * 1000 * elapsed / 3600
|
||||
self.battery_last_current = current
|
||||
self.battery_last_current_at = now
|
||||
battery["current_a"] = current
|
||||
consumed = data.get("consumed_mah")
|
||||
if consumed is not None and int(consumed) >= 0:
|
||||
battery["consumed_mah"] = int(consumed)
|
||||
battery["consumed_source"] = "Flight controller"
|
||||
elif self.battery_last_current is not None:
|
||||
battery["consumed_mah"] = round(self.battery_session_consumed_mah)
|
||||
battery["consumed_source"] = "Session estimate"
|
||||
remaining = data.get("remaining_percent")
|
||||
if remaining is not None and 0 <= int(remaining) <= 100:
|
||||
battery["remaining_percent"] = int(remaining)
|
||||
battery["charge_source"] = "Flight controller"
|
||||
capacity = self.battery_config["capacity_mah"]
|
||||
if battery.get("consumed_mah") is not None:
|
||||
battery["remaining_mah"] = max(0, capacity - battery["consumed_mah"])
|
||||
elif battery.get("remaining_percent") is not None:
|
||||
battery["remaining_mah"] = round(capacity * battery["remaining_percent"] / 100)
|
||||
if battery.get("remaining_percent") is not None:
|
||||
percent = battery["remaining_percent"]
|
||||
elif battery.get("per_cell") is not None:
|
||||
low = self.battery_config["empty_v_per_cell"]
|
||||
high = self.battery_config["full_v_per_cell"]
|
||||
percent = max(0, min(100, (battery["per_cell"] - low) * 100 / (high - low)))
|
||||
battery["charge_source"] = "Voltage estimate"
|
||||
else:
|
||||
percent = None
|
||||
battery["percent"] = percent
|
||||
battery["low"] = percent is not None and percent <= self.battery_config["warning_percent"]
|
||||
self.state["battery"] = battery
|
||||
|
||||
def _update_servo(self, servo):
|
||||
function = self.servo_functions.get(servo)
|
||||
motor = motor_number_for_function(function)
|
||||
@@ -501,6 +707,7 @@ class ControlApi:
|
||||
"label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
|
||||
"motor": motor,
|
||||
"direction": self.motor_directions.get(motor) if motor else None,
|
||||
"output_us": (self.state["servos"].get(str(servo)) or {}).get("output_us"),
|
||||
}
|
||||
|
||||
def close(self):
|
||||
@@ -585,8 +792,13 @@ def start_local_server(directory: Path) -> int:
|
||||
|
||||
|
||||
def main():
|
||||
if os.name == "nt":
|
||||
# Keep the running window separate from generic Python taskbar groups.
|
||||
import ctypes
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("SUBControl.Desktop")
|
||||
from desktop_shortcut import ensure_desktop_shortcut
|
||||
ensure_desktop_shortcut()
|
||||
api = ControlApi()
|
||||
api._migrate_legacy_model()
|
||||
project_root = Path(__file__).resolve().parent
|
||||
port = start_local_server(project_root)
|
||||
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
|
||||
@@ -602,7 +814,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()
|
||||
@@ -80,6 +87,8 @@ class MavlinkWorker:
|
||||
# At 20 Hz this produces five small steps from neutral to full command.
|
||||
self.ramp_seconds = 0.25
|
||||
self.gain = 0.20
|
||||
self.independent_motor_scaling = False
|
||||
self.motor_gains = {motor: 20 for motor in range(1, 13)}
|
||||
self.rate_hz = 20
|
||||
self.target_system = 0
|
||||
self.target_component = 0
|
||||
@@ -87,15 +96,51 @@ 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, "BATTERY_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
|
||||
self.param_retries = 0
|
||||
self.param_types = {}
|
||||
self.pending_sets = {}
|
||||
self.motor_test_lock = threading.Lock()
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
self.motor_test_acknowledged = False
|
||||
self.motor_test_last_motor = None
|
||||
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 +154,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 +186,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 +198,34 @@ 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:
|
||||
self._send_control()
|
||||
next_control = now + (1.0 / max(1, self.rate_hz))
|
||||
# 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.
|
||||
motor_test_mode = self._tick_motor_test(now)
|
||||
if self.target_system and self.armed and now >= next_control:
|
||||
if not motor_test_mode:
|
||||
self._send_control()
|
||||
# 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 +236,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 +248,23 @@ 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
|
||||
with self.motor_test_lock:
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
self.motor_test_last_motor = None
|
||||
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 +274,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 +350,17 @@ class MavlinkWorker:
|
||||
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
|
||||
self.armed = armed
|
||||
self.config_paused = armed
|
||||
if not armed:
|
||||
with self.motor_test_lock:
|
||||
was_testing = self.motor_test_mode
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
if was_testing:
|
||||
self.events.put(("motor_test_stopped", "Vehicle disarmed"))
|
||||
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 +369,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,16 +378,62 @@ 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_DO_MOTOR_TEST:
|
||||
self.log("RX_MOTOR_TEST_ACK", result_name)
|
||||
if result_name in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"):
|
||||
if not self.motor_test_acknowledged:
|
||||
self.motor_test_acknowledged = True
|
||||
self.events.put(("motor_test_ack", result_name))
|
||||
else:
|
||||
with self.motor_test_lock:
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
self.events.put(("motor_test_rejected", 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:
|
||||
self.events.put(("battery", voltage_mv / 1000.0))
|
||||
current_ca = int(msg.current_battery)
|
||||
remaining = int(msg.battery_remaining)
|
||||
self.events.put(("battery", {
|
||||
"voltage": voltage_mv / 1000.0 if 0 < voltage_mv < 65535 else None,
|
||||
"current_a": current_ca / 100.0 if current_ca >= 0 else None,
|
||||
"remaining_percent": remaining if 0 <= remaining <= 100 else None,
|
||||
}))
|
||||
elif msg_type == "BATTERY_STATUS":
|
||||
voltages = [int(value) for value in getattr(msg, "voltages", [])]
|
||||
valid_voltages = [value for value in voltages if 0 < value < 65535]
|
||||
voltage = sum(valid_voltages) / 1000.0 if valid_voltages else None
|
||||
current_ca = int(msg.current_battery)
|
||||
consumed = int(msg.current_consumed)
|
||||
remaining = int(msg.battery_remaining)
|
||||
self.events.put(("battery", {
|
||||
"voltage": voltage,
|
||||
"current_a": current_ca / 100.0 if current_ca >= 0 else None,
|
||||
"consumed_mah": consumed if consumed >= 0 else None,
|
||||
"remaining_percent": remaining if 0 <= remaining <= 100 else None,
|
||||
}))
|
||||
elif msg_type == "SERVO_OUTPUT_RAW":
|
||||
base = int(getattr(msg, "port", 0)) * 8 + 1
|
||||
outputs = {}
|
||||
for index in range(1, 9):
|
||||
raw = int(getattr(msg, f"servo{index}_raw", 0))
|
||||
outputs[str(base + index - 1)] = raw if 0 < raw < 65535 else None
|
||||
self.events.put(("servo_output", outputs))
|
||||
elif msg_type == "PARAM_VALUE":
|
||||
param_id = msg.param_id
|
||||
if isinstance(param_id, bytes):
|
||||
@@ -264,6 +456,300 @@ 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 _send_motor_test(self, motor, throttle_percent):
|
||||
if not self.connection or not self.target_system:
|
||||
raise RuntimeError("No autopilot heartbeat")
|
||||
self.log("TX_MOTOR_TEST", f"motor={motor} throttle={throttle_percent}%")
|
||||
self._send_mav(self.connection, "command_int_send",
|
||||
self.target_system, self.target_component,
|
||||
mavutil.mavlink.MAV_FRAME_GLOBAL,
|
||||
mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, 0, 0,
|
||||
float(motor), 0.0, float(throttle_percent), 1.0, 1, 0, 0,
|
||||
)
|
||||
|
||||
def start_motor_test(self, motor, throttle_percent):
|
||||
motor, throttle_percent = int(motor), int(throttle_percent)
|
||||
if not 1 <= motor <= 12 or not 0 <= throttle_percent <= 100:
|
||||
raise ValueError("Choose a motor and a test throttle from 0% to 100%")
|
||||
if not self.connection or not self.target_system:
|
||||
raise RuntimeError("Connect to the vehicle before testing a motor")
|
||||
if not self.armed or time.monotonic() - self.last_heartbeat > 2.0:
|
||||
raise RuntimeError("Arm the vehicle and confirm fresh telemetry before motor testing")
|
||||
with self.motor_test_lock:
|
||||
if self.motor_test_mode:
|
||||
raise RuntimeError("Finish the current motor test before starting another")
|
||||
self.motor_test_request = {"motor": motor, "throttle": throttle_percent, "next_at": 0.0}
|
||||
self.motor_test_last_motor = motor
|
||||
self.motor_test_mode = True
|
||||
self.motor_test_acknowledged = False
|
||||
self.set_axes(Axes())
|
||||
self.log("OPERATOR", f"held motor test for motor {motor} at {throttle_percent}%")
|
||||
return True
|
||||
|
||||
def stop_motor_test(self):
|
||||
with self.motor_test_lock:
|
||||
request = self.motor_test_request
|
||||
if request is not None:
|
||||
self.motor_test_last_motor = request["motor"]
|
||||
motor = self.motor_test_last_motor
|
||||
was_testing = self.motor_test_mode
|
||||
self.motor_test_request = None
|
||||
if was_testing and motor and self.connection and self.target_system:
|
||||
self._send_motor_test(motor, 0)
|
||||
return True
|
||||
|
||||
def _tick_motor_test(self, now):
|
||||
motor = throttle = None
|
||||
with self.motor_test_lock:
|
||||
request = self.motor_test_request
|
||||
active = self.motor_test_mode
|
||||
if request is not None and not self.armed:
|
||||
self.motor_test_request = None
|
||||
self.motor_test_mode = False
|
||||
active = False
|
||||
self.events.put(("motor_test_stopped", "Vehicle disarmed"))
|
||||
elif request is not None and now >= request["next_at"]:
|
||||
request["next_at"] = now + 0.25
|
||||
motor, throttle = request["motor"], request["throttle"]
|
||||
else:
|
||||
motor = throttle = None
|
||||
if motor is not None:
|
||||
self._send_motor_test(motor, throttle)
|
||||
return active
|
||||
|
||||
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 +770,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 +836,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 +850,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,
|
||||
)
|
||||
@@ -365,6 +872,8 @@ class MavlinkWorker:
|
||||
active = not (force_neutral or expired)
|
||||
target = self.axes if active else Axes()
|
||||
gain = self.gain
|
||||
independent_motor_scaling = self.independent_motor_scaling
|
||||
motor_gains = dict(self.motor_gains)
|
||||
if force_neutral:
|
||||
self.output_axes = Axes()
|
||||
else:
|
||||
@@ -387,16 +896,31 @@ class MavlinkWorker:
|
||||
)
|
||||
limited_forward = axes.forward / horizontal_mix_peak
|
||||
limited_yaw = axes.yaw / horizontal_mix_peak
|
||||
x = round(limited_forward * gain * 1000)
|
||||
y = round(axes.lateral * gain * 1000)
|
||||
r = round(limited_yaw * gain * 1000)
|
||||
z = round(NEUTRAL_Z + axes.vertical * gain * 500)
|
||||
if independent_motor_scaling:
|
||||
# The desktop controller's SimpleROV-3 profile maps motors 1/2
|
||||
# to surge/turn and motor 3 to vertical thrust. Convert the two
|
||||
# independently scaled horizontal demands back into MANUAL_CONTROL
|
||||
# axes so ArduSub's configured mixer remains in charge.
|
||||
motor_1 = (limited_forward + limited_yaw) * motor_gains.get(1, 100) / 100.0
|
||||
motor_2 = (limited_forward - limited_yaw) * motor_gains.get(2, 100) / 100.0
|
||||
limited_forward = (motor_1 + motor_2) / 2.0
|
||||
limited_yaw = (motor_1 - motor_2) / 2.0
|
||||
vertical_gain = motor_gains.get(3, 100) / 100.0
|
||||
x = round(limited_forward * 1000)
|
||||
y = round(axes.lateral * 1000)
|
||||
r = round(limited_yaw * 1000)
|
||||
z = round(NEUTRAL_Z + axes.vertical * vertical_gain * 500)
|
||||
else:
|
||||
x = round(limited_forward * gain * 1000)
|
||||
y = round(axes.lateral * gain * 1000)
|
||||
r = round(limited_yaw * gain * 1000)
|
||||
z = round(NEUTRAL_Z + axes.vertical * gain * 500)
|
||||
self.log(
|
||||
"TX_MANUAL_CONTROL",
|
||||
f"x={x} y={y} z={z} r={r} active={active} "
|
||||
f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})",
|
||||
)
|
||||
conn.mav.manual_control_send(self.target_system, x, y, z, r, 0)
|
||||
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 +933,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,12 +981,23 @@ class MavlinkWorker:
|
||||
if manual is None:
|
||||
raise RuntimeError("MANUAL mode is not advertised by this firmware")
|
||||
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
|
||||
conn.mav.set_mode_send(
|
||||
self._send_mav(conn, "set_mode_send",
|
||||
self.target_system,
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
|
||||
manual,
|
||||
)
|
||||
|
||||
def set_motor_scaling(self, independent, gains):
|
||||
clean = {motor: max(0, min(100, int(gains.get(motor, 100)))) for motor in range(1, 13)}
|
||||
with self.lock:
|
||||
self.independent_motor_scaling = bool(independent)
|
||||
self.motor_gains = clean
|
||||
|
||||
def motor_output_scale(self, motor):
|
||||
with self.lock:
|
||||
return (self.motor_gains.get(int(motor), 100) / 100.0
|
||||
if self.independent_motor_scaling else self.gain)
|
||||
|
||||
def set_axes(self, axes: Axes, sequence=None):
|
||||
def conditioned(value):
|
||||
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
|
||||
@@ -467,7 +1029,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});
|
||||
+454
-48
@@ -6,7 +6,8 @@ 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,battery_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
|
||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||
const activity=[];
|
||||
let lastReportedStatus='';
|
||||
@@ -14,26 +15,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 +172,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 +205,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 +235,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 +415,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 +430,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,39 +442,57 @@ 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();}};
|
||||
}
|
||||
const engageButton=$('#engage');
|
||||
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
|
||||
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
|
||||
const ENGAGE_HOLD_MS=1400;
|
||||
let engageLabelValue=$('#engage-label')?.textContent||'engage';
|
||||
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null,engageAppendFrame=null;
|
||||
const ENGAGE_HOLD_MS=600;
|
||||
|
||||
function applyEngageVisual(next){
|
||||
if(!engageButton)return;
|
||||
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||
// Interaction updates immediately; the short fade never gates emergency access.
|
||||
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging','DISARMING…':'disengaging','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||
// Update the state label without delaying the requested control action.
|
||||
engageButton.disabled=!next.action;
|
||||
engageButton.setAttribute('aria-busy',String(next.pending));
|
||||
engageButton.setAttribute('aria-label',label);
|
||||
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message;
|
||||
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 0.6 seconds to engage (F2)':next.message;
|
||||
const key=`${next.label}/${next.tone}`;
|
||||
if(engageVisualState===key)return;
|
||||
const previous=engageVisualState;engageVisualState=key;
|
||||
if(engageAppendFrame)cancelAnimationFrame(engageAppendFrame);
|
||||
clearTimeout(engageTransitionTimer);
|
||||
engageButton.classList.remove('arm','danger','subtle','charged','pending');
|
||||
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
|
||||
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%');
|
||||
$('#engage-label').textContent=label;
|
||||
const mainLabel=$('#engage-label'),previousLabel=engageLabelValue;
|
||||
const appended=previousLabel==='engage'&&label==='engaging'||previousLabel==='disengage'&&label==='disengaging';
|
||||
let suffix=null;
|
||||
if(appended){
|
||||
const previousWidth=mainLabel.getBoundingClientRect().width;
|
||||
const stem=previousLabel.slice(0,-1);
|
||||
mainLabel.replaceChildren(document.createTextNode(stem));
|
||||
suffix=document.createElement('span');suffix.className='engage-suffix';suffix.textContent=label.slice(stem.length);mainLabel.append(suffix);
|
||||
const appendedWidth=mainLabel.getBoundingClientRect().width;
|
||||
mainLabel.style.setProperty('--engage-append-shift',`${(appendedWidth-previousWidth)/2}px`);
|
||||
mainLabel.classList.add('is-appending');
|
||||
}else{
|
||||
mainLabel.classList.remove('is-appending');
|
||||
mainLabel.style.removeProperty('--engage-append-shift');
|
||||
mainLabel.textContent=label;
|
||||
}
|
||||
engageLabelValue=label;
|
||||
if(engageProgressLabel)engageProgressLabel.textContent=label;
|
||||
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
|
||||
if(previous){
|
||||
engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
|
||||
engageButton.classList.add('state-transition');
|
||||
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240);
|
||||
if(suffix)engageAppendFrame=requestAnimationFrame(()=>suffix.isConnected&&suffix.classList.add('is-visible'));
|
||||
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),200);
|
||||
}
|
||||
}
|
||||
function completeEngage(){
|
||||
@@ -356,12 +536,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 +554,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,7 +562,72 @@ if(engageButton){
|
||||
}
|
||||
|
||||
$('#logs').onclick=()=>api('open_logs');
|
||||
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||
const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
|
||||
let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0;
|
||||
const consolePulseTimers={};
|
||||
function pulseConsole(side){
|
||||
const dot=$('.'+side+'-dot');
|
||||
clearTimeout(consolePulseTimers[side]);dot.classList.add('lit');
|
||||
consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260);
|
||||
}
|
||||
function renderConsole(){
|
||||
const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache);
|
||||
if(signature===consoleSignature)return;
|
||||
consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter);
|
||||
}
|
||||
function setConsoleOpen(open){
|
||||
consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open);
|
||||
if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')}
|
||||
else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180);
|
||||
$('#console').setAttribute('aria-expanded',String(open));
|
||||
if(open)refreshConsole();else $('#console').focus({preventScroll:true});
|
||||
}
|
||||
$('#console').setAttribute('aria-controls','console-dock');
|
||||
$('#console').setAttribute('aria-expanded','false');
|
||||
$('#console').onclick=()=>setConsoleOpen(!consoleOpen);
|
||||
$('#console-close').onclick=()=>setConsoleOpen(false);
|
||||
$('#console-filter').onchange=renderConsole;
|
||||
$('#console-pause').onclick=()=>{
|
||||
consolePaused=!consolePaused;
|
||||
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
|
||||
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
|
||||
$('#console-state').textContent=consolePaused?'Paused':'';
|
||||
$('#console-state').hidden=!consolePaused;
|
||||
if(!consolePaused)refreshConsole();
|
||||
};
|
||||
async function refreshConsole(){
|
||||
if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
|
||||
consoleRefreshBusy=true;
|
||||
try{
|
||||
const sides=await withTimeout(api('read_console',150),1500);
|
||||
if(consolePaused||!consoleOpen)return;
|
||||
const filter=$('#console-filter').value;
|
||||
for(const side of ['rx','tx']){
|
||||
const previous=new Set(consoleCache[side]||[]);
|
||||
if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side);
|
||||
}
|
||||
consoleCache=sides;renderConsole();
|
||||
$('#console-state').textContent='';$('#console-state').hidden=true;
|
||||
}catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}}
|
||||
finally{consoleRefreshBusy=false}
|
||||
}
|
||||
setInterval(refreshConsole,750);
|
||||
function syncMotorScalingUI(){
|
||||
const independent=!!motorSettings.independent;
|
||||
document.body.dataset.motorScaling=independent?'independent':'shared';
|
||||
const toggle=$('#motor-scale-independent');if(toggle)toggle.checked=independent;
|
||||
const gain=$('#gain'),down=$('#gain-down'),up=$('#gain-up');
|
||||
if(gain){gain.disabled=independent;gain.title=independent?'Set motor scales in Motor setup':'Shared limit for every motor';}
|
||||
if(down)down.disabled=independent;if(up)up.disabled=independent;
|
||||
}
|
||||
function pushMotorSettings(persist=true){
|
||||
if(!bridgeReady)return Promise.resolve(motorSettings);
|
||||
const payload=JSON.parse(JSON.stringify(motorSettings));
|
||||
motorSettingsQueue=motorSettingsQueue.catch(()=>{}).then(()=>api('save_motor_settings',payload,persist)).then(saved=>{motorSettings=saved;syncMotorScalingUI();if(persist){servoSignature='';renderMotors(vehicleSnapshot.servos||{});toast('Motor settings saved')}return saved}).catch(error=>{setFeedback($('#motor-feedback'),error.message||'Could not save motor settings.');throw error});
|
||||
return motorSettingsQueue;
|
||||
}
|
||||
function motorScale(motor){return motorSettings.independent?(motorSettings.motors[String(motor)]?.scale??100):+$('#gain').value}
|
||||
function setGain(value){if(motorSettings.independent)return;const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;servoSignature='';renderMotors(vehicleSnapshot.servos||{});if(bridgeReady)api('set_gain',+gain.value)}
|
||||
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||
// Explicit pointer ownership makes the full range rail a reliable touch target
|
||||
// in the embedded window, without depending on native range drag defaults.
|
||||
@@ -391,21 +638,128 @@ 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();
|
||||
$('#motor-scale-independent').onchange=e=>{
|
||||
if(e.target.checked&&!motorSettings.customized){for(let motor=1;motor<=12;motor++)motorSettings.motors[String(motor)].scale=+$('#gain').value;motorSettings.customized=true}
|
||||
motorSettings={...motorSettings,independent:e.target.checked};syncMotorScalingUI();servoSignature='';renderMotors(vehicleSnapshot.servos||{});pushMotorSettings(true).catch(()=>{});
|
||||
};
|
||||
async function requestMotorEdit(servo,kind,value){
|
||||
if(motorEdits.has(servo))return;
|
||||
const s=vehicleSnapshot.servos?.[String(servo)]||{};
|
||||
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
|
||||
setFeedback($('#motor-feedback'));
|
||||
if(demoActive){
|
||||
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
|
||||
else s.direction=expected;
|
||||
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
|
||||
}
|
||||
motorEdits.set(servo,{kind,expected,started:performance.now()});
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
try{
|
||||
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
|
||||
}catch(error){
|
||||
motorEdits.delete(servo);
|
||||
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
|
||||
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
|
||||
}
|
||||
}
|
||||
function renderMotors(servos){
|
||||
for(const [servo,edit] of motorEdits){
|
||||
const state=servos[String(servo)]||{};
|
||||
if((edit.kind==='function'?state.function:state.direction)===edit.expected){motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`)}
|
||||
else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){motorEdits.delete(servo);setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received.`)}
|
||||
}
|
||||
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
|
||||
const assignments=Object.entries(servos).filter(([,state])=>state.motor).sort((a,b)=>a[1].motor-b[1].motor);
|
||||
const servoByMotor=new Map(assignments.map(([servo,state])=>[String(state.motor),{servo,...state}]));
|
||||
$('#motor-status').textContent=!vehicleSnapshot.connected?'Offline':vehicleSnapshot.armed?'Armed':`${assignments.length} outputs`;
|
||||
const shownMotors=[...new Set(assignments.map(([,state])=>state.motor))].sort((a,b)=>a-b);
|
||||
const structure=JSON.stringify([assignments.map(([port,state])=>[port,state.function,state.motor,state.direction]),editable,[...motorEdits],motorSettings.independent,shownMotors,Object.values(motorSettings.motors).map(m=>m.name)]);
|
||||
if(structure!==servoSignature){
|
||||
servoSignature=structure;const list=$('#motor-list');list.replaceChildren();
|
||||
for(const motor of shownMotors){
|
||||
const row=document.createElement('article');row.className='motor-row';row.dataset.motor=String(motor);
|
||||
row.innerHTML='<input class="motor-name" type="text" maxlength="24" autocomplete="off" aria-label="Motor name"><input class="motor-scale" type="range" min="0" max="100" step="1" aria-label="Motor output scale"><output class="motor-scale-value"></output><button type="button" class="button subtle motor-reverse">Reverse</button><span class="motor-channel"></span>';
|
||||
const name=row.querySelector('.motor-name');name.onchange=()=>{motorSettings.motors[String(motor)].name=name.value.trim().slice(0,24)||`Motor ${motor}`;name.value=motorSettings.motors[String(motor)].name;pushMotorSettings(true).catch(()=>{})};
|
||||
const scale=row.querySelector('.motor-scale');scale.oninput=()=>{motorSettings.customized=true;motorSettings.motors[String(motor)].scale=+scale.value;row.querySelector('output').textContent=`${scale.value}%`;pushMotorSettings(false).catch(()=>{})};scale.onchange=()=>pushMotorSettings(true).catch(()=>{});
|
||||
row.querySelector('.motor-reverse').onclick=()=>{const state=servoByMotor.get(String(motor));if(state?.servo)requestMotorEdit(state.servo,'direction')};list.append(row);
|
||||
}
|
||||
const details=document.createElement('details');details.className='motor-mapping';const summary=document.createElement('summary');summary.textContent='Assignments';details.append(summary);
|
||||
for(let i=1;i<=16;i++){
|
||||
const state=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-map-row';row.innerHTML='<span class="motor-map-port"></span><select aria-label="Servo output motor assignment"></select><span class="motor-map-direction"></span>';
|
||||
row.querySelector('.motor-map-port').textContent=`SERVO${i}`;const select=row.querySelector('select');select.add(new Option(state.function==null?'Reading…':'Unassigned','0'));for(let motor=1;motor<=12;motor++)select.add(new Option(motorSettings.motors[String(motor)]?.name||`Motor ${motor}`,String(motor)));select.value=String(state.motor||0);select.disabled=!editable||!!pending||state.function==null;select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
|
||||
row.querySelector('.motor-map-direction').textContent=pending?'Saving…':state.direction==null?'—':state.direction===-1?'Reversed':'Normal';details.append(row);
|
||||
}
|
||||
list.append(details);
|
||||
}
|
||||
for(const motor of shownMotors){
|
||||
const row=$(`#motor-list .motor-row[data-motor="${motor}"]`),state=servoByMotor.get(String(motor))||{},settings=motorSettings.motors[String(motor)]||{name:`Motor ${motor}`,scale:20};if(!row)continue;
|
||||
const servo=state.servo||null,name=row.querySelector('.motor-name');if(document.activeElement!==name)name.value=settings.name;
|
||||
const scale=row.querySelector('.motor-scale');scale.disabled=!motorSettings.independent;if(document.activeElement!==scale)scale.value=motorScale(motor);
|
||||
row.querySelector('.motor-scale-value').textContent=`${motorScale(motor)}%`;row.querySelector('.motor-channel').textContent=servo?`SERVO${servo} · ${state.output_us||'—'} µs`:'Unassigned';
|
||||
const reverse=row.querySelector('.motor-reverse');reverse.disabled=!editable||!servo||state.direction==null||motorEdits.has(servo);reverse.textContent=state.direction===-1?'Restore':'Reverse';reverse.setAttribute('aria-label',`${reverse.textContent} ${settings.name}`);
|
||||
}
|
||||
}
|
||||
function renderHeavyQuality(test,connected,armed){
|
||||
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
|
||||
if(!status||!results||!run||!stop)return;
|
||||
const quality=demoActive?null:vehicleSnapshot.link_quality;
|
||||
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
|
||||
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
|
||||
const running=!!test?.running;
|
||||
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
|
||||
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
|
||||
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
|
||||
stop.hidden=!running;
|
||||
stop.disabled=heavyActionBusy;
|
||||
results.replaceChildren();
|
||||
for(const row of test?.results||[]){
|
||||
const line=document.createElement('div');line.className='heavy-result-row';
|
||||
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
|
||||
const stats=document.createElement('span');
|
||||
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
|
||||
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
|
||||
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
|
||||
line.append(rate,stats);results.append(line);
|
||||
}
|
||||
}
|
||||
function render(s,confirmed=false){
|
||||
vehicleSnapshot=s;
|
||||
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
|
||||
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
|
||||
const previous=view;
|
||||
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||
syncInputModeControls();
|
||||
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);
|
||||
const driveEnabled=view.drive;
|
||||
document.body.dataset.controlsEnabled=String(driveEnabled);
|
||||
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||
$('#port').disabled=!!s.connected;
|
||||
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||
const connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
@@ -436,8 +790,8 @@ function render(s,confirmed=false){
|
||||
controlStatus.classList.toggle('is-error',view.error);
|
||||
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
|
||||
const motorBtn=$('[data-open="motors-modal"]');
|
||||
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||
|
||||
if(motorBtn)motorBtn.disabled=false;
|
||||
|
||||
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)}
|
||||
@@ -447,14 +801,60 @@ function render(s,confirmed=false){
|
||||
updateReadouts();
|
||||
const b=s.battery;
|
||||
if(b){
|
||||
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
|
||||
$('#battery-fill').style.width=`${b.percent}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
|
||||
$('#battery-title').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'Voltage unavailable';
|
||||
$('#battery-percent').textContent=Number.isFinite(b.percent)?`${Math.round(b.percent)}%`:'—';
|
||||
$('#battery-live-voltage').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'—';
|
||||
$('#battery-live-current').textContent=Number.isFinite(b.current_a)?`${b.current_a.toFixed(1)} A`:'Unavailable';
|
||||
$('#battery-live-consumed').textContent=Number.isFinite(b.consumed_mah)?`${Math.round(b.consumed_mah).toLocaleString()} mAh`:'Unavailable';
|
||||
$('#battery-live-consumed-source').textContent=b.consumed_source||'';
|
||||
$('#battery-live-remaining').textContent=Number.isFinite(b.remaining_mah)?`${Math.round(b.remaining_mah).toLocaleString()} mAh`:Number.isFinite(b.remaining_percent)?`${Math.round(b.remaining_percent)}%`:'Unavailable';
|
||||
$('#battery-live-note').textContent=s.battery_stale?'Battery telemetry is stale. Check the telemetry rate and battery monitor.':b.charge_source==='Voltage estimate'?'Charge percentage is estimated from voltage. Add a calibrated current sensor for consumption readings.':'Live values come from the flight controller; accuracy depends on its battery monitor setup.';
|
||||
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
|
||||
$('.battery-card').title=`${b.charge_source||'Battery status'} · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
|
||||
$('#battery-fill').style.width=`${Number.isFinite(b.percent)?b.percent:0}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
|
||||
$('.battery-card').dataset.low=String(!!b.low);
|
||||
}else{
|
||||
$('#battery-title').textContent='—';
|
||||
$('#battery-percent').textContent='waiting';
|
||||
$('#battery-title').textContent='No voltage';
|
||||
$('#battery-percent').textContent='—';
|
||||
$('#battery-live-voltage').textContent='—';$('#battery-live-current').textContent='—';
|
||||
$('#battery-live-consumed').textContent='—';$('#battery-live-consumed-source').textContent='';
|
||||
$('#battery-live-remaining').textContent='—';$('#battery-live-note').textContent='Waiting for battery telemetry.';
|
||||
$('.battery-card').dataset.stale='false';
|
||||
$('.battery-card').title='Waiting for battery voltage · Battery settings';
|
||||
$('#battery-fill').style.width='0';
|
||||
$('.battery-card').dataset.low='false';
|
||||
}
|
||||
const quality=s.link_quality,qualityCard=$('#link-quality');
|
||||
if(qualityCard){
|
||||
if(demoActive){
|
||||
qualityCard.dataset.grade='offline';
|
||||
$('#link-quality-grade').textContent='Demo';
|
||||
$('#link-quality-roundtrip').textContent='no hardware link test';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
renderMotors(s.servos||{});
|
||||
return;
|
||||
}
|
||||
const grade=quality?.grade||(s.connected?'testing':'offline');
|
||||
qualityCard.dataset.grade=grade;
|
||||
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
|
||||
if(quality&&quality.probe_samples){
|
||||
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
|
||||
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
|
||||
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
|
||||
const downLoss=percent(quality.rx_sequence_loss_percent);
|
||||
const badFrames=quality.rx_corrupt_frames_20s??0;
|
||||
const badBytes=quality.rx_corrupt_bytes_20s??0;
|
||||
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
|
||||
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
|
||||
if(quality.probe_samples<10||quality.rx_packets_30s<10){
|
||||
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
|
||||
}
|
||||
}else{
|
||||
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
}
|
||||
}
|
||||
renderMotors(s.servos||{});
|
||||
}
|
||||
@@ -560,18 +960,22 @@ async function boot(){
|
||||
if(booted||booting||!hasDesktopApi())return;
|
||||
booting=true;clearTimeout(bootRetry);
|
||||
try{
|
||||
// OS port discovery must not depend on model/config initialization succeeding.
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
const init=await withTimeout(api('initial_state'),4000);
|
||||
batteryConfig=init.battery_config||{};
|
||||
populatePorts(init.ports);
|
||||
linkConfig=init.link_config||{};
|
||||
motorSettings=init.motor_settings||motorSettings;
|
||||
syncMotorScalingUI();
|
||||
fillBatteryForm();
|
||||
fillLinkForm();
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
// Populate the serial selector before starting the optional model load.
|
||||
// A large cached mesh must never delay an operator seeing USB COM ports.
|
||||
loadDefaultModel();
|
||||
// COM discovery can block on Windows drivers; finish the first paint first.
|
||||
setTimeout(()=>withTimeout(api('refresh_ports'),4000).then(populatePorts).catch(()=>{}),0);
|
||||
// Let the controls paint before fetching and parsing the large STL mesh.
|
||||
const startModelLoad=()=>{void loadDefaultModel()};
|
||||
if('requestIdleCallback' in window)requestIdleCallback(startModelLoad,{timeout:2500});
|
||||
else setTimeout(startModelLoad,1500);
|
||||
if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
|
||||
if(appState==='disconnected'||demoActive){
|
||||
try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
|
||||
@@ -595,8 +999,10 @@ function waitForBridge(){
|
||||
addEventListener('pywebviewready',boot);
|
||||
render(vehicleSnapshot);
|
||||
if(document.readyState==='loading'){
|
||||
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
|
||||
addEventListener('DOMContentLoaded',waitForBridge);
|
||||
}else{
|
||||
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);
|
||||
@@ -18,7 +18,7 @@
|
||||
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
|
||||
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
|
||||
} else if (known && online) {
|
||||
action=focused?'arm':null; message='Disarmed · hold 1.4 seconds to engage.';
|
||||
action=focused?'arm':null; message='Disarmed · hold 0.6 seconds to engage.';
|
||||
}
|
||||
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
|
||||
const failure=error || state.command_error;
|
||||
|
||||
@@ -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()});
|
||||
})();
|
||||
+108
-21
@@ -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=20261017-engage-center-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,107 @@
|
||||
<label class="output-limit">
|
||||
<span>Output limit <b id="gain-value">20%</b></span>
|
||||
<div class="range-pill">
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
|
||||
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
|
||||
<input id="gain" aria-label="Shared motor output limit" type="range" min="0" max="100" step="1" value="20">
|
||||
<button id="gain-up" type="button" aria-label="Increase output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
||||
</div>
|
||||
</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>Battery detail · Hz</span><select name="battery_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="quality-title">Link quality</h2>
|
||||
|
||||
<div class="quality-summary" title="Round-trip replies, incoming packet loss, and heartbeat freshness."><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
|
||||
<section class="heavy-test-panel"><h3 title="Tests 1, 5 and 10 Hz for about 35 seconds while disarmed. The live check resumes afterward.">Extended test</h3>
|
||||
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
|
||||
<div id="heavy-quality-results" class="heavy-quality-results"></div>
|
||||
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button id="heavy-quality-run" type="button" class="button modal-primary">Run extended test</button><button id="heavy-quality-cancel" type="button" class="button subtle" hidden>Stop test</button></div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery</h2>
|
||||
<section class="battery-live" aria-label="Live battery readings">
|
||||
<div class="battery-live-grid">
|
||||
<div><span>Pack voltage</span><strong id="battery-live-voltage">—</strong></div>
|
||||
<div><span>Current draw</span><strong id="battery-live-current">—</strong></div>
|
||||
<div><span>Charge used</span><strong id="battery-live-consumed">—</strong><small id="battery-live-consumed-source"></small></div>
|
||||
<div><span>Capacity left</span><strong id="battery-live-remaining">—</strong></div>
|
||||
</div>
|
||||
<p id="battery-live-note" class="note">Waiting for battery telemetry.</p>
|
||||
</section>
|
||||
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
|
||||
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
|
||||
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
|
||||
<p class="form-feedback" role="alert" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save battery</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Offline</p>
|
||||
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
|
||||
<div class="motor-scale-heading"><span>Output</span><label class="switch-control"><input id="motor-scale-independent" type="checkbox"><span class="switch-track" aria-hidden="true"></span><span>Independent</span></label></div>
|
||||
<div id="motor-list" class="motor-list"></div>
|
||||
</dialog>
|
||||
<dialog id="controller-modal" class="modal" 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=20261017-engage-center-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}}};
|
||||
})();
|
||||
|
||||
+435
-31
@@ -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);
|
||||
@@ -645,23 +713,27 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
pointer-events: none;
|
||||
}
|
||||
.engage-button.charging { transition: background-color .24s ease, border-color .24s ease, color .2s ease; }
|
||||
.engage-face { position: relative; display: grid; place-items: center; min-width: 74px; }
|
||||
.engage-face > span { grid-area: 1/1; font-size: 11px; font-weight: 650; letter-spacing: .12em; }
|
||||
.engage-face { position:relative; display:grid; place-items:center; width:7.5em; height:1em; min-width:0; text-align:center; font-size:clamp(52px,11cqw,90px); }
|
||||
.engage-face > span { grid-area:1/1; justify-self:center; align-self:start; font-size:11px; font-weight:650; letter-spacing:.12em; white-space:nowrap; }
|
||||
.engage-label {
|
||||
position:relative;
|
||||
color: transparent;
|
||||
background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%);
|
||||
background-size: 250% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
text-align:center;
|
||||
transition:transform 180ms cubic-bezier(.77,0,.175,1);
|
||||
}
|
||||
#engage .engage-label.is-appending { transform:translateX(var(--engage-append-shift,0px)); }
|
||||
.arm:not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-label { animation: engage-shimmer 4.8s linear infinite; }
|
||||
.engage-suffix { display:inline-block; opacity:0; clip-path:inset(0 100% 0 0); transform:translateX(-.08em); transition:clip-path 180ms cubic-bezier(.77,0,.175,1),opacity 120ms ease-out,transform 180ms cubic-bezier(.77,0,.175,1); }
|
||||
.state-transition .engage-suffix,.engage-suffix.is-visible { opacity:1; clip-path:inset(0); transform:translateX(0); }
|
||||
.engage-progress-label {
|
||||
color: #a9ed72;
|
||||
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
|
||||
transition: opacity .18s ease;
|
||||
}
|
||||
.state-transition .engage-face { animation: state-label .24s ease-out; }
|
||||
@keyframes state-label { from {opacity:.35} to {opacity:1} }
|
||||
@keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} }
|
||||
.engage-button:hover { transform:none; }
|
||||
.engage-button.pending { background:#405346; border-color:#516e56; }
|
||||
@@ -688,7 +760,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 +951,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);
|
||||
@@ -947,21 +1019,14 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
|
||||
color: #7a8280;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.motor-modal { width: min(600px, calc(100vw - 40px)); }
|
||||
.motor-head, .motor-row {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 80px 75px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #e0e4df;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.motor-head {
|
||||
font-weight: 500;
|
||||
color: #727a7c;
|
||||
border-bottom: 1.5px solid #c8cec6;
|
||||
}
|
||||
.motor-modal { width: min(860px, calc(100vw - 40px)); }
|
||||
.battery-live { margin:14px 0 20px; padding:14px; border-radius:16px; background:#f0f3ef; }
|
||||
.battery-live-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
|
||||
.battery-live-grid > div { min-width:0; padding:10px 11px; border-radius:12px; background:#fff; }
|
||||
.battery-live-grid span,.battery-live-grid small { display:block; color:#727b77; font-size:11px; }
|
||||
.battery-live-grid strong { display:block; margin-top:7px; color:#303936; font-size:18px; font-weight:500; font-variant-numeric:tabular-nums; }
|
||||
.battery-live-grid small { margin-top:4px; }
|
||||
.battery-live .note { margin:10px 0 0; line-height:1.5; font-size:12px; }
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast {
|
||||
@@ -1011,6 +1076,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 +1135,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,27 +1148,31 @@ 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(52px,11cqw,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; }
|
||||
#engage .engage-label.is-appending { transition:none; transform:none; }
|
||||
.engage-suffix { transition:opacity var(--ui-duration,180ms) ease-out; }
|
||||
.state-transition .engage-suffix { clip-path:inset(0); transform:none; }
|
||||
}
|
||||
.controls-grid {
|
||||
--joy-size:min(54vh,calc(100vw - 480px),460px);
|
||||
@@ -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,306 @@ 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(860px,calc(100vw - 32px)); }
|
||||
@media (max-width:650px) { .battery-live-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
.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; }
|
||||
.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 .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; }
|
||||
|
||||
/* Motor scaling and control-plane fit: keep interaction areas inside their rows. */
|
||||
.primary-control-zone { grid-template-rows:clamp(76px,14vh,108px) minmax(0,1fr) auto; }
|
||||
.engage-header-zone { height:auto; min-height:0; align-self:stretch; }
|
||||
body #engage { height:100%; min-height:0; }
|
||||
.controls-grid { --joy-size:min(40vh,calc(100cqw - 168px),380px); min-height:0; align-content:center; }
|
||||
.joystick { min-width:0; min-height:0; max-width:100%; max-height:100%; }
|
||||
.joystick-pad,.heave-pad { min-width:0; min-height:0; flex:1 1 auto; overflow:hidden; }
|
||||
.control-card { min-width:0; min-height:0; overflow:hidden; }
|
||||
.control-toolbar { flex-wrap:nowrap; min-height:48px; padding:5px 0 0; gap:clamp(8px,1.5vw,18px); }
|
||||
.control-toolbar .output-limit { min-width:0; }
|
||||
.control-toolbar .output-limit > span { flex:0 1 120px; width:120px; }
|
||||
.control-toolbar .range-pill { width:clamp(112px,20cqw,190px); flex:0 1 190px; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
|
||||
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
|
||||
@media (max-height:610px) {
|
||||
.primary-control-zone { grid-template-rows:clamp(64px,12vh,82px) minmax(0,1fr) auto; }
|
||||
.controls-grid { --joy-size:min(34vh,calc(100cqw - 168px),300px); }
|
||||
}
|
||||
.motor-modal { width:min(720px,calc(100vw - 36px)); max-height:calc(100dvh - 28px); padding:clamp(18px,3vw,28px); overflow:auto; }
|
||||
.motor-modal > h2 { margin:0 0 2px; }
|
||||
.motor-scale-heading { display:flex; align-items:center; justify-content:space-between; padding:12px 0 7px; border-bottom:1px solid #e7eae7; color:#56635b; font-size:12px; }
|
||||
.switch-control { display:flex; align-items:center; gap:9px; cursor:pointer; color:#34423a; font-size:12px; }
|
||||
.switch-control input { position:absolute; opacity:0; width:1px; height:1px; }
|
||||
.switch-track { position:relative; display:block; width:34px; height:20px; border-radius:999px; background:#d8ddda; transition:background-color 160ms ease-out; }
|
||||
.switch-track::after { content:""; position:absolute; width:14px; height:14px; left:3px; top:3px; border-radius:50%; background:#fff; box-shadow:0 1px 2px #0002; transition:transform 160ms ease-out; }
|
||||
.switch-control input:checked + .switch-track { background:#4d8767; }
|
||||
.switch-control input:checked + .switch-track::after { transform:translateX(14px); }
|
||||
.switch-control input:focus-visible + .switch-track { outline:2px solid #6598b5; outline-offset:3px; }
|
||||
.motor-list { display:grid; }
|
||||
.motor-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(100px,1.5fr) 42px 76px; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid #e7eae7; }
|
||||
.motor-name { min-width:0; height:34px; padding:0 8px; border:1px solid transparent; border-radius:6px; background:transparent; font-size:13px; }
|
||||
.motor-name:hover,.motor-name:focus { border-color:#d8dfda; background:#fff; }
|
||||
.motor-row .motor-scale { width:100%; min-width:0; height:22px; padding:0; border:0; background:transparent; accent-color:#4d8767; }
|
||||
.motor-scale-value { color:#344b3c; font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; text-align:right; }
|
||||
.motor-reverse { min-height:30px; padding:5px 9px; font-size:11px; }
|
||||
.motor-reverse:disabled { opacity:.42; cursor:not-allowed; }
|
||||
.motor-channel { grid-column:1/-1; margin-top:-8px; color:#87918a; font-size:10px; }
|
||||
.motor-mapping { margin-top:8px; }
|
||||
.motor-mapping summary { padding:9px 0; color:#657168; font-size:12px; cursor:pointer; }
|
||||
.motor-map-row { display:grid; grid-template-columns:65px minmax(0,1fr) 72px; align-items:center; gap:9px; padding:6px 0; border-bottom:1px solid #edf0ed; }
|
||||
.motor-map-port { color:#4b5a50; font:11px ui-monospace,Consolas,monospace; }
|
||||
.motor-map-row select { height:32px; font-size:12px; }
|
||||
.motor-map-direction { color:#77827b; font-size:11px; text-align:right; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
|
||||
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
|
||||
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
|
||||
.controls-grid { align-content:start; }
|
||||
.joystick-pad,.heave-pad { padding-top:0; }
|
||||
.joystick { transform:translateY(-7px); }
|
||||
@media (max-width:640px) { .motor-row { grid-template-columns:minmax(0,1fr) minmax(75px,1.3fr) 38px 70px; gap:7px; } .motor-map-row { grid-template-columns:54px minmax(0,1fr) 64px; gap:6px; } }
|
||||
@media (max-width:520px) { .motor-modal { padding:18px 14px; } .motor-row { grid-template-columns:minmax(0,1fr) minmax(64px,1fr) 36px 66px; gap:5px; } .motor-name { font-size:12px; } .motor-reverse { padding:5px 6px; } }
|
||||
@media (prefers-reduced-motion:reduce) { .switch-track,.switch-track::after { transition:none; } }
|
||||
|
||||
@@ -28,6 +28,9 @@ class ControlApi:
|
||||
self.worker.require_input_lease = True
|
||||
self.vehicle_state = VehicleState()
|
||||
self.battery_received_at = None
|
||||
self.battery_last_current = None
|
||||
self.battery_last_current_at = None
|
||||
self.battery_session_consumed_mah = 0.0
|
||||
self.demo_mode = False
|
||||
self.lock = threading.Lock()
|
||||
self._poll_lock = threading.Lock()
|
||||
@@ -40,12 +43,31 @@ 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)},
|
||||
"motor_test_active": False,
|
||||
"servos": {str(i): {"function": None, "direction": None, "output_us": None} for i in range(1, 17)},
|
||||
}
|
||||
self.servo_functions: dict[int, int] = {}
|
||||
self.motor_directions: dict[int, int] = {}
|
||||
self.motor_settings_path = Path(__file__).resolve().parent / "motor_settings.json"
|
||||
self.motor_settings = {
|
||||
"independent": False,
|
||||
"customized": False,
|
||||
"motors": {str(i): {"name": f"Motor {i}", "scale": 20} for i in range(1, 13)},
|
||||
}
|
||||
try:
|
||||
saved_motor_settings = json.loads(self.motor_settings_path.read_text(encoding="utf-8"))
|
||||
self.motor_settings = self._clean_motor_settings(saved_motor_settings)
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
self.worker.set_motor_scaling(
|
||||
self.motor_settings["independent"],
|
||||
{int(i): entry["scale"] for i, entry in self.motor_settings["motors"].items()},
|
||||
)
|
||||
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
|
||||
self.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,13 +84,23 @@ 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, "battery_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,
|
||||
"motor_settings": self.motor_settings,
|
||||
"state": self._snapshot(),
|
||||
"model_info": self.get_saved_model_info(),
|
||||
}
|
||||
|
||||
def refresh_ports(self):
|
||||
@@ -233,6 +265,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},
|
||||
)
|
||||
@@ -242,8 +277,12 @@ class ControlApi:
|
||||
self.state["servos"][str(servo)] = {
|
||||
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
|
||||
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
|
||||
"output_us": None,
|
||||
}
|
||||
return self._snapshot()
|
||||
self.battery_last_current = None
|
||||
self.battery_last_current_at = None
|
||||
self.battery_session_consumed_mah = 0.0
|
||||
self.worker.rate_hz = int(rate)
|
||||
with self.lock:
|
||||
self.vehicle_state.disconnect()
|
||||
@@ -268,15 +307,57 @@ class ControlApi:
|
||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||
|
||||
def disconnect(self):
|
||||
self.worker.stop_motor_test()
|
||||
with self.lock:
|
||||
self.state["motor_test_active"] = False
|
||||
if self.worker.heavy_quality_test is not None:
|
||||
self.worker.cancel_heavy_quality_test()
|
||||
if self.demo_mode:
|
||||
self.demo_mode = False
|
||||
with self.lock:
|
||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||
armed=False, mode=None, battery=None, 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 start_motor_test(self, motor, throttle_percent):
|
||||
motor, throttle_percent = int(motor), int(throttle_percent)
|
||||
if self.demo_mode:
|
||||
raise RuntimeError("Motor tests require a real flight-controller connection")
|
||||
with self.lock:
|
||||
if not self.state.get("connected") or not self.state.get("armed"):
|
||||
raise RuntimeError("Connect and arm the vehicle before motor testing")
|
||||
assignment = next((item for item in self.state["servos"].values() if item.get("motor") == motor), None)
|
||||
if assignment is None:
|
||||
raise ValueError(f"Motor {motor} is not assigned to a servo output")
|
||||
if self.state.get("motor_test_active"):
|
||||
raise RuntimeError("A motor test is already active")
|
||||
effective_percent = round(throttle_percent * self.worker.motor_output_scale(motor))
|
||||
self.state["motor_test_active"] = True
|
||||
self.state["status"] = f"Motor {motor} test active at {effective_percent}% output"
|
||||
try:
|
||||
return self.worker.start_motor_test(motor, effective_percent)
|
||||
except Exception:
|
||||
with self.lock:
|
||||
self.state["motor_test_active"] = False
|
||||
raise
|
||||
|
||||
def stop_motor_test(self):
|
||||
self.worker.stop_motor_test()
|
||||
with self.lock:
|
||||
self.state["status"] = "Motor test released; waiting for flight-controller stop"
|
||||
return True
|
||||
|
||||
def set_axes(self, forward, turn, vertical, sequence=None):
|
||||
if self.demo_mode:
|
||||
with self.lock:
|
||||
@@ -302,10 +383,44 @@ class ControlApi:
|
||||
return True
|
||||
|
||||
def set_gain(self, value):
|
||||
value = max(5, min(50, int(value)))
|
||||
self.worker.gain = value / 100.0
|
||||
value = max(0, min(100, int(value)))
|
||||
if self.motor_settings["independent"]:
|
||||
raise RuntimeError("Use per-motor scales while independent scaling is enabled")
|
||||
with self.worker.lock:
|
||||
self.worker.gain = value / 100.0
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _clean_motor_settings(settings):
|
||||
if not isinstance(settings, dict):
|
||||
raise ValueError("Motor settings must be an object")
|
||||
requested = settings.get("motors", {})
|
||||
if not isinstance(requested, dict):
|
||||
raise ValueError("Motor settings are invalid")
|
||||
motors = {}
|
||||
for motor in range(1, 13):
|
||||
entry = requested.get(str(motor), {})
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
name = str(entry.get("name", f"Motor {motor}")).strip()[:24] or f"Motor {motor}"
|
||||
try:
|
||||
scale = max(0, min(100, int(entry.get("scale", 20))))
|
||||
except (TypeError, ValueError):
|
||||
scale = 20
|
||||
motors[str(motor)] = {"name": name, "scale": scale}
|
||||
return {"independent": bool(settings.get("independent", False)), "customized": bool(settings.get("customized", False)), "motors": motors}
|
||||
|
||||
def save_motor_settings(self, settings, persist=True):
|
||||
clean = self._clean_motor_settings(settings)
|
||||
if persist:
|
||||
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||
self.motor_settings = clean
|
||||
self.worker.set_motor_scaling(
|
||||
clean["independent"],
|
||||
{int(i): entry["scale"] for i, entry in clean["motors"].items()},
|
||||
)
|
||||
return clean
|
||||
|
||||
def command(self, name, sequence=None, expires_at=None):
|
||||
if self.demo_mode:
|
||||
if name not in ("manual", "arm", "disarm"):
|
||||
@@ -389,8 +504,34 @@ 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"],
|
||||
"BATTERY_STATUS": self.link_config["battery_hz"],
|
||||
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
|
||||
"RC_CHANNELS": self.link_config["rc_hz"],
|
||||
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
|
||||
})
|
||||
|
||||
def save_link_settings(self, config):
|
||||
allowed = {0, 1, 2, 5, 10}
|
||||
clean = {key: int(config.get(key, value)) for key, value in self.link_config.items()}
|
||||
if clean["baud"] not in {9600, 19200, 38400, 57600, 115200}:
|
||||
raise ValueError("Choose a supported baud rate")
|
||||
if clean["control_hz"] not in {10, 20}:
|
||||
raise ValueError("Control rate must be 10 or 20 Hz")
|
||||
if clean["ping_hz"] not in {1, 2, 5, 10}:
|
||||
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
|
||||
if any(clean[key] not in allowed for key in ("system_hz", "battery_hz", "servo_hz", "rc_hz", "esc_hz")):
|
||||
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 +540,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:
|
||||
@@ -431,6 +576,7 @@ class ControlApi:
|
||||
def _snapshot(self):
|
||||
with self.lock:
|
||||
snap = json.loads(json.dumps(self.state))
|
||||
snap["motor_settings"] = self.motor_settings
|
||||
snap["app_state"] = self._derive_app_state()
|
||||
if not self.demo_mode:
|
||||
snap.update(self.vehicle_state.snapshot())
|
||||
@@ -446,6 +592,9 @@ class ControlApi:
|
||||
if kind == "heartbeat":
|
||||
_, system, autopilot, mode, armed = event[:5]
|
||||
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
|
||||
if not armed and self.state.get("motor_test_active"):
|
||||
self.state["motor_test_active"] = False
|
||||
self.state["status"] = "Flight controller reports disarmed after motor test"
|
||||
self.state.update(
|
||||
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
|
||||
armed=bool(armed),
|
||||
@@ -458,16 +607,23 @@ class ControlApi:
|
||||
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
|
||||
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
||||
elif kind == "battery":
|
||||
self.battery_received_at = time.monotonic()
|
||||
voltage = float(event[1])
|
||||
cells = self.battery_config["cells"]
|
||||
per_cell = voltage / cells
|
||||
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
|
||||
percent = max(0, min(100, (per_cell - low) * 100 / (high - low)))
|
||||
self.state["battery"] = {
|
||||
"voltage": voltage, "per_cell": per_cell, "percent": percent,
|
||||
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
|
||||
}
|
||||
self._consume_battery(event[1])
|
||||
elif kind == "servo_output":
|
||||
for servo, output_us in event[1].items():
|
||||
if servo in self.state["servos"]:
|
||||
self.state["servos"][servo]["output_us"] = output_us
|
||||
elif kind == "motor_test_ack":
|
||||
self.state["status"] = "Motor test accepted by flight controller"
|
||||
elif kind == "motor_test_rejected":
|
||||
self.state["motor_test_active"] = False
|
||||
self.state["status"] = f"Motor test rejected: {event[1]}"
|
||||
elif kind == "motor_test_stopped":
|
||||
self.state["motor_test_active"] = False
|
||||
self.state["status"] = f"Motor test ended: {event[1]}"
|
||||
elif kind == "link_quality":
|
||||
self.state["link_quality"] = event[1]
|
||||
elif kind == "heavy_quality":
|
||||
self.state["heavy_quality"] = event[1]
|
||||
elif kind == "servo_param":
|
||||
_, param_id, value = event
|
||||
servo = int(param_id[5:param_id.index("_")])
|
||||
@@ -490,9 +646,59 @@ 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, motor_test_active=False)
|
||||
self.state["last_error"] = None
|
||||
|
||||
def _consume_battery(self, data):
|
||||
self.battery_received_at = time.monotonic()
|
||||
now = self.battery_received_at
|
||||
previous = self.state.get("battery") or {}
|
||||
battery = dict(previous)
|
||||
battery["cells"] = self.battery_config["cells"]
|
||||
voltage = data.get("voltage")
|
||||
if voltage is not None and float(voltage) > 0:
|
||||
battery["voltage"] = float(voltage)
|
||||
battery["per_cell"] = float(voltage) / self.battery_config["cells"]
|
||||
current = data.get("current_a")
|
||||
if current is not None and float(current) >= 0:
|
||||
current = float(current)
|
||||
if self.battery_last_current is not None and self.battery_last_current_at is not None:
|
||||
elapsed = now - self.battery_last_current_at
|
||||
if 0 < elapsed <= 10:
|
||||
average = (current + self.battery_last_current) / 2
|
||||
self.battery_session_consumed_mah += average * 1000 * elapsed / 3600
|
||||
self.battery_last_current = current
|
||||
self.battery_last_current_at = now
|
||||
battery["current_a"] = current
|
||||
consumed = data.get("consumed_mah")
|
||||
if consumed is not None and int(consumed) >= 0:
|
||||
battery["consumed_mah"] = int(consumed)
|
||||
battery["consumed_source"] = "Flight controller"
|
||||
elif self.battery_last_current is not None:
|
||||
battery["consumed_mah"] = round(self.battery_session_consumed_mah)
|
||||
battery["consumed_source"] = "Session estimate"
|
||||
remaining = data.get("remaining_percent")
|
||||
if remaining is not None and 0 <= int(remaining) <= 100:
|
||||
battery["remaining_percent"] = int(remaining)
|
||||
battery["charge_source"] = "Flight controller"
|
||||
capacity = self.battery_config["capacity_mah"]
|
||||
if battery.get("consumed_mah") is not None:
|
||||
battery["remaining_mah"] = max(0, capacity - battery["consumed_mah"])
|
||||
elif battery.get("remaining_percent") is not None:
|
||||
battery["remaining_mah"] = round(capacity * battery["remaining_percent"] / 100)
|
||||
if battery.get("remaining_percent") is not None:
|
||||
percent = battery["remaining_percent"]
|
||||
elif battery.get("per_cell") is not None:
|
||||
low = self.battery_config["empty_v_per_cell"]
|
||||
high = self.battery_config["full_v_per_cell"]
|
||||
percent = max(0, min(100, (battery["per_cell"] - low) * 100 / (high - low)))
|
||||
battery["charge_source"] = "Voltage estimate"
|
||||
else:
|
||||
percent = None
|
||||
battery["percent"] = percent
|
||||
battery["low"] = percent is not None and percent <= self.battery_config["warning_percent"]
|
||||
self.state["battery"] = battery
|
||||
|
||||
def _update_servo(self, servo):
|
||||
function = self.servo_functions.get(servo)
|
||||
motor = motor_number_for_function(function)
|
||||
@@ -501,6 +707,7 @@ class ControlApi:
|
||||
"label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
|
||||
"motor": motor,
|
||||
"direction": self.motor_directions.get(motor) if motor else None,
|
||||
"output_us": (self.state["servos"].get(str(servo)) or {}).get("output_us"),
|
||||
}
|
||||
|
||||
def close(self):
|
||||
@@ -585,8 +792,13 @@ def start_local_server(directory: Path) -> int:
|
||||
|
||||
|
||||
def main():
|
||||
if os.name == "nt":
|
||||
# Keep the running window separate from generic Python taskbar groups.
|
||||
import ctypes
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("SUBControl.Desktop")
|
||||
from desktop_shortcut import ensure_desktop_shortcut
|
||||
ensure_desktop_shortcut()
|
||||
api = ControlApi()
|
||||
api._migrate_legacy_model()
|
||||
project_root = Path(__file__).resolve().parent
|
||||
port = start_local_server(project_root)
|
||||
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
|
||||
@@ -602,7 +814,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