Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e91c28f1e | |||
| a4d8f1b7af | |||
| 31f720a316 | |||
| 0093f3f4aa | |||
| 7c85397af0 | |||
| 73c20a0ff2 | |||
| 84ec93543b | |||
| 25bbefda71 | |||
| 10cd37eea7 | |||
| 3d47f479e6 | |||
| 7a9ab0e553 | |||
| bd3e9b20d1 | |||
| b8e299e95f |
@@ -3,4 +3,6 @@ __pycache__/
|
||||
*.py[cod]
|
||||
logs/
|
||||
model_settings.json
|
||||
link_settings.json
|
||||
runtime/
|
||||
motor_settings.json
|
||||
|
||||
@@ -1,124 +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.
|
||||
Install Python 3.11+ with the Python launcher, then run:
|
||||
|
||||
## Safety first
|
||||
```powershell
|
||||
./run.ps1
|
||||
```
|
||||
|
||||
- Remove propellers for initial tests and restrain the vehicle.
|
||||
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range,
|
||||
leak/failsafe, and emergency-stop arrangements before arming.
|
||||
- The application starts disarmed and limits pilot demand to 20% by default.
|
||||
Once armed, control movement is sent directly with no additional gate.
|
||||
- Closing or disconnecting sends neutral controls and a disarm request, but a
|
||||
physical power cutoff is still required.
|
||||
The script creates a virtual environment and installs dependencies automatically.
|
||||
On first launch, it also adds a **SUB Control** desktop shortcut.
|
||||
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
|
||||
Connection settings are under **•••**.
|
||||
Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details.
|
||||
|
||||
The controls are pilot-demand axes, not direct PWM values. ArduSub performs
|
||||
the frame mixing and generates the configured ESC outputs. For ArduSub,
|
||||
`MANUAL_CONTROL.z = 500` is neutral vertical thrust.
|
||||
## UI design rule
|
||||
|
||||
## Run on Windows
|
||||
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.
|
||||
|
||||
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.
|
||||
## Controls
|
||||
|
||||
`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`.
|
||||
- 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.
|
||||
|
||||
## Repository layout
|
||||
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
|
||||
|
||||
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.
|
||||
## Safety
|
||||
|
||||
Keyboard controls for SimpleROV-3: W/S forward/back, A/D yaw left/right,
|
||||
Q/E alternative yaw control, and R/F rise/dive.
|
||||
Releasing movement keys eases their controls back to neutral.
|
||||
|
||||
## Three-thruster limitation
|
||||
|
||||
The common ArduSub `SimpleROV-3` arrangement controls forward, yaw, and depth.
|
||||
It cannot independently control lateral, roll, or pitch. Accordingly, the main
|
||||
pad is mapped to forward/yaw, while the vertical slider controls depth thrust.
|
||||
|
||||
## Link behavior
|
||||
|
||||
- MAVLink GCS heartbeat: 1 Hz
|
||||
- `MANUAL_CONTROL`: selectable 10 or 20 Hz (default 20 Hz). Lower rates are
|
||||
intentionally excluded because they make short return ramps become abrupt
|
||||
steps at the flight controller.
|
||||
- No full parameter download
|
||||
- On connection, inherited bulk telemetry streams are stopped. Only
|
||||
`SYS_STATUS` at 1 Hz and `SERVO_OUTPUT_RAW` / `RC_CHANNELS` at 2 Hz are
|
||||
requested, in addition to the normal heartbeat and event-driven status text.
|
||||
- Arm/disarm uses `MAV_CMD_COMPONENT_ARM_DISARM` and displays `COMMAND_ACK`
|
||||
- Link health is based on received autopilot heartbeats
|
||||
- Battery voltage is read from `SYS_STATUS` at 1 Hz and shown as pack voltage,
|
||||
estimated per-cell voltage, and a voltage-only percentage. Cell count,
|
||||
capacity, C rating, full/empty cell voltages, and warning percentage are
|
||||
configurable and saved locally in `battery_settings.json`. Capacity consumed
|
||||
cannot be measured without a current sensor.
|
||||
- Servo configuration scans `SERVO1_FUNCTION` through `SERVO16_FUNCTION` and
|
||||
ArduSub's `MOT_1_DIRECTION` through `MOT_12_DIRECTION` one at a time, waiting
|
||||
for each response and retrying missed replies. A selected output can be
|
||||
assigned to Motor 1–12 or set unassigned. Motor direction is toggled with the
|
||||
corresponding `MOT_n_DIRECTION` value while disarmed; `SERVOx_REVERSED` is
|
||||
intentionally not used for Motor functions. It never downloads the full
|
||||
parameter list.
|
||||
The diagnostic scan also reads `SERVO1`–`SERVO3` MIN/TRIM/MAX and ArduSub's
|
||||
joystick gain values into the local session log; these values remain
|
||||
read-only in the application.
|
||||
- Every session creates a local text log under `logs/`, including operator
|
||||
actions, outgoing manual-control values and commands, acknowledgements,
|
||||
status text, and all received MAVLink messages. This does not use ArduPilot's
|
||||
onboard logger or add any link traffic.
|
||||
|
||||
The car-style virtual joystick maps only to forward/reverse and continuous
|
||||
differential turning; it never sends lateral motion. The joystick and vertical
|
||||
slider ease back to center over 400 ms after mouse/key release, so neither can
|
||||
be left commanding motion.
|
||||
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are
|
||||
intentionally not eased.
|
||||
|
||||
In addition, transmitted pilot demands have a small 4% input deadband and a
|
||||
0.8-second full-range slew limit. This makes both acceleration and deceleration
|
||||
arrive at the flight controller as a sequence of stable intermediate values.
|
||||
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
|
||||
limit is applied, preventing their mixer contributions from adding beyond the
|
||||
selected horizontal limit.
|
||||
|
||||
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
|
||||
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
|
||||
MAVLink input. Those parameters must already be correctly configured.
|
||||
Verify motor direction, ESC settings, and vehicle failsafes before use. Remove propellers for initial bench tests and keep a physical power cutoff available. A lost connection cannot guarantee the motors stop.
|
||||
|
||||
@@ -3,4 +3,5 @@ __pycache__/
|
||||
*.py[cod]
|
||||
logs/
|
||||
model_settings.json
|
||||
link_settings.json
|
||||
runtime/
|
||||
|
||||
+24
-110
@@ -1,124 +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.
|
||||
Install Python 3.11+ with the Python launcher, then run:
|
||||
|
||||
## Safety first
|
||||
```powershell
|
||||
./run.ps1
|
||||
```
|
||||
|
||||
- Remove propellers for initial tests and restrain the vehicle.
|
||||
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range,
|
||||
leak/failsafe, and emergency-stop arrangements before arming.
|
||||
- The application starts disarmed and limits pilot demand to 20% by default.
|
||||
Once armed, control movement is sent directly with no additional gate.
|
||||
- Closing or disconnecting sends neutral controls and a disarm request, but a
|
||||
physical power cutoff is still required.
|
||||
The script creates a virtual environment and installs dependencies automatically.
|
||||
On first launch, it also adds a **SUB Control** desktop shortcut.
|
||||
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
|
||||
Connection settings are under **•••**.
|
||||
Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details.
|
||||
|
||||
The controls are pilot-demand axes, not direct PWM values. ArduSub performs
|
||||
the frame mixing and generates the configured ESC outputs. For ArduSub,
|
||||
`MANUAL_CONTROL.z = 500` is neutral vertical thrust.
|
||||
## UI design rule
|
||||
|
||||
## Run on Windows
|
||||
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.
|
||||
|
||||
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.
|
||||
## Controls
|
||||
|
||||
`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`.
|
||||
- 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.
|
||||
|
||||
## Repository layout
|
||||
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
|
||||
|
||||
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.
|
||||
## Safety
|
||||
|
||||
Keyboard controls for SimpleROV-3: W/S forward/back, A/D yaw left/right,
|
||||
Q/E alternative yaw control, and R/F rise/dive.
|
||||
Releasing movement keys eases their controls back to neutral.
|
||||
|
||||
## Three-thruster limitation
|
||||
|
||||
The common ArduSub `SimpleROV-3` arrangement controls forward, yaw, and depth.
|
||||
It cannot independently control lateral, roll, or pitch. Accordingly, the main
|
||||
pad is mapped to forward/yaw, while the vertical slider controls depth thrust.
|
||||
|
||||
## Link behavior
|
||||
|
||||
- MAVLink GCS heartbeat: 1 Hz
|
||||
- `MANUAL_CONTROL`: selectable 10 or 20 Hz (default 20 Hz). Lower rates are
|
||||
intentionally excluded because they make short return ramps become abrupt
|
||||
steps at the flight controller.
|
||||
- No full parameter download
|
||||
- On connection, inherited bulk telemetry streams are stopped. Only
|
||||
`SYS_STATUS` at 1 Hz and `SERVO_OUTPUT_RAW` / `RC_CHANNELS` at 2 Hz are
|
||||
requested, in addition to the normal heartbeat and event-driven status text.
|
||||
- Arm/disarm uses `MAV_CMD_COMPONENT_ARM_DISARM` and displays `COMMAND_ACK`
|
||||
- Link health is based on received autopilot heartbeats
|
||||
- Battery voltage is read from `SYS_STATUS` at 1 Hz and shown as pack voltage,
|
||||
estimated per-cell voltage, and a voltage-only percentage. Cell count,
|
||||
capacity, C rating, full/empty cell voltages, and warning percentage are
|
||||
configurable and saved locally in `battery_settings.json`. Capacity consumed
|
||||
cannot be measured without a current sensor.
|
||||
- Servo configuration scans `SERVO1_FUNCTION` through `SERVO16_FUNCTION` and
|
||||
ArduSub's `MOT_1_DIRECTION` through `MOT_12_DIRECTION` one at a time, waiting
|
||||
for each response and retrying missed replies. A selected output can be
|
||||
assigned to Motor 1–12 or set unassigned. Motor direction is toggled with the
|
||||
corresponding `MOT_n_DIRECTION` value while disarmed; `SERVOx_REVERSED` is
|
||||
intentionally not used for Motor functions. It never downloads the full
|
||||
parameter list.
|
||||
The diagnostic scan also reads `SERVO1`–`SERVO3` MIN/TRIM/MAX and ArduSub's
|
||||
joystick gain values into the local session log; these values remain
|
||||
read-only in the application.
|
||||
- Every session creates a local text log under `logs/`, including operator
|
||||
actions, outgoing manual-control values and commands, acknowledgements,
|
||||
status text, and all received MAVLink messages. This does not use ArduPilot's
|
||||
onboard logger or add any link traffic.
|
||||
|
||||
The car-style virtual joystick maps only to forward/reverse and continuous
|
||||
differential turning; it never sends lateral motion. The joystick and vertical
|
||||
slider ease back to center over 400 ms after mouse/key release, so neither can
|
||||
be left commanding motion.
|
||||
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are
|
||||
intentionally not eased.
|
||||
|
||||
In addition, transmitted pilot demands have a small 4% input deadband and a
|
||||
0.8-second full-range slew limit. This makes both acceleration and deceleration
|
||||
arrive at the flight controller as a sequence of stable intermediate values.
|
||||
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
|
||||
limit is applied, preventing their mixer contributions from adding beyond the
|
||||
selected horizontal limit.
|
||||
|
||||
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
|
||||
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
|
||||
MAVLink input. Those parameters must already be correctly configured.
|
||||
Verify motor direction, ESC settings, and vehicle failsafes before use. Remove propellers for initial bench tests and keep a physical power cutoff available. A lost connection cannot guarantee the motors stop.
|
||||
|
||||
+678
-48
@@ -13,6 +13,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
# ESC_TELEMETRY_1_TO_4 uses a MAVLink 2 message ID. Select the v2 dialect
|
||||
# before importing pymavlink so received ESC packets can be decoded/logged.
|
||||
os.environ["MAVLINK20"] = "1"
|
||||
from pymavlink import mavutil
|
||||
from serial.tools import list_ports
|
||||
|
||||
@@ -42,20 +45,50 @@ class Axes:
|
||||
yaw: float = 0.0
|
||||
|
||||
|
||||
def normalize_endpoint(port: str) -> str:
|
||||
p = str(port).strip()
|
||||
if not p:
|
||||
return p
|
||||
lower = p.lower()
|
||||
if "sim" in lower or "5760" in lower or lower in ("tcp", "tcp:", "tcp:127.0.0.1", "127.0.0.1"):
|
||||
return "tcp:127.0.0.1:5760"
|
||||
if "14550" in lower or lower in ("udp", "udp:", "udpin", "udpin:"):
|
||||
return "udpin:127.0.0.1:14550"
|
||||
if p.startswith("127.0.0.1:") or p.startswith("localhost:"):
|
||||
num = p.split(":")[1]
|
||||
if num == "14550":
|
||||
return f"udpin:{p}"
|
||||
return f"tcp:{p}"
|
||||
if ":" in p and not p.startswith(("tcp:", "tcpin:", "udp:", "udpin:", "udpout:")):
|
||||
return f"tcp:{p}"
|
||||
return p
|
||||
|
||||
|
||||
class MavlinkWorker:
|
||||
def __init__(self, events: queue.Queue):
|
||||
self.events = events
|
||||
self.connection = None
|
||||
self.stop_event = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
# pymavlink writes are not thread-safe. The web/API thread can issue
|
||||
# commands while this worker sends heartbeats and controls, so serialize
|
||||
# every packet before it reaches a low-bandwidth serial link.
|
||||
self.tx_lock = threading.Lock()
|
||||
self.thread = None
|
||||
self.axes = Axes()
|
||||
self.output_axes = Axes()
|
||||
self.last_control_at = 0.0
|
||||
# Enabled by the web bridge only; the legacy Tk frontend is unchanged.
|
||||
self.require_input_lease = False
|
||||
self.last_input_at = 0.0
|
||||
self.last_input_sequence = -1
|
||||
self.input_timeout = 1.0
|
||||
# Smooth ESC commands without adding a perceptible one-second lag.
|
||||
# At 20 Hz this produces five small steps from neutral to full command.
|
||||
self.ramp_seconds = 0.25
|
||||
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
|
||||
@@ -63,56 +96,136 @@ 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}")
|
||||
if self.thread and self.thread.is_alive():
|
||||
print(f"[MavlinkWorker] Thread already alive, ignoring connect()")
|
||||
return
|
||||
self.stop_event.clear()
|
||||
log_dir = Path(__file__).resolve().parent / "logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log"
|
||||
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
|
||||
self.log("SESSION", f"start port={port} baud={baud} control_hz={self.rate_hz}")
|
||||
normalized = normalize_endpoint(port)
|
||||
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
|
||||
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} "
|
||||
f"control_hz={self.rate_hz} rx=MAVLink2 tx=MAVLink2")
|
||||
self.events.put(("log_path", str(self.log_path)))
|
||||
self.last_heartbeat = 0.0
|
||||
self.last_rx = 0.0
|
||||
self.next_quality_publish_at = 0.0
|
||||
self.thread = threading.Thread(
|
||||
target=self._run, args=(port, baud), daemon=True
|
||||
target=self._run, args=(normalized, baud), daemon=True
|
||||
)
|
||||
self.thread.start()
|
||||
print(f"[MavlinkWorker] worker thread started")
|
||||
|
||||
def _run(self, port: str, baud: int):
|
||||
print(f"[MavlinkWorker._run] ENTERED: port={port!r}, baud={baud}")
|
||||
try:
|
||||
print(f"[MavlinkWorker._run] Creating mavlink_connection...")
|
||||
conn = mavutil.mavlink_connection(
|
||||
port, baud=baud, source_system=255, source_component=190,
|
||||
autoreconnect=False, force_connected=True,
|
||||
# Fail a missing TCP simulator immediately. pymavlink otherwise
|
||||
# retries the refused socket several times and leaves the UI in
|
||||
# "Opening" long after the actual failure.
|
||||
autoreconnect=False, retries=0, force_connected=True,
|
||||
)
|
||||
print(f"[MavlinkWorker._run] mavlink_connection created: {type(conn).__name__}")
|
||||
self.connection = conn
|
||||
self.events.put(("status", f"Port open; waiting for heartbeat at {baud} baud…"))
|
||||
next_gcs_hb = 0.0
|
||||
if port.startswith("tcp:"):
|
||||
self.events.put(("status", f"Connected to {port}; waiting for vehicle heartbeat…"))
|
||||
else:
|
||||
self.events.put(("status", f"Port {port} open; waiting for heartbeat at {baud} baud…"))
|
||||
|
||||
# Send initial GCS heartbeat immediately
|
||||
self.log("TX_HEARTBEAT", "GCS initial")
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
print(f"[MavlinkWorker._run] Initial heartbeat sent, entering recv loop")
|
||||
next_gcs_hb = time.monotonic() + 1.0
|
||||
next_control = 0.0
|
||||
while not self.stop_event.is_set():
|
||||
now = time.monotonic()
|
||||
if now >= next_gcs_hb:
|
||||
self.log("TX_HEARTBEAT", "GCS")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
next_gcs_hb = now + 1.0
|
||||
if self.target_system and now >= next_control:
|
||||
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:
|
||||
@@ -120,8 +233,11 @@ class MavlinkWorker:
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
except Exception as exc:
|
||||
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
|
||||
self.events.put(("error", f"Connection error: {exc}"))
|
||||
finally:
|
||||
if self.heavy_quality_test is not None:
|
||||
self.cancel_heavy_quality_test()
|
||||
self._safe_stop()
|
||||
if self.connection:
|
||||
try:
|
||||
@@ -132,6 +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:
|
||||
@@ -141,22 +274,75 @@ class MavlinkWorker:
|
||||
|
||||
def log(self, category: str, message: str):
|
||||
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
line = f"{timestamp} [{category}] {message}"
|
||||
with self.log_lock:
|
||||
self.console_lines.append(line[:2000])
|
||||
if category.startswith("RX") or category == "ONBOARD_LOG_ERROR":
|
||||
self.console_rx_lines.append(line[:2000])
|
||||
elif category.startswith("TX") or category == "OPERATOR":
|
||||
self.console_tx_lines.append(line[:2000])
|
||||
if self.log_file:
|
||||
self.log_file.write(f"{timestamp} [{category}] {message}\n")
|
||||
self.log_file.write(line + "\n")
|
||||
|
||||
def read_console(self, limit=150):
|
||||
with self.log_lock:
|
||||
limit = max(1, min(300, int(limit)))
|
||||
return {
|
||||
"rx": list(self.console_rx_lines)[-limit:],
|
||||
"tx": list(self.console_tx_lines)[-limit:],
|
||||
}
|
||||
|
||||
def _send_mav(self, connection, method: str, *args):
|
||||
"""Serialize MAVLink 2 writes and protect the start of each burst."""
|
||||
with self.tx_lock:
|
||||
# This full-duplex module pair loses the opening bytes of some
|
||||
# PC-to-FC bursts after the line has been idle. Put a valid,
|
||||
# expendable GCS heartbeat first so the following packet remains
|
||||
# intact and the FC also sees a healthy GCS heartbeat stream.
|
||||
if method != "heartbeat_send" and hasattr(connection.mav, "heartbeat_send"):
|
||||
connection.mav.heartbeat_send(
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
getattr(connection.mav, method)(*args)
|
||||
|
||||
def _handle_message(self, msg):
|
||||
msg_type = msg.get_type()
|
||||
if msg_type == "BAD_DATA":
|
||||
now = time.monotonic()
|
||||
self.bad_frame_times.append(now)
|
||||
self.bad_frame_bytes.append((now, len(getattr(msg, "data", b""))))
|
||||
self._trim_bad_frames(now)
|
||||
self.log("RX_BAD_DATA", repr(getattr(msg, "data", b"")))
|
||||
return
|
||||
self.last_rx = time.monotonic()
|
||||
self._track_rx_delivery(msg)
|
||||
try:
|
||||
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
|
||||
except Exception:
|
||||
payload = str(msg)
|
||||
self.log("RX", payload)
|
||||
if msg_type == "HEARTBEAT" and msg.get_srcComponent() == 1:
|
||||
if msg_type == "TIMESYNC" and int(getattr(msg, "tc1", 0)) != 0:
|
||||
token = int(getattr(msg, "ts1", -1))
|
||||
heavy_probe = self.heavy_pending_pings.pop(token, None)
|
||||
if heavy_probe is not None:
|
||||
stage_index, sent_at = heavy_probe
|
||||
self._record_heavy_probe(stage_index, (time.monotonic() - sent_at) * 1000.0)
|
||||
self._publish_heavy_quality()
|
||||
sent_at = None
|
||||
else:
|
||||
sent_at = self.pending_pings.pop(token, None)
|
||||
if sent_at is not None:
|
||||
rtt_ms = (time.monotonic() - sent_at) * 1000.0
|
||||
self.ping_results.append((time.monotonic(), rtt_ms))
|
||||
self.log("RX_LINK_PROBE", f"timesync={token} rtt_ms={rtt_ms:.1f}")
|
||||
self._publish_link_quality()
|
||||
elif msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
|
||||
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
|
||||
return
|
||||
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||
return
|
||||
first = not self.target_system
|
||||
self.target_system = msg.get_srcSystem()
|
||||
self.target_component = msg.get_srcComponent()
|
||||
@@ -164,30 +350,90 @@ 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:
|
||||
mode = str(msg.custom_mode)
|
||||
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
|
||||
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed))
|
||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
|
||||
if first:
|
||||
self.next_ping_at = time.monotonic()
|
||||
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
||||
self._configure_low_bandwidth_streams()
|
||||
self._start_servo_scan()
|
||||
elif msg_type == "COMMAND_ACK":
|
||||
if (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||
return
|
||||
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
|
||||
result_name = result.name if result else str(msg.result)
|
||||
self.events.put(("ack", int(msg.command), result_name))
|
||||
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
|
||||
self.log("RX_STREAM_CONFIG_ACK", result_name)
|
||||
return
|
||||
if int(msg.command) == mavutil.mavlink.MAV_CMD_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):
|
||||
@@ -210,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,
|
||||
@@ -230,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:
|
||||
@@ -275,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
|
||||
)
|
||||
|
||||
@@ -289,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,
|
||||
)
|
||||
@@ -300,10 +860,19 @@ class MavlinkWorker:
|
||||
if not conn or not self.target_system:
|
||||
return
|
||||
with self.lock:
|
||||
active = not force_neutral
|
||||
now = time.monotonic()
|
||||
expired = self.require_input_lease and (
|
||||
not self.armed or now - self.last_heartbeat > 3.0
|
||||
or now - self.last_input_at > self.input_timeout)
|
||||
if expired or force_neutral:
|
||||
# Never replay a stored command after telemetry/UI recovery.
|
||||
self.axes = Axes()
|
||||
self.output_axes = Axes()
|
||||
active = not (force_neutral or expired)
|
||||
target = self.axes if active else Axes()
|
||||
gain = self.gain
|
||||
now = time.monotonic()
|
||||
independent_motor_scaling = self.independent_motor_scaling
|
||||
motor_gains = dict(self.motor_gains)
|
||||
if force_neutral:
|
||||
self.output_axes = Axes()
|
||||
else:
|
||||
@@ -326,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:
|
||||
@@ -348,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:
|
||||
@@ -369,13 +980,24 @@ class MavlinkWorker:
|
||||
if manual is None:
|
||||
raise RuntimeError("MANUAL mode is not advertised by this firmware")
|
||||
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
|
||||
conn.mav.set_mode_send(
|
||||
self._send_mav(conn, "set_mode_send",
|
||||
self.target_system,
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
|
||||
manual,
|
||||
)
|
||||
|
||||
def set_axes(self, axes: Axes):
|
||||
def set_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.
|
||||
if abs(value) < 0.04:
|
||||
@@ -383,6 +1005,11 @@ class MavlinkWorker:
|
||||
return round(max(-1.0, min(1.0, value)), 2)
|
||||
|
||||
with self.lock:
|
||||
if sequence is not None:
|
||||
if sequence <= self.last_input_sequence:
|
||||
return
|
||||
self.last_input_sequence = sequence
|
||||
self.last_input_at = time.monotonic()
|
||||
self.axes = Axes(
|
||||
conditioned(axes.forward), conditioned(axes.lateral),
|
||||
conditioned(axes.vertical), conditioned(axes.yaw),
|
||||
@@ -401,7 +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,
|
||||
@@ -887,6 +1514,9 @@ class App(tk.Tk):
|
||||
|
||||
def refresh_ports(self):
|
||||
ports = [p.device for p in list_ports.comports()]
|
||||
for sim in ("tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
|
||||
if sim not in ports:
|
||||
ports.append(sim)
|
||||
self.port.configure(values=ports)
|
||||
if ports and self.port.get() not in ports:
|
||||
self.port.set(ports[0])
|
||||
@@ -1139,7 +1769,7 @@ class App(tk.Tk):
|
||||
event = self.events.get_nowait()
|
||||
kind = event[0]
|
||||
if kind == "heartbeat":
|
||||
_, system, autopilot, mode, armed = event
|
||||
_, system, autopilot, mode, armed = event[:5]
|
||||
self.vehicle_label.configure(
|
||||
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -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 |
@@ -0,0 +1,553 @@
|
||||
"""ArduSub Flight Controller (FC) Spoofer & Simulator.
|
||||
|
||||
Emulates an ArduSub SimpleROV-3 vehicle over MAVLink (TCP, UDP, or Serial).
|
||||
Supports full parameter scans, live parameter updates (servo functions / motor reversal),
|
||||
arming/disarming commands, manual control processing, and realistic telemetry streaming
|
||||
(battery voltage/current with load sag, live thruster PWM mixing, simulated attitude & depth).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pymavlink import mavutil
|
||||
|
||||
|
||||
# Default SimpleROV-3 motor mappings and ArduSub parameters
|
||||
DEFAULT_PARAMETERS: dict[str, tuple[float, int]] = {
|
||||
# Servo functions: 33 = Motor 1 (Port), 34 = Motor 2 (Starboard), 35 = Motor 3 (Vertical)
|
||||
"SERVO1_FUNCTION": (33.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
|
||||
"SERVO2_FUNCTION": (34.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
|
||||
"SERVO3_FUNCTION": (35.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
|
||||
# Remaining servos unassigned
|
||||
**{f"SERVO{i}_FUNCTION": (0.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16) for i in range(4, 17)},
|
||||
# Motor directions: 1 = normal, -1 = reversed
|
||||
**{f"MOT_{i}_DIRECTION": (1.0, mavutil.mavlink.MAV_PARAM_TYPE_INT8) for i in range(1, 13)},
|
||||
# SimpleROV-3 thruster PWM limits
|
||||
"SERVO1_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO1_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO1_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO2_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO2_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO2_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO3_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO3_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO3_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
# Joystick gain parameters
|
||||
"JS_GAIN_MIN": (0.10, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"JS_GAIN_DEFAULT": (0.20, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"JS_GAIN_MAX": (1.00, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlState:
|
||||
x: float = 0.0 # Surge (forward/back) -1000..1000
|
||||
y: float = 0.0 # Sway (lateral) -1000..1000
|
||||
z: float = 500.0 # Heave (vertical) 0..1000 (500 = neutral)
|
||||
r: float = 0.0 # Yaw (turn) -1000..1000
|
||||
last_rx: float = 0.0
|
||||
|
||||
|
||||
class FlightControllerSpoofer:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str = "tcpin:127.0.0.1:5760",
|
||||
baud: int = 57600,
|
||||
battery_cells: int = 4,
|
||||
nominal_voltage: float = 16.0,
|
||||
show_dashboard: bool = True,
|
||||
):
|
||||
self.endpoint = endpoint
|
||||
self.baud = baud
|
||||
self.battery_cells = battery_cells
|
||||
self.battery_base_v = nominal_voltage
|
||||
self.show_dashboard = show_dashboard
|
||||
|
||||
self.running = True
|
||||
self.armed = False
|
||||
self.mode = 19 # ArduSub MANUAL mode
|
||||
self.system_status = mavutil.mavlink.MAV_STATE_ACTIVE
|
||||
self.target_system = 255
|
||||
self.target_component = 190
|
||||
|
||||
self.params = dict(DEFAULT_PARAMETERS)
|
||||
self.controls = ControlState()
|
||||
|
||||
# Simulated physical state
|
||||
self.depth_m = 1.5 # Positive depth under water
|
||||
self.heading_deg = 0.0
|
||||
self.pitch_deg = 0.0
|
||||
self.roll_deg = 0.0
|
||||
self.m1_pwm = 1500 # Port
|
||||
self.m2_pwm = 1500 # Starboard
|
||||
self.m3_pwm = 1500 # Vertical
|
||||
self.current_a = 0.35
|
||||
|
||||
self.events: deque[str] = deque(maxlen=6)
|
||||
self.client_connected = False
|
||||
self.total_packets_rx = 0
|
||||
self.total_packets_tx = 0
|
||||
self.start_time = time.monotonic()
|
||||
|
||||
def log_event(self, text: str):
|
||||
now_str = time.strftime("%H:%M:%S")
|
||||
self.events.append(f"[{now_str}] {text}")
|
||||
|
||||
def run(self):
|
||||
print(f"\n{'='*72}")
|
||||
print(f" ARDUSUB SIMPLE-ROV FLIGHT CONTROLLER SPOOFER IS RUNNING")
|
||||
print(f" Target Endpoint: {self.endpoint}")
|
||||
print(f" --> In Sub Control app, select or connect to: {self.endpoint.replace('tcpin:', 'tcp:')}")
|
||||
print(f"{'='*72}\n", flush=True)
|
||||
self.log_event(f"Simulator starting on {self.endpoint}...")
|
||||
while self.running:
|
||||
try:
|
||||
self._run_server_session()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except OSError as exc:
|
||||
if getattr(exc, "winerror", None) == 10048 or "already in use" in str(exc).lower():
|
||||
print(f"\n[ERROR] Port {self.endpoint} is already in use by another program or previous spoofer instance.")
|
||||
print("Please close any running python/spoofer processes and try again.\n")
|
||||
break
|
||||
self.log_event(f"Session reset: {exc}")
|
||||
time.sleep(1.0)
|
||||
except Exception as exc:
|
||||
self.log_event(f"Session reset: {exc}")
|
||||
time.sleep(1.0)
|
||||
print("\nSpoofer stopped.")
|
||||
|
||||
def _run_server_session(self):
|
||||
# Bind server / open connection
|
||||
if self.endpoint.startswith("tcpin:"):
|
||||
shown_endpoint = self.endpoint.replace("tcpin:", "tcp:", 1)
|
||||
print(f"[READY] Listening for SUB Control on {shown_endpoint}", flush=True)
|
||||
print(" Leave this window open, then click Connect in the app.\n", flush=True)
|
||||
conn = mavutil.mavlink_connection(
|
||||
self.endpoint,
|
||||
baud=self.baud,
|
||||
source_system=1,
|
||||
source_component=1,
|
||||
autoreconnect=True,
|
||||
force_connected=True,
|
||||
)
|
||||
|
||||
self.log_event(f"Client connected on {self.endpoint}")
|
||||
|
||||
next_heartbeat = 0.0
|
||||
next_sys_status = 0.0
|
||||
next_servos = 0.0
|
||||
next_attitude = 0.0
|
||||
next_dashboard = 0.0
|
||||
|
||||
# Send initial announcement statustext
|
||||
initial_sent = False
|
||||
|
||||
while self.running:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.start_time
|
||||
time_boot_ms = int(elapsed * 1000) & 0xFFFFFFFF
|
||||
time_boot_us = int(elapsed * 1000000) & 0xFFFFFFFF
|
||||
|
||||
# 1. Heartbeat at 1.0 Hz
|
||||
if now >= next_heartbeat:
|
||||
base_mode = (
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
|
||||
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
|
||||
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
|
||||
)
|
||||
if self.armed:
|
||||
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
|
||||
|
||||
conn.mav.heartbeat_send(
|
||||
mavutil.mavlink.MAV_TYPE_SUBMARINE,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
|
||||
base_mode,
|
||||
self.mode,
|
||||
self.system_status,
|
||||
)
|
||||
self.total_packets_tx += 1
|
||||
next_heartbeat = now + 1.0
|
||||
|
||||
if not initial_sent and self.client_connected:
|
||||
conn.mav.statustext_send(
|
||||
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||
b"ArduSub V4.1.0 (SimpleROV-3 Simulator)",
|
||||
)
|
||||
conn.mav.statustext_send(
|
||||
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||
b"EKF3 IMU0 initialised",
|
||||
)
|
||||
initial_sent = True
|
||||
|
||||
# 2. Physics & Motor Mixing step
|
||||
self._update_physics(now)
|
||||
|
||||
# 3. SYS_STATUS at 1.0 Hz (Battery & Sensor Health)
|
||||
if now >= next_sys_status:
|
||||
# Voltage with slight noise + load sag
|
||||
load_factor = (
|
||||
abs(self.m1_pwm - 1500)
|
||||
+ abs(self.m2_pwm - 1500)
|
||||
+ abs(self.m3_pwm - 1500)
|
||||
) / 1200.0 # 0.0 to 1.0
|
||||
voltage_v = self.battery_base_v - (0.45 * load_factor) + (0.02 * math.sin(now * 1.5))
|
||||
voltage_mv = int(max(10.0, voltage_v) * 1000)
|
||||
|
||||
# Current: 0.35A base + up to 12.5A at full throttle
|
||||
self.current_a = 0.35 + (12.5 * load_factor)
|
||||
current_ca = int(self.current_a * 100)
|
||||
|
||||
# Battery remaining percentage (e.g. 88% draining slowly)
|
||||
remaining_pct = max(10, int(88 - (elapsed / 300.0)))
|
||||
|
||||
conn.mav.sys_status_send(
|
||||
322042887, # onboard_control_sensors_present
|
||||
320969735, # onboard_control_sensors_enabled
|
||||
305241095, # onboard_control_sensors_health
|
||||
175, # load (17.5%)
|
||||
voltage_mv,
|
||||
current_ca,
|
||||
remaining_pct,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
)
|
||||
self.total_packets_tx += 1
|
||||
next_sys_status = now + 1.0
|
||||
|
||||
# 4. SERVO_OUTPUT_RAW & RC_CHANNELS at 4.0 Hz
|
||||
if now >= next_servos:
|
||||
conn.mav.servo_output_raw_send(
|
||||
time_boot_us,
|
||||
0,
|
||||
self.m1_pwm,
|
||||
self.m2_pwm,
|
||||
self.m3_pwm,
|
||||
0, 0, 0, 0, 0,
|
||||
)
|
||||
conn.mav.rc_channels_send(
|
||||
time_boot_ms,
|
||||
8,
|
||||
1500 + int(self.controls.r * 0.4), # Chan 1: Yaw
|
||||
1500, # Chan 2: Roll
|
||||
int(self.controls.z), # Chan 3: Throttle/Vertical
|
||||
1500 + int(self.controls.x * 0.4), # Chan 4: Pitch/Forward
|
||||
1500, 1500, 1500, 1500,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
)
|
||||
self.total_packets_tx += 2
|
||||
next_servos = now + 0.25
|
||||
|
||||
# 5. ATTITUDE & VFR_HUD at 4.0 Hz
|
||||
if now >= next_attitude:
|
||||
conn.mav.attitude_send(
|
||||
time_boot_ms,
|
||||
math.radians(self.roll_deg),
|
||||
math.radians(self.pitch_deg),
|
||||
math.radians(self.heading_deg),
|
||||
0.0, 0.0, 0.0,
|
||||
)
|
||||
conn.mav.vfr_hud_send(
|
||||
abs(self.m1_pwm - 1500) / 400.0, # airspeed
|
||||
abs(self.m2_pwm - 1500) / 400.0, # groundspeed
|
||||
int(self.heading_deg) % 360, # heading
|
||||
int((self.controls.z - 500) / 5), # throttle %
|
||||
-self.depth_m, # altitude (negative depth)
|
||||
(self.controls.z - 500) / 500.0, # climb rate
|
||||
)
|
||||
self.total_packets_tx += 2
|
||||
next_attitude = now + 0.25
|
||||
|
||||
# 6. Process incoming MAVLink packets from App
|
||||
self._handle_incoming_messages(conn)
|
||||
|
||||
# 7. Render Console Dashboard
|
||||
if self.show_dashboard and now >= next_dashboard:
|
||||
self._render_dashboard()
|
||||
next_dashboard = now + 0.15
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
def _update_physics(self, now: float):
|
||||
# If manual control packet hasn't arrived for > 1.5s, auto-neutralize
|
||||
if now - self.controls.last_rx > 1.5:
|
||||
self.controls.x = 0.0
|
||||
self.controls.y = 0.0
|
||||
self.controls.z = 500.0
|
||||
self.controls.r = 0.0
|
||||
|
||||
if not self.armed:
|
||||
self.m1_pwm = 1500
|
||||
self.m2_pwm = 1500
|
||||
self.m3_pwm = 1500
|
||||
else:
|
||||
# Read motor direction parameters
|
||||
mot1_dir = int(self.params.get("MOT_1_DIRECTION", (1.0,))[0])
|
||||
mot2_dir = int(self.params.get("MOT_2_DIRECTION", (1.0,))[0])
|
||||
mot3_dir = int(self.params.get("MOT_3_DIRECTION", (1.0,))[0])
|
||||
|
||||
# Differential drive mixing for SimpleROV-3:
|
||||
# Motor 1 (Port): Forward + Yaw
|
||||
# Motor 2 (Starboard): Forward - Yaw
|
||||
# Motor 3 (Vertical): Vertical (0..1000 with 500 center)
|
||||
forward_cmd = (self.controls.x / 1000.0) * 400.0 # -400..+400
|
||||
yaw_cmd = (self.controls.r / 1000.0) * 400.0 # -400..+400
|
||||
vert_cmd = ((self.controls.z - 500.0) / 500.0) * 400.0 # -400..+400
|
||||
|
||||
m1_offset = (forward_cmd + yaw_cmd) * mot1_dir
|
||||
m2_offset = (forward_cmd - yaw_cmd) * mot2_dir
|
||||
m3_offset = vert_cmd * mot3_dir
|
||||
|
||||
self.m1_pwm = int(max(1100, min(1900, 1500 + m1_offset)))
|
||||
self.m2_pwm = int(max(1100, min(1900, 1500 + m2_offset)))
|
||||
self.m3_pwm = int(max(1100, min(1900, 1500 + m3_offset)))
|
||||
|
||||
# Simulate subtle underwater motion
|
||||
self.roll_deg = 2.5 * math.sin(now * 0.8)
|
||||
self.pitch_deg = (self.controls.x / 1000.0) * 5.0 + 1.2 * math.cos(now * 0.7)
|
||||
if self.armed and abs(self.controls.r) > 10:
|
||||
self.heading_deg = (self.heading_deg + (self.controls.r / 1000.0) * 2.0) % 360.0
|
||||
|
||||
if self.armed and abs(self.controls.z - 500) > 20:
|
||||
vert_delta = ((self.controls.z - 500.0) / 500.0) * 0.02
|
||||
self.depth_m = max(0.1, min(50.0, self.depth_m - vert_delta))
|
||||
|
||||
def _handle_incoming_messages(self, conn):
|
||||
while True:
|
||||
msg = conn.recv_match(blocking=False)
|
||||
if not msg:
|
||||
break
|
||||
|
||||
self.total_packets_rx += 1
|
||||
if not self.client_connected:
|
||||
self.client_connected = True
|
||||
self.log_event(f"GCS Connected (SysID {msg.get_srcSystem()}, CompID {msg.get_srcComponent()})")
|
||||
# Immediately send heartbeat so GCS locks on with zero delay
|
||||
base_mode = (
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
|
||||
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
|
||||
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
|
||||
)
|
||||
if self.armed:
|
||||
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
|
||||
conn.mav.heartbeat_send(
|
||||
mavutil.mavlink.MAV_TYPE_SUBMARINE,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
|
||||
base_mode,
|
||||
self.mode,
|
||||
self.system_status,
|
||||
)
|
||||
|
||||
mtype = msg.get_type()
|
||||
if mtype == "HEARTBEAT":
|
||||
self.target_system = msg.get_srcSystem()
|
||||
self.target_component = msg.get_srcComponent()
|
||||
|
||||
elif mtype == "COMMAND_LONG":
|
||||
cmd = int(msg.command)
|
||||
if cmd == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM:
|
||||
should_arm = bool(msg.param1 == 1.0)
|
||||
self.armed = should_arm
|
||||
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||
status_str = "Motors ARMED" if self.armed else "Motors DISARMED"
|
||||
self.log_event(f"Command: {status_str}")
|
||||
conn.mav.statustext_send(
|
||||
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||
status_str.encode("ascii"),
|
||||
)
|
||||
|
||||
elif cmd == mavutil.mavlink.MAV_CMD_DO_SET_MODE:
|
||||
self.mode = int(msg.param2)
|
||||
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||
self.log_event(f"Command: SET_MODE {self.mode}")
|
||||
conn.mav.statustext_send(
|
||||
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||
b"Mode: MANUAL",
|
||||
)
|
||||
|
||||
elif cmd == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
|
||||
msg_id = int(msg.param1)
|
||||
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||
self.log_event(f"Stream interval set for MsgID {msg_id}")
|
||||
|
||||
else:
|
||||
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||
|
||||
elif mtype == "SET_MODE":
|
||||
self.mode = int(msg.custom_mode)
|
||||
self.log_event(f"Set custom mode -> {self.mode}")
|
||||
conn.mav.statustext_send(mavutil.mavlink.MAV_SEVERITY_INFO, b"Mode: MANUAL")
|
||||
|
||||
elif mtype == "REQUEST_DATA_STREAM":
|
||||
pass # Acknowledged implicitly by sending streams
|
||||
|
||||
elif mtype == "PARAM_REQUEST_READ":
|
||||
param_id = msg.param_id
|
||||
if isinstance(param_id, bytes):
|
||||
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
|
||||
if param_id in self.params:
|
||||
val, ptype = self.params[param_id]
|
||||
else:
|
||||
val, ptype = (0.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32)
|
||||
conn.mav.param_value_send(
|
||||
param_id.encode("ascii"),
|
||||
float(val),
|
||||
ptype,
|
||||
len(self.params),
|
||||
0,
|
||||
)
|
||||
self.log_event(f"Param read: {param_id} = {val}")
|
||||
|
||||
elif mtype == "PARAM_REQUEST_LIST":
|
||||
self.log_event("Param list requested, streaming all...")
|
||||
for idx, (p_name, (p_val, p_type)) in enumerate(self.params.items()):
|
||||
conn.mav.param_value_send(
|
||||
p_name.encode("ascii"),
|
||||
float(p_val),
|
||||
p_type,
|
||||
len(self.params),
|
||||
idx,
|
||||
)
|
||||
time.sleep(0.002)
|
||||
|
||||
elif mtype == "PARAM_SET":
|
||||
param_id = msg.param_id
|
||||
if isinstance(param_id, bytes):
|
||||
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
|
||||
new_val = float(msg.param_value)
|
||||
ptype = int(msg.param_type)
|
||||
self.params[param_id] = (new_val, ptype)
|
||||
conn.mav.param_value_send(
|
||||
param_id.encode("ascii"),
|
||||
new_val,
|
||||
ptype,
|
||||
len(self.params),
|
||||
0,
|
||||
)
|
||||
self.log_event(f"Param updated: {param_id} -> {int(new_val) if new_val.is_integer() else new_val}")
|
||||
|
||||
elif mtype == "MANUAL_CONTROL":
|
||||
self.controls.x = float(msg.x)
|
||||
self.controls.y = float(msg.y)
|
||||
self.controls.z = float(msg.z)
|
||||
self.controls.r = float(msg.r)
|
||||
self.controls.last_rx = time.monotonic()
|
||||
|
||||
def _render_dashboard(self):
|
||||
# Clear screen ANSI
|
||||
out = sys.stdout
|
||||
out.write("\033[H")
|
||||
|
||||
# Color codes
|
||||
GREEN = "\033[92m"
|
||||
RED = "\033[91m"
|
||||
YELLOW = "\033[93m"
|
||||
CYAN = "\033[96m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
def bar(val: float, min_v: float, max_v: float, width: int = 14) -> str:
|
||||
norm = max(0.0, min(1.0, (val - min_v) / (max_v - min_v)))
|
||||
pos = int(norm * width)
|
||||
return f"[{'=' * pos}{' ' * (width - pos)}]"
|
||||
|
||||
status_tag = f"{GREEN}{BOLD} ARMED {RESET}" if self.armed else f"{YELLOW}DISARMED{RESET}"
|
||||
link_tag = f"{GREEN}ONLINE{RESET}" if self.client_connected else f"{RED}WAITING{RESET}"
|
||||
|
||||
lines = [
|
||||
f"{BOLD}{CYAN}========================================================================{RESET}",
|
||||
f"{BOLD} ArduSub SimpleROV-3 Flight Controller Spoofer & Telemetry Simulator {RESET}",
|
||||
f"{BOLD}{CYAN}========================================================================{RESET}",
|
||||
f" Endpoint: {BOLD}{self.endpoint}{RESET} Baud: {self.baud} Link: {link_tag} State: {status_tag} Mode: {BOLD}MANUAL{RESET}",
|
||||
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||
f" {BOLD}BATTERY & POWER:{RESET}",
|
||||
f" Voltage: {BOLD}{self.battery_base_v:.2f} V{RESET} ({self.battery_cells}S) Current: {BOLD}{self.current_a:.2f} A{RESET} Power: {BOLD}{self.battery_base_v * self.current_a:.1f} W{RESET}",
|
||||
f" Depth: {BOLD}{self.depth_m:.2f} m{RESET} Heading: {BOLD}{self.heading_deg:.0f}°{RESET} Roll/Pitch: {self.roll_deg:+.1f}° / {self.pitch_deg:+.1f}°",
|
||||
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||
f" {BOLD}INCOMING MANUAL CONTROLS (from Sub Control app):{RESET}",
|
||||
f" Surge (Forward/Back): {self.controls.x:+5.0f} {bar(self.controls.x, -1000, 1000)}",
|
||||
f" Yaw (Steer/Turn): {self.controls.r:+5.0f} {bar(self.controls.r, -1000, 1000)}",
|
||||
f" Heave (Vertical): {self.controls.z:5.0f} {bar(self.controls.z, 0, 1000)}",
|
||||
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||
f" {BOLD}SIMULATED THRUSTER PWM OUTPUTS:{RESET}",
|
||||
f" Motor 1 (Port / Left): {self.m1_pwm:4d} µs {bar(self.m1_pwm, 1100, 1900)}",
|
||||
f" Motor 2 (Starboard / Right): {self.m2_pwm:4d} µs {bar(self.m2_pwm, 1100, 1900)}",
|
||||
f" Motor 3 (Vertical / Center): {self.m3_pwm:4d} µs {bar(self.m3_pwm, 1100, 1900)}",
|
||||
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||
f" {BOLD}RECENT EVENT LOG:{RESET}",
|
||||
]
|
||||
|
||||
if not self.events:
|
||||
lines.append(f" {DIM}No events yet. Connect Sub Control app to begin.{RESET}")
|
||||
else:
|
||||
for ev in list(self.events)[-5:]:
|
||||
lines.append(f" {ev}")
|
||||
|
||||
lines.append(f"{CYAN}------------------------------------------------------------------------{RESET}")
|
||||
lines.append(f" Packets RX: {self.total_packets_rx:<8d} Packets TX: {self.total_packets_tx:<8d} (Press Ctrl+C to exit)")
|
||||
|
||||
out.write("\n".join(lines) + "\n")
|
||||
out.flush()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ArduSub Flight Controller (FC) Spoofer & Telemetry Simulator",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--endpoint",
|
||||
type=str,
|
||||
default="tcpin:127.0.0.1:5760",
|
||||
help="MAVLink connection string (e.g. 'tcpin:127.0.0.1:5760', 'udpin:127.0.0.1:14550', 'COM3')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=57600,
|
||||
help="Baud rate (for serial COM ports)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cells",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Battery cell count (4S = 16.0V nominal)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--voltage",
|
||||
type=float,
|
||||
default=15.8,
|
||||
help="Nominal starting battery voltage (V)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-dashboard",
|
||||
action="store_true",
|
||||
help="Disable interactive terminal dashboard and use plain output",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Enable ANSI escape sequences on Windows console
|
||||
if os.name == "nt":
|
||||
os.system("")
|
||||
|
||||
spoofer = FlightControllerSpoofer(
|
||||
endpoint=args.endpoint,
|
||||
baud=args.baud,
|
||||
battery_cells=args.cells,
|
||||
nominal_voltage=args.voltage,
|
||||
show_dashboard=not args.no_dashboard,
|
||||
)
|
||||
spoofer.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
title ArduSub FC Spoofer
|
||||
cd /d "%~dp0"
|
||||
if exist ".venv\Scripts\python.exe" (
|
||||
".venv\Scripts\python.exe" -u fc_spoofer.py %*
|
||||
) else (
|
||||
python -u fc_spoofer.py %*
|
||||
)
|
||||
pause
|
||||
@@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location -LiteralPath $projectRoot
|
||||
|
||||
$existingListener = Get-NetTCPConnection -LocalPort 5760 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($existingListener) {
|
||||
$existingProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$($existingListener.OwningProcess)" -ErrorAction SilentlyContinue
|
||||
Write-Host ""
|
||||
Write-Host "The simulator is already LISTENING on tcp:127.0.0.1:5760." -ForegroundColor Green
|
||||
Write-Host "Process: $($existingListener.OwningProcess) $($existingProcess.Name)"
|
||||
Write-Host "Leave that simulator running and select 'Simulator (TCP 5760)' in SUB Control."
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath ".venv\Scripts\python.exe") {
|
||||
& .venv\Scripts\python.exe -u fc_spoofer.py @args
|
||||
} else {
|
||||
& python -u fc_spoofer.py @args
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// Deterministic DOM/pointer harness. No browser, vehicle, or serial port involved.
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
|
||||
const {present}=require('../web/control-state.js');
|
||||
const armed={connected:true,armed_known:true,armed:true,app_state:'armed',servos:{}};
|
||||
|
||||
test('model viewport fills the available space above battery status',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.match(css,/\.rov-stage \{ flex:1; height:auto; min-height:0; width:100%/);
|
||||
assert.match(css,/\.telemetry-zone \.model-card \{ flex: 1; min-height: 0;/);
|
||||
});
|
||||
|
||||
test('white wash stays behind controls; right panel is gray and edges reach 50 percent',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.match(css,/\.workspace-light \{[^}]*z-index:-1/);
|
||||
assert.match(css,/\.controls-workspace \{[^}]*isolation:isolate/);
|
||||
assert.match(css,/\.controls-workspace \{[^}]*background:#ededed/);
|
||||
assert.match(css,/\.sidebar-deck \{[^}]*background:linear-gradient\(160deg,#333,#242424\)/);
|
||||
assert.match(css,/\.cursor-light::after \{[^}]*rgba\(255,255,255,\.50\)/);
|
||||
});
|
||||
|
||||
test('keyboard easing publishes intermediate demands to the real Python bridge',async()=>{
|
||||
const h=harness();h.state();h.run('bridgeReady=true');
|
||||
keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');
|
||||
for(let i=0;i<10;i++){h.advance(50);await Promise.resolve();await Promise.resolve();await Promise.resolve();}
|
||||
const sent=h.calls.filter(call=>call[0]==='axes');
|
||||
assert.ok(sent.some(call=>call[1]>0&&call[1]<1),'surge ramp is transmitted, not just drawn');
|
||||
assert.ok(sent.some(call=>call[3]>0&&call[3]<1),'vertical ramp is transmitted, not just drawn');
|
||||
assert.ok(sent.every(call=>call[1]>=0&&call[1]<=1&&call[3]>=0&&call[3]<=1));
|
||||
});
|
||||
|
||||
test('cursor lighting stays decorative and arrows use centered geometry',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
|
||||
assert.doesNotMatch(js,/set_axes|setJoyControl|setHeave|requestControl/);
|
||||
assert.match(js,/workspace\.append\(wash\)/);
|
||||
assert.match(js,/pointerleave',clear/);
|
||||
assert.match(css,/\.cursor-light \{[^}]*pointer-events:none/);
|
||||
assert.match(css,/range-pill button \{[^}]*place-items:center/);
|
||||
assert.match(css,/#engage\.danger \.engage-label \{ color:#777/);
|
||||
});
|
||||
|
||||
test('edge lighting uses angle; only visible pills get blue and sidebar is excluded',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
|
||||
assert.match(js,/Math\.atan2/);
|
||||
assert.match(js,/!host\.closest\('\.sidebar-deck,#engage'\)/);
|
||||
assert.match(js,/host\.matches\(pillSelector\)/);
|
||||
assert.match(css,/\.cursor-light\.pill-light::before \{ background:radial-gradient/);
|
||||
assert.match(css,/\.cursor-light::after \{[^}]*linear-gradient\(var\(--light-angle/);
|
||||
assert.doesNotMatch(css,/\.cursor-light::after \{[^}]*radial-gradient/);
|
||||
assert.match(css,/\.rov-stage \{[^}]*border-radius:31px; overflow:hidden/);
|
||||
});
|
||||
|
||||
test('all interface typography uses Helvetica with inherited control fonts',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
const families=[...css.matchAll(/font-family\s*:\s*([^;}]+)/g)].map(match=>match[1].trim());
|
||||
assert.ok(families.includes('"Helvetica Neue", Helvetica, Arial, sans-serif'));
|
||||
assert.ok(families.every(family=>family==='inherit'||family==='"Helvetica Neue", Helvetica, Arial, sans-serif'));
|
||||
assert.match(css,/button, select, input \{ font: inherit; \}/);
|
||||
});
|
||||
|
||||
function keyEvent(h,type,key,repeat=false){h.fire(type,{key,repeat,target:{tagName:'BODY'},preventDefault(){}})}
|
||||
test('keyboard ramps joystick and vertical together, then eases back on release',()=>{
|
||||
const h=harness();h.state();
|
||||
keyEvent(h,'keydown','w');keyEvent(h,'keydown','d');keyEvent(h,'keydown','r');
|
||||
assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
|
||||
h.advance(180);
|
||||
for(const axis of ['forward','turn','vertical'])assert.ok(h.run(`control.${axis}`)>0&&h.run(`control.${axis}`)<1);
|
||||
keyEvent(h,'keydown','w',true);h.advance(240);
|
||||
assert.ok(Math.abs(h.run('control.forward')-Math.SQRT1_2)<1e-6);
|
||||
assert.ok(Math.abs(h.run('control.turn')-Math.SQRT1_2)<1e-6);
|
||||
assert.equal(h.run('control.vertical'),1);
|
||||
keyEvent(h,'keyup','w');keyEvent(h,'keyup','d');keyEvent(h,'keyup','r');
|
||||
h.advance(180);assert.ok(h.run('control.forward')>0);assert.ok(h.run('control.vertical')>0);
|
||||
h.advance(250);assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
|
||||
});
|
||||
test('keyboard reversal is continuous and focus loss cancels its ramp',()=>{
|
||||
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(420);
|
||||
keyEvent(h,'keyup','w');keyEvent(h,'keyup','r');keyEvent(h,'keydown','s');keyEvent(h,'keydown','f');
|
||||
assert.equal(h.run('control.forward'),1);assert.equal(h.run('control.vertical'),1);
|
||||
h.advance(200);assert.ok(Math.abs(h.run('control.forward'))<.3);assert.ok(Math.abs(h.run('control.vertical'))<.3);
|
||||
h.advance(220);assert.equal(h.run('control.forward'),-1);assert.equal(h.run('control.vertical'),-1);
|
||||
keyEvent(h,'keyup','s');keyEvent(h,'keyup','f');h.fire('blur');h.advance(500);
|
||||
assert.equal(h.run('control.forward+control.vertical'),0);
|
||||
});
|
||||
test('touch takes ownership from a keyboard ramp without later keyboard overwrite',()=>{
|
||||
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(100);
|
||||
h.el('#joystick-canvas').emit('pointerdown',{clientX:300,clientY:160});
|
||||
h.el('#heave').emit('pointerdown',{pointerId:2,clientY:300});
|
||||
keyEvent(h,'keydown','a');h.advance(450);
|
||||
assert.equal(h.run('control.turn'),1);assert.equal(Math.abs(h.run('control.forward')),0);assert.equal(h.run('control.vertical'),-1);
|
||||
});
|
||||
test('hold fill and pending engagement share the same green and glow',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.match(css,/\.engage-progress-label \{ color:var\(--engage-green\).*filter:var\(--engage-glow\)/);
|
||||
assert.match(css,/#engage\.pending \.engage-label \{ color:var\(--engage-green\); filter:var\(--engage-glow\)/);
|
||||
});
|
||||
|
||||
test('native pywebview result wrapper can execute alongside app globals',()=>{
|
||||
const h=harness();
|
||||
// Window.evaluate_js declares these variables in the page's global scope.
|
||||
// A global const/let collision prevents every Python reply from executing.
|
||||
assert.doesNotThrow(()=>h.run(`var value; try { value = ['COM3','COM4']; }
|
||||
catch(e) { value = {name:e.name}; var keys = Object.getOwnPropertyNames(e);
|
||||
keys.forEach(function(key) { value[key] = e[key]; }); }
|
||||
populatePorts(value);`));
|
||||
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
|
||||
});
|
||||
|
||||
test('an empty pywebview API cannot permanently latch startup as completed',async()=>{
|
||||
const h=harness();h.context.pywebview.api={};await h.run('boot()');
|
||||
assert.equal(h.run('booted'),false);assert.equal(h.run('bridgeReady'),false);
|
||||
});
|
||||
|
||||
test('desktop startup retries after failure and populates detected COM ports',async()=>{
|
||||
const h=harness();let attempts=0;
|
||||
h.run('loadDefaultModel=()=>Promise.resolve(false)');
|
||||
h.context.pywebview.api.refresh_ports=()=>Promise.resolve([{device:'COM3',label:'COM3'},{device:'COM4',label:'COM4'}]);
|
||||
h.context.pywebview.api.initial_state=()=>++attempts===1?Promise.reject(Error('Temporary startup failure')):Promise.resolve({ports:['COM3','COM4'],battery_config:{},state:{connected:false,armed:null,servos:{}}});
|
||||
h.context.pywebview.api.poll=()=>Promise.resolve({connected:false,armed:null,servos:{}});
|
||||
await h.run('boot()');assert.equal(h.run('booted'),false);
|
||||
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'),'COM discovery survives a configuration startup failure');
|
||||
await h.run('boot()');assert.equal(h.run('booted'),true);
|
||||
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
|
||||
assert.ok(h.el('#port').options.some(p=>p.value==='COM4'));
|
||||
});
|
||||
|
||||
test('visual status changes cannot resize the header or percentage label',()=>{
|
||||
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.doesNotMatch(html,/class="water-ring/);
|
||||
assert.match(html,/class="engage-sheen-label"/);
|
||||
assert.match(css,/padding-bottom:\.22em; margin-bottom:-\.22em/);
|
||||
assert.match(css,/grid-template-rows:120px minmax\(0,1fr\) 52px/);
|
||||
assert.match(css,/\.control-status \{ position:absolute/);
|
||||
assert.match(css,/flex:0 0 128px; width:128px/);
|
||||
assert.match(css,/\.vertical-card footer \{ justify-content:center/);
|
||||
assert.match(css,/mask-image:linear-gradient\(to right,#000 calc\(var\(--engage-progress\) - 8%\),transparent var\(--engage-progress\)\)/);
|
||||
});
|
||||
|
||||
test('activity renders oldest at top, latest above its footer; both control states sweep',()=>{
|
||||
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||
assert.ok(html.indexOf('id="activity-oldest"')<html.indexOf('id="activity-previous"'));
|
||||
assert.ok(html.indexOf('id="activity-previous"')<html.indexOf('id="status-text"'));
|
||||
assert.ok(html.indexOf('id="status-text"')<html.indexOf('class="activity-heading"'));
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.match(css,/#engage:is\(\.arm,\.danger\).*animation:engage-sweep/);
|
||||
});
|
||||
|
||||
test('native drag surface has no interactive descendants; output limit is outside sidebar',()=>{
|
||||
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||
assert.match(html,/<aside class="sidebar-deck"/);
|
||||
assert.match(html,/<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"><\/div>/);
|
||||
assert.ok(html.indexOf('id="gain"')>html.indexOf('</aside>'));
|
||||
});
|
||||
|
||||
function harness(){
|
||||
let now=100,id=0;const timers=new Map(),elements=new Map(),listeners=new Map();
|
||||
class Element{
|
||||
constructor(name){this.name=name;this.dataset={};this.style={setProperty(k,v){this[k]=v}};this.classes=new Set();this.classList={add:(...n)=>n.forEach(x=>this.classes.add(x)),remove:(...n)=>n.forEach(x=>this.classes.delete(x)),contains:n=>this.classes.has(n),toggle:(n,b)=>b?this.classes.add(n):this.classes.delete(n)};this.handlers={};this.captured=new Set();this.value='demo';this.options=[];this.tagName='DIV';this.children=[];this.textContent='';}
|
||||
addEventListener(n,fn){(this.handlers[n]??=[]).push(fn)}
|
||||
removeEventListener(){}
|
||||
emit(n,fields={}){const e={pointerId:1,button:0,pointerType:'touch',clientX:160,clientY:160,preventDefault(){},target:this,...fields};for(const fn of this.handlers[n]||[])fn(e);return e}
|
||||
getBoundingClientRect(){return this.name==='#heave'?{left:0,top:0,width:48,height:300,right:48,bottom:300}:this.name==='#engage'?{left:0,top:0,width:180,height:44,right:180,bottom:44}:{left:0,top:0,width:320,height:320,right:320,bottom:320}}
|
||||
setPointerCapture(p){this.captured.add(p)}
|
||||
releasePointerCapture(p){if(this.captured.delete(p))this.emit('lostpointercapture',{pointerId:p})}
|
||||
getContext(){return new Proxy({}, {get:()=>()=>{}})}
|
||||
querySelector(s){return el(this.name+' '+s)}
|
||||
setAttribute(k,v){this[k]=v}
|
||||
append(n){this.children.push(n)}
|
||||
add(n){this.options.push(n)}
|
||||
closest(){return null}
|
||||
click(){if(!this.disabled)this.onclick?.({target:this,currentTarget:this})}
|
||||
}
|
||||
function el(n){if(!elements.has(n))elements.set(n,new Element(n));return elements.get(n)}
|
||||
const document={querySelector:el,querySelectorAll:()=>[],getElementById:n=>el('#'+n),createElement:n=>new Element(n),body:el('body'),readyState:'loading',hidden:false,hasFocus:()=>true,addEventListener(n,f){listeners.set('doc:'+n,f)}};
|
||||
const schedule=(fn,ms,interval=0)=>{timers.set(++id,{fn,at:now+ms,interval});return id};
|
||||
const calls=[];
|
||||
const context=vm.createContext({console,document,devicePixelRatio:1,performance:{now:()=>now},setTimeout:(f,ms=0)=>schedule(f,ms),clearTimeout:i=>timers.delete(i),setInterval:(f,ms)=>schedule(f,ms,ms),requestAnimationFrame:f=>schedule(()=>f(now),16),cancelAnimationFrame:i=>timers.delete(i),addEventListener(n,f){listeners.set(n,f)},Option:function(label,value){this.label=label;this.value=value},fetch:()=>Promise.reject(Error('No network in unit tests'))});
|
||||
context.window=context;
|
||||
context.pywebview={api:{set_axes:(...a)=>{calls.push(['axes',...a]);return Promise.resolve()},stop_controls:(...a)=>{calls.push(['stop',...a]);return Promise.resolve()},command:(name)=>{calls.push(['command',name]);return Promise.resolve(true)}}};
|
||||
for(const f of ['control-state.js','joystick-3d.js','app.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'../web',f),'utf8'),context,{filename:f});
|
||||
function run(s){return vm.runInContext(s,context)}
|
||||
function advance(ms){const end=now+ms;while(true){let next=[...timers].filter(([,t])=>t.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!next)break;const [i,t]=next;now=t.at;timers.delete(i);if(t.interval)timers.set(i,{...t,at:now+t.interval});t.fn()}now=end;}
|
||||
function state(s=armed){context.snapshot=s;run('render(snapshot)')}
|
||||
return {el,run,advance,state,calls,context,fire:(name,e={})=>listeners.get(name)?.(e)};
|
||||
}
|
||||
|
||||
test('pending/timeout/unknown presentation never invents armed state',()=>{
|
||||
let p=present({...armed,pending_command:'disarm'});
|
||||
assert.equal(p.label,'DISARMING…');assert.equal(p.drive,false);
|
||||
p=present({...armed,command_error:'Disarm confirmation timed out.'});
|
||||
assert.equal(p.label,'DISARM');assert.match(p.message,/Vehicle armed/);
|
||||
p=present({...armed,armed:false,pending_command:'arm'});
|
||||
assert.equal(p.label,'ENGAGING…');assert.equal(p.drive,false);
|
||||
p=present({...armed,armed:false,command_error:'Engage failed'});
|
||||
assert.equal(p.label,'ENGAGE');assert.equal(p.action,'arm');
|
||||
p=present({...armed,connected:false,armed_known:false});
|
||||
assert.equal(p.label,'STATE UNKNOWN');assert.equal(p.drive,false);
|
||||
});
|
||||
|
||||
test('activity history keeps three unique recent events, latest first',()=>{
|
||||
const h=harness();h.run("recordActivity('One');recordActivity('Two');recordActivity('Three');recordActivity('Three');recordActivity('Four')");
|
||||
assert.equal(h.el('#status-text').textContent,'Four');assert.equal(h.el('#activity-previous').textContent,'Three');assert.equal(h.el('#activity-oldest').textContent,'Two');
|
||||
});
|
||||
|
||||
test('output limit drag is bounded, owns one pointer and does not recenter',()=>{
|
||||
const h=harness(),slider=h.el('#gain');slider.min='5';slider.max='50';slider.value='20';
|
||||
slider.emit('pointerdown',{pointerId:1,clientX:9});assert.equal(h.el('#gain-value').textContent,'5%');
|
||||
slider.emit('pointermove',{pointerId:2,clientX:311});assert.equal(h.el('#gain-value').textContent,'5%');
|
||||
slider.emit('pointermove',{pointerId:1,clientX:311});assert.equal(h.el('#gain-value').textContent,'50%');
|
||||
slider.emit('pointerup');h.advance(500);assert.equal(h.el('#gain-value').textContent,'50%');
|
||||
});
|
||||
|
||||
test('maximum travel, diagonal and smooth touch return',()=>{
|
||||
const h=harness();h.state();const c=h.el('#joystick-canvas');
|
||||
c.emit('pointerdown',{clientX:1000,clientY:160});
|
||||
assert.equal(h.context.joystick3D.getState().x,1);
|
||||
c.emit('pointermove',{clientX:1000,clientY:1000});
|
||||
const s=h.context.joystick3D.getState();assert.ok(Math.abs(s.x-Math.SQRT1_2)<1e-6);assert.ok(Math.abs(s.y-Math.SQRT1_2)<1e-6);
|
||||
c.emit('pointerup');h.advance(180);assert.ok(h.context.joystick3D.getState().x>0);h.advance(220);assert.equal(h.context.joystick3D.getState().x,0);
|
||||
});
|
||||
|
||||
test('independent multi-touch: second joystick finger ignored; heave continues on joystick release',()=>{
|
||||
const h=harness();h.state();const c=h.el('#joystick-canvas'),v=h.el('#heave');
|
||||
c.emit('pointerdown',{pointerId:1,clientX:250});
|
||||
v.emit('pointerdown',{pointerId:2,clientY:0});
|
||||
const x=h.context.joystick3D.getState().x;
|
||||
c.emit('pointerdown',{pointerId:3,clientX:0});c.emit('pointerup',{pointerId:3});
|
||||
assert.equal(h.context.joystick3D.getState().x,x);
|
||||
c.emit('pointerup',{pointerId:1});h.advance(400);
|
||||
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,1);
|
||||
v.emit('pointercancel',{pointerId:2});h.advance(450);assert.equal(h.context.verticalControl.getState().value,0);
|
||||
});
|
||||
|
||||
test('mouse leaving joystick returns to center',()=>{
|
||||
const h=harness();h.state();const c=h.el('#joystick-canvas');
|
||||
c.emit('pointerdown',{pointerType:'mouse',clientX:300});c.emit('pointerleave',{pointerType:'mouse'});h.advance(400);
|
||||
assert.equal(h.context.joystick3D.getState().x,0);
|
||||
});
|
||||
|
||||
test('focus loss clears both captured pointers and prevents replay',()=>{
|
||||
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.el('#heave').emit('pointerdown',{pointerId:2,clientY:0});
|
||||
h.fire('blur');h.advance(500);
|
||||
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,0);
|
||||
assert.equal(h.context.joystick3D.getState().active,false);assert.equal(h.context.verticalControl.getState().active,false);
|
||||
assert.equal(h.run('view.drive'),false);h.fire('focus');assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
|
||||
});
|
||||
|
||||
test('lost connection during commanded joystick clears command and shows unknown',()=>{
|
||||
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
|
||||
h.state({...armed,connected:false,armed_known:false,app_state:'disconnected'});
|
||||
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||
h.state();assert.equal(h.run('control.turn'),0);
|
||||
});
|
||||
|
||||
test('half hold cancels on touch interruption and focus loss; extra fingers cannot complete it',()=>{
|
||||
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});const b=h.el('#engage');
|
||||
b.emit('pointerdown');h.advance(600);b.emit('pointerdown',{pointerId:2});b.emit('pointerup',{pointerId:2});
|
||||
assert.equal(h.run('engagePointer'),1);b.emit('pointercancel');h.advance(1500);assert.equal(h.calls.length,0);
|
||||
b.emit('pointerdown');h.advance(600);h.fire('blur');h.advance(1500);assert.equal(h.calls.length,0);
|
||||
});
|
||||
|
||||
test('demo full hold waits for simulated confirmation; release cannot immediately disarm',()=>{
|
||||
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
|
||||
b.emit('pointerdown');h.advance(1450);assert.equal(h.el('#engage-label').textContent,'engaging…');assert.equal(h.run('demoState.armed'),false);
|
||||
b.emit('pointerup');b.click();h.advance(700);assert.equal(h.el('#engage-label').textContent,'disengage');
|
||||
b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');assert.equal(h.run('demoState.armed'),true);
|
||||
h.advance(700);assert.equal(h.el('#engage-label').textContent,'engage');
|
||||
});
|
||||
|
||||
test('real send result and accepted ACK cannot optimistically arm; timeout is visible',async()=>{
|
||||
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});
|
||||
h.run("requestControl('arm')");await Promise.resolve();assert.equal(h.el('#engage-label').textContent,'engaging…');
|
||||
assert.equal(h.run('vehicleSnapshot.armed'),false);h.advance(6100);
|
||||
assert.equal(h.el('#engage-label').textContent,'engage');assert.match(h.el('#control-status').textContent,/timed out/);
|
||||
});
|
||||
|
||||
test('bridge freeze clears input even if armed snapshot has not changed',()=>{
|
||||
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.run('markBridgeStale()');
|
||||
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||
assert.match(h.el('#control-status').textContent,/Telemetry unavailable/);
|
||||
});
|
||||
|
||||
test('hung bridge poll has an independent watchdog and cannot trap the controls',async()=>{
|
||||
const h=harness();h.state();h.context.pywebview.api.poll=()=>new Promise(()=>{});
|
||||
h.run('bridgeReady=true;lastBridgeAt=performance.now();poll()');
|
||||
h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
|
||||
h.advance(1700);await Promise.resolve();
|
||||
assert.equal(h.run('view.drive'),false);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||
assert.equal(h.context.joystick3D.getState().x,0);
|
||||
});
|
||||
|
||||
test('holding through confirmation then releasing outside cannot swallow the next disarm tap',()=>{
|
||||
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
|
||||
b.emit('pointerdown');h.advance(2300);assert.equal(h.el('#engage-label').textContent,'disengage');assert.ok(b.captured.has(1));
|
||||
b.emit('pointerup',{clientX:500});h.advance(1);b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');
|
||||
});
|
||||
@@ -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.');
|
||||
@@ -0,0 +1,333 @@
|
||||
"""No serial port is opened by these tests."""
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app import Axes, MavlinkWorker, NEUTRAL_Z
|
||||
from vehicle_state import VehicleState
|
||||
from web_ui import ControlApi
|
||||
|
||||
|
||||
class VehicleStateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = 10.0
|
||||
self.state = VehicleState(lambda: self.now)
|
||||
self.state.heartbeat(False)
|
||||
|
||||
def test_ack_is_not_armed_confirmation(self):
|
||||
self.state.request("arm")
|
||||
self.now += .2
|
||||
self.state.ack("MAV_RESULT_ACCEPTED")
|
||||
self.assertFalse(self.state.snapshot()["armed"])
|
||||
self.assertEqual(self.state.snapshot()["pending_command"], "arm")
|
||||
self.state.heartbeat(True)
|
||||
self.assertTrue(self.state.snapshot()["controls_allowed"])
|
||||
|
||||
def test_heartbeat_confirms_even_when_ack_packet_is_lost(self):
|
||||
self.state.request("arm")
|
||||
self.now += .5
|
||||
self.state.heartbeat(True)
|
||||
self.assertIsNone(self.state.pending)
|
||||
self.assertTrue(self.state.armed)
|
||||
|
||||
def test_old_events_cannot_confirm_new_request(self):
|
||||
self.state.request("arm")
|
||||
self.state.ack("MAV_RESULT_DENIED", received_at=9)
|
||||
self.state.heartbeat(True, received_at=9)
|
||||
self.assertIsNotNone(self.state.pending)
|
||||
self.assertFalse(self.state.armed)
|
||||
|
||||
def test_rejected_engage_stays_disarmed(self):
|
||||
self.state.request("arm")
|
||||
self.now += .1
|
||||
self.state.ack("MAV_RESULT_DENIED")
|
||||
self.assertIsNone(self.state.pending)
|
||||
self.assertIn("rejected", self.state.error)
|
||||
self.assertFalse(self.state.armed)
|
||||
|
||||
def test_engage_timeout_then_late_armed_heartbeat_is_truth(self):
|
||||
self.state.request("arm")
|
||||
self.now += 6
|
||||
self.state.tick()
|
||||
self.assertIn("timed out", self.state.error)
|
||||
self.assertFalse(self.state.snapshot()["armed_known"])
|
||||
self.state.heartbeat(True)
|
||||
self.assertTrue(self.state.snapshot()["armed"])
|
||||
|
||||
def test_disarm_timeout_never_claims_disarmed(self):
|
||||
self.state.heartbeat(True)
|
||||
self.state.request("disarm")
|
||||
self.now += 6
|
||||
self.state.heartbeat(True)
|
||||
snap = self.state.snapshot()
|
||||
self.assertTrue(snap["armed"])
|
||||
self.assertIn("timed out", snap["command_error"])
|
||||
|
||||
def test_disarm_ack_waits_for_disarmed_heartbeat(self):
|
||||
self.state.heartbeat(True)
|
||||
self.state.request("disarm")
|
||||
self.now += .2
|
||||
self.state.ack("MAV_RESULT_ACCEPTED")
|
||||
self.assertTrue(self.state.snapshot()["armed"])
|
||||
self.assertFalse(self.state.snapshot()["controls_allowed"])
|
||||
self.state.heartbeat(False)
|
||||
self.assertIsNone(self.state.pending)
|
||||
self.assertFalse(self.state.armed)
|
||||
|
||||
def test_disconnect_preserves_last_known_armed_but_invalidates_it(self):
|
||||
self.state.heartbeat(True)
|
||||
self.state.disconnect()
|
||||
snap = self.state.snapshot()
|
||||
self.assertTrue(snap["armed"])
|
||||
self.assertFalse(snap["armed_known"])
|
||||
self.assertFalse(snap["controls_allowed"])
|
||||
|
||||
def test_frozen_telemetry_blocks_new_engage(self):
|
||||
self.now += 3.1
|
||||
self.assertFalse(self.state.snapshot()["armed_known"])
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.state.request("arm")
|
||||
|
||||
|
||||
class WorkerWatchdogTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.worker = MavlinkWorker(queue.Queue())
|
||||
self.worker.require_input_lease = True
|
||||
self.worker.connection = Mock()
|
||||
self.worker.target_system = 1
|
||||
self.worker.armed = True
|
||||
self.worker.last_heartbeat = 10
|
||||
|
||||
def assertNeutral(self):
|
||||
self.worker._send_control()
|
||||
self.assertEqual(self.control_values(),
|
||||
(1, 0, 0, NEUTRAL_Z, 0, 0))
|
||||
|
||||
def control_values(self):
|
||||
return self.worker.connection.mav.manual_control_send.call_args.args
|
||||
|
||||
def test_manual_control_uses_native_v2_without_forced_long_payload(self):
|
||||
self.worker._send_mav(self.worker.connection, 'manual_control_send',
|
||||
1, 50, 0, 525, -20, 0)
|
||||
self.assertEqual(self.control_values(), (1, 50, 0, 525, -20, 0))
|
||||
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.kwargs, {})
|
||||
|
||||
def test_ui_freeze_neutralizes_and_does_not_replay(self):
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.set_axes(Axes(forward=1), 1)
|
||||
self.worker._send_control()
|
||||
self.assertGreater(self.worker.output_axes.forward, 0)
|
||||
with patch("app.time.monotonic", return_value=11.1):
|
||||
self.assertNeutral()
|
||||
self.assertEqual(self.worker.axes, Axes())
|
||||
|
||||
def test_intermediate_demands_reach_mavlink_with_output_limit(self):
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.last_control_at = 9.9
|
||||
self.worker.set_axes(Axes(forward=.25, vertical=.25), 1)
|
||||
self.worker._send_control()
|
||||
# 25% input at the default 20% limit: 5% command, not 100%.
|
||||
self.assertEqual(self.control_values(),
|
||||
(1, 50, 0, 525, 0, 0))
|
||||
|
||||
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
|
||||
with patch("app.time.monotonic", return_value=13.1):
|
||||
self.worker.set_axes(Axes(forward=1), 1)
|
||||
self.assertNeutral()
|
||||
|
||||
def test_stale_queued_input_cannot_override_neutral(self):
|
||||
self.worker.set_axes(Axes(), 20)
|
||||
self.worker.set_axes(Axes(forward=1), 19)
|
||||
self.assertEqual(self.worker.axes, Axes())
|
||||
|
||||
def test_mavlink_transmissions_are_serialized(self):
|
||||
first_entered = threading.Event()
|
||||
release_first = threading.Event()
|
||||
second_entered = threading.Event()
|
||||
|
||||
class Mav:
|
||||
def first_send(self, **_):
|
||||
first_entered.set()
|
||||
release_first.wait(1)
|
||||
|
||||
def second_send(self, **_):
|
||||
second_entered.set()
|
||||
|
||||
connection = SimpleNamespace(mav=Mav())
|
||||
first = threading.Thread(target=self.worker._send_mav,
|
||||
args=(connection, "first_send"))
|
||||
second = threading.Thread(target=self.worker._send_mav,
|
||||
args=(connection, "second_send"))
|
||||
first.start()
|
||||
self.assertTrue(first_entered.wait(.5))
|
||||
second.start()
|
||||
time.sleep(.05)
|
||||
self.assertFalse(second_entered.is_set())
|
||||
release_first.set()
|
||||
first.join(1)
|
||||
second.join(1)
|
||||
self.assertTrue(second_entered.is_set())
|
||||
|
||||
def test_live_console_is_read_only_bounded_and_keeps_newest_lines(self):
|
||||
for index in range(310):
|
||||
self.worker.log("RX_TEST", f"vehicle {index}")
|
||||
self.worker.log("TX_TEST", f"computer {index}")
|
||||
sides = self.worker.read_console(2)
|
||||
self.assertEqual(set(sides), {"rx", "tx"})
|
||||
self.assertTrue(sides["rx"][0].endswith("[RX_TEST] vehicle 308"))
|
||||
self.assertTrue(sides["rx"][1].endswith("[RX_TEST] vehicle 309"))
|
||||
self.assertTrue(sides["tx"][1].endswith("[TX_TEST] computer 309"))
|
||||
self.assertEqual(len(self.worker.console_rx_lines), 300)
|
||||
self.assertEqual(len(self.worker.console_tx_lines), 300)
|
||||
self.assertEqual(len(self.worker.console_lines), 500)
|
||||
|
||||
def test_arm_request_pauses_parameter_traffic(self):
|
||||
self.worker.armed = False
|
||||
self.worker.param_scan.append("SERVO1_FUNCTION")
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(True)
|
||||
self.worker.config_paused = False # A disarmed heartbeat arrived.
|
||||
self.worker._tick_param_scan(10.1)
|
||||
self.assertEqual(self.worker.command_quiet_until, 16)
|
||||
self.worker.connection.mav.param_request_read_send.assert_not_called()
|
||||
self.worker.connection.mav.command_long_send.assert_called_once()
|
||||
|
||||
def test_arm_request_retries_until_state_confirmation(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(True)
|
||||
self.worker._tick_arm_retry(10.24)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_arm_retry(10.25)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
self.worker.pending_arm_state = None
|
||||
self.worker._tick_arm_retry(10.50)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
|
||||
def test_outbound_command_has_valid_heartbeat_lead_in(self):
|
||||
self.worker._send_mav(self.worker.connection, "timesync_send", 0, 123)
|
||||
names = [call[0] for call in self.worker.connection.mav.method_calls]
|
||||
self.assertEqual(names[-2:], ["heartbeat_send", "timesync_send"])
|
||||
|
||||
def test_disarm_sends_neutral_and_pauses_parameter_traffic(self):
|
||||
self.worker.param_scan.append("SERVO1_FUNCTION")
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(False)
|
||||
self.worker.config_paused = False
|
||||
self.worker._tick_param_scan(10.1)
|
||||
self.assertEqual(self.worker.disarm_requested_until, 16)
|
||||
self.assertEqual(self.control_values(),
|
||||
(1, 0, 0, NEUTRAL_Z, 0, 0))
|
||||
self.worker.connection.mav.param_request_read_send.assert_not_called()
|
||||
|
||||
def test_telemetry_config_is_paced_and_includes_low_rate_esc(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._configure_low_bandwidth_streams()
|
||||
self.assertEqual(len(self.worker.stream_config), 10)
|
||||
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
|
||||
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
|
||||
self.worker.connection.mav.command_long_send.assert_not_called()
|
||||
self.worker._tick_stream_config(10)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_stream_config(10.1)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker.command_quiet_until = 16
|
||||
self.worker._tick_stream_config(11)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_stream_config(16)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
|
||||
def test_link_probe_uses_configured_rate_and_measures_exact_echo(self):
|
||||
self.worker.armed = False
|
||||
self.worker.ping_rate_hz = 5
|
||||
with patch("app.time.monotonic", return_value=10), \
|
||||
patch("app.time.monotonic_ns", return_value=10_000_000_000):
|
||||
self.worker._tick_link_probe(10)
|
||||
self.worker.connection.mav.timesync_send.assert_called_once_with(
|
||||
0, 10_000_000_000)
|
||||
self.assertEqual(self.worker.pending_pings, {10_000_000_000: 10})
|
||||
self.assertEqual(self.worker.next_ping_at, 10.2)
|
||||
message = SimpleNamespace(
|
||||
tc1=1, ts1=10_000_000_000, get_type=lambda: "TIMESYNC",
|
||||
to_dict=lambda: {"tc1": 1, "ts1": 10_000_000_000})
|
||||
with patch("app.time.monotonic", return_value=10.04):
|
||||
self.worker._handle_message(message)
|
||||
quality = self.worker.events.get_nowait()
|
||||
self.assertEqual(quality[0], "link_quality")
|
||||
self.assertEqual(quality[1]["latency_ms"], 40.0)
|
||||
self.assertEqual(quality[1]["loss_percent"], 0.0)
|
||||
|
||||
def test_link_rates_rebuild_requested_telemetry_intervals(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.set_link_rates(10, {
|
||||
"SYS_STATUS": 5, "SERVO_OUTPUT_RAW": 10,
|
||||
"RC_CHANNELS": 0, "ESC_TELEMETRY_1_TO_4": 2,
|
||||
})
|
||||
self.assertEqual(self.worker.ping_rate_hz, 10)
|
||||
configured = {name: interval for name, _, interval in self.worker.stream_config}
|
||||
self.assertEqual(configured["SYS_STATUS"], 200_000)
|
||||
self.assertEqual(configured["SERVO_OUTPUT_RAW"], 100_000)
|
||||
self.assertEqual(configured["RC_CHANNELS"], -1)
|
||||
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 500_000)
|
||||
|
||||
def test_link_probe_counts_timeout_and_pauses_for_commands(self):
|
||||
self.worker.pending_pings[4] = 5
|
||||
self.worker.rx_delivery_results.extend(((10, True), (10, False)))
|
||||
self.worker.next_ping_at = 0
|
||||
self.worker.command_quiet_until = 20
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._tick_link_probe(10)
|
||||
self.worker.connection.mav.timesync_send.assert_not_called()
|
||||
self.assertEqual(list(self.worker.ping_results), [(10, None)])
|
||||
quality = self.worker.events.get_nowait()[1]
|
||||
self.assertEqual(quality["loss_percent"], 100.0)
|
||||
self.assertEqual(quality["grade"], "testing")
|
||||
|
||||
def test_clean_downlink_cannot_hide_failed_round_trips(self):
|
||||
self.worker.ping_results.extend([(10, None)] * 20)
|
||||
self.worker.rx_delivery_results.extend([(10, True)] * 200)
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._publish_link_quality()
|
||||
quality = self.worker.events.get_nowait()[1]
|
||||
self.assertEqual(quality["loss_percent"], 100.0)
|
||||
self.assertEqual(quality["grade"], "poor")
|
||||
|
||||
|
||||
class BridgeTests(unittest.TestCase):
|
||||
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
|
||||
api = ControlApi()
|
||||
api.worker.connection = Mock()
|
||||
api.worker.target_system = 1
|
||||
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
|
||||
api.worker.set_axes(Axes(), 20)
|
||||
with self.assertRaisesRegex(RuntimeError, "newer input reset"):
|
||||
api.command("arm", 19)
|
||||
with self.assertRaisesRegex(RuntimeError, "expired"):
|
||||
api.command("arm", 21, expires_at=0)
|
||||
api.worker.connection.mav.command_long_send.assert_not_called()
|
||||
|
||||
def test_real_bridge_ack_pending_disconnect_and_stale_battery(self):
|
||||
api = ControlApi()
|
||||
api.worker.connection = Mock()
|
||||
api.worker.target_system = 1
|
||||
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
|
||||
pending = api.command("arm")
|
||||
self.assertEqual(pending["pending_command"], "arm")
|
||||
self.assertFalse(pending["armed"])
|
||||
api._consume(("ack", 400, "MAV_RESULT_ACCEPTED"))
|
||||
self.assertFalse(api._snapshot()["armed"])
|
||||
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", True))
|
||||
self.assertTrue(api._snapshot()["armed"])
|
||||
api._consume(("disconnected", "Disconnected"))
|
||||
self.assertTrue(api._snapshot()["armed"])
|
||||
self.assertFalse(api._snapshot()["armed_known"])
|
||||
self.assertTrue(api._snapshot()["battery_stale"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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});
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Vehicle truth and command transactions; independent of UI/animation clocks."""
|
||||
import time
|
||||
|
||||
|
||||
class VehicleState:
|
||||
HEARTBEAT_TIMEOUT = 3.0
|
||||
COMMAND_TIMEOUT = 5.0
|
||||
|
||||
def __init__(self, clock=time.monotonic):
|
||||
self.clock = clock
|
||||
self.connected = False
|
||||
self.armed = None
|
||||
self.heartbeat_at = None
|
||||
self.pending = None
|
||||
self.error = ""
|
||||
|
||||
def fresh(self):
|
||||
return (self.connected and self.heartbeat_at is not None
|
||||
and self.clock() - self.heartbeat_at <= self.HEARTBEAT_TIMEOUT)
|
||||
|
||||
def heartbeat(self, armed, received_at=None):
|
||||
received_at = self.clock() if received_at is None else received_at
|
||||
if self.heartbeat_at is not None and received_at < self.heartbeat_at:
|
||||
return
|
||||
self.connected = True
|
||||
self.heartbeat_at = received_at
|
||||
self.armed = bool(armed)
|
||||
# A fresh heartbeat is the authoritative confirmation of actual state.
|
||||
# ACK ACCEPTED alone only confirms that a command was accepted.
|
||||
if (self.pending and received_at > self.pending["started"]
|
||||
and self.armed == (self.pending["name"] == "arm")):
|
||||
self.pending = None
|
||||
|
||||
def request(self, name):
|
||||
self.tick()
|
||||
if name == "arm" and (not self.fresh() or self.armed is not False):
|
||||
raise RuntimeError("Fresh, disarmed vehicle telemetry is required to engage")
|
||||
if self.pending and (name == "arm" or self.pending["name"] == name):
|
||||
raise RuntimeError("A vehicle confirmation is already pending")
|
||||
now = self.clock()
|
||||
self.pending = {"name": name, "started": now,
|
||||
"deadline": now + self.COMMAND_TIMEOUT, "ack": None}
|
||||
self.error = ""
|
||||
|
||||
def ack(self, result, received_at=None):
|
||||
received_at = self.clock() if received_at is None else received_at
|
||||
if not self.pending or received_at <= self.pending["started"]:
|
||||
return
|
||||
self.pending["ack"] = result
|
||||
if result not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"):
|
||||
self.fail(f"{self.pending['name'].capitalize()} rejected: {result}")
|
||||
|
||||
def fail(self, message):
|
||||
self.error = message
|
||||
self.pending = None
|
||||
|
||||
def disconnect(self):
|
||||
self.connected = False
|
||||
if self.pending:
|
||||
self.fail("Connection lost before vehicle confirmation. State unknown.")
|
||||
|
||||
def tick(self):
|
||||
if self.pending and self.clock() >= self.pending["deadline"]:
|
||||
name = self.pending["name"].capitalize()
|
||||
self.fail(f"{name} confirmation timed out. Check current vehicle state.")
|
||||
|
||||
def snapshot(self):
|
||||
self.tick()
|
||||
fresh = self.fresh()
|
||||
return {"armed": self.armed, "armed_known": fresh,
|
||||
"heartbeat_age": None if self.heartbeat_at is None else self.clock() - self.heartbeat_at,
|
||||
"pending_command": self.pending["name"] if self.pending else None,
|
||||
"command_ack": self.pending["ack"] if self.pending else None,
|
||||
"command_error": self.error,
|
||||
"controls_allowed": fresh and self.armed is True and self.pending is None}
|
||||
+805
-86
@@ -1,49 +1,298 @@
|
||||
const STATUS_LABELS = {
|
||||
disconnected: 'Disconnected — select a port and connect',
|
||||
connecting: 'Connecting…',
|
||||
connected_disarmed: 'Connected · Disarmed',
|
||||
armed: 'Connected · Armed',
|
||||
link_degraded: 'Signal lost — check tether',
|
||||
};
|
||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0;
|
||||
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,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);
|
||||
addEventListener('pointermove',e=>{const button=e.target.closest?.('button:not(.battery-card), .stick');if(!button)return;const rect=button.getBoundingClientRect();button.style.setProperty('--glow-x',`${e.clientX-rect.left}px`);button.style.setProperty('--glow-y',`${e.clientY-rect.top}px`)});
|
||||
const activity=[];
|
||||
let lastReportedStatus='';
|
||||
function recordActivity(message){
|
||||
if(!message||activity[0]===message)return;
|
||||
activity.unshift(String(message));activity.length=Math.min(activity.length,3);
|
||||
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
|
||||
const row=$(selector);if(row){const changed=row.textContent!==(activity[i]||'');row.textContent=activity[i]||'';row.hidden=!activity[i];if(changed&&!row.hidden&&!matchMedia('(prefers-reduced-motion:reduce)').matches)row.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'})}
|
||||
});
|
||||
}
|
||||
function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})}
|
||||
let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true,
|
||||
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
|
||||
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
|
||||
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
|
||||
|
||||
window.canDrive=canDrive;
|
||||
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
|
||||
const controllerMotion=new ControllerInput.Motion();
|
||||
const controllerId=pad=>pad.index+':'+pad.id;
|
||||
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
|
||||
function loadControllerConfig(pad){
|
||||
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
|
||||
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
|
||||
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
|
||||
}
|
||||
function selectController(pad){
|
||||
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
|
||||
}
|
||||
function setInputMode(mode){
|
||||
if(mode===inputMode||inputConfigurationLocked())return false;
|
||||
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
|
||||
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
|
||||
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
|
||||
$('#controller-settings').hidden=mode!=='joystick';
|
||||
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys');
|
||||
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
|
||||
return true;
|
||||
}
|
||||
function inputConfigurationLocked(){
|
||||
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
|
||||
}
|
||||
function syncInputModeControls(){
|
||||
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
||||
$('.input-mode-switch').dataset.locked=String(locked);
|
||||
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
|
||||
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
|
||||
}
|
||||
$('#controller-settings').disabled=locked||!pads.length;
|
||||
$('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup';
|
||||
if($('#controller-modal').open){
|
||||
const form=$('#controller-form');
|
||||
for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad;
|
||||
$('#controller-device').disabled=locked||!pads.length;
|
||||
const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.';
|
||||
if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback);
|
||||
}
|
||||
}
|
||||
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
|
||||
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
|
||||
$('#input-joystick').onclick=()=>setInputMode('joystick');
|
||||
function fillControllerForm(){
|
||||
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
|
||||
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
|
||||
if(!pads.length)device.add(new Option('No controller detected',''));
|
||||
device.value=controllerKey||'';
|
||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
||||
const config=controllerConfig||ControllerInput.defaults(pad);
|
||||
for(const name of ['turn','forward','vertical']){
|
||||
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
|
||||
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
|
||||
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
|
||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);
|
||||
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
|
||||
}
|
||||
form.elements.deadZone.value=Math.round(config.deadZone*100);
|
||||
device.disabled=inputConfigurationLocked()||!pads.length;
|
||||
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
|
||||
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
|
||||
}
|
||||
$('#controller-device').onchange=e=>{
|
||||
if(inputConfigurationLocked())return;
|
||||
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
|
||||
if(pad){selectController(pad);fillControllerForm()}
|
||||
};
|
||||
$('#controller-form').onsubmit=e=>{
|
||||
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
|
||||
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
|
||||
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
|
||||
const config={deadZone:Number(form.elements.deadZone.value)/100};
|
||||
for(const name of ['turn','forward','vertical']){
|
||||
config[name]=Number(form.elements[name].value);
|
||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
|
||||
}
|
||||
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
|
||||
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
|
||||
}
|
||||
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
|
||||
resetInputs();closeModal($('#controller-modal'));
|
||||
};
|
||||
function pollController(now){
|
||||
try{
|
||||
if(inputMode==='joystick'){
|
||||
const pads=getControllers();
|
||||
if(controllerKey===null&&pads.length)selectController(pads[0]);
|
||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
|
||||
let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id;
|
||||
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
|
||||
if(!target||!canDrive()){
|
||||
if(controllerWasDriving)resetInputs();
|
||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
||||
}else{
|
||||
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
|
||||
if(!controllerNeedCenter){
|
||||
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
|
||||
control.turn=demand.turn;control.forward=demand.forward;
|
||||
window.joystick3D?.setVisual(demand.turn,-demand.forward);
|
||||
setHeave(demand.vertical,false);updateReadouts();sendAxes();
|
||||
}
|
||||
}
|
||||
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
|
||||
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
|
||||
if($('#controller-modal').open){
|
||||
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
|
||||
if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes;
|
||||
if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm();
|
||||
}
|
||||
}else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'}
|
||||
}catch(error){
|
||||
if(controllerWasDriving)resetInputs();
|
||||
$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard';
|
||||
}
|
||||
requestAnimationFrame(pollController);
|
||||
}
|
||||
document.body.dataset.inputMode=inputMode;
|
||||
|
||||
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
|
||||
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
||||
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||
window.showToast=toast;
|
||||
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
|
||||
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
||||
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
|
||||
const manualPorts=new Map();
|
||||
function populatePorts(ports){
|
||||
const p=$('#port');
|
||||
if(!p)return;
|
||||
const old=p.value||'';
|
||||
p.innerHTML='';
|
||||
|
||||
window.setJoyControl=(x,y)=>{control.turn=x;control.forward=-y;updateReadouts();sendAxes()};
|
||||
const allList=[];
|
||||
const seen=new Set();
|
||||
|
||||
const addPort=item=>{
|
||||
let dev=item, lbl=item;
|
||||
if(item&&typeof item==='object'){
|
||||
dev=item.device||'';
|
||||
lbl=item.label||dev;
|
||||
}
|
||||
if(dev&&!seen.has(dev)){
|
||||
seen.add(dev);
|
||||
allList.push({device:dev, label:lbl});
|
||||
}
|
||||
};
|
||||
|
||||
if(Array.isArray(ports)) ports.forEach(addPort);
|
||||
manualPorts.forEach(addPort);
|
||||
addPort({device:'demo', label:'Demo Mode (no hardware)'});
|
||||
|
||||
let matched=false;
|
||||
allList.forEach(item=>{
|
||||
const opt=new Option(item.label, item.device);
|
||||
p.add(opt);
|
||||
if(item.device===old){
|
||||
p.value=item.device;
|
||||
matched=true;
|
||||
}
|
||||
});
|
||||
if(!matched&&allList.length>0){
|
||||
p.value=allList[0].device;
|
||||
}
|
||||
}
|
||||
function sendAxes(){
|
||||
if(!canDrive()||demoActive||!bridgeReady||axisBusy)return;
|
||||
const transmit=()=>{
|
||||
axisTimer=0;
|
||||
if(!canDrive()||demoActive||axisBusy)return;
|
||||
lastAxisSend=performance.now();axisBusy=true;
|
||||
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence))
|
||||
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
|
||||
};
|
||||
const wait=50-(performance.now()-lastAxisSend);
|
||||
if(wait<=0)transmit();else if(!axisTimer)axisTimer=setTimeout(transmit,wait);
|
||||
}
|
||||
// This is a local bridge lease, not extra MAVLink traffic. The worker retains
|
||||
// the configured wire rate and neutralizes if the UI stops renewing its input.
|
||||
setInterval(()=>{if(canDrive())sendAxes()},250);
|
||||
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||
|
||||
window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||
|
||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
|
||||
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes()}
|
||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
|
||||
function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();}
|
||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||
heave.addEventListener('pointerdown',e=>{heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});
|
||||
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
|
||||
heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
|
||||
function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
|
||||
|
||||
function applyKeys(){const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()}
|
||||
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startArmHold();return}const shortcuts={F1:'#manual',F3:'#disarm'};if(shortcuts[e.key]){e.preventDefault();if(!e.repeat)$(shortcuts[e.key]).click();return}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopArmHold(true);return}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
|
||||
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave()});
|
||||
function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
|
||||
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||
function resetInputs(){
|
||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
||||
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
|
||||
window.joystick3D?.reset();
|
||||
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
|
||||
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
|
||||
hknob.classList.remove('pressed','released');
|
||||
control.forward=control.turn=control.vertical=0;setHeave(0,false);
|
||||
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
|
||||
}
|
||||
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
|
||||
addEventListener('blur',()=>focusChanged(false));
|
||||
addEventListener('focus',()=>focusChanged(!document.hidden));
|
||||
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
|
||||
addEventListener('pagehide',()=>focusChanged(false));
|
||||
|
||||
function setFeedback(el,message=''){
|
||||
if(!el)return;
|
||||
el.textContent=message;el.hidden=!message;
|
||||
}
|
||||
function openModal(id){
|
||||
const el=typeof id==='string'?document.getElementById(id):id;
|
||||
if(!el)return;
|
||||
if(!el||el.open)return;
|
||||
if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return}
|
||||
resetInputs();
|
||||
el._returnFocus=document.activeElement;
|
||||
if(el.id==='battery-modal')fillBatteryForm();
|
||||
if(el.id==='controller-modal')fillControllerForm();
|
||||
if(el.id==='link-modal'){
|
||||
fillLinkForm();
|
||||
$('#custom-port-status').textContent='';
|
||||
$('#custom-port-input').value='';
|
||||
}
|
||||
el.querySelectorAll('.form-feedback').forEach(feedback=>setFeedback(feedback));
|
||||
el.classList.remove('closing');
|
||||
if(!el.open)el.showModal();
|
||||
el.showModal();
|
||||
}
|
||||
function closeModal(dialog){
|
||||
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
|
||||
if(!dialog||!dialog.open||dialog.classList.contains('closing')||dialog.querySelector('[data-busy="true"]'))return;
|
||||
dialog.classList.add('closing');
|
||||
let done=false;
|
||||
const finish=()=>{
|
||||
if(done)return;
|
||||
done=true;
|
||||
dialog.removeEventListener('animationend',finish);
|
||||
dialog.removeEventListener('animationend',onEnd);
|
||||
dialog.classList.remove('closing');
|
||||
dialog.close();
|
||||
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
|
||||
};
|
||||
dialog.addEventListener('animationend',finish,{once:true});
|
||||
setTimeout(finish,300);
|
||||
const onEnd=e=>{if(e.target===dialog)finish()};
|
||||
dialog.addEventListener('animationend',onEnd);
|
||||
setTimeout(finish,180);
|
||||
}
|
||||
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
|
||||
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
|
||||
$('#link-quality').onclick=()=>openModal('link-quality-modal');
|
||||
let heavyActionBusy=false;
|
||||
async function requestHeavyTest(action){
|
||||
if(heavyActionBusy)return;
|
||||
heavyActionBusy=true;
|
||||
setFeedback($('#heavy-quality-error'));
|
||||
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
try{
|
||||
const test=await api(action);
|
||||
vehicleSnapshot.heavy_quality=test;
|
||||
renderHeavyQuality(test,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
}catch(e){setFeedback($('#heavy-quality-error'),e.message||'Link test unavailable. Try again.')}
|
||||
finally{
|
||||
heavyActionBusy=false;
|
||||
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
|
||||
}
|
||||
}
|
||||
$('#heavy-quality-run').onclick=()=>requestHeavyTest('start_heavy_quality_test');
|
||||
$('#heavy-quality-cancel').onclick=()=>requestHeavyTest('cancel_heavy_quality_test');
|
||||
$$('dialog').forEach(d=>{
|
||||
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
|
||||
d.addEventListener('click',e=>{
|
||||
@@ -51,7 +300,7 @@ $$('dialog').forEach(d=>{
|
||||
if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d);
|
||||
});
|
||||
});
|
||||
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};$('.topbar').ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}};
|
||||
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};const windowBar=$('.workspace-topbar');if(windowBar)windowBar.ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}};
|
||||
const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage');
|
||||
|
||||
async function handleStlFile(file){
|
||||
@@ -119,68 +368,519 @@ if(rovStage){
|
||||
if(file)handleStlFile(file);
|
||||
});
|
||||
}
|
||||
$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)populatePorts(await api('refresh_ports'))};$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}};
|
||||
$('#manual').onclick=async()=>{try{await api('command','manual');toast('Manual mode command sent')}catch(e){toast(e.message,true)}};
|
||||
const armButton=$('#arm');
|
||||
let armHoldTimer=null, armStart=0, armCharged=false;
|
||||
const ARM_HOLD_MS=1000;
|
||||
$('#refresh').onclick=async e=>{
|
||||
const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);
|
||||
try{
|
||||
if(typeof window.pywebview?.api?.refresh_ports!=='function')throw new Error('Desktop connection is not ready. Open the app using run.ps1.');
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
if(!booted)boot();
|
||||
}catch(error){recordActivity(`Port refresh failed: ${error.message}`);toast(error.message,true)}
|
||||
};
|
||||
$('#connect').onclick=async()=>{
|
||||
const btn=$('#connect');
|
||||
const label=btn.querySelector('.btn-label')||btn;
|
||||
const p=$('#port');
|
||||
const port=p?p.value:'';
|
||||
|
||||
function updateArmProgress(){
|
||||
if(!armStart)return;
|
||||
const elapsed=performance.now()-armStart;
|
||||
const progress=Math.min(1,elapsed/ARM_HOLD_MS);
|
||||
armButton.style.setProperty('--arm-progress',`${progress*100}%`);
|
||||
if(progress>=1){
|
||||
armCharged=true;
|
||||
stopArmHold(false);
|
||||
armButton.classList.add('charged');
|
||||
setTimeout(()=>armButton.classList.remove('charged'),600);
|
||||
api('command','arm').then(()=>toast('Vehicle armed')).catch(e=>toast(e.message,true));
|
||||
if(port==='demo'||demoActive){
|
||||
resetInputs();clearTimeout(demoCommandTimer);requestSerial++;localRequest=null;commandError='';bridgeStale=false;
|
||||
if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')}
|
||||
else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')}
|
||||
return;
|
||||
}
|
||||
armHoldTimer=requestAnimationFrame(updateArmProgress);
|
||||
}
|
||||
|
||||
function startArmHold(e){
|
||||
if(armStart||armCharged)return;
|
||||
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||
armStart=performance.now();
|
||||
armCharged=false;
|
||||
armButton.classList.add('charging');
|
||||
armButton.style.setProperty('--arm-progress','0%');
|
||||
armHoldTimer=requestAnimationFrame(updateArmProgress);
|
||||
}
|
||||
|
||||
function stopArmHold(wasCancelled=true){
|
||||
if(!armStart)return;
|
||||
const elapsed=performance.now()-armStart;
|
||||
cancelAnimationFrame(armHoldTimer);
|
||||
armHoldTimer=null;
|
||||
armStart=0;
|
||||
armButton.classList.remove('charging');
|
||||
armButton.style.setProperty('--arm-progress','0%');
|
||||
if(wasCancelled&&!armCharged&&elapsed<ARM_HOLD_MS&&elapsed>70){
|
||||
toast('Hold for 1s to Arm vehicle');
|
||||
if(appState==='connecting'){
|
||||
label.textContent='Canceling…';
|
||||
try{ await api('disconnect'); }catch(_){}
|
||||
return;
|
||||
}
|
||||
armCharged=false;
|
||||
|
||||
if(appState!=='disconnected'){
|
||||
resetInputs();
|
||||
label.textContent='Disconnecting…';
|
||||
try{
|
||||
await api('disconnect');
|
||||
toast('Disconnected');
|
||||
}catch(e){
|
||||
toast(e.message,true);
|
||||
}
|
||||
}else{
|
||||
if(!port){
|
||||
toast('Select or enter a port to connect',true);
|
||||
return;
|
||||
}
|
||||
btn.classList.add('is-connecting');
|
||||
label.textContent='Connecting…';
|
||||
const statusEl=$('#status-text');
|
||||
recordActivity(`Connecting to ${port}…`);
|
||||
toast(`Connecting to ${port}…`);
|
||||
try{
|
||||
const confirmedState=await api('connect',port,+linkConfig.baud||57600,+linkConfig.control_hz||20);
|
||||
if(confirmedState)render(confirmedState);
|
||||
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
|
||||
}catch(e){
|
||||
btn.classList.remove('is-connecting');
|
||||
label.textContent='Connect';
|
||||
toast(e.message,true);
|
||||
recordActivity(`Connection error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input');
|
||||
if(customPortBtn&&customPortInput){
|
||||
const applyCustomPort=()=>{
|
||||
const val=customPortInput.value.trim();
|
||||
if(!val){$('#custom-port-status').textContent='Enter a port first.';customPortInput.focus();return;}
|
||||
if(vehicleSnapshot.connected)return;
|
||||
manualPorts.set(val,{device:val,label:`Custom (${val})`});
|
||||
const p=$('#port');
|
||||
let exists=false;
|
||||
for(const opt of p.options){
|
||||
if(opt.value===val){opt.selected=true;exists=true;break;}
|
||||
}
|
||||
if(!exists){
|
||||
p.add(new Option(`Custom (${val})`, val), 0);
|
||||
p.value=val;
|
||||
}
|
||||
$('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
|
||||
};
|
||||
customPortBtn.onclick=applyCustomPort;
|
||||
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
||||
}
|
||||
const engageButton=$('#engage');
|
||||
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
|
||||
let engageLabelValue=$('#engage-label')?.textContent||'engage';
|
||||
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null,engageAppendFrame=null;
|
||||
const ENGAGE_HOLD_MS=600;
|
||||
|
||||
function applyEngageVisual(next){
|
||||
if(!engageButton)return;
|
||||
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging','DISARMING…':'disengaging','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||
// Update the state label without delaying the requested control action.
|
||||
engageButton.disabled=!next.action;
|
||||
engageButton.setAttribute('aria-busy',String(next.pending));
|
||||
engageButton.setAttribute('aria-label',label);
|
||||
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 0.6 seconds to engage (F2)':next.message;
|
||||
const key=`${next.label}/${next.tone}`;
|
||||
if(engageVisualState===key)return;
|
||||
const previous=engageVisualState;engageVisualState=key;
|
||||
if(engageAppendFrame)cancelAnimationFrame(engageAppendFrame);
|
||||
clearTimeout(engageTransitionTimer);
|
||||
engageButton.classList.remove('arm','danger','subtle','charged','pending');
|
||||
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
|
||||
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%');
|
||||
const mainLabel=$('#engage-label'),previousLabel=engageLabelValue;
|
||||
const appended=previousLabel==='engage'&&label==='engaging'||previousLabel==='disengage'&&label==='disengaging';
|
||||
let suffix=null;
|
||||
if(appended){
|
||||
const previousWidth=mainLabel.getBoundingClientRect().width;
|
||||
const stem=previousLabel.slice(0,-1);
|
||||
mainLabel.replaceChildren(document.createTextNode(stem));
|
||||
suffix=document.createElement('span');suffix.className='engage-suffix';suffix.textContent=label.slice(stem.length);mainLabel.append(suffix);
|
||||
const appendedWidth=mainLabel.getBoundingClientRect().width;
|
||||
mainLabel.style.setProperty('--engage-append-shift',`${(appendedWidth-previousWidth)/2}px`);
|
||||
mainLabel.classList.add('is-appending');
|
||||
}else{
|
||||
mainLabel.classList.remove('is-appending');
|
||||
mainLabel.style.removeProperty('--engage-append-shift');
|
||||
mainLabel.textContent=label;
|
||||
}
|
||||
engageLabelValue=label;
|
||||
if(engageProgressLabel)engageProgressLabel.textContent=label;
|
||||
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
|
||||
if(previous){
|
||||
engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
|
||||
engageButton.classList.add('state-transition');
|
||||
if(suffix)engageAppendFrame=requestAnimationFrame(()=>suffix.isConnected&&suffix.classList.add('is-visible'));
|
||||
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),200);
|
||||
}
|
||||
}
|
||||
function completeEngage(){
|
||||
const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%');
|
||||
requestControl('arm');
|
||||
}
|
||||
async function requestControl(name){
|
||||
if((name==='arm'&&view.action!=='arm')||(name==='disarm'&&view.action!=='disarm'))return;
|
||||
const id=++requestSerial;
|
||||
commandError='';localRequest={name,deadline:performance.now()+6000,sent:false};
|
||||
recordActivity(name==='arm'?'Engage requested':'Disengage requested');
|
||||
resetInputs();render(vehicleSnapshot);
|
||||
try{
|
||||
if(demoActive){
|
||||
// Separate simulated request and simulated heartbeat, just like the real UI.
|
||||
clearTimeout(demoCommandTimer);
|
||||
updateDemo({pending_command:name,command_error:''});
|
||||
demoCommandTimer=setTimeout(()=>{
|
||||
if(!demoActive||id!==requestSerial)return;
|
||||
localRequest=null;
|
||||
updateDemo({pending_command:null,armed:name==='arm',armed_known:true,
|
||||
app_state:name==='arm'?'armed':'connected_disarmed',
|
||||
vehicle:`DEMO • ${name==='arm'?'ARMED':'DISARMED'}`,status:`Demo: ${name} confirmed`});
|
||||
},650);
|
||||
}else{
|
||||
await api('command',name,++inputSequence,Date.now()+2000);
|
||||
if(id===requestSerial&&localRequest)localRequest.sent=true;
|
||||
// Only poll snapshots may confirm state; a send result is not confirmation.
|
||||
}
|
||||
}catch(error){
|
||||
if(id!==requestSerial)return;
|
||||
localRequest=null;commandError=`${name==='arm'?'Engage':'Disarm'} request failed: ${error.message}`;
|
||||
render(vehicleSnapshot);toast(commandError,true);
|
||||
}
|
||||
}
|
||||
function updateEngageProgress(now){
|
||||
if(!engageStart||view.action!=='arm'||!inputFocused)return cancelEngageHold(false);
|
||||
const progress=Math.min(1,(now-engageStart)/ENGAGE_HOLD_MS);engageButton.style.setProperty('--engage-progress',`${progress*100}%`);if(progress>=1)return void completeEngage();engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||
}
|
||||
function startEngageHold(e){
|
||||
if(view.action!=='arm'||!inputFocused||engageStart)return;
|
||||
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
|
||||
syncInputModeControls();
|
||||
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
|
||||
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||
}
|
||||
function cancelEngageHold(showHint=true){
|
||||
if(!engageStart)return;
|
||||
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
|
||||
syncInputModeControls();
|
||||
}
|
||||
|
||||
armButton.addEventListener('pointerdown',startArmHold);
|
||||
armButton.addEventListener('pointerup',()=>stopArmHold(true));
|
||||
armButton.addEventListener('pointerleave',()=>stopArmHold(true));
|
||||
armButton.addEventListener('pointercancel',()=>stopArmHold(true));
|
||||
armButton.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
if(engageButton){
|
||||
engageButton.addEventListener('pointerdown',startEngageHold);
|
||||
engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;try{engageButton.releasePointerCapture(e.pointerId)}catch(_){}suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
||||
engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
||||
engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
|
||||
engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null});
|
||||
engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)});
|
||||
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
|
||||
engageButton.onclick=()=>{
|
||||
if(suppressEngageClick){suppressEngageClick=false;return}
|
||||
if(view.action==='disarm')requestControl('disarm');
|
||||
};
|
||||
}
|
||||
|
||||
$('#disarm').onclick=async()=>{try{await api('command','disarm');toast('Disarm command sent')}catch(e){toast(e.message,true)}};
|
||||
$('#logs').onclick=()=>api('open_logs');
|
||||
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);
|
||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||
// Explicit pointer ownership makes the full range rail a reliable touch target
|
||||
// in the embedded window, without depending on native range drag defaults.
|
||||
const gainSlider=$('#gain');let gainPointer=null;
|
||||
function gainFromPointer(e){const r=gainSlider.getBoundingClientRect(),fraction=Math.max(0,Math.min(1,(e.clientX-r.left-9)/Math.max(1,r.width-18)));setGain(Math.round(+gainSlider.min+fraction*(+gainSlider.max-+gainSlider.min)))}
|
||||
function releaseGain(e){if(e.pointerId!==gainPointer)return;gainPointer=null;try{gainSlider.releasePointerCapture(e.pointerId)}catch(_){}}
|
||||
gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)return;gainPointer=e.pointerId;gainSlider.setPointerCapture(e.pointerId);gainFromPointer(e);e.preventDefault()});
|
||||
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
|
||||
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
|
||||
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
|
||||
async function saveSettings(form,method,commit,message){
|
||||
if(form.dataset.busy==='true'||!form.reportValidity())return;
|
||||
const data=Object.fromEntries(new FormData(form));
|
||||
const button=form.querySelector('[type="submit"]'),label=button.textContent;
|
||||
const disabled=new Map([...form.querySelectorAll('input,select,button')].map(el=>[el,el.disabled]));
|
||||
form.dataset.busy='true';disabled.forEach((_,el)=>el.disabled=true);button.textContent='Saving…';
|
||||
setFeedback(form.querySelector('.form-feedback'));
|
||||
let saved=false;
|
||||
try{commit(await api(method,data));saved=true;toast(message)}
|
||||
catch(error){setFeedback(form.querySelector('.form-feedback'),error.message||'Could not save settings. Try again.')}
|
||||
finally{
|
||||
delete form.dataset.busy;disabled.forEach((value,el)=>el.disabled=value);button.textContent=label;
|
||||
if(saved)closeModal(form.closest('dialog'));
|
||||
}
|
||||
}
|
||||
$('#battery-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_battery',value=>batteryConfig=value,'Battery settings saved')};
|
||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}}
|
||||
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';const linkText=s.link==='LINK OK'?'connected':(s.link?s.link.toLowerCase():'disconnected');$('#link').textContent=linkText;$('.link-state').classList.toggle('online',s.link==='LINK OK'||s.link==='connected');$('#vehicle').textContent=s.vehicle?s.vehicle.toLowerCase():'no heartbeat';$('#status').textContent=s.status?s.status.toLowerCase():'ready';const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)}
|
||||
$('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
|
||||
function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
|
||||
const motorEdits=new Map();
|
||||
$('#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;
|
||||
const driveEnabled=view.drive;
|
||||
document.body.dataset.controlsEnabled=String(driveEnabled);
|
||||
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||
$('#port').disabled=!!s.connected;
|
||||
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
|
||||
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||
const connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
if(connectBtn){
|
||||
if(appState==='connecting'){
|
||||
if(connectLabel)connectLabel.textContent='Connecting…';
|
||||
else connectBtn.textContent='Connecting…';
|
||||
connectBtn.classList.add('is-connecting');
|
||||
connectBtn.classList.remove('is-connected');
|
||||
}else if(appState!=='disconnected'){
|
||||
if(connectLabel)connectLabel.textContent='Disconnect';
|
||||
else connectBtn.textContent='Disconnect';
|
||||
connectBtn.classList.remove('is-connecting');
|
||||
connectBtn.classList.add('is-connected');
|
||||
}else{
|
||||
if(connectLabel)connectLabel.textContent='Connect';
|
||||
else connectBtn.textContent='Connect';
|
||||
connectBtn.classList.remove('is-connecting', 'is-connected');
|
||||
}
|
||||
}
|
||||
const engageBtn=$('#engage');
|
||||
const engageLabel=$('#engage-label');
|
||||
if(engageBtn&&engageLabel){
|
||||
applyEngageVisual(view);
|
||||
}
|
||||
const controlStatus=$('#control-status');
|
||||
controlStatus.textContent=view.message;
|
||||
controlStatus.classList.toggle('is-error',view.error);
|
||||
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
|
||||
const motorBtn=$('[data-open="motors-modal"]');
|
||||
if(motorBtn)motorBtn.disabled=false;
|
||||
|
||||
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
|
||||
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
|
||||
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
|
||||
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
|
||||
if(confirmedCommand){
|
||||
recordActivity(s.armed?'Engage confirmed':'Disengage confirmed');
|
||||
}
|
||||
updateReadouts();
|
||||
const b=s.battery;
|
||||
if(b){
|
||||
$('#battery-title').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'Voltage unavailable';
|
||||
$('#battery-percent').textContent=Number.isFinite(b.percent)?`${Math.round(b.percent)}%`:'—';
|
||||
$('#battery-live-voltage').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'—';
|
||||
$('#battery-live-current').textContent=Number.isFinite(b.current_a)?`${b.current_a.toFixed(1)} A`:'Unavailable';
|
||||
$('#battery-live-consumed').textContent=Number.isFinite(b.consumed_mah)?`${Math.round(b.consumed_mah).toLocaleString()} mAh`:'Unavailable';
|
||||
$('#battery-live-consumed-source').textContent=b.consumed_source||'';
|
||||
$('#battery-live-remaining').textContent=Number.isFinite(b.remaining_mah)?`${Math.round(b.remaining_mah).toLocaleString()} mAh`:Number.isFinite(b.remaining_percent)?`${Math.round(b.remaining_percent)}%`:'Unavailable';
|
||||
$('#battery-live-note').textContent=s.battery_stale?'Battery telemetry is stale. Check the telemetry rate and battery monitor.':b.charge_source==='Voltage estimate'?'Charge percentage is estimated from voltage. Add a calibrated current sensor for consumption readings.':'Live values come from the flight controller; accuracy depends on its battery monitor setup.';
|
||||
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
|
||||
$('.battery-card').title=`${b.charge_source||'Battery status'} · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
|
||||
$('#battery-fill').style.width=`${Number.isFinite(b.percent)?b.percent:0}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
|
||||
$('.battery-card').dataset.low=String(!!b.low);
|
||||
}else{
|
||||
$('#battery-title').textContent='No voltage';
|
||||
$('#battery-percent').textContent='—';
|
||||
$('#battery-live-voltage').textContent='—';$('#battery-live-current').textContent='—';
|
||||
$('#battery-live-consumed').textContent='—';$('#battery-live-consumed-source').textContent='';
|
||||
$('#battery-live-remaining').textContent='—';$('#battery-live-note').textContent='Waiting for battery telemetry.';
|
||||
$('.battery-card').dataset.stale='false';
|
||||
$('.battery-card').title='Waiting for battery voltage · Battery settings';
|
||||
$('#battery-fill').style.width='0';
|
||||
$('.battery-card').dataset.low='false';
|
||||
}
|
||||
const quality=s.link_quality,qualityCard=$('#link-quality');
|
||||
if(qualityCard){
|
||||
if(demoActive){
|
||||
qualityCard.dataset.grade='offline';
|
||||
$('#link-quality-grade').textContent='Demo';
|
||||
$('#link-quality-roundtrip').textContent='no hardware link test';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
renderMotors(s.servos||{});
|
||||
return;
|
||||
}
|
||||
const grade=quality?.grade||(s.connected?'testing':'offline');
|
||||
qualityCard.dataset.grade=grade;
|
||||
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
|
||||
if(quality&&quality.probe_samples){
|
||||
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
|
||||
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
|
||||
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
|
||||
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
|
||||
const downLoss=percent(quality.rx_sequence_loss_percent);
|
||||
const badFrames=quality.rx_corrupt_frames_20s??0;
|
||||
const badBytes=quality.rx_corrupt_bytes_20s??0;
|
||||
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
|
||||
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
|
||||
if(quality.probe_samples<10||quality.rx_packets_30s<10){
|
||||
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
|
||||
}
|
||||
}else{
|
||||
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
|
||||
$('#link-quality-downlink').textContent='';
|
||||
}
|
||||
}
|
||||
renderMotors(s.servos||{});
|
||||
}
|
||||
|
||||
function markBridgeStale(){
|
||||
if(demoActive||bridgeStale)return;
|
||||
bridgeStale=true;resetInputs();render(vehicleSnapshot);
|
||||
}
|
||||
setInterval(()=>{
|
||||
const now=performance.now();
|
||||
if(now-lastSafetyTick>750)resetInputs();
|
||||
lastSafetyTick=now;
|
||||
if(!demoActive&&bridgeReady&&vehicleSnapshot.connected&&performance.now()-lastBridgeAt>1500)markBridgeStale();
|
||||
if(localRequest&&performance.now()>localRequest.deadline){
|
||||
commandError=`${localRequest.name==='arm'?'Engage':'Disarm'} confirmation timed out. Check the vehicle state.`;
|
||||
localRequest=null;render(vehicleSnapshot);
|
||||
}
|
||||
},100);
|
||||
async function poll(){
|
||||
if(!bridgeReady)return;
|
||||
if(!demoActive)try{
|
||||
const snapshot=await withTimeout(api('poll'));
|
||||
if(!demoActive){lastBridgeAt=performance.now();bridgeStale=false;render(snapshot,true)}
|
||||
}catch(e){markBridgeStale()}
|
||||
setTimeout(poll,250);
|
||||
}
|
||||
let modelLoadPromise=null;
|
||||
async function loadDefaultModel(){
|
||||
if(modelLoadPromise)return modelLoadPromise;
|
||||
@@ -254,36 +954,55 @@ async function loadDefaultModel(){
|
||||
return modelLoadPromise;
|
||||
}
|
||||
|
||||
let booted=false;
|
||||
let booted=false,booting=false,bootRetry=0,portRefreshTimer=0;
|
||||
function hasDesktopApi(){return ['initial_state','refresh_ports','poll'].every(name=>typeof window.pywebview?.api?.[name]==='function')}
|
||||
async function boot(){
|
||||
if(booted||!window.pywebview?.api)return;
|
||||
booted=true;
|
||||
bridgeReady=true;
|
||||
loadDefaultModel();
|
||||
if(booted||booting||!hasDesktopApi())return;
|
||||
booting=true;clearTimeout(bootRetry);
|
||||
try{
|
||||
const init=await api('initial_state');
|
||||
batteryConfig=init.battery_config;
|
||||
populatePorts(init.ports);
|
||||
const init=await withTimeout(api('initial_state'),4000);
|
||||
batteryConfig=init.battery_config||{};
|
||||
linkConfig=init.link_config||{};
|
||||
motorSettings=init.motor_settings||motorSettings;
|
||||
syncMotorScalingUI();
|
||||
fillBatteryForm();
|
||||
render(init.state);
|
||||
fillLinkForm();
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
setInterval(async()=>{
|
||||
if(!connected){
|
||||
try{populatePorts(await api('refresh_ports'))}catch(_){}
|
||||
// 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))}
|
||||
catch(error){recordActivity(`Port scan failed: ${error.message}`)}
|
||||
}
|
||||
},2000);
|
||||
}catch(e){
|
||||
booted=false;bridgeReady=false;
|
||||
recordActivity(`Desktop startup failed; retrying: ${e.message}`);
|
||||
toast(`Python bridge: ${e.message}`,true);
|
||||
bootRetry=setTimeout(boot,1000);
|
||||
}finally{
|
||||
booting=false;
|
||||
}
|
||||
}
|
||||
function waitForBridge(){
|
||||
if(window.pywebview?.api)boot();
|
||||
else setTimeout(waitForBridge,50);
|
||||
if(booted)return;
|
||||
if(hasDesktopApi())boot();
|
||||
setTimeout(waitForBridge,250);
|
||||
}
|
||||
addEventListener('pywebviewready',boot);
|
||||
render(vehicleSnapshot);
|
||||
if(document.readyState==='loading'){
|
||||
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
|
||||
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);
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Presentation derived from telemetry. No animation is allowed to set armed. */
|
||||
((root) => {
|
||||
function present(state, {stale=false, focused=true, pending=null, error=''}={}) {
|
||||
const known = state.armed_known === true && !stale;
|
||||
const online = !!state.connected && !stale;
|
||||
const request = pending || state.pending_command;
|
||||
const uncertain = !known && (state.armed !== null && state.armed !== undefined || !!request || state.heartbeat_age != null);
|
||||
let label='ENGAGE', tone='idle', action=null, message='Connect to receive vehicle state.';
|
||||
if (request === 'disarm') {
|
||||
label='DISARMING…'; tone='danger'; message='Awaiting vehicle confirmation. Not yet confirmed disarmed.';
|
||||
} else if (request === 'arm') {
|
||||
label='ENGAGING…'; tone='pending'; action=online?'disarm':null;
|
||||
message='Awaiting vehicle confirmation. Tap to request disarm.';
|
||||
} else if (known && state.armed) {
|
||||
label='DISARM'; tone='danger'; action=online?'disarm':null;
|
||||
message=focused?'Armed · tap or F3 to disarm.':'Armed · controls neutral while the app is unfocused.';
|
||||
} else if (uncertain) {
|
||||
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
|
||||
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
|
||||
} else if (known && online) {
|
||||
action=focused?'arm':null; message='Disarmed · hold 0.6 seconds to engage.';
|
||||
}
|
||||
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
|
||||
const failure=error || state.command_error;
|
||||
return {label,tone,action,pending:!!request,known,
|
||||
drive:known && online && state.armed === true && !request && focused,
|
||||
message:failure ? `${failure} ${known ? (state.armed?'Vehicle armed.':'Vehicle disarmed.') : 'Vehicle state unknown.'}` : message,
|
||||
error:!!(error || state.command_error || uncertain || stale)};
|
||||
}
|
||||
root.ControlState={present};
|
||||
if(typeof module!=='undefined') module.exports=root.ControlState;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
@@ -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()});
|
||||
})();
|
||||
+209
-60
@@ -4,93 +4,242 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sub Control</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<aside class="vehicle-rail">
|
||||
<header class="brand pywebview-drag-region">
|
||||
<span>Sub Control</span>
|
||||
</header>
|
||||
<div class="eyebrow">live vehicle</div>
|
||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||
<div class="water-ring r1"></div><div class="water-ring r2"></div>
|
||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||
<div id="model-status" class="model-status">Initializing…</div>
|
||||
<div class="model-tools">
|
||||
<button id="load-model">Load STL</button>
|
||||
<input id="model-input" type="file" accept=".stl,.STL" hidden>
|
||||
|
||||
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
|
||||
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
|
||||
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
|
||||
<!-- Connection Controls -->
|
||||
<div class="deck-connection">
|
||||
<div class="port-row">
|
||||
<select id="port" aria-label="Serial port">
|
||||
<option value="demo" selected>Demo Mode (no hardware)</option>
|
||||
</select>
|
||||
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg>
|
||||
</button>
|
||||
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings">
|
||||
<svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button id="connect" class="button connect-button" aria-label="Toggle connection">
|
||||
<span class="btn-spinner" aria-hidden="true"></span>
|
||||
<span class="btn-label">Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry / Status Zone -->
|
||||
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
|
||||
<div class="deck-card model-card">
|
||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||
<div id="model-status" class="model-status">Initializing…</div>
|
||||
<div class="model-tools">
|
||||
<button id="load-model">Load STL</button>
|
||||
<input id="model-input" type="file" accept=".stl,.STL" hidden>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vehicle-name"><small>vehicle</small><strong id="vehicle">no heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
|
||||
<div class="rail-bottom">
|
||||
<button class="battery-card ghost" data-open="battery-modal">
|
||||
<span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i>
|
||||
<b><em id="battery-fill"></em></b>
|
||||
</button>
|
||||
<div class="link-state"><span id="link-dot"></span><strong id="link">disconnected</strong></div>
|
||||
|
||||
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
|
||||
<span class="link-quality-dot" aria-hidden="true"></span>
|
||||
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
|
||||
<i><span id="link-quality-roundtrip">waiting for connection</span><span id="link-quality-downlink"></span></i>
|
||||
</button>
|
||||
|
||||
<div class="deck-status-bar">
|
||||
<header class="activity-heading">Activity:</header>
|
||||
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
|
||||
<li id="activity-oldest" hidden></li>
|
||||
<li id="activity-previous" hidden></li>
|
||||
<li id="status-text">Not connected</li>
|
||||
</ol>
|
||||
<div class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></div>
|
||||
</div>
|
||||
<button class="battery-card ghost" data-open="battery-modal" aria-label="Battery status and settings">
|
||||
<span class="battery-meter" aria-hidden="true"><em id="battery-fill"></em></span>
|
||||
<span class="battery-copy"><span class="battery-summary"><strong id="battery-percent">—</strong><small id="battery-title">No voltage</small></span></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="connection">
|
||||
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select>
|
||||
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg></button>
|
||||
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings"><svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg></button>
|
||||
<button id="connect" class="button connect-button">Connect</button>
|
||||
</div>
|
||||
<div class="topbar-spacer pywebview-drag-region"></div>
|
||||
<!-- ── PRIMARY CONTROL ZONE ── -->
|
||||
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
|
||||
<!-- Frameless Drag & Window Controls Header -->
|
||||
<div class="workspace-topbar">
|
||||
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
|
||||
<div class="workspace-drag-area pywebview-drag-region"></div>
|
||||
<div class="window-actions">
|
||||
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
||||
<button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button>
|
||||
<button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<section class="drive-grid">
|
||||
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
|
||||
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
||||
<!-- Reserved Engage / Disarm row -->
|
||||
<div class="engage-header-zone">
|
||||
<button id="engage" class="button subtle engage-button" aria-label="Engage" aria-describedby="control-status" title="Engage (F2)">
|
||||
<span class="engage-face">
|
||||
<span id="engage-label" class="engage-label">engage</span>
|
||||
<span class="engage-sheen-label" aria-hidden="true">engage</span>
|
||||
<span class="engage-progress-label" aria-hidden="true">engage</span>
|
||||
</span>
|
||||
</button>
|
||||
<p id="control-status" class="control-status" role="status" aria-live="polite">Connect to receive vehicle state.</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Joysticks & Thrust Control Grid -->
|
||||
<div class="controls-grid">
|
||||
<!-- Steering / Throttle Joystick -->
|
||||
<article class="control-card steer-card">
|
||||
<header><div><small>steer + throttle</small><strong>differential drive</strong></div><span class="key-hint">w a s d</span></header>
|
||||
<div id="joystick" class="joystick" touch-action="none">
|
||||
<canvas id="joystick-canvas" aria-label="3D Interactive Spherical Joystick"></canvas>
|
||||
<header>
|
||||
<div title="Steer and throttle · W A S D"></div>
|
||||
|
||||
</header>
|
||||
<div class="joystick-pad">
|
||||
<div id="joystick" class="joystick" touch-action="none">
|
||||
<canvas id="joystick-canvas" title="Steer and throttle · W A S D" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<footer><span class="steer-readout-left">surge <b id="surge-readout">0</b></span><span class="steer-readout-right"><b id="yaw-readout">0</b> turn</span></footer>
|
||||
<footer>
|
||||
<span class="steer-readout-left">surge <b id="surge-readout">—</b></span>
|
||||
<span class="steer-readout-right"><b id="yaw-readout">—</b> turn</span>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<!-- Vertical Thruster Slider -->
|
||||
<article class="control-card vertical-card">
|
||||
<header><div><small>vertical</small><strong>center thruster</strong></div><span class="key-hint">r / f</span></header>
|
||||
<div class="vertical-control">
|
||||
<span>rise</span>
|
||||
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div>
|
||||
<span>dive</span>
|
||||
</div>
|
||||
<footer><span><b id="heave-readout">0</b> vertical</span></footer>
|
||||
</article>
|
||||
</section>
|
||||
<header>
|
||||
<div title="Vertical thrust · R / F"></div>
|
||||
|
||||
<section class="bottom-actions">
|
||||
<section class="command-bar">
|
||||
<div class="commands">
|
||||
<button id="manual" class="button subtle"><span>Manual</span></button>
|
||||
<button id="arm" class="button arm" aria-label="Hold to arm" title="Hold to arm (F2)"><span class="arm-copy"><span class="arm-label">Arm</span><span class="arm-holding-label">Holding…</span></span></button>
|
||||
<button id="disarm" class="button danger"><span>Disarm</span></button>
|
||||
</header>
|
||||
<div class="heave-pad">
|
||||
<div class="vertical-control">
|
||||
<span>rise</span>
|
||||
<div id="heave" title="Vertical thrust · R / F" class="heave-track" touch-action="none">
|
||||
<div class="heave-center"></div>
|
||||
<div id="heave-fill" class="heave-fill"></div>
|
||||
<div id="heave-knob" class="heave-knob"></div>
|
||||
</div>
|
||||
<span>dive</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="output-limit"><span>output limit <b id="gain-value">20%</b></span><div class="range-pill"><button id="gain-down" type="button" aria-label="Decrease output limit">‹</button><input id="gain" type="range" min="5" max="50" value="20"><button id="gain-up" type="button" aria-label="Increase output limit">›</button></div></label>
|
||||
<button class="button subtle" data-open="motors-modal">Motor Setup</button>
|
||||
</section>
|
||||
<footer>
|
||||
<span><b id="heave-readout">—</b> vertical</span>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<div class="control-toolbar">
|
||||
<label class="output-limit">
|
||||
<span>Output limit <b id="gain-value">20%</b></span>
|
||||
<div class="range-pill">
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
|
||||
<input id="gain" aria-label="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>
|
||||
<div class="status-line"><span></span><p id="status">ready. select a USB port to begin.</p><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">logs</a></div>
|
||||
</section>
|
||||
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
|
||||
<header class="console-dock-header"><div><h2 id="console-title" title="Local session messages. Select a message to expand its details.">Console</h2></div><button id="console-close" type="button" class="console-close" aria-label="Hide console" title="Hide console"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
|
||||
<div class="console-toolbar"><label><span class="visually-hidden">Console filter</span><select id="console-filter"><option value="useful">Useful traffic</option><option value="all">All traffic</option></select></label><button id="console-pause" type="button" aria-pressed="false">Pause</button></div>
|
||||
<p id="console-state" class="console-state" role="status" title="Pausing the viewer does not stop session logging." hidden></p>
|
||||
<div class="console-streams"><section class="console-side"><header><i class="rx-dot" aria-hidden="true" title="Vehicle to PC · new displayed message"></i><span>RX</span></header><div id="console-rx-output" class="console-output" tabindex="0" aria-label="Received messages"></div></section><section class="console-side"><header><i class="tx-dot" aria-hidden="true" title="PC to vehicle · new displayed message"></i><span>TX</span></header><div id="console-tx-output" class="console-output" tabindex="0" aria-label="Sent messages"></div></section></div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||
<!-- Modals -->
|
||||
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="link-title">Link settings</h2>
|
||||
<form id="link-form" title="Baud changes apply on the next connection.">
|
||||
<section class="settings-section"><h3>Serial connection</h3>
|
||||
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
|
||||
|
||||
<script src="model-viewer.js"></script>
|
||||
<script src="joystick-3d.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
|
||||
<p id="custom-port-status" class="note" role="status"></p>
|
||||
</section>
|
||||
<section class="settings-section"><h3 title="Lower rates leave more bandwidth for controls. Rates reset after a reboot.">Telemetry rates</h3>
|
||||
<div class="form-grid two">
|
||||
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>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=20261002-spacing-motion-v2"></script>
|
||||
<script src="control-state.js?v=20260827-state-v1"></script>
|
||||
<script src="controller-input.js?v=20261001-controller-v1"></script>
|
||||
<script src="console-view.js?v=20261002-console-v2"></script>
|
||||
<script src="app.js?v=20261017-engage-center-v2"></script>
|
||||
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+40
-306
@@ -1,317 +1,51 @@
|
||||
/* joystick-3d.js – Clean Canvas joystick with tactile press animation,
|
||||
expanded soft red boundary glow, clean lowercase curved labels, and 20% larger knob. */
|
||||
|
||||
/* Visual joystick. Control transport remains in app.js through setJoyControl. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const host=document.getElementById('joystick'),canvas=document.getElementById('joystick-canvas');
|
||||
if(!host||!canvas)return;
|
||||
const ctx=canvas.getContext('2d');
|
||||
let width=0,height=0,dpr=1,x=0,y=0,activePointer=null,returnFrame=0;
|
||||
|
||||
const host = document.getElementById('joystick');
|
||||
if (!host) return;
|
||||
|
||||
let canvas = document.getElementById('joystick-canvas');
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.id = 'joystick-canvas';
|
||||
host.appendChild(canvas);
|
||||
function geometry(){
|
||||
const radius=Math.min(width,height)*.43;
|
||||
return {cx:width/2,cy:height/2,radius,travel:radius*.67,knob:Math.max(25,Math.min(34,radius*.22))};
|
||||
}
|
||||
function resize(){const rect=host.getBoundingClientRect();width=rect.width;height=rect.height;dpr=Math.min(devicePixelRatio||1,2);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;draw()}
|
||||
function draw(){
|
||||
if(!width||!height)return;
|
||||
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
|
||||
|
||||
canvas.style.cssText =
|
||||
'position:absolute;inset:0;width:100%;height:100%;z-index:5;touch-action:none;cursor:grab;';
|
||||
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eee';ctx.fill();ctx.strokeStyle='#d3d3d3';ctx.lineWidth=1.5;ctx.stroke();
|
||||
ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
|
||||
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
|
||||
|
||||
let W = 0, H = 0, dpr = 1;
|
||||
let nx = 0, ny = 0; // normalised knob position: -1 to +1
|
||||
let dragging = false;
|
||||
let animId = 0;
|
||||
let pressAnimId = 0;
|
||||
let pressScale = 1.0; // smooth press scale: 1.0 down to 0.93
|
||||
|
||||
function resize() {
|
||||
const r = host.getBoundingClientRect();
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
W = r.width;
|
||||
H = r.height;
|
||||
canvas.width = Math.round(W * dpr);
|
||||
canvas.height = Math.round(H * dpr);
|
||||
draw();
|
||||
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
||||
const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819);
|
||||
handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939');
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;ctx.fill();ctx.restore();
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
|
||||
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
function drawCurvedText(text, cx, cy, radius, centerAngle, flip = false) {
|
||||
ctx.save();
|
||||
ctx.font = '300 11.5px Inter, "SF Pro Display", "Helvetica Neue", Arial, sans-serif';
|
||||
ctx.fillStyle = '#656f6c';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const charWidths = [];
|
||||
let totalWidth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const w = ctx.measureText(text[i]).width + 1.8;
|
||||
charWidths.push(w);
|
||||
totalWidth += w;
|
||||
}
|
||||
|
||||
const totalAngle = totalWidth / radius;
|
||||
|
||||
if (!flip) {
|
||||
let currentAngle = centerAngle - totalAngle / 2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charAngle = charWidths[i] / radius;
|
||||
const midAngle = currentAngle + charAngle / 2;
|
||||
const x = cx + radius * Math.cos(midAngle);
|
||||
const y = cy + radius * Math.sin(midAngle);
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(midAngle + Math.PI / 2);
|
||||
ctx.fillText(text[i], 0, 0);
|
||||
ctx.restore();
|
||||
currentAngle += charAngle;
|
||||
}
|
||||
} else {
|
||||
let currentAngle = centerAngle + totalAngle / 2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charAngle = charWidths[i] / radius;
|
||||
const midAngle = currentAngle - charAngle / 2;
|
||||
const x = cx + radius * Math.cos(midAngle);
|
||||
const y = cy + radius * Math.sin(midAngle);
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(midAngle - Math.PI / 2);
|
||||
ctx.fillText(text[i], 0, 0);
|
||||
ctx.restore();
|
||||
currentAngle -= charAngle;
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
function publish(){window.setJoyControl?.(x,y)}
|
||||
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
|
||||
function positionFromEvent(event){const rect=canvas.getBoundingClientRect(),g=geometry();setPosition((event.clientX-rect.left-g.cx)/g.travel,(event.clientY-rect.top-g.cy)/g.travel)}
|
||||
function easeTo(nextX,nextY){
|
||||
cancelAnimationFrame(returnFrame);
|
||||
const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}
|
||||
const startX=x,startY=y,start=performance.now();
|
||||
const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX+(nextX-startX)*smooth,startY+(nextY-startY)*smooth);if(p<1)returnFrame=requestAnimationFrame(tick)};
|
||||
returnFrame=requestAnimationFrame(tick);
|
||||
}
|
||||
function returnToCenter(){easeTo(0,0)}
|
||||
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
|
||||
|
||||
function draw() {
|
||||
if (!W || !H) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const cx = W / 2;
|
||||
const cy = H / 2;
|
||||
const R = Math.min(W, H) * 0.45;
|
||||
const baseKnobR = R * 0.22; // 20% larger knob
|
||||
const knobR = baseKnobR * pressScale;
|
||||
const travel = R * 0.58;
|
||||
const dist = Math.hypot(nx, ny);
|
||||
|
||||
/* ── 1. SPHERICAL BOWL BASE ── */
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, R, 0, TAU);
|
||||
ctx.clip();
|
||||
|
||||
// Base smooth light background (clean, no blue edge gradient)
|
||||
const bg = ctx.createRadialGradient(cx, cy - R * 0.1, R * 0.1, cx, cy, R);
|
||||
bg.addColorStop(0, '#ffffff');
|
||||
bg.addColorStop(0.55, '#f7f9f8');
|
||||
bg.addColorStop(0.85, '#edf1f0');
|
||||
bg.addColorStop(1, '#e3e8e7');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
|
||||
|
||||
// 30% larger, 20% lighter transparent red boundary glow when pulled near edge (dist > 0.65)
|
||||
if (dist > 0.65) {
|
||||
const redFactor = Math.min(1, (dist - 0.65) / 0.35);
|
||||
const angle = Math.atan2(ny, nx);
|
||||
const edgeX = cx + R * Math.cos(angle);
|
||||
const edgeY = cy + R * Math.sin(angle);
|
||||
const redGrad = ctx.createRadialGradient(edgeX, edgeY, 0, edgeX, edgeY, R * 0.85);
|
||||
redGrad.addColorStop(0, `rgba(240, 110, 95, ${0.14 * redFactor})`);
|
||||
redGrad.addColorStop(0.5, `rgba(240, 110, 95, ${0.045 * redFactor})`);
|
||||
redGrad.addColorStop(1, 'rgba(240, 110, 95, 0)');
|
||||
ctx.fillStyle = redGrad;
|
||||
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
|
||||
}
|
||||
|
||||
// Straight vertical and horizontal axis lines
|
||||
ctx.strokeStyle = 'rgba(155, 175, 178, 0.32)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - R * 0.85);
|
||||
ctx.lineTo(cx, cy + R * 0.85);
|
||||
ctx.moveTo(cx - R * 0.85, cy);
|
||||
ctx.lineTo(cx + R * 0.85, cy);
|
||||
ctx.stroke();
|
||||
|
||||
// ── Curved Lowercase Directional Labels ──
|
||||
const labelR = R * 0.84;
|
||||
drawCurvedText('forward', cx, cy, labelR, -Math.PI / 2, false);
|
||||
drawCurvedText('reverse', cx, cy, labelR, Math.PI / 2, true);
|
||||
drawCurvedText('turn left', cx, cy, labelR, Math.PI, false);
|
||||
drawCurvedText('turn right', cx, cy, labelR, 0, false);
|
||||
|
||||
ctx.restore(); // un-clip
|
||||
|
||||
// Outer rim border
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, R, 0, TAU);
|
||||
ctx.strokeStyle = '#cdd6d4';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
/* ── 2. SOFT KNOB SHADOW ── */
|
||||
const kx = cx + nx * travel;
|
||||
const ky = cy + ny * travel;
|
||||
|
||||
const shadowY = ky + 2.5;
|
||||
const sg = ctx.createRadialGradient(kx, shadowY, knobR * 0.3, kx, shadowY, knobR * 1.25);
|
||||
sg.addColorStop(0, 'rgba(18, 28, 24, 0.15)');
|
||||
sg.addColorStop(0.55, 'rgba(18, 28, 24, 0.05)');
|
||||
sg.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
ctx.beginPath();
|
||||
ctx.arc(kx, shadowY, knobR * 1.25, 0, TAU);
|
||||
ctx.fillStyle = sg;
|
||||
ctx.fill();
|
||||
|
||||
/* ── 3. SINGLE 20% LARGER LIGHTER KNOB ── */
|
||||
ctx.save();
|
||||
ctx.translate(kx, ky);
|
||||
|
||||
// Lighter, clean metallic/slate gradient
|
||||
const kg = ctx.createLinearGradient(0, -knobR, 0, knobR);
|
||||
kg.addColorStop(0, '#687271');
|
||||
kg.addColorStop(0.45, '#515958');
|
||||
kg.addColorStop(1, '#3c4443');
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, knobR, 0, TAU);
|
||||
ctx.fillStyle = kg;
|
||||
ctx.fill();
|
||||
|
||||
// Clean 1px top highlight bevel
|
||||
const bevel = ctx.createLinearGradient(0, -knobR, 0, knobR);
|
||||
bevel.addColorStop(0, 'rgba(255, 255, 255, 0.55)');
|
||||
bevel.addColorStop(0.4, 'rgba(255, 255, 255, 0.05)');
|
||||
bevel.addColorStop(0.7, 'rgba(0, 0, 0, 0.1)');
|
||||
bevel.addColorStop(1, 'rgba(0, 0, 0, 0.40)');
|
||||
ctx.strokeStyle = bevel;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Outer light edge border (matching vertical knob)
|
||||
ctx.strokeStyle = '#d7dedc';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Subtle cyan light glow ONLY when actively holding/dragging
|
||||
if (dragging) {
|
||||
const gg = ctx.createRadialGradient(0, 0, 0, 0, 0, knobR * 1.15);
|
||||
gg.addColorStop(0, 'rgba(255, 255, 255, 0.32)');
|
||||
gg.addColorStop(0.4, 'rgba(200, 245, 255, 0.18)');
|
||||
gg.addColorStop(0.75, 'rgba(114, 216, 232, 0.10)');
|
||||
gg.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, knobR, 0, TAU);
|
||||
ctx.fillStyle = gg;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore(); // un-knob translate
|
||||
ctx.restore(); // top-level save
|
||||
}
|
||||
|
||||
function setPos(x, y) {
|
||||
const len = Math.hypot(x, y);
|
||||
if (len > 1) { x /= len; y /= len; }
|
||||
nx = x;
|
||||
ny = y;
|
||||
draw();
|
||||
if (window.setJoyControl) window.setJoyControl(nx, ny);
|
||||
}
|
||||
|
||||
function pointerToNorm(e) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const cx = r.width / 2;
|
||||
const cy = r.height / 2;
|
||||
const travel = Math.min(r.width, r.height) * 0.45 * 0.58;
|
||||
const px = e.clientX - r.left;
|
||||
const py = e.clientY - r.top;
|
||||
setPos((px - cx) / travel, (py - cy) / travel);
|
||||
}
|
||||
|
||||
/* ── Tactile Press Animation (matching vertical knob) ── */
|
||||
function animatePress(toPressed) {
|
||||
cancelAnimationFrame(pressAnimId);
|
||||
const startScale = pressScale;
|
||||
const targetScale = toPressed ? 0.93 : 1.0;
|
||||
const duration = toPressed ? 110 : 450;
|
||||
const startTime = performance.now();
|
||||
|
||||
function step(now) {
|
||||
const p = Math.min(1, (now - startTime) / duration);
|
||||
const curve = toPressed
|
||||
? p * (2 - p)
|
||||
: 1 + Math.sin((p - 1) * Math.PI * 2.5) * Math.pow(1 - p, 2) * 0.35;
|
||||
|
||||
pressScale = startScale + (targetScale - startScale) * (toPressed ? curve : p);
|
||||
if (!toPressed) {
|
||||
pressScale = 1.0 + (startScale - 1.0) * Math.pow(1 - p, 2) + Math.sin(p * Math.PI) * 0.02 * (1 - p);
|
||||
}
|
||||
draw();
|
||||
|
||||
if (p < 1) {
|
||||
pressAnimId = requestAnimationFrame(step);
|
||||
} else {
|
||||
pressScale = targetScale;
|
||||
draw();
|
||||
}
|
||||
}
|
||||
pressAnimId = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function easeBack() {
|
||||
cancelAnimationFrame(animId);
|
||||
const sx = nx, sy = ny, t0 = performance.now();
|
||||
(function step(now) {
|
||||
const p = Math.min(1, (now - t0) / 320);
|
||||
const s = p * p * (3 - 2 * p);
|
||||
nx = sx * (1 - s);
|
||||
ny = sy * (1 - s);
|
||||
draw();
|
||||
if (window.setJoyControl) window.setJoyControl(nx, ny);
|
||||
if (p < 1) animId = requestAnimationFrame(step);
|
||||
})(t0);
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
dragging = true;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
cancelAnimationFrame(animId);
|
||||
animatePress(true);
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
pointerToNorm(e);
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (dragging) pointerToNorm(e);
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerup', () => {
|
||||
dragging = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
animatePress(false);
|
||||
easeBack();
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => {
|
||||
dragging = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
animatePress(false);
|
||||
easeBack();
|
||||
});
|
||||
|
||||
resize();
|
||||
|
||||
window.joystick3D = {
|
||||
setPos(x, y) { cancelAnimationFrame(animId); setPos(x, y); },
|
||||
easeBack() { if (!dragging) easeBack(); }
|
||||
};
|
||||
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canTouchControl?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
||||
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
|
||||
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
|
||||
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
|
||||
addEventListener('resize',resize);new ResizeObserver(resize).observe(host);resize();
|
||||
window.joystick3D={setVisual(nextX,nextY){setPosition(nextX,nextY,false)},setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
|
||||
})();
|
||||
|
||||
+1100
-648
File diff suppressed because it is too large
Load Diff
+423
-49
@@ -16,6 +16,7 @@ import webview
|
||||
from serial.tools import list_ports
|
||||
|
||||
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
||||
from vehicle_state import VehicleState
|
||||
|
||||
|
||||
class ControlApi:
|
||||
@@ -24,20 +25,49 @@ class ControlApi:
|
||||
def __init__(self):
|
||||
self.events: queue.Queue = queue.Queue()
|
||||
self.worker = MavlinkWorker(self.events)
|
||||
self.worker.require_input_lease = True
|
||||
self.vehicle_state = VehicleState()
|
||||
self.battery_received_at = None
|
||||
self.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()
|
||||
self.state = {
|
||||
"connected": False,
|
||||
"link": "DISCONNECTED",
|
||||
"vehicle": "No heartbeat",
|
||||
"armed": False,
|
||||
"mode": None,
|
||||
"app_state": "disconnected",
|
||||
"status": "Ready. Select a USB port to begin.",
|
||||
"battery": None,
|
||||
"link_quality": None,
|
||||
"heavy_quality": None,
|
||||
"log_path": None,
|
||||
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
|
||||
"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"
|
||||
@@ -54,17 +84,39 @@ 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": [p.device for p in list_ports.comports()],
|
||||
"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):
|
||||
return [p.device for p in list_ports.comports()]
|
||||
ports = []
|
||||
# Add physical and virtual serial COM ports
|
||||
for p in list_ports.comports():
|
||||
desc = p.description or ""
|
||||
if desc and desc != "n/a" and desc != p.device:
|
||||
label = f"{p.device} ({desc})"
|
||||
else:
|
||||
label = p.device
|
||||
ports.append({"device": p.device, "label": label, "type": "serial"})
|
||||
|
||||
# Built-in UI testing: no external process, port, or MAVLink socket.
|
||||
ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"})
|
||||
return ports
|
||||
|
||||
def minimize_window(self):
|
||||
if self._window:
|
||||
@@ -202,36 +254,183 @@ class ControlApi:
|
||||
return None
|
||||
|
||||
def connect(self, port, baud=57600, rate=20):
|
||||
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
|
||||
if not port:
|
||||
raise ValueError("Select a USB serial port")
|
||||
if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
|
||||
self.demo_mode = True
|
||||
self.servo_functions = {1: 33, 2: 34, 3: 35}
|
||||
self.motor_directions = {1: 1, 2: 1, 3: 1}
|
||||
with self.lock:
|
||||
self.state.update(
|
||||
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
|
||||
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
|
||||
link_quality={"grade": "excellent", "latency_ms": 12.0,
|
||||
"loss_percent": 0.0, "samples": 20,
|
||||
"corrupt_frames_20s": 0, "heartbeat_age_s": 0.1},
|
||||
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
|
||||
"cells": 4, "low": False},
|
||||
)
|
||||
for servo in range(1, 17):
|
||||
function = self.servo_functions.get(servo, 0)
|
||||
motor = motor_number_for_function(function)
|
||||
self.state["servos"][str(servo)] = {
|
||||
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
|
||||
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
|
||||
"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)
|
||||
self.worker.connect(str(port), int(baud))
|
||||
with self.lock:
|
||||
self.vehicle_state.disconnect()
|
||||
self.state.update(
|
||||
connected=True, link="CONNECTING",
|
||||
status=f"Opening {port} at {baud} baud…",
|
||||
)
|
||||
return True
|
||||
self.worker.connect(str(port), int(baud))
|
||||
print(f"[web_ui] connect() dispatched to worker thread OK")
|
||||
# Return only once the worker has either seen a heartbeat or reported a
|
||||
# connection failure. This makes the button independent of the regular
|
||||
# UI polling loop and prevents an indefinite visual "Connecting" state.
|
||||
deadline = time.monotonic() + 8.0
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = self.poll()
|
||||
if snapshot["app_state"] != "connecting":
|
||||
return snapshot
|
||||
time.sleep(0.05)
|
||||
self.worker.disconnect()
|
||||
time.sleep(0.1)
|
||||
self.poll()
|
||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||
|
||||
def disconnect(self):
|
||||
self.worker.stop_motor_test()
|
||||
with self.lock:
|
||||
self.state["motor_test_active"] = False
|
||||
if self.worker.heavy_quality_test is not None:
|
||||
self.worker.cancel_heavy_quality_test()
|
||||
if self.demo_mode:
|
||||
self.demo_mode = False
|
||||
with self.lock:
|
||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||
armed=False, mode=None, battery=None, link_quality=None,
|
||||
status="Demo mode disconnected")
|
||||
return True
|
||||
self.worker.disconnect()
|
||||
return True
|
||||
|
||||
def set_axes(self, forward, turn, vertical):
|
||||
self.worker.set_axes(Axes(
|
||||
max(-1.0, min(1.0, float(forward))),
|
||||
0.0,
|
||||
max(-1.0, min(1.0, float(vertical))),
|
||||
max(-1.0, min(1.0, float(turn))),
|
||||
))
|
||||
def start_heavy_quality_test(self):
|
||||
if self.demo_mode:
|
||||
raise RuntimeError("The heavy link test requires a real flight-controller connection")
|
||||
return self.worker.start_heavy_quality_test()
|
||||
|
||||
def cancel_heavy_quality_test(self):
|
||||
return self.worker.cancel_heavy_quality_test()
|
||||
|
||||
def 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:
|
||||
self.state["status"] = (
|
||||
f"Demo controls • surge {float(forward):+.2f} • "
|
||||
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
|
||||
)
|
||||
return True
|
||||
with self.lock:
|
||||
allowed = self.vehicle_state.snapshot()["controls_allowed"]
|
||||
self.worker.set_axes(Axes(
|
||||
max(-1.0, min(1.0, float(forward))), 0.0,
|
||||
max(-1.0, min(1.0, float(vertical))),
|
||||
max(-1.0, min(1.0, float(turn))),
|
||||
) if allowed else Axes(), sequence)
|
||||
return True
|
||||
|
||||
def stop_controls(self, sequence=None):
|
||||
"""Invalidate queued input without claiming that the vehicle disarmed."""
|
||||
with self.lock:
|
||||
self.worker.set_axes(Axes(), sequence)
|
||||
self.worker._send_control(force_neutral=True)
|
||||
return True
|
||||
|
||||
def set_gain(self, value):
|
||||
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
|
||||
|
||||
def command(self, name):
|
||||
@staticmethod
|
||||
def _clean_motor_settings(settings):
|
||||
if not isinstance(settings, dict):
|
||||
raise ValueError("Motor settings must be an object")
|
||||
requested = settings.get("motors", {})
|
||||
if not isinstance(requested, dict):
|
||||
raise ValueError("Motor settings are invalid")
|
||||
motors = {}
|
||||
for motor in range(1, 13):
|
||||
entry = requested.get(str(motor), {})
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
name = str(entry.get("name", f"Motor {motor}")).strip()[:24] or f"Motor {motor}"
|
||||
try:
|
||||
scale = max(0, min(100, int(entry.get("scale", 20))))
|
||||
except (TypeError, ValueError):
|
||||
scale = 20
|
||||
motors[str(motor)] = {"name": name, "scale": scale}
|
||||
return {"independent": bool(settings.get("independent", False)), "customized": bool(settings.get("customized", False)), "motors": motors}
|
||||
|
||||
def save_motor_settings(self, settings, persist=True):
|
||||
clean = self._clean_motor_settings(settings)
|
||||
if persist:
|
||||
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||
self.motor_settings = clean
|
||||
self.worker.set_motor_scaling(
|
||||
clean["independent"],
|
||||
{int(i): entry["scale"] for i, entry in clean["motors"].items()},
|
||||
)
|
||||
return clean
|
||||
|
||||
def command(self, name, sequence=None, expires_at=None):
|
||||
if self.demo_mode:
|
||||
if name not in ("manual", "arm", "disarm"):
|
||||
raise ValueError(f"Unknown command: {name}")
|
||||
armed = name == "arm" if name != "manual" else self.state.get("armed", False)
|
||||
with self.lock:
|
||||
self.state.update(armed=armed, mode="MANUAL",
|
||||
vehicle=f"DEMO • {'ARMED' if armed else 'DISARMED'}",
|
||||
status=f"Demo: {'Manual mode' if name == 'manual' else name.capitalize()} confirmed")
|
||||
return True
|
||||
if not self.worker.connection or not self.worker.target_system:
|
||||
if name == "manual":
|
||||
raise RuntimeError("Connect to vehicle to enable Manual mode")
|
||||
@@ -240,15 +439,25 @@ class ControlApi:
|
||||
elif name == "disarm":
|
||||
raise RuntimeError("Connect to vehicle before disarming")
|
||||
raise RuntimeError("No autopilot heartbeat")
|
||||
if name in ("arm", "disarm"):
|
||||
self.poll()
|
||||
with self.lock:
|
||||
if name == "arm":
|
||||
if expires_at is not None and time.time() * 1000 > expires_at:
|
||||
raise RuntimeError("Engage request expired before it could be sent")
|
||||
if sequence is not None and sequence <= self.worker.last_input_sequence:
|
||||
raise RuntimeError("Engage canceled by a newer input reset")
|
||||
self.vehicle_state.request(name)
|
||||
self.worker.set_axes(Axes(), sequence)
|
||||
try:
|
||||
self.worker.command_arm(name == "arm")
|
||||
except Exception as exc:
|
||||
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
|
||||
raise
|
||||
return self._snapshot()
|
||||
if name == "manual":
|
||||
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
||||
self.worker.set_manual_mode()
|
||||
elif name == "arm":
|
||||
self.worker.log("OPERATOR", "clicked ARM")
|
||||
self.worker.command_arm(True)
|
||||
elif name == "disarm":
|
||||
self.worker.log("OPERATOR", "clicked DISARM")
|
||||
self.worker.command_arm(False)
|
||||
else:
|
||||
raise ValueError(f"Unknown command: {name}")
|
||||
return True
|
||||
@@ -258,6 +467,10 @@ class ControlApi:
|
||||
if not 1 <= servo <= 16 or not 0 <= motor <= 12:
|
||||
raise ValueError("Invalid servo or motor")
|
||||
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
|
||||
if self.demo_mode:
|
||||
self.servo_functions[servo] = function
|
||||
self._update_servo(servo)
|
||||
return True
|
||||
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
|
||||
return True
|
||||
|
||||
@@ -269,6 +482,10 @@ class ControlApi:
|
||||
current = self.motor_directions.get(motor)
|
||||
if current is None:
|
||||
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
|
||||
if self.demo_mode:
|
||||
self.motor_directions[motor] = -1 if current == 1 else 1
|
||||
self._update_servo(servo)
|
||||
return True
|
||||
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
|
||||
return True
|
||||
|
||||
@@ -287,8 +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):
|
||||
@@ -297,14 +540,20 @@ class ControlApi:
|
||||
os.startfile(path)
|
||||
return True
|
||||
|
||||
def read_console(self, limit=150):
|
||||
"""Return a bounded, read-only view of the local session console."""
|
||||
return self.worker.read_console(limit)
|
||||
|
||||
def poll(self):
|
||||
while True:
|
||||
try:
|
||||
event = self.events.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._consume(event)
|
||||
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5:
|
||||
# Bound a poll even if telemetry arrives continuously.
|
||||
with self._poll_lock:
|
||||
for _ in range(500):
|
||||
try:
|
||||
event = self.events.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._consume(event)
|
||||
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
|
||||
with self.lock:
|
||||
self.state["link"] = (
|
||||
"DATA OK / HEARTBEAT DELAYED"
|
||||
@@ -312,33 +561,69 @@ class ControlApi:
|
||||
)
|
||||
return self._snapshot()
|
||||
|
||||
def _derive_app_state(self) -> str:
|
||||
if not self.state.get("connected"):
|
||||
return "disconnected"
|
||||
link = self.state.get("link", "")
|
||||
if link == "CONNECTING":
|
||||
return "connecting"
|
||||
if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"):
|
||||
return "link_degraded"
|
||||
if self.state.get("armed"):
|
||||
return "armed"
|
||||
return "connected_disarmed"
|
||||
|
||||
def _snapshot(self):
|
||||
with self.lock:
|
||||
return json.loads(json.dumps(self.state))
|
||||
snap = json.loads(json.dumps(self.state))
|
||||
snap["motor_settings"] = self.motor_settings
|
||||
snap["app_state"] = self._derive_app_state()
|
||||
if not self.demo_mode:
|
||||
snap.update(self.vehicle_state.snapshot())
|
||||
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
|
||||
snap["app_state"] = "link_degraded"
|
||||
snap["battery_stale"] = (self.battery_received_at is None
|
||||
or time.monotonic() - self.battery_received_at > 5)
|
||||
return snap
|
||||
|
||||
def _consume(self, event):
|
||||
kind = event[0]
|
||||
with self.lock:
|
||||
if kind == "heartbeat":
|
||||
_, system, autopilot, mode, armed = event
|
||||
_, system, autopilot, mode, armed = event[:5]
|
||||
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
|
||||
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),
|
||||
mode=str(mode).upper(),
|
||||
)
|
||||
elif kind in ("status", "statustext"):
|
||||
self.state["status"] = event[1]
|
||||
elif kind == "ack":
|
||||
if event[1] == 400:
|
||||
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
|
||||
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
||||
elif kind == "battery":
|
||||
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("_")])
|
||||
@@ -355,9 +640,64 @@ class ControlApi:
|
||||
self.state["log_path"] = event[1]
|
||||
elif kind == "error":
|
||||
self.state["status"] = event[1]
|
||||
self.state["last_error"] = event[1]
|
||||
elif kind == "disconnected":
|
||||
self.vehicle_state.disconnect()
|
||||
last_err = self.state.get("last_error")
|
||||
status_msg = last_err if last_err else "Disconnected"
|
||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||
armed=False, battery=None, status="Disconnected")
|
||||
mode=None, link_quality=None, status=status_msg, 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)
|
||||
@@ -367,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):
|
||||
@@ -380,22 +721,49 @@ def _apply_dwm_styling(window):
|
||||
hwnd = int(window.native.Handle.ToInt64())
|
||||
if hwnd:
|
||||
import ctypes
|
||||
user32 = ctypes.windll.user32
|
||||
dwmapi = ctypes.windll.dwmapi
|
||||
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
|
||||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||
dwmapi.DwmSetWindowAttribute(
|
||||
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
|
||||
)
|
||||
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||
dwmapi.DwmSetWindowAttribute(
|
||||
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
|
||||
)
|
||||
user32.SetForegroundWindow(ctypes.c_void_p(hwnd))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
|
||||
target_w, target_h = 1100, 650
|
||||
min_w, min_h = 960, 580
|
||||
try:
|
||||
import ctypes
|
||||
user32 = ctypes.windll.user32
|
||||
sw = user32.GetSystemMetrics(0)
|
||||
sh = user32.GetSystemMetrics(1)
|
||||
if sw > 0 and sh > 0:
|
||||
target_w = min(target_w, max(min_w, int(sw * 0.92)))
|
||||
target_h = min(target_h, max(min_h, int(sh * 0.90)))
|
||||
min_w = min(min_w, target_w)
|
||||
min_h = min(min_h, target_h)
|
||||
except Exception:
|
||||
pass
|
||||
return target_w, target_h, (min_w, min_h)
|
||||
|
||||
|
||||
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress HTTP request logs in console
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("Expires", "0")
|
||||
super().end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
unquoted = urllib.parse.unquote(self.path)
|
||||
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
|
||||
@@ -409,7 +777,6 @@ class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "model/stl")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
@@ -425,14 +792,20 @@ def start_local_server(directory: Path) -> int:
|
||||
|
||||
|
||||
def main():
|
||||
if os.name == "nt":
|
||||
# Keep the running window separate from generic Python taskbar groups.
|
||||
import ctypes
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("SUBControl.Desktop")
|
||||
from desktop_shortcut import ensure_desktop_shortcut
|
||||
ensure_desktop_shortcut()
|
||||
api = ControlApi()
|
||||
api._migrate_legacy_model()
|
||||
project_root = Path(__file__).resolve().parent
|
||||
port = start_local_server(project_root)
|
||||
app_url = f"http://127.0.0.1:{port}/web/index.html"
|
||||
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
|
||||
win_w, win_h, min_dims = _get_window_size()
|
||||
window = webview.create_window(
|
||||
"SUB Control", url=app_url, js_api=api,
|
||||
width=1400, height=808, min_size=(1125, 690),
|
||||
width=win_w, height=win_h, min_size=min_dims,
|
||||
background_color="#111719", frameless=True, easy_drag=False,
|
||||
shadow=True,
|
||||
)
|
||||
@@ -441,7 +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
|
||||
|
||||
@@ -42,20 +45,50 @@ class Axes:
|
||||
yaw: float = 0.0
|
||||
|
||||
|
||||
def normalize_endpoint(port: str) -> str:
|
||||
p = str(port).strip()
|
||||
if not p:
|
||||
return p
|
||||
lower = p.lower()
|
||||
if "sim" in lower or "5760" in lower or lower in ("tcp", "tcp:", "tcp:127.0.0.1", "127.0.0.1"):
|
||||
return "tcp:127.0.0.1:5760"
|
||||
if "14550" in lower or lower in ("udp", "udp:", "udpin", "udpin:"):
|
||||
return "udpin:127.0.0.1:14550"
|
||||
if p.startswith("127.0.0.1:") or p.startswith("localhost:"):
|
||||
num = p.split(":")[1]
|
||||
if num == "14550":
|
||||
return f"udpin:{p}"
|
||||
return f"tcp:{p}"
|
||||
if ":" in p and not p.startswith(("tcp:", "tcpin:", "udp:", "udpin:", "udpout:")):
|
||||
return f"tcp:{p}"
|
||||
return p
|
||||
|
||||
|
||||
class MavlinkWorker:
|
||||
def __init__(self, events: queue.Queue):
|
||||
self.events = events
|
||||
self.connection = None
|
||||
self.stop_event = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
# pymavlink writes are not thread-safe. The web/API thread can issue
|
||||
# commands while this worker sends heartbeats and controls, so serialize
|
||||
# every packet before it reaches a low-bandwidth serial link.
|
||||
self.tx_lock = threading.Lock()
|
||||
self.thread = None
|
||||
self.axes = Axes()
|
||||
self.output_axes = Axes()
|
||||
self.last_control_at = 0.0
|
||||
# Enabled by the web bridge only; the legacy Tk frontend is unchanged.
|
||||
self.require_input_lease = False
|
||||
self.last_input_at = 0.0
|
||||
self.last_input_sequence = -1
|
||||
self.input_timeout = 1.0
|
||||
# Smooth ESC commands without adding a perceptible one-second lag.
|
||||
# At 20 Hz this produces five small steps from neutral to full command.
|
||||
self.ramp_seconds = 0.25
|
||||
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
|
||||
@@ -63,56 +96,136 @@ 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}")
|
||||
if self.thread and self.thread.is_alive():
|
||||
print(f"[MavlinkWorker] Thread already alive, ignoring connect()")
|
||||
return
|
||||
self.stop_event.clear()
|
||||
log_dir = Path(__file__).resolve().parent / "logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log"
|
||||
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
|
||||
self.log("SESSION", f"start port={port} baud={baud} control_hz={self.rate_hz}")
|
||||
normalized = normalize_endpoint(port)
|
||||
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
|
||||
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} "
|
||||
f"control_hz={self.rate_hz} rx=MAVLink2 tx=MAVLink2")
|
||||
self.events.put(("log_path", str(self.log_path)))
|
||||
self.last_heartbeat = 0.0
|
||||
self.last_rx = 0.0
|
||||
self.next_quality_publish_at = 0.0
|
||||
self.thread = threading.Thread(
|
||||
target=self._run, args=(port, baud), daemon=True
|
||||
target=self._run, args=(normalized, baud), daemon=True
|
||||
)
|
||||
self.thread.start()
|
||||
print(f"[MavlinkWorker] worker thread started")
|
||||
|
||||
def _run(self, port: str, baud: int):
|
||||
print(f"[MavlinkWorker._run] ENTERED: port={port!r}, baud={baud}")
|
||||
try:
|
||||
print(f"[MavlinkWorker._run] Creating mavlink_connection...")
|
||||
conn = mavutil.mavlink_connection(
|
||||
port, baud=baud, source_system=255, source_component=190,
|
||||
autoreconnect=False, force_connected=True,
|
||||
# Fail a missing TCP simulator immediately. pymavlink otherwise
|
||||
# retries the refused socket several times and leaves the UI in
|
||||
# "Opening" long after the actual failure.
|
||||
autoreconnect=False, retries=0, force_connected=True,
|
||||
)
|
||||
print(f"[MavlinkWorker._run] mavlink_connection created: {type(conn).__name__}")
|
||||
self.connection = conn
|
||||
self.events.put(("status", f"Port open; waiting for heartbeat at {baud} baud…"))
|
||||
next_gcs_hb = 0.0
|
||||
if port.startswith("tcp:"):
|
||||
self.events.put(("status", f"Connected to {port}; waiting for vehicle heartbeat…"))
|
||||
else:
|
||||
self.events.put(("status", f"Port {port} open; waiting for heartbeat at {baud} baud…"))
|
||||
|
||||
# Send initial GCS heartbeat immediately
|
||||
self.log("TX_HEARTBEAT", "GCS initial")
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
print(f"[MavlinkWorker._run] Initial heartbeat sent, entering recv loop")
|
||||
next_gcs_hb = time.monotonic() + 1.0
|
||||
next_control = 0.0
|
||||
while not self.stop_event.is_set():
|
||||
now = time.monotonic()
|
||||
if now >= next_gcs_hb:
|
||||
self.log("TX_HEARTBEAT", "GCS")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
next_gcs_hb = now + 1.0
|
||||
if self.target_system and now >= next_control:
|
||||
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:
|
||||
@@ -120,8 +233,11 @@ class MavlinkWorker:
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
except Exception as exc:
|
||||
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
|
||||
self.events.put(("error", f"Connection error: {exc}"))
|
||||
finally:
|
||||
if self.heavy_quality_test is not None:
|
||||
self.cancel_heavy_quality_test()
|
||||
self._safe_stop()
|
||||
if self.connection:
|
||||
try:
|
||||
@@ -132,6 +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:
|
||||
@@ -141,22 +274,75 @@ class MavlinkWorker:
|
||||
|
||||
def log(self, category: str, message: str):
|
||||
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
line = f"{timestamp} [{category}] {message}"
|
||||
with self.log_lock:
|
||||
self.console_lines.append(line[:2000])
|
||||
if category.startswith("RX") or category == "ONBOARD_LOG_ERROR":
|
||||
self.console_rx_lines.append(line[:2000])
|
||||
elif category.startswith("TX") or category == "OPERATOR":
|
||||
self.console_tx_lines.append(line[:2000])
|
||||
if self.log_file:
|
||||
self.log_file.write(f"{timestamp} [{category}] {message}\n")
|
||||
self.log_file.write(line + "\n")
|
||||
|
||||
def read_console(self, limit=150):
|
||||
with self.log_lock:
|
||||
limit = max(1, min(300, int(limit)))
|
||||
return {
|
||||
"rx": list(self.console_rx_lines)[-limit:],
|
||||
"tx": list(self.console_tx_lines)[-limit:],
|
||||
}
|
||||
|
||||
def _send_mav(self, connection, method: str, *args):
|
||||
"""Serialize MAVLink 2 writes and protect the start of each burst."""
|
||||
with self.tx_lock:
|
||||
# This full-duplex module pair loses the opening bytes of some
|
||||
# PC-to-FC bursts after the line has been idle. Put a valid,
|
||||
# expendable GCS heartbeat first so the following packet remains
|
||||
# intact and the FC also sees a healthy GCS heartbeat stream.
|
||||
if method != "heartbeat_send" and hasattr(connection.mav, "heartbeat_send"):
|
||||
connection.mav.heartbeat_send(
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
getattr(connection.mav, method)(*args)
|
||||
|
||||
def _handle_message(self, msg):
|
||||
msg_type = msg.get_type()
|
||||
if msg_type == "BAD_DATA":
|
||||
now = time.monotonic()
|
||||
self.bad_frame_times.append(now)
|
||||
self.bad_frame_bytes.append((now, len(getattr(msg, "data", b""))))
|
||||
self._trim_bad_frames(now)
|
||||
self.log("RX_BAD_DATA", repr(getattr(msg, "data", b"")))
|
||||
return
|
||||
self.last_rx = time.monotonic()
|
||||
self._track_rx_delivery(msg)
|
||||
try:
|
||||
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
|
||||
except Exception:
|
||||
payload = str(msg)
|
||||
self.log("RX", payload)
|
||||
if msg_type == "HEARTBEAT" and msg.get_srcComponent() == 1:
|
||||
if msg_type == "TIMESYNC" and int(getattr(msg, "tc1", 0)) != 0:
|
||||
token = int(getattr(msg, "ts1", -1))
|
||||
heavy_probe = self.heavy_pending_pings.pop(token, None)
|
||||
if heavy_probe is not None:
|
||||
stage_index, sent_at = heavy_probe
|
||||
self._record_heavy_probe(stage_index, (time.monotonic() - sent_at) * 1000.0)
|
||||
self._publish_heavy_quality()
|
||||
sent_at = None
|
||||
else:
|
||||
sent_at = self.pending_pings.pop(token, None)
|
||||
if sent_at is not None:
|
||||
rtt_ms = (time.monotonic() - sent_at) * 1000.0
|
||||
self.ping_results.append((time.monotonic(), rtt_ms))
|
||||
self.log("RX_LINK_PROBE", f"timesync={token} rtt_ms={rtt_ms:.1f}")
|
||||
self._publish_link_quality()
|
||||
elif msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
|
||||
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
|
||||
return
|
||||
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||
return
|
||||
first = not self.target_system
|
||||
self.target_system = msg.get_srcSystem()
|
||||
self.target_component = msg.get_srcComponent()
|
||||
@@ -164,30 +350,90 @@ 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:
|
||||
mode = str(msg.custom_mode)
|
||||
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
|
||||
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed))
|
||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
|
||||
if first:
|
||||
self.next_ping_at = time.monotonic()
|
||||
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
||||
self._configure_low_bandwidth_streams()
|
||||
self._start_servo_scan()
|
||||
elif msg_type == "COMMAND_ACK":
|
||||
if (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||
return
|
||||
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
|
||||
result_name = result.name if result else str(msg.result)
|
||||
self.events.put(("ack", int(msg.command), result_name))
|
||||
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
|
||||
self.log("RX_STREAM_CONFIG_ACK", result_name)
|
||||
return
|
||||
if int(msg.command) == mavutil.mavlink.MAV_CMD_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):
|
||||
@@ -210,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,
|
||||
@@ -230,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:
|
||||
@@ -275,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
|
||||
)
|
||||
|
||||
@@ -289,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,
|
||||
)
|
||||
@@ -300,10 +861,19 @@ class MavlinkWorker:
|
||||
if not conn or not self.target_system:
|
||||
return
|
||||
with self.lock:
|
||||
active = not force_neutral
|
||||
now = time.monotonic()
|
||||
expired = self.require_input_lease and (
|
||||
not self.armed or now - self.last_heartbeat > 3.0
|
||||
or now - self.last_input_at > self.input_timeout)
|
||||
if expired or force_neutral:
|
||||
# Never replay a stored command after telemetry/UI recovery.
|
||||
self.axes = Axes()
|
||||
self.output_axes = Axes()
|
||||
active = not (force_neutral or expired)
|
||||
target = self.axes if active else Axes()
|
||||
gain = self.gain
|
||||
now = time.monotonic()
|
||||
independent_motor_scaling = self.independent_motor_scaling
|
||||
motor_gains = dict(self.motor_gains)
|
||||
if force_neutral:
|
||||
self.output_axes = Axes()
|
||||
else:
|
||||
@@ -326,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:
|
||||
@@ -348,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:
|
||||
@@ -369,13 +981,24 @@ class MavlinkWorker:
|
||||
if manual is None:
|
||||
raise RuntimeError("MANUAL mode is not advertised by this firmware")
|
||||
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
|
||||
conn.mav.set_mode_send(
|
||||
self._send_mav(conn, "set_mode_send",
|
||||
self.target_system,
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
|
||||
manual,
|
||||
)
|
||||
|
||||
def set_axes(self, axes: Axes):
|
||||
def set_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.
|
||||
if abs(value) < 0.04:
|
||||
@@ -383,6 +1006,11 @@ class MavlinkWorker:
|
||||
return round(max(-1.0, min(1.0, value)), 2)
|
||||
|
||||
with self.lock:
|
||||
if sequence is not None:
|
||||
if sequence <= self.last_input_sequence:
|
||||
return
|
||||
self.last_input_sequence = sequence
|
||||
self.last_input_at = time.monotonic()
|
||||
self.axes = Axes(
|
||||
conditioned(axes.forward), conditioned(axes.lateral),
|
||||
conditioned(axes.vertical), conditioned(axes.yaw),
|
||||
@@ -401,7 +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,
|
||||
@@ -887,6 +1515,9 @@ class App(tk.Tk):
|
||||
|
||||
def refresh_ports(self):
|
||||
ports = [p.device for p in list_ports.comports()]
|
||||
for sim in ("tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
|
||||
if sim not in ports:
|
||||
ports.append(sim)
|
||||
self.port.configure(values=ports)
|
||||
if ports and self.port.get() not in ports:
|
||||
self.port.set(ports[0])
|
||||
@@ -1139,7 +1770,7 @@ class App(tk.Tk):
|
||||
event = self.events.get_nowait()
|
||||
kind = event[0]
|
||||
if kind == "heartbeat":
|
||||
_, system, autopilot, mode, armed = event
|
||||
_, system, autopilot, mode, armed = event[:5]
|
||||
self.vehicle_label.configure(
|
||||
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -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 |
+553
@@ -0,0 +1,553 @@
|
||||
"""ArduSub Flight Controller (FC) Spoofer & Simulator.
|
||||
|
||||
Emulates an ArduSub SimpleROV-3 vehicle over MAVLink (TCP, UDP, or Serial).
|
||||
Supports full parameter scans, live parameter updates (servo functions / motor reversal),
|
||||
arming/disarming commands, manual control processing, and realistic telemetry streaming
|
||||
(battery voltage/current with load sag, live thruster PWM mixing, simulated attitude & depth).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pymavlink import mavutil
|
||||
|
||||
|
||||
# Default SimpleROV-3 motor mappings and ArduSub parameters
|
||||
DEFAULT_PARAMETERS: dict[str, tuple[float, int]] = {
|
||||
# Servo functions: 33 = Motor 1 (Port), 34 = Motor 2 (Starboard), 35 = Motor 3 (Vertical)
|
||||
"SERVO1_FUNCTION": (33.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
|
||||
"SERVO2_FUNCTION": (34.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
|
||||
"SERVO3_FUNCTION": (35.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
|
||||
# Remaining servos unassigned
|
||||
**{f"SERVO{i}_FUNCTION": (0.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16) for i in range(4, 17)},
|
||||
# Motor directions: 1 = normal, -1 = reversed
|
||||
**{f"MOT_{i}_DIRECTION": (1.0, mavutil.mavlink.MAV_PARAM_TYPE_INT8) for i in range(1, 13)},
|
||||
# SimpleROV-3 thruster PWM limits
|
||||
"SERVO1_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO1_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO1_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO2_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO2_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO2_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO3_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO3_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"SERVO3_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
# Joystick gain parameters
|
||||
"JS_GAIN_MIN": (0.10, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"JS_GAIN_DEFAULT": (0.20, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
"JS_GAIN_MAX": (1.00, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlState:
|
||||
x: float = 0.0 # Surge (forward/back) -1000..1000
|
||||
y: float = 0.0 # Sway (lateral) -1000..1000
|
||||
z: float = 500.0 # Heave (vertical) 0..1000 (500 = neutral)
|
||||
r: float = 0.0 # Yaw (turn) -1000..1000
|
||||
last_rx: float = 0.0
|
||||
|
||||
|
||||
class FlightControllerSpoofer:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str = "tcpin:127.0.0.1:5760",
|
||||
baud: int = 57600,
|
||||
battery_cells: int = 4,
|
||||
nominal_voltage: float = 16.0,
|
||||
show_dashboard: bool = True,
|
||||
):
|
||||
self.endpoint = endpoint
|
||||
self.baud = baud
|
||||
self.battery_cells = battery_cells
|
||||
self.battery_base_v = nominal_voltage
|
||||
self.show_dashboard = show_dashboard
|
||||
|
||||
self.running = True
|
||||
self.armed = False
|
||||
self.mode = 19 # ArduSub MANUAL mode
|
||||
self.system_status = mavutil.mavlink.MAV_STATE_ACTIVE
|
||||
self.target_system = 255
|
||||
self.target_component = 190
|
||||
|
||||
self.params = dict(DEFAULT_PARAMETERS)
|
||||
self.controls = ControlState()
|
||||
|
||||
# Simulated physical state
|
||||
self.depth_m = 1.5 # Positive depth under water
|
||||
self.heading_deg = 0.0
|
||||
self.pitch_deg = 0.0
|
||||
self.roll_deg = 0.0
|
||||
self.m1_pwm = 1500 # Port
|
||||
self.m2_pwm = 1500 # Starboard
|
||||
self.m3_pwm = 1500 # Vertical
|
||||
self.current_a = 0.35
|
||||
|
||||
self.events: deque[str] = deque(maxlen=6)
|
||||
self.client_connected = False
|
||||
self.total_packets_rx = 0
|
||||
self.total_packets_tx = 0
|
||||
self.start_time = time.monotonic()
|
||||
|
||||
def log_event(self, text: str):
|
||||
now_str = time.strftime("%H:%M:%S")
|
||||
self.events.append(f"[{now_str}] {text}")
|
||||
|
||||
def run(self):
|
||||
print(f"\n{'='*72}")
|
||||
print(f" ARDUSUB SIMPLE-ROV FLIGHT CONTROLLER SPOOFER IS RUNNING")
|
||||
print(f" Target Endpoint: {self.endpoint}")
|
||||
print(f" --> In Sub Control app, select or connect to: {self.endpoint.replace('tcpin:', 'tcp:')}")
|
||||
print(f"{'='*72}\n", flush=True)
|
||||
self.log_event(f"Simulator starting on {self.endpoint}...")
|
||||
while self.running:
|
||||
try:
|
||||
self._run_server_session()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except OSError as exc:
|
||||
if getattr(exc, "winerror", None) == 10048 or "already in use" in str(exc).lower():
|
||||
print(f"\n[ERROR] Port {self.endpoint} is already in use by another program or previous spoofer instance.")
|
||||
print("Please close any running python/spoofer processes and try again.\n")
|
||||
break
|
||||
self.log_event(f"Session reset: {exc}")
|
||||
time.sleep(1.0)
|
||||
except Exception as exc:
|
||||
self.log_event(f"Session reset: {exc}")
|
||||
time.sleep(1.0)
|
||||
print("\nSpoofer stopped.")
|
||||
|
||||
def _run_server_session(self):
|
||||
# Bind server / open connection
|
||||
if self.endpoint.startswith("tcpin:"):
|
||||
shown_endpoint = self.endpoint.replace("tcpin:", "tcp:", 1)
|
||||
print(f"[READY] Listening for SUB Control on {shown_endpoint}", flush=True)
|
||||
print(" Leave this window open, then click Connect in the app.\n", flush=True)
|
||||
conn = mavutil.mavlink_connection(
|
||||
self.endpoint,
|
||||
baud=self.baud,
|
||||
source_system=1,
|
||||
source_component=1,
|
||||
autoreconnect=True,
|
||||
force_connected=True,
|
||||
)
|
||||
|
||||
self.log_event(f"Client connected on {self.endpoint}")
|
||||
|
||||
next_heartbeat = 0.0
|
||||
next_sys_status = 0.0
|
||||
next_servos = 0.0
|
||||
next_attitude = 0.0
|
||||
next_dashboard = 0.0
|
||||
|
||||
# Send initial announcement statustext
|
||||
initial_sent = False
|
||||
|
||||
while self.running:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.start_time
|
||||
time_boot_ms = int(elapsed * 1000) & 0xFFFFFFFF
|
||||
time_boot_us = int(elapsed * 1000000) & 0xFFFFFFFF
|
||||
|
||||
# 1. Heartbeat at 1.0 Hz
|
||||
if now >= next_heartbeat:
|
||||
base_mode = (
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
|
||||
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
|
||||
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
|
||||
)
|
||||
if self.armed:
|
||||
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
|
||||
|
||||
conn.mav.heartbeat_send(
|
||||
mavutil.mavlink.MAV_TYPE_SUBMARINE,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
|
||||
base_mode,
|
||||
self.mode,
|
||||
self.system_status,
|
||||
)
|
||||
self.total_packets_tx += 1
|
||||
next_heartbeat = now + 1.0
|
||||
|
||||
if not initial_sent and self.client_connected:
|
||||
conn.mav.statustext_send(
|
||||
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||
b"ArduSub V4.1.0 (SimpleROV-3 Simulator)",
|
||||
)
|
||||
conn.mav.statustext_send(
|
||||
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||
b"EKF3 IMU0 initialised",
|
||||
)
|
||||
initial_sent = True
|
||||
|
||||
# 2. Physics & Motor Mixing step
|
||||
self._update_physics(now)
|
||||
|
||||
# 3. SYS_STATUS at 1.0 Hz (Battery & Sensor Health)
|
||||
if now >= next_sys_status:
|
||||
# Voltage with slight noise + load sag
|
||||
load_factor = (
|
||||
abs(self.m1_pwm - 1500)
|
||||
+ abs(self.m2_pwm - 1500)
|
||||
+ abs(self.m3_pwm - 1500)
|
||||
) / 1200.0 # 0.0 to 1.0
|
||||
voltage_v = self.battery_base_v - (0.45 * load_factor) + (0.02 * math.sin(now * 1.5))
|
||||
voltage_mv = int(max(10.0, voltage_v) * 1000)
|
||||
|
||||
# Current: 0.35A base + up to 12.5A at full throttle
|
||||
self.current_a = 0.35 + (12.5 * load_factor)
|
||||
current_ca = int(self.current_a * 100)
|
||||
|
||||
# Battery remaining percentage (e.g. 88% draining slowly)
|
||||
remaining_pct = max(10, int(88 - (elapsed / 300.0)))
|
||||
|
||||
conn.mav.sys_status_send(
|
||||
322042887, # onboard_control_sensors_present
|
||||
320969735, # onboard_control_sensors_enabled
|
||||
305241095, # onboard_control_sensors_health
|
||||
175, # load (17.5%)
|
||||
voltage_mv,
|
||||
current_ca,
|
||||
remaining_pct,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
)
|
||||
self.total_packets_tx += 1
|
||||
next_sys_status = now + 1.0
|
||||
|
||||
# 4. SERVO_OUTPUT_RAW & RC_CHANNELS at 4.0 Hz
|
||||
if now >= next_servos:
|
||||
conn.mav.servo_output_raw_send(
|
||||
time_boot_us,
|
||||
0,
|
||||
self.m1_pwm,
|
||||
self.m2_pwm,
|
||||
self.m3_pwm,
|
||||
0, 0, 0, 0, 0,
|
||||
)
|
||||
conn.mav.rc_channels_send(
|
||||
time_boot_ms,
|
||||
8,
|
||||
1500 + int(self.controls.r * 0.4), # Chan 1: Yaw
|
||||
1500, # Chan 2: Roll
|
||||
int(self.controls.z), # Chan 3: Throttle/Vertical
|
||||
1500 + int(self.controls.x * 0.4), # Chan 4: Pitch/Forward
|
||||
1500, 1500, 1500, 1500,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
)
|
||||
self.total_packets_tx += 2
|
||||
next_servos = now + 0.25
|
||||
|
||||
# 5. ATTITUDE & VFR_HUD at 4.0 Hz
|
||||
if now >= next_attitude:
|
||||
conn.mav.attitude_send(
|
||||
time_boot_ms,
|
||||
math.radians(self.roll_deg),
|
||||
math.radians(self.pitch_deg),
|
||||
math.radians(self.heading_deg),
|
||||
0.0, 0.0, 0.0,
|
||||
)
|
||||
conn.mav.vfr_hud_send(
|
||||
abs(self.m1_pwm - 1500) / 400.0, # airspeed
|
||||
abs(self.m2_pwm - 1500) / 400.0, # groundspeed
|
||||
int(self.heading_deg) % 360, # heading
|
||||
int((self.controls.z - 500) / 5), # throttle %
|
||||
-self.depth_m, # altitude (negative depth)
|
||||
(self.controls.z - 500) / 500.0, # climb rate
|
||||
)
|
||||
self.total_packets_tx += 2
|
||||
next_attitude = now + 0.25
|
||||
|
||||
# 6. Process incoming MAVLink packets from App
|
||||
self._handle_incoming_messages(conn)
|
||||
|
||||
# 7. Render Console Dashboard
|
||||
if self.show_dashboard and now >= next_dashboard:
|
||||
self._render_dashboard()
|
||||
next_dashboard = now + 0.15
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
def _update_physics(self, now: float):
|
||||
# If manual control packet hasn't arrived for > 1.5s, auto-neutralize
|
||||
if now - self.controls.last_rx > 1.5:
|
||||
self.controls.x = 0.0
|
||||
self.controls.y = 0.0
|
||||
self.controls.z = 500.0
|
||||
self.controls.r = 0.0
|
||||
|
||||
if not self.armed:
|
||||
self.m1_pwm = 1500
|
||||
self.m2_pwm = 1500
|
||||
self.m3_pwm = 1500
|
||||
else:
|
||||
# Read motor direction parameters
|
||||
mot1_dir = int(self.params.get("MOT_1_DIRECTION", (1.0,))[0])
|
||||
mot2_dir = int(self.params.get("MOT_2_DIRECTION", (1.0,))[0])
|
||||
mot3_dir = int(self.params.get("MOT_3_DIRECTION", (1.0,))[0])
|
||||
|
||||
# Differential drive mixing for SimpleROV-3:
|
||||
# Motor 1 (Port): Forward + Yaw
|
||||
# Motor 2 (Starboard): Forward - Yaw
|
||||
# Motor 3 (Vertical): Vertical (0..1000 with 500 center)
|
||||
forward_cmd = (self.controls.x / 1000.0) * 400.0 # -400..+400
|
||||
yaw_cmd = (self.controls.r / 1000.0) * 400.0 # -400..+400
|
||||
vert_cmd = ((self.controls.z - 500.0) / 500.0) * 400.0 # -400..+400
|
||||
|
||||
m1_offset = (forward_cmd + yaw_cmd) * mot1_dir
|
||||
m2_offset = (forward_cmd - yaw_cmd) * mot2_dir
|
||||
m3_offset = vert_cmd * mot3_dir
|
||||
|
||||
self.m1_pwm = int(max(1100, min(1900, 1500 + m1_offset)))
|
||||
self.m2_pwm = int(max(1100, min(1900, 1500 + m2_offset)))
|
||||
self.m3_pwm = int(max(1100, min(1900, 1500 + m3_offset)))
|
||||
|
||||
# Simulate subtle underwater motion
|
||||
self.roll_deg = 2.5 * math.sin(now * 0.8)
|
||||
self.pitch_deg = (self.controls.x / 1000.0) * 5.0 + 1.2 * math.cos(now * 0.7)
|
||||
if self.armed and abs(self.controls.r) > 10:
|
||||
self.heading_deg = (self.heading_deg + (self.controls.r / 1000.0) * 2.0) % 360.0
|
||||
|
||||
if self.armed and abs(self.controls.z - 500) > 20:
|
||||
vert_delta = ((self.controls.z - 500.0) / 500.0) * 0.02
|
||||
self.depth_m = max(0.1, min(50.0, self.depth_m - vert_delta))
|
||||
|
||||
def _handle_incoming_messages(self, conn):
|
||||
while True:
|
||||
msg = conn.recv_match(blocking=False)
|
||||
if not msg:
|
||||
break
|
||||
|
||||
self.total_packets_rx += 1
|
||||
if not self.client_connected:
|
||||
self.client_connected = True
|
||||
self.log_event(f"GCS Connected (SysID {msg.get_srcSystem()}, CompID {msg.get_srcComponent()})")
|
||||
# Immediately send heartbeat so GCS locks on with zero delay
|
||||
base_mode = (
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
|
||||
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
|
||||
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
|
||||
)
|
||||
if self.armed:
|
||||
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
|
||||
conn.mav.heartbeat_send(
|
||||
mavutil.mavlink.MAV_TYPE_SUBMARINE,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
|
||||
base_mode,
|
||||
self.mode,
|
||||
self.system_status,
|
||||
)
|
||||
|
||||
mtype = msg.get_type()
|
||||
if mtype == "HEARTBEAT":
|
||||
self.target_system = msg.get_srcSystem()
|
||||
self.target_component = msg.get_srcComponent()
|
||||
|
||||
elif mtype == "COMMAND_LONG":
|
||||
cmd = int(msg.command)
|
||||
if cmd == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM:
|
||||
should_arm = bool(msg.param1 == 1.0)
|
||||
self.armed = should_arm
|
||||
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||
status_str = "Motors ARMED" if self.armed else "Motors DISARMED"
|
||||
self.log_event(f"Command: {status_str}")
|
||||
conn.mav.statustext_send(
|
||||
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||
status_str.encode("ascii"),
|
||||
)
|
||||
|
||||
elif cmd == mavutil.mavlink.MAV_CMD_DO_SET_MODE:
|
||||
self.mode = int(msg.param2)
|
||||
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||
self.log_event(f"Command: SET_MODE {self.mode}")
|
||||
conn.mav.statustext_send(
|
||||
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||
b"Mode: MANUAL",
|
||||
)
|
||||
|
||||
elif cmd == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
|
||||
msg_id = int(msg.param1)
|
||||
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||
self.log_event(f"Stream interval set for MsgID {msg_id}")
|
||||
|
||||
else:
|
||||
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||
|
||||
elif mtype == "SET_MODE":
|
||||
self.mode = int(msg.custom_mode)
|
||||
self.log_event(f"Set custom mode -> {self.mode}")
|
||||
conn.mav.statustext_send(mavutil.mavlink.MAV_SEVERITY_INFO, b"Mode: MANUAL")
|
||||
|
||||
elif mtype == "REQUEST_DATA_STREAM":
|
||||
pass # Acknowledged implicitly by sending streams
|
||||
|
||||
elif mtype == "PARAM_REQUEST_READ":
|
||||
param_id = msg.param_id
|
||||
if isinstance(param_id, bytes):
|
||||
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
|
||||
if param_id in self.params:
|
||||
val, ptype = self.params[param_id]
|
||||
else:
|
||||
val, ptype = (0.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32)
|
||||
conn.mav.param_value_send(
|
||||
param_id.encode("ascii"),
|
||||
float(val),
|
||||
ptype,
|
||||
len(self.params),
|
||||
0,
|
||||
)
|
||||
self.log_event(f"Param read: {param_id} = {val}")
|
||||
|
||||
elif mtype == "PARAM_REQUEST_LIST":
|
||||
self.log_event("Param list requested, streaming all...")
|
||||
for idx, (p_name, (p_val, p_type)) in enumerate(self.params.items()):
|
||||
conn.mav.param_value_send(
|
||||
p_name.encode("ascii"),
|
||||
float(p_val),
|
||||
p_type,
|
||||
len(self.params),
|
||||
idx,
|
||||
)
|
||||
time.sleep(0.002)
|
||||
|
||||
elif mtype == "PARAM_SET":
|
||||
param_id = msg.param_id
|
||||
if isinstance(param_id, bytes):
|
||||
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
|
||||
new_val = float(msg.param_value)
|
||||
ptype = int(msg.param_type)
|
||||
self.params[param_id] = (new_val, ptype)
|
||||
conn.mav.param_value_send(
|
||||
param_id.encode("ascii"),
|
||||
new_val,
|
||||
ptype,
|
||||
len(self.params),
|
||||
0,
|
||||
)
|
||||
self.log_event(f"Param updated: {param_id} -> {int(new_val) if new_val.is_integer() else new_val}")
|
||||
|
||||
elif mtype == "MANUAL_CONTROL":
|
||||
self.controls.x = float(msg.x)
|
||||
self.controls.y = float(msg.y)
|
||||
self.controls.z = float(msg.z)
|
||||
self.controls.r = float(msg.r)
|
||||
self.controls.last_rx = time.monotonic()
|
||||
|
||||
def _render_dashboard(self):
|
||||
# Clear screen ANSI
|
||||
out = sys.stdout
|
||||
out.write("\033[H")
|
||||
|
||||
# Color codes
|
||||
GREEN = "\033[92m"
|
||||
RED = "\033[91m"
|
||||
YELLOW = "\033[93m"
|
||||
CYAN = "\033[96m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
def bar(val: float, min_v: float, max_v: float, width: int = 14) -> str:
|
||||
norm = max(0.0, min(1.0, (val - min_v) / (max_v - min_v)))
|
||||
pos = int(norm * width)
|
||||
return f"[{'=' * pos}{' ' * (width - pos)}]"
|
||||
|
||||
status_tag = f"{GREEN}{BOLD} ARMED {RESET}" if self.armed else f"{YELLOW}DISARMED{RESET}"
|
||||
link_tag = f"{GREEN}ONLINE{RESET}" if self.client_connected else f"{RED}WAITING{RESET}"
|
||||
|
||||
lines = [
|
||||
f"{BOLD}{CYAN}========================================================================{RESET}",
|
||||
f"{BOLD} ArduSub SimpleROV-3 Flight Controller Spoofer & Telemetry Simulator {RESET}",
|
||||
f"{BOLD}{CYAN}========================================================================{RESET}",
|
||||
f" Endpoint: {BOLD}{self.endpoint}{RESET} Baud: {self.baud} Link: {link_tag} State: {status_tag} Mode: {BOLD}MANUAL{RESET}",
|
||||
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||
f" {BOLD}BATTERY & POWER:{RESET}",
|
||||
f" Voltage: {BOLD}{self.battery_base_v:.2f} V{RESET} ({self.battery_cells}S) Current: {BOLD}{self.current_a:.2f} A{RESET} Power: {BOLD}{self.battery_base_v * self.current_a:.1f} W{RESET}",
|
||||
f" Depth: {BOLD}{self.depth_m:.2f} m{RESET} Heading: {BOLD}{self.heading_deg:.0f}°{RESET} Roll/Pitch: {self.roll_deg:+.1f}° / {self.pitch_deg:+.1f}°",
|
||||
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||
f" {BOLD}INCOMING MANUAL CONTROLS (from Sub Control app):{RESET}",
|
||||
f" Surge (Forward/Back): {self.controls.x:+5.0f} {bar(self.controls.x, -1000, 1000)}",
|
||||
f" Yaw (Steer/Turn): {self.controls.r:+5.0f} {bar(self.controls.r, -1000, 1000)}",
|
||||
f" Heave (Vertical): {self.controls.z:5.0f} {bar(self.controls.z, 0, 1000)}",
|
||||
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||
f" {BOLD}SIMULATED THRUSTER PWM OUTPUTS:{RESET}",
|
||||
f" Motor 1 (Port / Left): {self.m1_pwm:4d} µs {bar(self.m1_pwm, 1100, 1900)}",
|
||||
f" Motor 2 (Starboard / Right): {self.m2_pwm:4d} µs {bar(self.m2_pwm, 1100, 1900)}",
|
||||
f" Motor 3 (Vertical / Center): {self.m3_pwm:4d} µs {bar(self.m3_pwm, 1100, 1900)}",
|
||||
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||
f" {BOLD}RECENT EVENT LOG:{RESET}",
|
||||
]
|
||||
|
||||
if not self.events:
|
||||
lines.append(f" {DIM}No events yet. Connect Sub Control app to begin.{RESET}")
|
||||
else:
|
||||
for ev in list(self.events)[-5:]:
|
||||
lines.append(f" {ev}")
|
||||
|
||||
lines.append(f"{CYAN}------------------------------------------------------------------------{RESET}")
|
||||
lines.append(f" Packets RX: {self.total_packets_rx:<8d} Packets TX: {self.total_packets_tx:<8d} (Press Ctrl+C to exit)")
|
||||
|
||||
out.write("\n".join(lines) + "\n")
|
||||
out.flush()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ArduSub Flight Controller (FC) Spoofer & Telemetry Simulator",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--endpoint",
|
||||
type=str,
|
||||
default="tcpin:127.0.0.1:5760",
|
||||
help="MAVLink connection string (e.g. 'tcpin:127.0.0.1:5760', 'udpin:127.0.0.1:14550', 'COM3')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=57600,
|
||||
help="Baud rate (for serial COM ports)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cells",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Battery cell count (4S = 16.0V nominal)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--voltage",
|
||||
type=float,
|
||||
default=15.8,
|
||||
help="Nominal starting battery voltage (V)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-dashboard",
|
||||
action="store_true",
|
||||
help="Disable interactive terminal dashboard and use plain output",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Enable ANSI escape sequences on Windows console
|
||||
if os.name == "nt":
|
||||
os.system("")
|
||||
|
||||
spoofer = FlightControllerSpoofer(
|
||||
endpoint=args.endpoint,
|
||||
baud=args.baud,
|
||||
battery_cells=args.cells,
|
||||
nominal_voltage=args.voltage,
|
||||
show_dashboard=not args.no_dashboard,
|
||||
)
|
||||
spoofer.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
title ArduSub FC Spoofer
|
||||
cd /d "%~dp0"
|
||||
if exist ".venv\Scripts\python.exe" (
|
||||
".venv\Scripts\python.exe" -u fc_spoofer.py %*
|
||||
) else (
|
||||
python -u fc_spoofer.py %*
|
||||
)
|
||||
pause
|
||||
@@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location -LiteralPath $projectRoot
|
||||
|
||||
$existingListener = Get-NetTCPConnection -LocalPort 5760 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($existingListener) {
|
||||
$existingProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$($existingListener.OwningProcess)" -ErrorAction SilentlyContinue
|
||||
Write-Host ""
|
||||
Write-Host "The simulator is already LISTENING on tcp:127.0.0.1:5760." -ForegroundColor Green
|
||||
Write-Host "Process: $($existingListener.OwningProcess) $($existingProcess.Name)"
|
||||
Write-Host "Leave that simulator running and select 'Simulator (TCP 5760)' in SUB Control."
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath ".venv\Scripts\python.exe") {
|
||||
& .venv\Scripts\python.exe -u fc_spoofer.py @args
|
||||
} else {
|
||||
& python -u fc_spoofer.py @args
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// Deterministic DOM/pointer harness. No browser, vehicle, or serial port involved.
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
|
||||
const {present}=require('../web/control-state.js');
|
||||
const armed={connected:true,armed_known:true,armed:true,app_state:'armed',servos:{}};
|
||||
|
||||
test('model viewport fills the available space above battery status',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.match(css,/\.rov-stage \{ flex:1; height:auto; min-height:0; width:100%/);
|
||||
assert.match(css,/\.telemetry-zone \.model-card \{ flex: 1; min-height: 0;/);
|
||||
});
|
||||
|
||||
test('white wash stays behind controls; right panel is gray and edges reach 50 percent',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.match(css,/\.workspace-light \{[^}]*z-index:-1/);
|
||||
assert.match(css,/\.controls-workspace \{[^}]*isolation:isolate/);
|
||||
assert.match(css,/\.controls-workspace \{[^}]*background:#ededed/);
|
||||
assert.match(css,/\.sidebar-deck \{[^}]*background:linear-gradient\(160deg,#333,#242424\)/);
|
||||
assert.match(css,/\.cursor-light::after \{[^}]*rgba\(255,255,255,\.50\)/);
|
||||
});
|
||||
|
||||
test('keyboard easing publishes intermediate demands to the real Python bridge',async()=>{
|
||||
const h=harness();h.state();h.run('bridgeReady=true');
|
||||
keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');
|
||||
for(let i=0;i<10;i++){h.advance(50);await Promise.resolve();await Promise.resolve();await Promise.resolve();}
|
||||
const sent=h.calls.filter(call=>call[0]==='axes');
|
||||
assert.ok(sent.some(call=>call[1]>0&&call[1]<1),'surge ramp is transmitted, not just drawn');
|
||||
assert.ok(sent.some(call=>call[3]>0&&call[3]<1),'vertical ramp is transmitted, not just drawn');
|
||||
assert.ok(sent.every(call=>call[1]>=0&&call[1]<=1&&call[3]>=0&&call[3]<=1));
|
||||
});
|
||||
|
||||
test('cursor lighting stays decorative and arrows use centered geometry',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
|
||||
assert.doesNotMatch(js,/set_axes|setJoyControl|setHeave|requestControl/);
|
||||
assert.match(js,/workspace\.append\(wash\)/);
|
||||
assert.match(js,/pointerleave',clear/);
|
||||
assert.match(css,/\.cursor-light \{[^}]*pointer-events:none/);
|
||||
assert.match(css,/range-pill button \{[^}]*place-items:center/);
|
||||
assert.match(css,/#engage\.danger \.engage-label \{ color:#777/);
|
||||
});
|
||||
|
||||
test('edge lighting uses angle; only visible pills get blue and sidebar is excluded',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
|
||||
assert.match(js,/Math\.atan2/);
|
||||
assert.match(js,/!host\.closest\('\.sidebar-deck,#engage'\)/);
|
||||
assert.match(js,/host\.matches\(pillSelector\)/);
|
||||
assert.match(css,/\.cursor-light\.pill-light::before \{ background:radial-gradient/);
|
||||
assert.match(css,/\.cursor-light::after \{[^}]*linear-gradient\(var\(--light-angle/);
|
||||
assert.doesNotMatch(css,/\.cursor-light::after \{[^}]*radial-gradient/);
|
||||
assert.match(css,/\.rov-stage \{[^}]*border-radius:31px; overflow:hidden/);
|
||||
});
|
||||
|
||||
test('all interface typography uses Helvetica with inherited control fonts',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
const families=[...css.matchAll(/font-family\s*:\s*([^;}]+)/g)].map(match=>match[1].trim());
|
||||
assert.ok(families.includes('"Helvetica Neue", Helvetica, Arial, sans-serif'));
|
||||
assert.ok(families.every(family=>family==='inherit'||family==='"Helvetica Neue", Helvetica, Arial, sans-serif'));
|
||||
assert.match(css,/button, select, input \{ font: inherit; \}/);
|
||||
});
|
||||
|
||||
function keyEvent(h,type,key,repeat=false){h.fire(type,{key,repeat,target:{tagName:'BODY'},preventDefault(){}})}
|
||||
test('keyboard ramps joystick and vertical together, then eases back on release',()=>{
|
||||
const h=harness();h.state();
|
||||
keyEvent(h,'keydown','w');keyEvent(h,'keydown','d');keyEvent(h,'keydown','r');
|
||||
assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
|
||||
h.advance(180);
|
||||
for(const axis of ['forward','turn','vertical'])assert.ok(h.run(`control.${axis}`)>0&&h.run(`control.${axis}`)<1);
|
||||
keyEvent(h,'keydown','w',true);h.advance(240);
|
||||
assert.ok(Math.abs(h.run('control.forward')-Math.SQRT1_2)<1e-6);
|
||||
assert.ok(Math.abs(h.run('control.turn')-Math.SQRT1_2)<1e-6);
|
||||
assert.equal(h.run('control.vertical'),1);
|
||||
keyEvent(h,'keyup','w');keyEvent(h,'keyup','d');keyEvent(h,'keyup','r');
|
||||
h.advance(180);assert.ok(h.run('control.forward')>0);assert.ok(h.run('control.vertical')>0);
|
||||
h.advance(250);assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
|
||||
});
|
||||
test('keyboard reversal is continuous and focus loss cancels its ramp',()=>{
|
||||
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(420);
|
||||
keyEvent(h,'keyup','w');keyEvent(h,'keyup','r');keyEvent(h,'keydown','s');keyEvent(h,'keydown','f');
|
||||
assert.equal(h.run('control.forward'),1);assert.equal(h.run('control.vertical'),1);
|
||||
h.advance(200);assert.ok(Math.abs(h.run('control.forward'))<.3);assert.ok(Math.abs(h.run('control.vertical'))<.3);
|
||||
h.advance(220);assert.equal(h.run('control.forward'),-1);assert.equal(h.run('control.vertical'),-1);
|
||||
keyEvent(h,'keyup','s');keyEvent(h,'keyup','f');h.fire('blur');h.advance(500);
|
||||
assert.equal(h.run('control.forward+control.vertical'),0);
|
||||
});
|
||||
test('touch takes ownership from a keyboard ramp without later keyboard overwrite',()=>{
|
||||
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(100);
|
||||
h.el('#joystick-canvas').emit('pointerdown',{clientX:300,clientY:160});
|
||||
h.el('#heave').emit('pointerdown',{pointerId:2,clientY:300});
|
||||
keyEvent(h,'keydown','a');h.advance(450);
|
||||
assert.equal(h.run('control.turn'),1);assert.equal(Math.abs(h.run('control.forward')),0);assert.equal(h.run('control.vertical'),-1);
|
||||
});
|
||||
test('hold fill and pending engagement share the same green and glow',()=>{
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.match(css,/\.engage-progress-label \{ color:var\(--engage-green\).*filter:var\(--engage-glow\)/);
|
||||
assert.match(css,/#engage\.pending \.engage-label \{ color:var\(--engage-green\); filter:var\(--engage-glow\)/);
|
||||
});
|
||||
|
||||
test('native pywebview result wrapper can execute alongside app globals',()=>{
|
||||
const h=harness();
|
||||
// Window.evaluate_js declares these variables in the page's global scope.
|
||||
// A global const/let collision prevents every Python reply from executing.
|
||||
assert.doesNotThrow(()=>h.run(`var value; try { value = ['COM3','COM4']; }
|
||||
catch(e) { value = {name:e.name}; var keys = Object.getOwnPropertyNames(e);
|
||||
keys.forEach(function(key) { value[key] = e[key]; }); }
|
||||
populatePorts(value);`));
|
||||
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
|
||||
});
|
||||
|
||||
test('an empty pywebview API cannot permanently latch startup as completed',async()=>{
|
||||
const h=harness();h.context.pywebview.api={};await h.run('boot()');
|
||||
assert.equal(h.run('booted'),false);assert.equal(h.run('bridgeReady'),false);
|
||||
});
|
||||
|
||||
test('desktop startup retries after failure and populates detected COM ports',async()=>{
|
||||
const h=harness();let attempts=0;
|
||||
h.run('loadDefaultModel=()=>Promise.resolve(false)');
|
||||
h.context.pywebview.api.refresh_ports=()=>Promise.resolve([{device:'COM3',label:'COM3'},{device:'COM4',label:'COM4'}]);
|
||||
h.context.pywebview.api.initial_state=()=>++attempts===1?Promise.reject(Error('Temporary startup failure')):Promise.resolve({ports:['COM3','COM4'],battery_config:{},state:{connected:false,armed:null,servos:{}}});
|
||||
h.context.pywebview.api.poll=()=>Promise.resolve({connected:false,armed:null,servos:{}});
|
||||
await h.run('boot()');assert.equal(h.run('booted'),false);
|
||||
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'),'COM discovery survives a configuration startup failure');
|
||||
await h.run('boot()');assert.equal(h.run('booted'),true);
|
||||
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
|
||||
assert.ok(h.el('#port').options.some(p=>p.value==='COM4'));
|
||||
});
|
||||
|
||||
test('visual status changes cannot resize the header or percentage label',()=>{
|
||||
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.doesNotMatch(html,/class="water-ring/);
|
||||
assert.match(html,/class="engage-sheen-label"/);
|
||||
assert.match(css,/padding-bottom:\.22em; margin-bottom:-\.22em/);
|
||||
assert.match(css,/grid-template-rows:120px minmax\(0,1fr\) 52px/);
|
||||
assert.match(css,/\.control-status \{ position:absolute/);
|
||||
assert.match(css,/flex:0 0 128px; width:128px/);
|
||||
assert.match(css,/\.vertical-card footer \{ justify-content:center/);
|
||||
assert.match(css,/mask-image:linear-gradient\(to right,#000 calc\(var\(--engage-progress\) - 8%\),transparent var\(--engage-progress\)\)/);
|
||||
});
|
||||
|
||||
test('activity renders oldest at top, latest above its footer; both control states sweep',()=>{
|
||||
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||
assert.ok(html.indexOf('id="activity-oldest"')<html.indexOf('id="activity-previous"'));
|
||||
assert.ok(html.indexOf('id="activity-previous"')<html.indexOf('id="status-text"'));
|
||||
assert.ok(html.indexOf('id="status-text"')<html.indexOf('class="activity-heading"'));
|
||||
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||
assert.match(css,/#engage:is\(\.arm,\.danger\).*animation:engage-sweep/);
|
||||
});
|
||||
|
||||
test('native drag surface has no interactive descendants; output limit is outside sidebar',()=>{
|
||||
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||
assert.match(html,/<aside class="sidebar-deck"/);
|
||||
assert.match(html,/<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"><\/div>/);
|
||||
assert.ok(html.indexOf('id="gain"')>html.indexOf('</aside>'));
|
||||
});
|
||||
|
||||
function harness(){
|
||||
let now=100,id=0;const timers=new Map(),elements=new Map(),listeners=new Map();
|
||||
class Element{
|
||||
constructor(name){this.name=name;this.dataset={};this.style={setProperty(k,v){this[k]=v}};this.classes=new Set();this.classList={add:(...n)=>n.forEach(x=>this.classes.add(x)),remove:(...n)=>n.forEach(x=>this.classes.delete(x)),contains:n=>this.classes.has(n),toggle:(n,b)=>b?this.classes.add(n):this.classes.delete(n)};this.handlers={};this.captured=new Set();this.value='demo';this.options=[];this.tagName='DIV';this.children=[];this.textContent='';}
|
||||
addEventListener(n,fn){(this.handlers[n]??=[]).push(fn)}
|
||||
removeEventListener(){}
|
||||
emit(n,fields={}){const e={pointerId:1,button:0,pointerType:'touch',clientX:160,clientY:160,preventDefault(){},target:this,...fields};for(const fn of this.handlers[n]||[])fn(e);return e}
|
||||
getBoundingClientRect(){return this.name==='#heave'?{left:0,top:0,width:48,height:300,right:48,bottom:300}:this.name==='#engage'?{left:0,top:0,width:180,height:44,right:180,bottom:44}:{left:0,top:0,width:320,height:320,right:320,bottom:320}}
|
||||
setPointerCapture(p){this.captured.add(p)}
|
||||
releasePointerCapture(p){if(this.captured.delete(p))this.emit('lostpointercapture',{pointerId:p})}
|
||||
getContext(){return new Proxy({}, {get:()=>()=>{}})}
|
||||
querySelector(s){return el(this.name+' '+s)}
|
||||
setAttribute(k,v){this[k]=v}
|
||||
append(n){this.children.push(n)}
|
||||
add(n){this.options.push(n)}
|
||||
closest(){return null}
|
||||
click(){if(!this.disabled)this.onclick?.({target:this,currentTarget:this})}
|
||||
}
|
||||
function el(n){if(!elements.has(n))elements.set(n,new Element(n));return elements.get(n)}
|
||||
const document={querySelector:el,querySelectorAll:()=>[],getElementById:n=>el('#'+n),createElement:n=>new Element(n),body:el('body'),readyState:'loading',hidden:false,hasFocus:()=>true,addEventListener(n,f){listeners.set('doc:'+n,f)}};
|
||||
const schedule=(fn,ms,interval=0)=>{timers.set(++id,{fn,at:now+ms,interval});return id};
|
||||
const calls=[];
|
||||
const context=vm.createContext({console,document,devicePixelRatio:1,performance:{now:()=>now},setTimeout:(f,ms=0)=>schedule(f,ms),clearTimeout:i=>timers.delete(i),setInterval:(f,ms)=>schedule(f,ms,ms),requestAnimationFrame:f=>schedule(()=>f(now),16),cancelAnimationFrame:i=>timers.delete(i),addEventListener(n,f){listeners.set(n,f)},Option:function(label,value){this.label=label;this.value=value},fetch:()=>Promise.reject(Error('No network in unit tests'))});
|
||||
context.window=context;
|
||||
context.pywebview={api:{set_axes:(...a)=>{calls.push(['axes',...a]);return Promise.resolve()},stop_controls:(...a)=>{calls.push(['stop',...a]);return Promise.resolve()},command:(name)=>{calls.push(['command',name]);return Promise.resolve(true)}}};
|
||||
for(const f of ['control-state.js','joystick-3d.js','app.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'../web',f),'utf8'),context,{filename:f});
|
||||
function run(s){return vm.runInContext(s,context)}
|
||||
function advance(ms){const end=now+ms;while(true){let next=[...timers].filter(([,t])=>t.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!next)break;const [i,t]=next;now=t.at;timers.delete(i);if(t.interval)timers.set(i,{...t,at:now+t.interval});t.fn()}now=end;}
|
||||
function state(s=armed){context.snapshot=s;run('render(snapshot)')}
|
||||
return {el,run,advance,state,calls,context,fire:(name,e={})=>listeners.get(name)?.(e)};
|
||||
}
|
||||
|
||||
test('pending/timeout/unknown presentation never invents armed state',()=>{
|
||||
let p=present({...armed,pending_command:'disarm'});
|
||||
assert.equal(p.label,'DISARMING…');assert.equal(p.drive,false);
|
||||
p=present({...armed,command_error:'Disarm confirmation timed out.'});
|
||||
assert.equal(p.label,'DISARM');assert.match(p.message,/Vehicle armed/);
|
||||
p=present({...armed,armed:false,pending_command:'arm'});
|
||||
assert.equal(p.label,'ENGAGING…');assert.equal(p.drive,false);
|
||||
p=present({...armed,armed:false,command_error:'Engage failed'});
|
||||
assert.equal(p.label,'ENGAGE');assert.equal(p.action,'arm');
|
||||
p=present({...armed,connected:false,armed_known:false});
|
||||
assert.equal(p.label,'STATE UNKNOWN');assert.equal(p.drive,false);
|
||||
});
|
||||
|
||||
test('activity history keeps three unique recent events, latest first',()=>{
|
||||
const h=harness();h.run("recordActivity('One');recordActivity('Two');recordActivity('Three');recordActivity('Three');recordActivity('Four')");
|
||||
assert.equal(h.el('#status-text').textContent,'Four');assert.equal(h.el('#activity-previous').textContent,'Three');assert.equal(h.el('#activity-oldest').textContent,'Two');
|
||||
});
|
||||
|
||||
test('output limit drag is bounded, owns one pointer and does not recenter',()=>{
|
||||
const h=harness(),slider=h.el('#gain');slider.min='5';slider.max='50';slider.value='20';
|
||||
slider.emit('pointerdown',{pointerId:1,clientX:9});assert.equal(h.el('#gain-value').textContent,'5%');
|
||||
slider.emit('pointermove',{pointerId:2,clientX:311});assert.equal(h.el('#gain-value').textContent,'5%');
|
||||
slider.emit('pointermove',{pointerId:1,clientX:311});assert.equal(h.el('#gain-value').textContent,'50%');
|
||||
slider.emit('pointerup');h.advance(500);assert.equal(h.el('#gain-value').textContent,'50%');
|
||||
});
|
||||
|
||||
test('maximum travel, diagonal and smooth touch return',()=>{
|
||||
const h=harness();h.state();const c=h.el('#joystick-canvas');
|
||||
c.emit('pointerdown',{clientX:1000,clientY:160});
|
||||
assert.equal(h.context.joystick3D.getState().x,1);
|
||||
c.emit('pointermove',{clientX:1000,clientY:1000});
|
||||
const s=h.context.joystick3D.getState();assert.ok(Math.abs(s.x-Math.SQRT1_2)<1e-6);assert.ok(Math.abs(s.y-Math.SQRT1_2)<1e-6);
|
||||
c.emit('pointerup');h.advance(180);assert.ok(h.context.joystick3D.getState().x>0);h.advance(220);assert.equal(h.context.joystick3D.getState().x,0);
|
||||
});
|
||||
|
||||
test('independent multi-touch: second joystick finger ignored; heave continues on joystick release',()=>{
|
||||
const h=harness();h.state();const c=h.el('#joystick-canvas'),v=h.el('#heave');
|
||||
c.emit('pointerdown',{pointerId:1,clientX:250});
|
||||
v.emit('pointerdown',{pointerId:2,clientY:0});
|
||||
const x=h.context.joystick3D.getState().x;
|
||||
c.emit('pointerdown',{pointerId:3,clientX:0});c.emit('pointerup',{pointerId:3});
|
||||
assert.equal(h.context.joystick3D.getState().x,x);
|
||||
c.emit('pointerup',{pointerId:1});h.advance(400);
|
||||
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,1);
|
||||
v.emit('pointercancel',{pointerId:2});h.advance(450);assert.equal(h.context.verticalControl.getState().value,0);
|
||||
});
|
||||
|
||||
test('mouse leaving joystick returns to center',()=>{
|
||||
const h=harness();h.state();const c=h.el('#joystick-canvas');
|
||||
c.emit('pointerdown',{pointerType:'mouse',clientX:300});c.emit('pointerleave',{pointerType:'mouse'});h.advance(400);
|
||||
assert.equal(h.context.joystick3D.getState().x,0);
|
||||
});
|
||||
|
||||
test('focus loss clears both captured pointers and prevents replay',()=>{
|
||||
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.el('#heave').emit('pointerdown',{pointerId:2,clientY:0});
|
||||
h.fire('blur');h.advance(500);
|
||||
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,0);
|
||||
assert.equal(h.context.joystick3D.getState().active,false);assert.equal(h.context.verticalControl.getState().active,false);
|
||||
assert.equal(h.run('view.drive'),false);h.fire('focus');assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
|
||||
});
|
||||
|
||||
test('lost connection during commanded joystick clears command and shows unknown',()=>{
|
||||
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
|
||||
h.state({...armed,connected:false,armed_known:false,app_state:'disconnected'});
|
||||
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||
h.state();assert.equal(h.run('control.turn'),0);
|
||||
});
|
||||
|
||||
test('half hold cancels on touch interruption and focus loss; extra fingers cannot complete it',()=>{
|
||||
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});const b=h.el('#engage');
|
||||
b.emit('pointerdown');h.advance(600);b.emit('pointerdown',{pointerId:2});b.emit('pointerup',{pointerId:2});
|
||||
assert.equal(h.run('engagePointer'),1);b.emit('pointercancel');h.advance(1500);assert.equal(h.calls.length,0);
|
||||
b.emit('pointerdown');h.advance(600);h.fire('blur');h.advance(1500);assert.equal(h.calls.length,0);
|
||||
});
|
||||
|
||||
test('demo full hold waits for simulated confirmation; release cannot immediately disarm',()=>{
|
||||
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
|
||||
b.emit('pointerdown');h.advance(1450);assert.equal(h.el('#engage-label').textContent,'engaging…');assert.equal(h.run('demoState.armed'),false);
|
||||
b.emit('pointerup');b.click();h.advance(700);assert.equal(h.el('#engage-label').textContent,'disengage');
|
||||
b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');assert.equal(h.run('demoState.armed'),true);
|
||||
h.advance(700);assert.equal(h.el('#engage-label').textContent,'engage');
|
||||
});
|
||||
|
||||
test('real send result and accepted ACK cannot optimistically arm; timeout is visible',async()=>{
|
||||
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});
|
||||
h.run("requestControl('arm')");await Promise.resolve();assert.equal(h.el('#engage-label').textContent,'engaging…');
|
||||
assert.equal(h.run('vehicleSnapshot.armed'),false);h.advance(6100);
|
||||
assert.equal(h.el('#engage-label').textContent,'engage');assert.match(h.el('#control-status').textContent,/timed out/);
|
||||
});
|
||||
|
||||
test('bridge freeze clears input even if armed snapshot has not changed',()=>{
|
||||
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.run('markBridgeStale()');
|
||||
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||
assert.match(h.el('#control-status').textContent,/Telemetry unavailable/);
|
||||
});
|
||||
|
||||
test('hung bridge poll has an independent watchdog and cannot trap the controls',async()=>{
|
||||
const h=harness();h.state();h.context.pywebview.api.poll=()=>new Promise(()=>{});
|
||||
h.run('bridgeReady=true;lastBridgeAt=performance.now();poll()');
|
||||
h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
|
||||
h.advance(1700);await Promise.resolve();
|
||||
assert.equal(h.run('view.drive'),false);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||
assert.equal(h.context.joystick3D.getState().x,0);
|
||||
});
|
||||
|
||||
test('holding through confirmation then releasing outside cannot swallow the next disarm tap',()=>{
|
||||
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
|
||||
b.emit('pointerdown');h.advance(2300);assert.equal(h.el('#engage-label').textContent,'disengage');assert.ok(b.captured.has(1));
|
||||
b.emit('pointerup',{clientX:500});h.advance(1);b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');
|
||||
});
|
||||
@@ -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.');
|
||||
@@ -0,0 +1,333 @@
|
||||
"""No serial port is opened by these tests."""
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app import Axes, MavlinkWorker, NEUTRAL_Z
|
||||
from vehicle_state import VehicleState
|
||||
from web_ui import ControlApi
|
||||
|
||||
|
||||
class VehicleStateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = 10.0
|
||||
self.state = VehicleState(lambda: self.now)
|
||||
self.state.heartbeat(False)
|
||||
|
||||
def test_ack_is_not_armed_confirmation(self):
|
||||
self.state.request("arm")
|
||||
self.now += .2
|
||||
self.state.ack("MAV_RESULT_ACCEPTED")
|
||||
self.assertFalse(self.state.snapshot()["armed"])
|
||||
self.assertEqual(self.state.snapshot()["pending_command"], "arm")
|
||||
self.state.heartbeat(True)
|
||||
self.assertTrue(self.state.snapshot()["controls_allowed"])
|
||||
|
||||
def test_heartbeat_confirms_even_when_ack_packet_is_lost(self):
|
||||
self.state.request("arm")
|
||||
self.now += .5
|
||||
self.state.heartbeat(True)
|
||||
self.assertIsNone(self.state.pending)
|
||||
self.assertTrue(self.state.armed)
|
||||
|
||||
def test_old_events_cannot_confirm_new_request(self):
|
||||
self.state.request("arm")
|
||||
self.state.ack("MAV_RESULT_DENIED", received_at=9)
|
||||
self.state.heartbeat(True, received_at=9)
|
||||
self.assertIsNotNone(self.state.pending)
|
||||
self.assertFalse(self.state.armed)
|
||||
|
||||
def test_rejected_engage_stays_disarmed(self):
|
||||
self.state.request("arm")
|
||||
self.now += .1
|
||||
self.state.ack("MAV_RESULT_DENIED")
|
||||
self.assertIsNone(self.state.pending)
|
||||
self.assertIn("rejected", self.state.error)
|
||||
self.assertFalse(self.state.armed)
|
||||
|
||||
def test_engage_timeout_then_late_armed_heartbeat_is_truth(self):
|
||||
self.state.request("arm")
|
||||
self.now += 6
|
||||
self.state.tick()
|
||||
self.assertIn("timed out", self.state.error)
|
||||
self.assertFalse(self.state.snapshot()["armed_known"])
|
||||
self.state.heartbeat(True)
|
||||
self.assertTrue(self.state.snapshot()["armed"])
|
||||
|
||||
def test_disarm_timeout_never_claims_disarmed(self):
|
||||
self.state.heartbeat(True)
|
||||
self.state.request("disarm")
|
||||
self.now += 6
|
||||
self.state.heartbeat(True)
|
||||
snap = self.state.snapshot()
|
||||
self.assertTrue(snap["armed"])
|
||||
self.assertIn("timed out", snap["command_error"])
|
||||
|
||||
def test_disarm_ack_waits_for_disarmed_heartbeat(self):
|
||||
self.state.heartbeat(True)
|
||||
self.state.request("disarm")
|
||||
self.now += .2
|
||||
self.state.ack("MAV_RESULT_ACCEPTED")
|
||||
self.assertTrue(self.state.snapshot()["armed"])
|
||||
self.assertFalse(self.state.snapshot()["controls_allowed"])
|
||||
self.state.heartbeat(False)
|
||||
self.assertIsNone(self.state.pending)
|
||||
self.assertFalse(self.state.armed)
|
||||
|
||||
def test_disconnect_preserves_last_known_armed_but_invalidates_it(self):
|
||||
self.state.heartbeat(True)
|
||||
self.state.disconnect()
|
||||
snap = self.state.snapshot()
|
||||
self.assertTrue(snap["armed"])
|
||||
self.assertFalse(snap["armed_known"])
|
||||
self.assertFalse(snap["controls_allowed"])
|
||||
|
||||
def test_frozen_telemetry_blocks_new_engage(self):
|
||||
self.now += 3.1
|
||||
self.assertFalse(self.state.snapshot()["armed_known"])
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.state.request("arm")
|
||||
|
||||
|
||||
class WorkerWatchdogTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.worker = MavlinkWorker(queue.Queue())
|
||||
self.worker.require_input_lease = True
|
||||
self.worker.connection = Mock()
|
||||
self.worker.target_system = 1
|
||||
self.worker.armed = True
|
||||
self.worker.last_heartbeat = 10
|
||||
|
||||
def assertNeutral(self):
|
||||
self.worker._send_control()
|
||||
self.assertEqual(self.control_values(),
|
||||
(1, 0, 0, NEUTRAL_Z, 0, 0))
|
||||
|
||||
def control_values(self):
|
||||
return self.worker.connection.mav.manual_control_send.call_args.args
|
||||
|
||||
def test_manual_control_uses_native_v2_without_forced_long_payload(self):
|
||||
self.worker._send_mav(self.worker.connection, 'manual_control_send',
|
||||
1, 50, 0, 525, -20, 0)
|
||||
self.assertEqual(self.control_values(), (1, 50, 0, 525, -20, 0))
|
||||
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.kwargs, {})
|
||||
|
||||
def test_ui_freeze_neutralizes_and_does_not_replay(self):
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.set_axes(Axes(forward=1), 1)
|
||||
self.worker._send_control()
|
||||
self.assertGreater(self.worker.output_axes.forward, 0)
|
||||
with patch("app.time.monotonic", return_value=11.1):
|
||||
self.assertNeutral()
|
||||
self.assertEqual(self.worker.axes, Axes())
|
||||
|
||||
def test_intermediate_demands_reach_mavlink_with_output_limit(self):
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.last_control_at = 9.9
|
||||
self.worker.set_axes(Axes(forward=.25, vertical=.25), 1)
|
||||
self.worker._send_control()
|
||||
# 25% input at the default 20% limit: 5% command, not 100%.
|
||||
self.assertEqual(self.control_values(),
|
||||
(1, 50, 0, 525, 0, 0))
|
||||
|
||||
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
|
||||
with patch("app.time.monotonic", return_value=13.1):
|
||||
self.worker.set_axes(Axes(forward=1), 1)
|
||||
self.assertNeutral()
|
||||
|
||||
def test_stale_queued_input_cannot_override_neutral(self):
|
||||
self.worker.set_axes(Axes(), 20)
|
||||
self.worker.set_axes(Axes(forward=1), 19)
|
||||
self.assertEqual(self.worker.axes, Axes())
|
||||
|
||||
def test_mavlink_transmissions_are_serialized(self):
|
||||
first_entered = threading.Event()
|
||||
release_first = threading.Event()
|
||||
second_entered = threading.Event()
|
||||
|
||||
class Mav:
|
||||
def first_send(self, **_):
|
||||
first_entered.set()
|
||||
release_first.wait(1)
|
||||
|
||||
def second_send(self, **_):
|
||||
second_entered.set()
|
||||
|
||||
connection = SimpleNamespace(mav=Mav())
|
||||
first = threading.Thread(target=self.worker._send_mav,
|
||||
args=(connection, "first_send"))
|
||||
second = threading.Thread(target=self.worker._send_mav,
|
||||
args=(connection, "second_send"))
|
||||
first.start()
|
||||
self.assertTrue(first_entered.wait(.5))
|
||||
second.start()
|
||||
time.sleep(.05)
|
||||
self.assertFalse(second_entered.is_set())
|
||||
release_first.set()
|
||||
first.join(1)
|
||||
second.join(1)
|
||||
self.assertTrue(second_entered.is_set())
|
||||
|
||||
def test_live_console_is_read_only_bounded_and_keeps_newest_lines(self):
|
||||
for index in range(310):
|
||||
self.worker.log("RX_TEST", f"vehicle {index}")
|
||||
self.worker.log("TX_TEST", f"computer {index}")
|
||||
sides = self.worker.read_console(2)
|
||||
self.assertEqual(set(sides), {"rx", "tx"})
|
||||
self.assertTrue(sides["rx"][0].endswith("[RX_TEST] vehicle 308"))
|
||||
self.assertTrue(sides["rx"][1].endswith("[RX_TEST] vehicle 309"))
|
||||
self.assertTrue(sides["tx"][1].endswith("[TX_TEST] computer 309"))
|
||||
self.assertEqual(len(self.worker.console_rx_lines), 300)
|
||||
self.assertEqual(len(self.worker.console_tx_lines), 300)
|
||||
self.assertEqual(len(self.worker.console_lines), 500)
|
||||
|
||||
def test_arm_request_pauses_parameter_traffic(self):
|
||||
self.worker.armed = False
|
||||
self.worker.param_scan.append("SERVO1_FUNCTION")
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(True)
|
||||
self.worker.config_paused = False # A disarmed heartbeat arrived.
|
||||
self.worker._tick_param_scan(10.1)
|
||||
self.assertEqual(self.worker.command_quiet_until, 16)
|
||||
self.worker.connection.mav.param_request_read_send.assert_not_called()
|
||||
self.worker.connection.mav.command_long_send.assert_called_once()
|
||||
|
||||
def test_arm_request_retries_until_state_confirmation(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(True)
|
||||
self.worker._tick_arm_retry(10.24)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_arm_retry(10.25)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
self.worker.pending_arm_state = None
|
||||
self.worker._tick_arm_retry(10.50)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
|
||||
def test_outbound_command_has_valid_heartbeat_lead_in(self):
|
||||
self.worker._send_mav(self.worker.connection, "timesync_send", 0, 123)
|
||||
names = [call[0] for call in self.worker.connection.mav.method_calls]
|
||||
self.assertEqual(names[-2:], ["heartbeat_send", "timesync_send"])
|
||||
|
||||
def test_disarm_sends_neutral_and_pauses_parameter_traffic(self):
|
||||
self.worker.param_scan.append("SERVO1_FUNCTION")
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.command_arm(False)
|
||||
self.worker.config_paused = False
|
||||
self.worker._tick_param_scan(10.1)
|
||||
self.assertEqual(self.worker.disarm_requested_until, 16)
|
||||
self.assertEqual(self.control_values(),
|
||||
(1, 0, 0, NEUTRAL_Z, 0, 0))
|
||||
self.worker.connection.mav.param_request_read_send.assert_not_called()
|
||||
|
||||
def test_telemetry_config_is_paced_and_includes_low_rate_esc(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._configure_low_bandwidth_streams()
|
||||
self.assertEqual(len(self.worker.stream_config), 10)
|
||||
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
|
||||
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
|
||||
self.worker.connection.mav.command_long_send.assert_not_called()
|
||||
self.worker._tick_stream_config(10)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_stream_config(10.1)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker.command_quiet_until = 16
|
||||
self.worker._tick_stream_config(11)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||
self.worker._tick_stream_config(16)
|
||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
|
||||
|
||||
def test_link_probe_uses_configured_rate_and_measures_exact_echo(self):
|
||||
self.worker.armed = False
|
||||
self.worker.ping_rate_hz = 5
|
||||
with patch("app.time.monotonic", return_value=10), \
|
||||
patch("app.time.monotonic_ns", return_value=10_000_000_000):
|
||||
self.worker._tick_link_probe(10)
|
||||
self.worker.connection.mav.timesync_send.assert_called_once_with(
|
||||
0, 10_000_000_000)
|
||||
self.assertEqual(self.worker.pending_pings, {10_000_000_000: 10})
|
||||
self.assertEqual(self.worker.next_ping_at, 10.2)
|
||||
message = SimpleNamespace(
|
||||
tc1=1, ts1=10_000_000_000, get_type=lambda: "TIMESYNC",
|
||||
to_dict=lambda: {"tc1": 1, "ts1": 10_000_000_000})
|
||||
with patch("app.time.monotonic", return_value=10.04):
|
||||
self.worker._handle_message(message)
|
||||
quality = self.worker.events.get_nowait()
|
||||
self.assertEqual(quality[0], "link_quality")
|
||||
self.assertEqual(quality[1]["latency_ms"], 40.0)
|
||||
self.assertEqual(quality[1]["loss_percent"], 0.0)
|
||||
|
||||
def test_link_rates_rebuild_requested_telemetry_intervals(self):
|
||||
self.worker.armed = False
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker.set_link_rates(10, {
|
||||
"SYS_STATUS": 5, "SERVO_OUTPUT_RAW": 10,
|
||||
"RC_CHANNELS": 0, "ESC_TELEMETRY_1_TO_4": 2,
|
||||
})
|
||||
self.assertEqual(self.worker.ping_rate_hz, 10)
|
||||
configured = {name: interval for name, _, interval in self.worker.stream_config}
|
||||
self.assertEqual(configured["SYS_STATUS"], 200_000)
|
||||
self.assertEqual(configured["SERVO_OUTPUT_RAW"], 100_000)
|
||||
self.assertEqual(configured["RC_CHANNELS"], -1)
|
||||
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 500_000)
|
||||
|
||||
def test_link_probe_counts_timeout_and_pauses_for_commands(self):
|
||||
self.worker.pending_pings[4] = 5
|
||||
self.worker.rx_delivery_results.extend(((10, True), (10, False)))
|
||||
self.worker.next_ping_at = 0
|
||||
self.worker.command_quiet_until = 20
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._tick_link_probe(10)
|
||||
self.worker.connection.mav.timesync_send.assert_not_called()
|
||||
self.assertEqual(list(self.worker.ping_results), [(10, None)])
|
||||
quality = self.worker.events.get_nowait()[1]
|
||||
self.assertEqual(quality["loss_percent"], 100.0)
|
||||
self.assertEqual(quality["grade"], "testing")
|
||||
|
||||
def test_clean_downlink_cannot_hide_failed_round_trips(self):
|
||||
self.worker.ping_results.extend([(10, None)] * 20)
|
||||
self.worker.rx_delivery_results.extend([(10, True)] * 200)
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
self.worker._publish_link_quality()
|
||||
quality = self.worker.events.get_nowait()[1]
|
||||
self.assertEqual(quality["loss_percent"], 100.0)
|
||||
self.assertEqual(quality["grade"], "poor")
|
||||
|
||||
|
||||
class BridgeTests(unittest.TestCase):
|
||||
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
|
||||
api = ControlApi()
|
||||
api.worker.connection = Mock()
|
||||
api.worker.target_system = 1
|
||||
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
|
||||
api.worker.set_axes(Axes(), 20)
|
||||
with self.assertRaisesRegex(RuntimeError, "newer input reset"):
|
||||
api.command("arm", 19)
|
||||
with self.assertRaisesRegex(RuntimeError, "expired"):
|
||||
api.command("arm", 21, expires_at=0)
|
||||
api.worker.connection.mav.command_long_send.assert_not_called()
|
||||
|
||||
def test_real_bridge_ack_pending_disconnect_and_stale_battery(self):
|
||||
api = ControlApi()
|
||||
api.worker.connection = Mock()
|
||||
api.worker.target_system = 1
|
||||
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
|
||||
pending = api.command("arm")
|
||||
self.assertEqual(pending["pending_command"], "arm")
|
||||
self.assertFalse(pending["armed"])
|
||||
api._consume(("ack", 400, "MAV_RESULT_ACCEPTED"))
|
||||
self.assertFalse(api._snapshot()["armed"])
|
||||
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", True))
|
||||
self.assertTrue(api._snapshot()["armed"])
|
||||
api._consume(("disconnected", "Disconnected"))
|
||||
self.assertTrue(api._snapshot()["armed"])
|
||||
self.assertFalse(api._snapshot()["armed_known"])
|
||||
self.assertTrue(api._snapshot()["battery_stale"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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});
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Vehicle truth and command transactions; independent of UI/animation clocks."""
|
||||
import time
|
||||
|
||||
|
||||
class VehicleState:
|
||||
HEARTBEAT_TIMEOUT = 3.0
|
||||
COMMAND_TIMEOUT = 5.0
|
||||
|
||||
def __init__(self, clock=time.monotonic):
|
||||
self.clock = clock
|
||||
self.connected = False
|
||||
self.armed = None
|
||||
self.heartbeat_at = None
|
||||
self.pending = None
|
||||
self.error = ""
|
||||
|
||||
def fresh(self):
|
||||
return (self.connected and self.heartbeat_at is not None
|
||||
and self.clock() - self.heartbeat_at <= self.HEARTBEAT_TIMEOUT)
|
||||
|
||||
def heartbeat(self, armed, received_at=None):
|
||||
received_at = self.clock() if received_at is None else received_at
|
||||
if self.heartbeat_at is not None and received_at < self.heartbeat_at:
|
||||
return
|
||||
self.connected = True
|
||||
self.heartbeat_at = received_at
|
||||
self.armed = bool(armed)
|
||||
# A fresh heartbeat is the authoritative confirmation of actual state.
|
||||
# ACK ACCEPTED alone only confirms that a command was accepted.
|
||||
if (self.pending and received_at > self.pending["started"]
|
||||
and self.armed == (self.pending["name"] == "arm")):
|
||||
self.pending = None
|
||||
|
||||
def request(self, name):
|
||||
self.tick()
|
||||
if name == "arm" and (not self.fresh() or self.armed is not False):
|
||||
raise RuntimeError("Fresh, disarmed vehicle telemetry is required to engage")
|
||||
if self.pending and (name == "arm" or self.pending["name"] == name):
|
||||
raise RuntimeError("A vehicle confirmation is already pending")
|
||||
now = self.clock()
|
||||
self.pending = {"name": name, "started": now,
|
||||
"deadline": now + self.COMMAND_TIMEOUT, "ack": None}
|
||||
self.error = ""
|
||||
|
||||
def ack(self, result, received_at=None):
|
||||
received_at = self.clock() if received_at is None else received_at
|
||||
if not self.pending or received_at <= self.pending["started"]:
|
||||
return
|
||||
self.pending["ack"] = result
|
||||
if result not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"):
|
||||
self.fail(f"{self.pending['name'].capitalize()} rejected: {result}")
|
||||
|
||||
def fail(self, message):
|
||||
self.error = message
|
||||
self.pending = None
|
||||
|
||||
def disconnect(self):
|
||||
self.connected = False
|
||||
if self.pending:
|
||||
self.fail("Connection lost before vehicle confirmation. State unknown.")
|
||||
|
||||
def tick(self):
|
||||
if self.pending and self.clock() >= self.pending["deadline"]:
|
||||
name = self.pending["name"].capitalize()
|
||||
self.fail(f"{name} confirmation timed out. Check current vehicle state.")
|
||||
|
||||
def snapshot(self):
|
||||
self.tick()
|
||||
fresh = self.fresh()
|
||||
return {"armed": self.armed, "armed_known": fresh,
|
||||
"heartbeat_age": None if self.heartbeat_at is None else self.clock() - self.heartbeat_at,
|
||||
"pending_command": self.pending["name"] if self.pending else None,
|
||||
"command_ack": self.pending["ack"] if self.pending else None,
|
||||
"command_error": self.error,
|
||||
"controls_allowed": fresh and self.armed is True and self.pending is None}
|
||||
+997
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
/* Read-only presentation of the existing local RX/TX logs. */
|
||||
((root)=>{
|
||||
const quiet=/^(HEARTBEAT|TIMESYNC|LINK_PROBE|HEAVY_PROBE|GCS)/;
|
||||
function parse(line){
|
||||
const match=String(line).match(/^(\S+) \[([^\]]+)\] ([\s\S]*)$/);
|
||||
if(!match)return {time:'',type:'Message',payload:String(line),raw:String(line),tone:'normal',quiet:false};
|
||||
const [,timestamp,category,message]=match;
|
||||
let payload=message,type=category.replace(/^(RX|TX)_?/,'')||category;
|
||||
try{payload=JSON.parse(message);type=payload.mavpackettype||type}catch(_){}
|
||||
const tone=/BAD_DATA|ERROR|TIMEOUT|FAIL/.test(category)||type==='STATUSTEXT'&&Number(payload.severity)<=4?'warning':/COMMAND|ACK|PARAM_SET|OPERATOR/.test(category+' '+type)?'command':'normal';
|
||||
const time=timestamp.match(/T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)?.[1]||timestamp;
|
||||
const pairs=typeof payload==='string'?[...payload.matchAll(/(?:^|\s)([a-zA-Z_]\w*)=([^\s]+)/g)]:[];
|
||||
const fields=pairs.length?Object.fromEntries(pairs.map(([,key,value])=>[key,value])):payload;
|
||||
return {time,type,payload,fields,raw:String(line),tone,quiet:tone!=='warning'&&(quiet.test(type)||/LINK_PROBE|HEAVY_PROBE|HEARTBEAT/.test(category))};
|
||||
}
|
||||
function valueText(value){
|
||||
if(typeof value==='number')return Number.isInteger(value)?String(value):Number(value.toFixed(3)).toString();
|
||||
if(Array.isArray(value))return value.slice(0,6).map(valueText).join(', ')+(value.length>6?'…':'');
|
||||
return typeof value==='object'?JSON.stringify(value):String(value);
|
||||
}
|
||||
function makeRow(row,previous){
|
||||
if(previous&&previous.dataset.key===row.raw&&previous.dataset.tone===row.tone)return previous;
|
||||
const details=previous||document.createElement('details');
|
||||
details.className='console-row';details.dataset.key=row.raw;details.dataset.tone=row.tone;
|
||||
let summary=details.querySelector('summary'),time,body,type,fields,raw;
|
||||
if(!summary){
|
||||
details.replaceChildren();summary=document.createElement('summary');time=document.createElement('time');
|
||||
body=document.createElement('span');body.className='console-message';type=document.createElement('strong');
|
||||
fields=document.createElement('span');fields.className='console-fields';body.append(type,fields);summary.append(time,body);
|
||||
raw=document.createElement('pre');details.append(summary,raw);
|
||||
}else{
|
||||
time=summary.querySelector('time');body=summary.querySelector('.console-message');
|
||||
type=body.querySelector('strong');fields=body.querySelector('.console-fields');raw=details.querySelector('pre');
|
||||
}
|
||||
time.textContent=row.time;type.textContent=row.type;fields.replaceChildren();
|
||||
const values=row.fields??row.payload;
|
||||
if(typeof values==='object'&&values!==null){
|
||||
for(const [key,value] of Object.entries(values).filter(([key])=>key!=='mavpackettype').slice(0,6)){
|
||||
const item=document.createElement('span'),label=document.createElement('i'),text=document.createElement('b');
|
||||
label.textContent=key+' ';text.textContent=valueText(value);item.append(label,text);fields.append(item);
|
||||
}
|
||||
}else fields.textContent=row.payload;
|
||||
raw.textContent=typeof row.payload==='object'?JSON.stringify(row.payload,null,2):row.raw;
|
||||
return details;
|
||||
}
|
||||
function render(host,lines,filter){
|
||||
const atBottom=host.scrollHeight-host.scrollTop-host.clientHeight<36,scroll=host.scrollTop;
|
||||
const rows=lines.map(parse).filter(row=>filter==='all'||!row.quiet);
|
||||
const existing=new Map();
|
||||
for(const node of host.querySelectorAll('details.console-row')){
|
||||
const key=node.dataset.key,list=existing.get(key)||[];list.push(node);existing.set(key,list);
|
||||
}
|
||||
if(!rows.length){
|
||||
if(!host.querySelector('.console-empty')){
|
||||
const empty=document.createElement('p');empty.className='console-empty';
|
||||
empty.textContent='No messages';
|
||||
empty.title=filter==='all'?'Waiting for session messages.':'Routine heartbeat and probe traffic is hidden. Choose All traffic to see it.';
|
||||
host.replaceChildren(empty);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wanted=rows.map(row=>makeRow(row,existing.get(row.raw)?.shift()||null));
|
||||
let cursor=host.firstElementChild;
|
||||
for(const node of wanted){if(node!==cursor)host.insertBefore(node,cursor);cursor=node.nextElementSibling}
|
||||
while(cursor){const next=cursor.nextElementSibling;cursor.remove();cursor=next}
|
||||
if(atBottom&&!host.querySelector('details[open]'))host.scrollTop=host.scrollHeight;else host.scrollTop=scroll;
|
||||
}
|
||||
root.ConsoleView={parse,render};
|
||||
if(typeof module!=='undefined')module.exports=root.ConsoleView;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Presentation derived from telemetry. No animation is allowed to set armed. */
|
||||
((root) => {
|
||||
function present(state, {stale=false, focused=true, pending=null, error=''}={}) {
|
||||
const known = state.armed_known === true && !stale;
|
||||
const online = !!state.connected && !stale;
|
||||
const request = pending || state.pending_command;
|
||||
const uncertain = !known && (state.armed !== null && state.armed !== undefined || !!request || state.heartbeat_age != null);
|
||||
let label='ENGAGE', tone='idle', action=null, message='Connect to receive vehicle state.';
|
||||
if (request === 'disarm') {
|
||||
label='DISARMING…'; tone='danger'; message='Awaiting vehicle confirmation. Not yet confirmed disarmed.';
|
||||
} else if (request === 'arm') {
|
||||
label='ENGAGING…'; tone='pending'; action=online?'disarm':null;
|
||||
message='Awaiting vehicle confirmation. Tap to request disarm.';
|
||||
} else if (known && state.armed) {
|
||||
label='DISARM'; tone='danger'; action=online?'disarm':null;
|
||||
message=focused?'Armed · tap or F3 to disarm.':'Armed · controls neutral while the app is unfocused.';
|
||||
} else if (uncertain) {
|
||||
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
|
||||
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
|
||||
} else if (known && online) {
|
||||
action=focused?'arm':null; message='Disarmed · hold 0.6 seconds to engage.';
|
||||
}
|
||||
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
|
||||
const failure=error || state.command_error;
|
||||
return {label,tone,action,pending:!!request,known,
|
||||
drive:known && online && state.armed === true && !request && focused,
|
||||
message:failure ? `${failure} ${known ? (state.armed?'Vehicle armed.':'Vehicle disarmed.') : 'Vehicle state unknown.'}` : message,
|
||||
error:!!(error || state.command_error || uncertain || stale)};
|
||||
}
|
||||
root.ControlState={present};
|
||||
if(typeof module!=='undefined') module.exports=root.ControlState;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
@@ -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()});
|
||||
})();
|
||||
+215
-89
@@ -3,117 +3,243 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SUB Control</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<title>Sub Control</title>
|
||||
<link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
|
||||
</head>
|
||||
<body>
|
||||
<header class="window-bar">
|
||||
<div class="window-drag pywebview-drag-region"><span class="mini-mark">S</span><strong>SUB Control</strong><i id="model-file-name">Built-in vehicle</i></div>
|
||||
<div class="window-actions">
|
||||
<button id="window-min" aria-label="Minimize">—</button>
|
||||
<button id="window-max" aria-label="Maximize">□</button>
|
||||
<button id="window-close" class="window-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-shell">
|
||||
<aside class="vehicle-rail">
|
||||
<header class="brand"><span class="brand-mark">S</span><span>SUB CONTROL</span><small>03</small></header>
|
||||
<div class="eyebrow">LIVE VEHICLE</div>
|
||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||
<div class="water-ring r1"></div><div class="water-ring r2"></div>
|
||||
<svg id="default-model" viewBox="0 0 260 310" role="img">
|
||||
<defs>
|
||||
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#566168"/><stop offset="1" stop-color="#22292d"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="13" flood-opacity=".45"/></filter>
|
||||
</defs>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="73" y="34" width="114" height="226" rx="55" fill="url(#body)" stroke="#707b81" stroke-width="2"/>
|
||||
<rect x="93" y="60" width="74" height="168" rx="35" fill="#30383c" stroke="#49545a"/>
|
||||
<circle cx="130" cy="145" r="28" fill="#7d898f" stroke="#a3adb1" stroke-width="3"/>
|
||||
<circle cx="130" cy="145" r="12" fill="#d9e0df"/>
|
||||
<path d="M78 98 48 80M182 98l30-18" stroke="#7e8a90" stroke-width="9" stroke-linecap="round"/>
|
||||
<g fill="#191f22" stroke="#7b878c" stroke-width="3">
|
||||
<ellipse cx="38" cy="74" rx="27" ry="34"/><ellipse cx="222" cy="74" rx="27" ry="34"/>
|
||||
</g>
|
||||
<g stroke="#59656a" stroke-width="2">
|
||||
<path d="M22 54l32 40M54 54 22 94M206 54l32 40M238 54l-32 40"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||
<div class="model-tools">
|
||||
<button id="load-model">Load STL</button>
|
||||
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden>
|
||||
|
||||
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
|
||||
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
|
||||
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
|
||||
<!-- Connection Controls -->
|
||||
<div class="deck-connection">
|
||||
<div class="port-row">
|
||||
<select id="port" aria-label="Serial port">
|
||||
<option value="demo" selected>Demo Mode (no hardware)</option>
|
||||
</select>
|
||||
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg>
|
||||
</button>
|
||||
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings">
|
||||
<svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button id="connect" class="button connect-button" aria-label="Toggle connection">
|
||||
<span class="btn-spinner" aria-hidden="true"></span>
|
||||
<span class="btn-label">Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry / Status Zone -->
|
||||
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
|
||||
<div class="deck-card model-card">
|
||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||
<div id="model-status" class="model-status">Initializing…</div>
|
||||
<div class="model-tools">
|
||||
<button id="load-model">Load STL</button>
|
||||
<input id="model-input" type="file" accept=".stl,.STL" hidden>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vehicle-name"><small>VEHICLE</small><strong id="vehicle">No heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
|
||||
<div class="rail-bottom">
|
||||
<button class="battery-card ghost" data-open="battery-modal">
|
||||
<span><small>BATTERY</small><strong id="battery-title">—</strong></span><i id="battery-percent">Waiting</i>
|
||||
<b><em id="battery-fill"></em></b>
|
||||
</button>
|
||||
<div class="link-state"><span id="link-dot"></span><strong id="link">DISCONNECTED</strong></div>
|
||||
|
||||
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
|
||||
<span class="link-quality-dot" aria-hidden="true"></span>
|
||||
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
|
||||
<i><span id="link-quality-roundtrip">waiting for connection</span><span id="link-quality-downlink"></span></i>
|
||||
</button>
|
||||
|
||||
<div class="deck-status-bar">
|
||||
<header class="activity-heading">Activity:</header>
|
||||
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
|
||||
<li id="activity-oldest" hidden></li>
|
||||
<li id="activity-previous" hidden></li>
|
||||
<li id="status-text">Not connected</li>
|
||||
</ol>
|
||||
<div class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></div>
|
||||
</div>
|
||||
<button class="battery-card ghost" data-open="battery-modal" aria-label="Battery status and settings">
|
||||
<span class="battery-meter" aria-hidden="true"><em id="battery-fill"></em></span>
|
||||
<span class="battery-copy"><span class="battery-summary"><strong id="battery-percent">—</strong><small id="battery-title">No voltage</small></span></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div>
|
||||
<div class="connection">
|
||||
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select>
|
||||
<button id="refresh" class="icon-button" title="Refresh ports">↻</button>
|
||||
<button class="icon-button" data-open="link-modal" title="Link settings">•••</button>
|
||||
<button id="connect" class="button connect-button">Connect</button>
|
||||
<!-- ── PRIMARY CONTROL ZONE ── -->
|
||||
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
|
||||
<!-- Frameless Drag & Window Controls Header -->
|
||||
<div class="workspace-topbar">
|
||||
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
|
||||
<div class="workspace-drag-area pywebview-drag-region"></div>
|
||||
<div class="window-actions">
|
||||
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
||||
<button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button>
|
||||
<button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<section class="drive-grid">
|
||||
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
|
||||
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
||||
<!-- Reserved Engage / Disarm row -->
|
||||
<div class="engage-header-zone">
|
||||
<button id="engage" class="button subtle engage-button" aria-label="Engage" aria-describedby="control-status" title="Engage (F2)">
|
||||
<span class="engage-face">
|
||||
<span id="engage-label" class="engage-label">engage</span>
|
||||
<span class="engage-sheen-label" aria-hidden="true">engage</span>
|
||||
<span class="engage-progress-label" aria-hidden="true">engage</span>
|
||||
</span>
|
||||
</button>
|
||||
<p id="control-status" class="control-status" role="status" aria-live="polite">Connect to receive vehicle state.</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Joysticks & Thrust Control Grid -->
|
||||
<div class="controls-grid">
|
||||
<!-- Steering / Throttle Joystick -->
|
||||
<article class="control-card steer-card">
|
||||
<header><div><small>STEER + THROTTLE</small><strong>Differential drive</strong></div><span class="key-hint">W A S D</span></header>
|
||||
<div id="joystick" class="joystick" touch-action="none">
|
||||
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div>
|
||||
<span class="axis-label forward">FORWARD</span><span class="axis-label reverse">REVERSE</span>
|
||||
<span class="axis-label left">TURN LEFT</span><span class="axis-label right">TURN RIGHT</span>
|
||||
<div class="stick-shadow"></div><div id="stick" class="stick"><span></span></div>
|
||||
<header>
|
||||
<div title="Steer and throttle · W A S D"></div>
|
||||
|
||||
</header>
|
||||
<div class="joystick-pad">
|
||||
<div id="joystick" class="joystick" touch-action="none">
|
||||
<canvas id="joystick-canvas" title="Steer and throttle · W A S D" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<footer><span><i class="dot surge"></i><b id="surge-readout">0</b>% surge</span><span><i class="dot yaw"></i><b id="yaw-readout">0</b>% turn</span></footer>
|
||||
<footer>
|
||||
<span class="steer-readout-left">surge <b id="surge-readout">—</b></span>
|
||||
<span class="steer-readout-right"><b id="yaw-readout">—</b> turn</span>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<!-- Vertical Thruster Slider -->
|
||||
<article class="control-card vertical-card">
|
||||
<header><div><small>VERTICAL</small><strong>Center thruster</strong></div><span class="key-hint">R / F</span></header>
|
||||
<div class="vertical-control">
|
||||
<span>RISE</span>
|
||||
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div>
|
||||
<span>DIVE</span>
|
||||
</div>
|
||||
<footer><span><i class="dot depth"></i><b id="heave-readout">0</b>% vertical</span></footer>
|
||||
</article>
|
||||
</section>
|
||||
<header>
|
||||
<div title="Vertical thrust · R / F"></div>
|
||||
|
||||
<section class="command-bar">
|
||||
<div class="commands">
|
||||
<button id="manual" class="button subtle">Manual</button>
|
||||
<button id="arm" class="button arm">Arm</button>
|
||||
<button id="disarm" class="button danger">Disarm</button>
|
||||
</div>
|
||||
<label class="output-limit"><span>Output limit <b id="gain-value">20%</b></span><input id="gain" type="range" min="5" max="50" value="20"></label>
|
||||
<button class="button subtle" data-open="motors-modal">Motor setup</button>
|
||||
<button id="logs" class="icon-button" title="Open session logs">⌁</button>
|
||||
</header>
|
||||
<div class="heave-pad">
|
||||
<div class="vertical-control">
|
||||
<span>rise</span>
|
||||
<div id="heave" title="Vertical thrust · R / F" class="heave-track" touch-action="none">
|
||||
<div class="heave-center"></div>
|
||||
<div id="heave-fill" class="heave-fill"></div>
|
||||
<div id="heave-knob" class="heave-knob"></div>
|
||||
</div>
|
||||
<span>dive</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<span><b id="heave-readout">—</b> vertical</span>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<div class="control-toolbar">
|
||||
<label class="output-limit">
|
||||
<span>Output limit <b id="gain-value">20%</b></span>
|
||||
<div class="range-pill">
|
||||
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
|
||||
<input id="gain" aria-label="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>
|
||||
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
|
||||
</section>
|
||||
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
|
||||
<header class="console-dock-header"><div><h2 id="console-title" title="Local session messages. Select a message to expand its details.">Console</h2></div><button id="console-close" type="button" class="console-close" aria-label="Hide console" title="Hide console"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
|
||||
<div class="console-toolbar"><label><span class="visually-hidden">Console filter</span><select id="console-filter"><option value="useful">Useful traffic</option><option value="all">All traffic</option></select></label><button id="console-pause" type="button" aria-pressed="false">Pause</button></div>
|
||||
<p id="console-state" class="console-state" role="status" title="Pausing the viewer does not stop session logging." hidden></p>
|
||||
<div class="console-streams"><section class="console-side"><header><i class="rx-dot" aria-hidden="true" title="Vehicle to PC · new displayed message"></i><span>RX</span></header><div id="console-rx-output" class="console-output" tabindex="0" aria-label="Received messages"></div></section><section class="console-side"><header><i class="tx-dot" aria-hidden="true" title="PC to vehicle · new displayed message"></i><span>TX</span></header><div id="console-tx-output" class="console-output" tabindex="0" aria-label="Sent messages"></div></section></div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<dialog id="arm-dialog" class="modal confirm-modal"><button class="modal-close" data-close>×</button><small>SAFETY CHECK</small><h2>Arm the vehicle?</h2><p>Remove propellers or securely restrain the ROV before enabling motor output.</p><div class="modal-actions"><button class="button subtle" data-close>Cancel</button><button id="confirm-arm" class="button arm">Confirm arm</button></div></dialog>
|
||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>CONNECTION</small><h2>Link settings</h2><div class="form-grid two"><label>Baud rate<select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label>Control rate<select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>POWER</small><h2>Battery settings</h2><form id="battery-form" class="form-grid three"><label>Cells (S)<input name="cells" type="number" min="1"></label><label>Capacity (mAh)<input name="capacity_mah" type="number" min="1"></label><label>C rating<input name="c_rating" type="number" min="1" step=".1"></label><label>Full V / cell<input name="full_v_per_cell" type="number" step=".01"></label><label>Empty V / cell<input name="empty_v_per_cell" type="number" step=".01"></label><label>Warning %<input name="warning_percent" type="number" min="0" max="100"></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save settings</button></div></form></dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>CONFIGURATION</small><h2>Motor outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||
<!-- Modals -->
|
||||
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="link-title">Link settings</h2>
|
||||
<form id="link-form" title="Baud changes apply on the next connection.">
|
||||
<section class="settings-section"><h3>Serial connection</h3>
|
||||
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
|
||||
|
||||
<script src="model-viewer.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
|
||||
<p id="custom-port-status" class="note" role="status"></p>
|
||||
</section>
|
||||
<section class="settings-section"><h3 title="Lower rates leave more bandwidth for controls. Rates reset after a reboot.">Telemetry rates</h3>
|
||||
<div class="form-grid two">
|
||||
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
|
||||
<label><span>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=20261002-spacing-motion-v2"></script>
|
||||
<script src="control-state.js?v=20260827-state-v1"></script>
|
||||
<script src="controller-input.js?v=20261001-controller-v1"></script>
|
||||
<script src="console-view.js?v=20261002-console-v2"></script>
|
||||
<script src="app.js?v=20261017-engage-center-v2"></script>
|
||||
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Visual joystick. Control transport remains in app.js through setJoyControl. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const host=document.getElementById('joystick'),canvas=document.getElementById('joystick-canvas');
|
||||
if(!host||!canvas)return;
|
||||
const ctx=canvas.getContext('2d');
|
||||
let width=0,height=0,dpr=1,x=0,y=0,activePointer=null,returnFrame=0;
|
||||
|
||||
function geometry(){
|
||||
const radius=Math.min(width,height)*.43;
|
||||
return {cx:width/2,cy:height/2,radius,travel:radius*.67,knob:Math.max(25,Math.min(34,radius*.22))};
|
||||
}
|
||||
function resize(){const rect=host.getBoundingClientRect();width=rect.width;height=rect.height;dpr=Math.min(devicePixelRatio||1,2);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;draw()}
|
||||
function draw(){
|
||||
if(!width||!height)return;
|
||||
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
|
||||
|
||||
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eee';ctx.fill();ctx.strokeStyle='#d3d3d3';ctx.lineWidth=1.5;ctx.stroke();
|
||||
ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
|
||||
|
||||
ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
|
||||
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
|
||||
|
||||
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
||||
const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819);
|
||||
handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939');
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;ctx.fill();ctx.restore();
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
|
||||
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
|
||||
}
|
||||
function publish(){window.setJoyControl?.(x,y)}
|
||||
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
|
||||
function positionFromEvent(event){const rect=canvas.getBoundingClientRect(),g=geometry();setPosition((event.clientX-rect.left-g.cx)/g.travel,(event.clientY-rect.top-g.cy)/g.travel)}
|
||||
function easeTo(nextX,nextY){
|
||||
cancelAnimationFrame(returnFrame);
|
||||
const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}
|
||||
const startX=x,startY=y,start=performance.now();
|
||||
const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX+(nextX-startX)*smooth,startY+(nextY-startY)*smooth);if(p<1)returnFrame=requestAnimationFrame(tick)};
|
||||
returnFrame=requestAnimationFrame(tick);
|
||||
}
|
||||
function returnToCenter(){easeTo(0,0)}
|
||||
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
|
||||
|
||||
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canTouchControl?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
||||
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
|
||||
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
|
||||
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
|
||||
addEventListener('resize',resize);new ResizeObserver(resize).observe(host);resize();
|
||||
window.joystick3D={setVisual(nextX,nextY){setPosition(nextX,nextY,false)},setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
|
||||
})();
|
||||
+514
-459
File diff suppressed because it is too large
Load Diff
+1521
-12
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,16 @@ import threading
|
||||
import time
|
||||
import base64
|
||||
import shutil
|
||||
import uuid
|
||||
import http.server
|
||||
import socketserver
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import webview
|
||||
from serial.tools import list_ports
|
||||
|
||||
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
||||
from vehicle_state import VehicleState
|
||||
|
||||
|
||||
class ControlApi:
|
||||
@@ -22,25 +25,52 @@ class ControlApi:
|
||||
def __init__(self):
|
||||
self.events: queue.Queue = queue.Queue()
|
||||
self.worker = MavlinkWorker(self.events)
|
||||
self.worker.require_input_lease = True
|
||||
self.vehicle_state = VehicleState()
|
||||
self.battery_received_at = None
|
||||
self.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()
|
||||
self.state = {
|
||||
"connected": False,
|
||||
"link": "DISCONNECTED",
|
||||
"vehicle": "No heartbeat",
|
||||
"armed": False,
|
||||
"mode": None,
|
||||
"app_state": "disconnected",
|
||||
"status": "Ready. Select a USB port to begin.",
|
||||
"battery": None,
|
||||
"link_quality": None,
|
||||
"heavy_quality": None,
|
||||
"log_path": None,
|
||||
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
|
||||
"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"
|
||||
self.model_uploads: dict[str, tuple[object, Path, str, int, int]] = {}
|
||||
self.model_lock = threading.Lock()
|
||||
# Leading underscore keeps the native WebView object out of js_api
|
||||
# serialization. Only the small public methods below are exposed.
|
||||
self._window = None
|
||||
@@ -54,17 +84,39 @@ 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": [p.device for p in list_ports.comports()],
|
||||
"battery_config": self.battery_config,
|
||||
"saved_model": self.get_saved_model_info(),
|
||||
"link_config": self.link_config,
|
||||
"motor_settings": self.motor_settings,
|
||||
"state": self._snapshot(),
|
||||
}
|
||||
|
||||
def refresh_ports(self):
|
||||
return [p.device for p in list_ports.comports()]
|
||||
ports = []
|
||||
# Add physical and virtual serial COM ports
|
||||
for p in list_ports.comports():
|
||||
desc = p.description or ""
|
||||
if desc and desc != "n/a" and desc != p.device:
|
||||
label = f"{p.device} ({desc})"
|
||||
else:
|
||||
label = p.device
|
||||
ports.append({"device": p.device, "label": label, "type": "serial"})
|
||||
|
||||
# Built-in UI testing: no external process, port, or MAVLink socket.
|
||||
ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"})
|
||||
return ports
|
||||
|
||||
def minimize_window(self):
|
||||
if self._window:
|
||||
@@ -86,62 +138,83 @@ class ControlApi:
|
||||
self._window.destroy()
|
||||
return True
|
||||
|
||||
def begin_model_save(self, name, size):
|
||||
def choose_model(self):
|
||||
selected = None
|
||||
# 1. Native Windows topmost open file dialog (instant, reliable)
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.attributes("-topmost", True)
|
||||
file_path = filedialog.askopenfilename(
|
||||
title="Select 3D STL Vehicle Model",
|
||||
initialdir=str(Path.home() / "Downloads"),
|
||||
filetypes=[("STL 3D Meshes (*.stl)", "*.stl"), ("All files (*.*)", "*.*")],
|
||||
)
|
||||
root.destroy()
|
||||
if file_path:
|
||||
selected = (file_path,)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Fallback to pywebview create_file_dialog
|
||||
if not selected and self._window:
|
||||
try:
|
||||
selected = self._window.create_file_dialog(
|
||||
webview.FileDialog.OPEN,
|
||||
directory=str(Path.home()),
|
||||
allow_multiple=False,
|
||||
file_types=("STL mesh (*.stl)", "All files (*.*)"),
|
||||
)
|
||||
except Exception:
|
||||
selected = None
|
||||
|
||||
if not selected:
|
||||
return None
|
||||
|
||||
path = Path(selected[0])
|
||||
if not path.is_file() or not path.suffix.lower().endswith(".stl"):
|
||||
raise ValueError("Choose an STL mesh")
|
||||
if path.stat().st_size > 30 * 1024 * 1024:
|
||||
raise ValueError("The STL is larger than 30 MB; export a lighter mesh")
|
||||
content = path.read_bytes()
|
||||
self.model_cache.parent.mkdir(exist_ok=True)
|
||||
self.model_cache.write_bytes(content)
|
||||
self.model_cache_name.write_text(path.name, encoding="utf-8")
|
||||
try:
|
||||
self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
return {"name": path.name, "data": base64.b64encode(content).decode("ascii")}
|
||||
|
||||
def save_model(self, name, data):
|
||||
safe_name = Path(str(name)).name
|
||||
if not safe_name.lower().endswith(".stl"):
|
||||
raise ValueError("Choose an STL mesh")
|
||||
size = int(size)
|
||||
if not 0 < size <= 30 * 1024 * 1024:
|
||||
try:
|
||||
content = base64.b64decode(data, validate=True)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Invalid STL data") from exc
|
||||
if not content or len(content) > 30 * 1024 * 1024:
|
||||
raise ValueError("STL must be between 1 byte and 30 MB")
|
||||
self.model_cache.parent.mkdir(exist_ok=True)
|
||||
token = uuid.uuid4().hex
|
||||
temp = self.model_cache.parent / f".{token}.upload"
|
||||
with self.model_lock:
|
||||
self.model_uploads[token] = (temp.open("wb"), temp, safe_name, size, 0)
|
||||
return token
|
||||
|
||||
def append_model_chunk(self, token, data):
|
||||
try:
|
||||
chunk = base64.b64decode(data, validate=True)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Invalid STL data chunk") from exc
|
||||
with self.model_lock:
|
||||
upload = self.model_uploads.get(str(token))
|
||||
if not upload:
|
||||
raise ValueError("Model upload expired")
|
||||
handle, temp, name, expected, received = upload
|
||||
if received + len(chunk) > expected:
|
||||
raise ValueError("Model upload is larger than expected")
|
||||
handle.write(chunk)
|
||||
self.model_uploads[str(token)] = (handle, temp, name, expected, received + len(chunk))
|
||||
return True
|
||||
|
||||
def finish_model_save(self, token):
|
||||
with self.model_lock:
|
||||
upload = self.model_uploads.pop(str(token), None)
|
||||
if not upload:
|
||||
raise ValueError("Model upload expired")
|
||||
handle, temp, name, expected, received = upload
|
||||
handle.close()
|
||||
if received != expected:
|
||||
temp.unlink(missing_ok=True)
|
||||
raise ValueError("Model upload was incomplete")
|
||||
os.replace(temp, self.model_cache)
|
||||
self.model_cache_name.write_text(name, encoding="utf-8")
|
||||
self.model_cache.write_bytes(content)
|
||||
self.model_cache_name.write_text(safe_name, encoding="utf-8")
|
||||
return True
|
||||
|
||||
def get_saved_model_info(self):
|
||||
self._migrate_legacy_model()
|
||||
try:
|
||||
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
|
||||
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "vehicle_model.stl"
|
||||
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
|
||||
return {"name": name, "size": self.model_cache.stat().st_size}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def read_saved_model_chunk(self, offset, size=1048576):
|
||||
offset, size = int(offset), min(int(size), 1048576)
|
||||
def read_saved_model_chunk(self, offset, size=262144):
|
||||
offset, size = int(offset), min(int(size), 524288)
|
||||
if offset < 0 or size < 1 or not self.model_cache.is_file():
|
||||
raise ValueError("Saved model is unavailable")
|
||||
with self.model_cache.open("rb") as stream:
|
||||
@@ -150,68 +223,243 @@ class ControlApi:
|
||||
return {"offset": offset, "data": base64.b64encode(content).decode("ascii"), "eof": offset + len(content) >= self.model_cache.stat().st_size}
|
||||
|
||||
def _migrate_legacy_model(self):
|
||||
if self.model_cache.is_file():
|
||||
return
|
||||
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
|
||||
if default_asset.is_file():
|
||||
if not self.model_cache.is_file():
|
||||
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
|
||||
if default_asset.is_file():
|
||||
try:
|
||||
self.model_cache.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(default_asset, self.model_cache)
|
||||
self.model_cache_name.write_text(default_asset.name, encoding="utf-8")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.model_cache.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(default_asset, self.model_cache)
|
||||
self.model_cache_name.write_text(default_asset.name, encoding="utf-8")
|
||||
return
|
||||
if self.model_path.is_file():
|
||||
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
|
||||
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
|
||||
self.model_cache.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(path, self.model_cache)
|
||||
self.model_cache_name.write_text(path.name, encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def saved_model(self):
|
||||
self._migrate_legacy_model()
|
||||
try:
|
||||
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
|
||||
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
|
||||
self.model_cache.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(path, self.model_cache)
|
||||
self.model_cache_name.write_text(path.name, encoding="utf-8")
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
|
||||
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
|
||||
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def connect(self, port, baud=57600, rate=20):
|
||||
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
|
||||
if not port:
|
||||
raise ValueError("Select a USB serial port")
|
||||
if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
|
||||
self.demo_mode = True
|
||||
self.servo_functions = {1: 33, 2: 34, 3: 35}
|
||||
self.motor_directions = {1: 1, 2: 1, 3: 1}
|
||||
with self.lock:
|
||||
self.state.update(
|
||||
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
|
||||
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
|
||||
link_quality={"grade": "excellent", "latency_ms": 12.0,
|
||||
"loss_percent": 0.0, "samples": 20,
|
||||
"corrupt_frames_20s": 0, "heartbeat_age_s": 0.1},
|
||||
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
|
||||
"cells": 4, "low": False},
|
||||
)
|
||||
for servo in range(1, 17):
|
||||
function = self.servo_functions.get(servo, 0)
|
||||
motor = motor_number_for_function(function)
|
||||
self.state["servos"][str(servo)] = {
|
||||
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
|
||||
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
|
||||
"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)
|
||||
self.worker.connect(str(port), int(baud))
|
||||
with self.lock:
|
||||
self.vehicle_state.disconnect()
|
||||
self.state.update(
|
||||
connected=True, link="CONNECTING",
|
||||
status=f"Opening {port} at {baud} baud…",
|
||||
)
|
||||
return True
|
||||
self.worker.connect(str(port), int(baud))
|
||||
print(f"[web_ui] connect() dispatched to worker thread OK")
|
||||
# Return only once the worker has either seen a heartbeat or reported a
|
||||
# connection failure. This makes the button independent of the regular
|
||||
# UI polling loop and prevents an indefinite visual "Connecting" state.
|
||||
deadline = time.monotonic() + 8.0
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = self.poll()
|
||||
if snapshot["app_state"] != "connecting":
|
||||
return snapshot
|
||||
time.sleep(0.05)
|
||||
self.worker.disconnect()
|
||||
time.sleep(0.1)
|
||||
self.poll()
|
||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||
|
||||
def disconnect(self):
|
||||
self.worker.stop_motor_test()
|
||||
with self.lock:
|
||||
self.state["motor_test_active"] = False
|
||||
if self.worker.heavy_quality_test is not None:
|
||||
self.worker.cancel_heavy_quality_test()
|
||||
if self.demo_mode:
|
||||
self.demo_mode = False
|
||||
with self.lock:
|
||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||
armed=False, mode=None, battery=None, link_quality=None,
|
||||
status="Demo mode disconnected")
|
||||
return True
|
||||
self.worker.disconnect()
|
||||
return True
|
||||
|
||||
def set_axes(self, forward, turn, vertical):
|
||||
self.worker.set_axes(Axes(
|
||||
max(-1.0, min(1.0, float(forward))),
|
||||
0.0,
|
||||
max(-1.0, min(1.0, float(vertical))),
|
||||
max(-1.0, min(1.0, float(turn))),
|
||||
))
|
||||
def start_heavy_quality_test(self):
|
||||
if self.demo_mode:
|
||||
raise RuntimeError("The heavy link test requires a real flight-controller connection")
|
||||
return self.worker.start_heavy_quality_test()
|
||||
|
||||
def cancel_heavy_quality_test(self):
|
||||
return self.worker.cancel_heavy_quality_test()
|
||||
|
||||
def 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:
|
||||
self.state["status"] = (
|
||||
f"Demo controls • surge {float(forward):+.2f} • "
|
||||
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
|
||||
)
|
||||
return True
|
||||
with self.lock:
|
||||
allowed = self.vehicle_state.snapshot()["controls_allowed"]
|
||||
self.worker.set_axes(Axes(
|
||||
max(-1.0, min(1.0, float(forward))), 0.0,
|
||||
max(-1.0, min(1.0, float(vertical))),
|
||||
max(-1.0, min(1.0, float(turn))),
|
||||
) if allowed else Axes(), sequence)
|
||||
return True
|
||||
|
||||
def stop_controls(self, sequence=None):
|
||||
"""Invalidate queued input without claiming that the vehicle disarmed."""
|
||||
with self.lock:
|
||||
self.worker.set_axes(Axes(), sequence)
|
||||
self.worker._send_control(force_neutral=True)
|
||||
return True
|
||||
|
||||
def set_gain(self, value):
|
||||
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
|
||||
|
||||
def command(self, name):
|
||||
@staticmethod
|
||||
def _clean_motor_settings(settings):
|
||||
if not isinstance(settings, dict):
|
||||
raise ValueError("Motor settings must be an object")
|
||||
requested = settings.get("motors", {})
|
||||
if not isinstance(requested, dict):
|
||||
raise ValueError("Motor settings are invalid")
|
||||
motors = {}
|
||||
for motor in range(1, 13):
|
||||
entry = requested.get(str(motor), {})
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
name = str(entry.get("name", f"Motor {motor}")).strip()[:24] or f"Motor {motor}"
|
||||
try:
|
||||
scale = max(0, min(100, int(entry.get("scale", 20))))
|
||||
except (TypeError, ValueError):
|
||||
scale = 20
|
||||
motors[str(motor)] = {"name": name, "scale": scale}
|
||||
return {"independent": bool(settings.get("independent", False)), "customized": bool(settings.get("customized", False)), "motors": motors}
|
||||
|
||||
def save_motor_settings(self, settings, persist=True):
|
||||
clean = self._clean_motor_settings(settings)
|
||||
if persist:
|
||||
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||
self.motor_settings = clean
|
||||
self.worker.set_motor_scaling(
|
||||
clean["independent"],
|
||||
{int(i): entry["scale"] for i, entry in clean["motors"].items()},
|
||||
)
|
||||
return clean
|
||||
|
||||
def command(self, name, sequence=None, expires_at=None):
|
||||
if self.demo_mode:
|
||||
if name not in ("manual", "arm", "disarm"):
|
||||
raise ValueError(f"Unknown command: {name}")
|
||||
armed = name == "arm" if name != "manual" else self.state.get("armed", False)
|
||||
with self.lock:
|
||||
self.state.update(armed=armed, mode="MANUAL",
|
||||
vehicle=f"DEMO • {'ARMED' if armed else 'DISARMED'}",
|
||||
status=f"Demo: {'Manual mode' if name == 'manual' else name.capitalize()} confirmed")
|
||||
return True
|
||||
if not self.worker.connection or not self.worker.target_system:
|
||||
if name == "manual":
|
||||
raise RuntimeError("Connect to vehicle to enable Manual mode")
|
||||
elif name == "arm":
|
||||
raise RuntimeError("Connect to vehicle before arming")
|
||||
elif name == "disarm":
|
||||
raise RuntimeError("Connect to vehicle before disarming")
|
||||
raise RuntimeError("No autopilot heartbeat")
|
||||
if name in ("arm", "disarm"):
|
||||
self.poll()
|
||||
with self.lock:
|
||||
if name == "arm":
|
||||
if expires_at is not None and time.time() * 1000 > expires_at:
|
||||
raise RuntimeError("Engage request expired before it could be sent")
|
||||
if sequence is not None and sequence <= self.worker.last_input_sequence:
|
||||
raise RuntimeError("Engage canceled by a newer input reset")
|
||||
self.vehicle_state.request(name)
|
||||
self.worker.set_axes(Axes(), sequence)
|
||||
try:
|
||||
self.worker.command_arm(name == "arm")
|
||||
except Exception as exc:
|
||||
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
|
||||
raise
|
||||
return self._snapshot()
|
||||
if name == "manual":
|
||||
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
||||
self.worker.set_manual_mode()
|
||||
elif name == "arm":
|
||||
self.worker.log("OPERATOR", "clicked ARM")
|
||||
self.worker.command_arm(True)
|
||||
elif name == "disarm":
|
||||
self.worker.log("OPERATOR", "clicked DISARM")
|
||||
self.worker.command_arm(False)
|
||||
else:
|
||||
raise ValueError("Unknown command")
|
||||
raise ValueError(f"Unknown command: {name}")
|
||||
return True
|
||||
|
||||
def set_servo_function(self, servo, motor):
|
||||
@@ -219,6 +467,10 @@ class ControlApi:
|
||||
if not 1 <= servo <= 16 or not 0 <= motor <= 12:
|
||||
raise ValueError("Invalid servo or motor")
|
||||
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
|
||||
if self.demo_mode:
|
||||
self.servo_functions[servo] = function
|
||||
self._update_servo(servo)
|
||||
return True
|
||||
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
|
||||
return True
|
||||
|
||||
@@ -230,6 +482,10 @@ class ControlApi:
|
||||
current = self.motor_directions.get(motor)
|
||||
if current is None:
|
||||
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
|
||||
if self.demo_mode:
|
||||
self.motor_directions[motor] = -1 if current == 1 else 1
|
||||
self._update_servo(servo)
|
||||
return True
|
||||
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
|
||||
return True
|
||||
|
||||
@@ -248,8 +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):
|
||||
@@ -258,14 +540,20 @@ class ControlApi:
|
||||
os.startfile(path)
|
||||
return True
|
||||
|
||||
def read_console(self, limit=150):
|
||||
"""Return a bounded, read-only view of the local session console."""
|
||||
return self.worker.read_console(limit)
|
||||
|
||||
def poll(self):
|
||||
while True:
|
||||
try:
|
||||
event = self.events.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._consume(event)
|
||||
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5:
|
||||
# Bound a poll even if telemetry arrives continuously.
|
||||
with self._poll_lock:
|
||||
for _ in range(500):
|
||||
try:
|
||||
event = self.events.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._consume(event)
|
||||
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
|
||||
with self.lock:
|
||||
self.state["link"] = (
|
||||
"DATA OK / HEARTBEAT DELAYED"
|
||||
@@ -273,33 +561,69 @@ class ControlApi:
|
||||
)
|
||||
return self._snapshot()
|
||||
|
||||
def _derive_app_state(self) -> str:
|
||||
if not self.state.get("connected"):
|
||||
return "disconnected"
|
||||
link = self.state.get("link", "")
|
||||
if link == "CONNECTING":
|
||||
return "connecting"
|
||||
if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"):
|
||||
return "link_degraded"
|
||||
if self.state.get("armed"):
|
||||
return "armed"
|
||||
return "connected_disarmed"
|
||||
|
||||
def _snapshot(self):
|
||||
with self.lock:
|
||||
return json.loads(json.dumps(self.state))
|
||||
snap = json.loads(json.dumps(self.state))
|
||||
snap["motor_settings"] = self.motor_settings
|
||||
snap["app_state"] = self._derive_app_state()
|
||||
if not self.demo_mode:
|
||||
snap.update(self.vehicle_state.snapshot())
|
||||
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
|
||||
snap["app_state"] = "link_degraded"
|
||||
snap["battery_stale"] = (self.battery_received_at is None
|
||||
or time.monotonic() - self.battery_received_at > 5)
|
||||
return snap
|
||||
|
||||
def _consume(self, event):
|
||||
kind = event[0]
|
||||
with self.lock:
|
||||
if kind == "heartbeat":
|
||||
_, system, autopilot, mode, armed = event
|
||||
_, system, autopilot, mode, armed = event[:5]
|
||||
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
|
||||
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),
|
||||
mode=str(mode).upper(),
|
||||
)
|
||||
elif kind in ("status", "statustext"):
|
||||
self.state["status"] = event[1]
|
||||
elif kind == "ack":
|
||||
if event[1] == 400:
|
||||
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
|
||||
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
||||
elif kind == "battery":
|
||||
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("_")])
|
||||
@@ -316,9 +640,64 @@ class ControlApi:
|
||||
self.state["log_path"] = event[1]
|
||||
elif kind == "error":
|
||||
self.state["status"] = event[1]
|
||||
self.state["last_error"] = event[1]
|
||||
elif kind == "disconnected":
|
||||
self.vehicle_state.disconnect()
|
||||
last_err = self.state.get("last_error")
|
||||
status_msg = last_err if last_err else "Disconnected"
|
||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||
armed=False, battery=None, status="Disconnected")
|
||||
mode=None, link_quality=None, status=status_msg, 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)
|
||||
@@ -328,29 +707,119 @@ 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):
|
||||
self.worker.disconnect()
|
||||
|
||||
|
||||
def _apply_dwm_styling(window):
|
||||
try:
|
||||
hwnd = None
|
||||
if hasattr(window, "native") and window.native:
|
||||
hwnd = int(window.native.Handle.ToInt64())
|
||||
if hwnd:
|
||||
import ctypes
|
||||
user32 = ctypes.windll.user32
|
||||
dwmapi = ctypes.windll.dwmapi
|
||||
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
|
||||
dwmapi.DwmSetWindowAttribute(
|
||||
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
|
||||
)
|
||||
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
dwmapi.DwmSetWindowAttribute(
|
||||
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
|
||||
)
|
||||
user32.SetForegroundWindow(ctypes.c_void_p(hwnd))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
|
||||
target_w, target_h = 1100, 650
|
||||
min_w, min_h = 960, 580
|
||||
try:
|
||||
import ctypes
|
||||
user32 = ctypes.windll.user32
|
||||
sw = user32.GetSystemMetrics(0)
|
||||
sh = user32.GetSystemMetrics(1)
|
||||
if sw > 0 and sh > 0:
|
||||
target_w = min(target_w, max(min_w, int(sw * 0.92)))
|
||||
target_h = min(target_h, max(min_h, int(sh * 0.90)))
|
||||
min_w = min(min_w, target_w)
|
||||
min_h = min(min_h, target_h)
|
||||
except Exception:
|
||||
pass
|
||||
return target_w, target_h, (min_w, min_h)
|
||||
|
||||
|
||||
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress HTTP request logs in console
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("Expires", "0")
|
||||
super().end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
unquoted = urllib.parse.unquote(self.path)
|
||||
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
|
||||
project_root = Path(__file__).resolve().parent
|
||||
model_file = project_root / "assets" / "Main Assembly V1 (2).stl"
|
||||
if not model_file.is_file():
|
||||
model_file = project_root / "runtime" / "vehicle_model.stl"
|
||||
if model_file.is_file():
|
||||
data = model_file.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "model/stl")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
return super().do_GET()
|
||||
|
||||
|
||||
def start_local_server(directory: Path) -> int:
|
||||
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
|
||||
server = socketserver.TCPServer(("127.0.0.1", 0), handler)
|
||||
port = server.server_address[1]
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
return port
|
||||
|
||||
|
||||
def main():
|
||||
if os.name == "nt":
|
||||
# Keep the running window separate from generic Python taskbar groups.
|
||||
import ctypes
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("SUBControl.Desktop")
|
||||
from desktop_shortcut import ensure_desktop_shortcut
|
||||
ensure_desktop_shortcut()
|
||||
api = ControlApi()
|
||||
ui_path = Path(__file__).resolve().parent / "web" / "index.html"
|
||||
project_root = Path(__file__).resolve().parent
|
||||
port = start_local_server(project_root)
|
||||
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
|
||||
win_w, win_h, min_dims = _get_window_size()
|
||||
window = webview.create_window(
|
||||
"SUB Control", url=str(ui_path), js_api=api,
|
||||
width=1120, height=760, min_size=(900, 650),
|
||||
"SUB Control", url=app_url, js_api=api,
|
||||
width=win_w, height=win_h, min_size=min_dims,
|
||||
background_color="#111719", frameless=True, easy_drag=False,
|
||||
shadow=True,
|
||||
)
|
||||
api._window = window
|
||||
window.events.shown += lambda: _apply_dwm_styling(window)
|
||||
window.events.maximized += lambda: setattr(api, "maximized", True)
|
||||
window.events.restored += lambda: setattr(api, "maximized", False)
|
||||
try:
|
||||
webview.start(debug=False)
|
||||
icon_path = project_root / "assets" / "sub-control.ico"
|
||||
webview.start(debug=True, icon=str(icon_path) if icon_path.is_file() else None)
|
||||
finally:
|
||||
api.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user