Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ccd7689f7 |
@@ -6,3 +6,7 @@ model_settings.json
|
|||||||
link_settings.json
|
link_settings.json
|
||||||
runtime/
|
runtime/
|
||||||
motor_settings.json
|
motor_settings.json
|
||||||
|
|
||||||
|
station_settings.json
|
||||||
|
controller_profiles.json
|
||||||
|
*.json.tmp
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# SUB Control
|
# Argo V2 · SUB Control
|
||||||
|
|
||||||
A lightweight Windows controller for ArduSub ROVs over USB/MAVLink.
|
A Windows control station for ArduSub ROVs over serial or network MAVLink.
|
||||||
|
|
||||||

|
V2 adds programmable Xbox inputs, six-axis motion, a camera dashboard, sensor readings and data logging. See the [V2 guide](docs/V2.md) for controls, supported frames, Raspberry Pi/fibre architecture, camera setup and hardware verification limits.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -26,12 +28,12 @@ Keep UI changes minimal and consistent with the existing app. Do not add explana
|
|||||||
|
|
||||||
- Joystick: forward/back and turning. Vertical slider: rise/dive.
|
- Joystick: forward/back and turning. Vertical slider: rise/dive.
|
||||||
- Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** 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.
|
- Physical controller: choose **Joystick**, then **Mapping** while disarmed. The trigger preset uses RT/LT for forward/back, B/A for rise/dive, and left stick X for turning. **Six axes** adds strafe, pitch and roll for compatible frames/firmware.
|
||||||
- Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
|
- Hold **engage** for 0.6 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
|
||||||
- Movement controls ease back to neutral when released.
|
- 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.
|
- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle.
|
||||||
|
|
||||||
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
|
Includes a 0–100% input limit, motor setup, live battery current/consumption when available, an STL viewer, WebRTC/HTTP camera viewing and WebM recording. Sensor CSV and session logs are in `logs/`; desktop videos are in `runtime/recordings/`.
|
||||||
|
|
||||||
## Safety
|
## Safety
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ logs/
|
|||||||
model_settings.json
|
model_settings.json
|
||||||
link_settings.json
|
link_settings.json
|
||||||
runtime/
|
runtime/
|
||||||
|
motor_settings.json
|
||||||
|
|
||||||
|
station_settings.json
|
||||||
|
controller_profiles.json
|
||||||
|
*.json.tmp
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# SUB Control
|
# Argo V2 · SUB Control
|
||||||
|
|
||||||
A lightweight Windows controller for ArduSub ROVs over USB/MAVLink.
|
A Windows control station for ArduSub ROVs over serial or network MAVLink.
|
||||||
|
|
||||||

|
V2 adds programmable Xbox inputs, six-axis motion, a camera dashboard, sensor readings and data logging. See the [V2 guide](docs/V2.md) for controls, supported frames, Raspberry Pi/fibre architecture, camera setup and hardware verification limits.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -26,12 +28,12 @@ Keep UI changes minimal and consistent with the existing app. Do not add explana
|
|||||||
|
|
||||||
- Joystick: forward/back and turning. Vertical slider: rise/dive.
|
- Joystick: forward/back and turning. Vertical slider: rise/dive.
|
||||||
- Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** 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.
|
- Physical controller: choose **Joystick**, then **Mapping** while disarmed. The trigger preset uses RT/LT for forward/back, B/A for rise/dive, and left stick X for turning. **Six axes** adds strafe, pitch and roll for compatible frames/firmware.
|
||||||
- Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
|
- Hold **engage** for 0.6 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
|
||||||
- Movement controls ease back to neutral when released.
|
- 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.
|
- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle.
|
||||||
|
|
||||||
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
|
Includes a 0–100% input limit, motor setup, live battery current/consumption when available, an STL viewer, WebRTC/HTTP camera viewing and WebM recording. Sensor CSV and session logs are in `logs/`; desktop videos are in `runtime/recordings/`.
|
||||||
|
|
||||||
## Safety
|
## Safety
|
||||||
|
|
||||||
|
|||||||
+89
-815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
|||||||
|
# V2 verification — 22 October 2026
|
||||||
|
|
||||||
|
## Passing checks
|
||||||
|
|
||||||
|
- 41 Python tests: vehicle-state confirmation, control leases/watchdogs, six-axis packet values, unsupported firmware/frame gating, endpoint handling, telemetry conversion/staleness, persisted names/profiles, recording chunks, and relay acknowledgement.
|
||||||
|
- `node tests/controller_v2.cjs`: synthetic Xbox triggers, B/A direction, opposing input cancellation, six axes, custom buttons, limits, invalid/missing inputs, migration, and actions.
|
||||||
|
- `node tests/controller_input.cjs`: existing stick conditioning and console parser checks.
|
||||||
|
- Python compilation, JavaScript syntax checks, and `git diff --check`.
|
||||||
|
|
||||||
|
No tests opened a real COM port or armed a physical vehicle.
|
||||||
|
|
||||||
|
## Rendered browser checks
|
||||||
|
|
||||||
|
Used the supported local in-app browser with the real app assets in Demo Mode:
|
||||||
|
|
||||||
|
- Mapping dialog: three-axis and six-axis presets, saving, reopening after a full page reload, and retained LB/RB roll bindings.
|
||||||
|
- Vehicle settings: saved BlueROV1 six-thruster preview, independent accessory relay selectors, and consistent FC/demo status.
|
||||||
|
- Drive: all six command controls at 1024×768, 960×580 (the native window minimum), and 700×572; no overlap with the output toolbar.
|
||||||
|
- Active-output list: six rows for the six-thruster preview, with scrolling and reversal buttons. General frames disable the SimpleROV-3-specific independent-scale mode.
|
||||||
|
- Engagement: held F2 in Demo Mode, inspected rendered **engaging**, then **disengaging** and their settled states. Both words are spelled correctly, centered, and have no trailing dots.
|
||||||
|
- Dashboard: camera-off state, active simulated inspection view, missing/offline readings, explicit Demo labeling, sensor values and video controls at desktop and compact sizes.
|
||||||
|
- Recording: started/stopped a canvas preview recording; the browser produced a 1,386,065-byte WebM file.
|
||||||
|
- Accessories: demo lights toggle, camera power toggle, and viewing stops after demo power-off.
|
||||||
|
|
||||||
|
Reduced-motion CSS is included. The connected browser offers viewport testing but no media-preference emulation, so a rendered reduced-motion override was not verified.
|
||||||
|
|
||||||
|
## Existing harness limitation
|
||||||
|
|
||||||
|
The older `tests/control_ui.test.cjs` fake-DOM harness is not passing. It reports 23 failures on the saved pre-V2 checkpoint and 21 on V2. Most fail before app initialization because the harness lacks `ResizeObserver`; it also has obsolete markup/animation assertions and omits browser dependencies. It is not counted as a passing check. Actual browser checks above establish the rendered behavior of this implementation.
|
||||||
|
|
||||||
|
## Startup measurement
|
||||||
|
|
||||||
|
Three fresh-process worker-module imports on this laptop:
|
||||||
|
|
||||||
|
| Build | Samples | Median |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pre-V2 checkpoint | 781.8 / 616.3 / 655.3 ms | 655.3 ms |
|
||||||
|
| V2 | 402.8 / 362.4 / 399.1 ms | 399.1 ms |
|
||||||
|
|
||||||
|
This measures the module import, not total app startup. The launcher also avoids pip on subsequent launches when requirements have not changed, and starts the web interface directly.
|
||||||
|
|
||||||
|
## Physical verification still required
|
||||||
|
|
||||||
|
Real Xbox hardware, live six-axis FC motor allocation, live sensor calibration, WHEP video from a Raspberry Pi, native WebView full-video recording, physical relay outputs, and tether/underwater behavior were not verified. The browser recording and native chunk-writing path were checked separately. BlueOS native camera discovery/signaling is not integrated; this release uses an explicit WHEP, HTTP video, or MJPEG URL.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Argo V2
|
||||||
|
|
||||||
|
V2 separates controller mapping, vehicle motion, ArduSub motor allocation, and camera transport. The pre-V2 app is preserved in commit `5e91c28` on `main`; development is on `v2`. A portable Git bundle was also saved locally. The checkpoint is also uploaded as `checkpoint/pre-v2-20261022`.
|
||||||
|
|
||||||
|
## Try it
|
||||||
|
|
||||||
|
1. Run `run.ps1`, select **Demo Mode**, and connect.
|
||||||
|
2. Open **Vehicle**, select a **Preview frame**, and save. This changes the demo, never the connected FC's `FRAME_TYPE`.
|
||||||
|
3. Open **Mapping**, choose **Triggers · 3** or **Six axes**, and save.
|
||||||
|
4. Open **Dashboard** and press **Start** to see the simulated inspection camera. **Record** produces a WebM clip. The camera badge and sensor status identify demo data.
|
||||||
|
|
||||||
|
No physical port is opened in Demo Mode. Real TCP and UDP endpoints now connect to MAVLink instead of silently entering the demo.
|
||||||
|
|
||||||
|
## Xbox controls
|
||||||
|
|
||||||
|
| Motion | Triggers · 3 | Six axes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Forward / back | RT / LT | RT / LT |
|
||||||
|
| Rise / dive | B / A | B / A |
|
||||||
|
| Turn | Left stick X | Left stick X |
|
||||||
|
| Sideways | Unassigned | Right stick X |
|
||||||
|
| Pitch | Unassigned | Left stick Y |
|
||||||
|
| Roll | Unassigned | RB / LB |
|
||||||
|
| Lights | X | X |
|
||||||
|
| View stream | Y | Y |
|
||||||
|
| Record | View | View |
|
||||||
|
|
||||||
|
Each motion has separate negative and positive bindings. Either direction can use a stick half, an analog trigger, a D-pad direction, or a button. Opposite commands cancel. Each motion has a limit; the profile also has dead zone and exponential response. The reverse arrow swaps its bindings. The **Buttons** disclosure configures optional accessory and media actions. Buttons do not arm the ROV.
|
||||||
|
|
||||||
|
Profiles are stored by controller ID in `controller_profiles.json`; **Xbox · offline** supplies a default before connecting a controller. Browser preview uses local storage. Existing stick mappings are migrated. Configuration is locked while armed or while the FC state is uncertain. Controls must return to neutral after input mode changes, reconnects, focus changes, or interruption. Accessory buttons also have to be released before they can trigger again.
|
||||||
|
|
||||||
|
Keyboard movement remains W/S, A/D (or Q/E), R/F. Additional axes use J/L for strafe, I/K for pitch, U/O for roll. The extra on-screen sliders return to center when released. F2 retains the 0.6-second engage hold; F3 requests disarm.
|
||||||
|
|
||||||
|
## Six thrusters and six axes
|
||||||
|
|
||||||
|
Your proposed arrangement can provide full control when the paired thrusters have useful moment arms:
|
||||||
|
|
||||||
|
| Pair | Shared thrust | Differential thrust |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Port / starboard, pointing forward | Surge | Yaw |
|
||||||
|
| Front / rear, pointing vertically | Heave | Pitch |
|
||||||
|
| Top / bottom, pointing sideways | Sway | Roll |
|
||||||
|
|
||||||
|
Translation uses the pair in the same direction; rotation uses opposite directions. Thruster directions, lever arms, balance and saturation still determine the result. The allocation matrix, whose columns are force direction and its moment `r × f`, must have rank six. Motor count alone does not establish this.
|
||||||
|
|
||||||
|
V2 sends body-motion demands to the FC. The autopilot owns its mixer and stabilization. Select/configure a firmware frame that matches the physical vehicle before using it. The app reads `FRAME_TYPE`; it does not infer a mixer from connected outputs or write a new frame automatically. Your orthogonal-pair design may require a custom firmware mixer if no stock frame matches its geometry.
|
||||||
|
|
||||||
|
| ArduSub frame | Motors | Available motion |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SimpleROV-3 (4) | 3 | Forward, vertical, yaw |
|
||||||
|
| SimpleROV-4 (5) | 4 | Forward, vertical, yaw, roll |
|
||||||
|
| SimpleROV-5 (6) | 5 | Forward, lateral, vertical, yaw, roll |
|
||||||
|
| BlueROV1 (0) | 6 | All six axes |
|
||||||
|
| Vectored (1) | 6 | All except pitch |
|
||||||
|
| Vectored 6DOF (2, 3) | 8 | All six axes |
|
||||||
|
|
||||||
|
The verified six-axis wire mapping is ArduSub's `MANUAL_CONTROL`: X forward, Y lateral, Z vertical (500 neutral), R yaw, S pitch, T roll. Some generic joystick documentation describes S/T differently; this implementation follows ArduSub source. Firmware version is requested at connection; pitch/roll remain unavailable until compatible firmware is identified. ArduSub 4.5 uses S/T as ±400 PWM offsets; the current 4.8 development implementation scales normalized S/T. These encodings have automated checks; physical behavior is unverified.
|
||||||
|
|
||||||
|
The shared output limit applies to all motion axes. Full-frame demands use a conservative shared envelope before the FC mixer. This is an input-demand limit, not a calibrated thrust or electrical-current limit. The earlier independent motor-scale reconstruction is specific to SimpleROV-3 and is unavailable on other frames; applying it to a general mixer would be incorrect. Motor names, assignments, and direction controls remain available for active outputs.
|
||||||
|
|
||||||
|
References: [ArduSub frame configurations](https://ardupilot.org/sub/docs/sub-frames.html), [ArduSub 4.5 joystick source](https://github.com/ArduPilot/ardupilot/blob/Sub-4.5/ArduSub/joystick.cpp), [current joystick source](https://github.com/ArduPilot/ardupilot/blob/master/ArduSub/joystick.cpp), [Cockpit mapping and vehicle-version support](https://blueos.cloud/cockpit/docs/latest/usage/advanced/).
|
||||||
|
|
||||||
|
## Companion computer and tether
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
PC[Argo V2 on Windows] <-- Ethernet --> TOP[Topside media converter]
|
||||||
|
TOP <-- Fibre tether --> ROV[Onboard media converter]
|
||||||
|
ROV <-- Ethernet --> PI[Raspberry Pi / BlueOS]
|
||||||
|
PI <-- USB or UART / MAVLink --> FC[ArduSub FC]
|
||||||
|
CAM[USB or CSI camera] --> PI
|
||||||
|
SENSOR[Pressure and power sensors] --> FC
|
||||||
|
FC --> ESC[ESCs / thrusters]
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a proposed architecture, not installed hardware. Use a compatible Ethernet-to-fibre interface at each end, with optics, cable, terminations and housings selected for the tether length and environment. Fibre carries the data link; onboard power or a separate power path is still required. Ethernet lets telemetry and compressed video share a network while remaining separate application streams.
|
||||||
|
|
||||||
|
BlueOS is the researched companion option. It routes the FC's MAVLink traffic and manages cameras on supported Raspberry Pi hardware. In BlueOS, configure a MAVLink UDP output to the topside computer's tether-interface IP on port 14550. In Argo, choose **Network · UDP 14550**, or enter an explicit endpoint in Link settings. The listener uses `udpin:0.0.0.0:14550`; choose an interface-specific address with a custom endpoint if needed. The PC and Pi must be reachable on the same tether network.
|
||||||
|
|
||||||
|
Video uses an independent browser media connection. This release accepts **WebRTC · WHEP**, a browser-playable **HTTP video** URL, or **MJPEG**. It does not directly decode RTSP or raw UDP H.264. BlueOS's camera service has its own signaling interface, so entering its normal web-dashboard address as a WHEP URL will not work. Use a compatible WHEP endpoint, such as MediaMTX connected to the Pi camera or an RTSP source. Integrating BlueOS's native camera discovery/signaling is a separate follow-up.
|
||||||
|
|
||||||
|
Example MediaMTX path for a supported Pi camera:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
paths:
|
||||||
|
cam:
|
||||||
|
source: rpiCamera
|
||||||
|
```
|
||||||
|
|
||||||
|
With the server configured on the tether network, a typical WHEP endpoint is `http://<pi-address>:8889/cam/whep`. Verify it against your MediaMTX configuration. The browser needs CORS permission for the local Argo origin, and the negotiated WebRTC media ports must be reachable. Use a browser-compatible codec. No firewall, authentication, or origin policy is changed automatically by Argo.
|
||||||
|
|
||||||
|
**Start/Stop** controls viewing. **Camera power** is a separate optional relay action. **Record** records the incoming browser video; desktop clips are written incrementally to `runtime/recordings/`, and browser-preview clips download as WebM. MJPEG playback has no video recording control. For recordings that must survive tether loss, configure recording on the Pi as well.
|
||||||
|
|
||||||
|
References: [BlueOS companion hardware](https://blueos.cloud/docs/latest/integrations/hardware/required/onboard-computer/), [tether overview](https://blueos.cloud/docs/latest/integrations/hardware/required/tether/), [BlueOS endpoints and video services](https://blueos.cloud/docs/latest/usage/advanced/), [MediaMTX Pi cameras](https://mediamtx.org/docs/static-sources/raspberry-pi-cameras), [browser playback](https://mediamtx.org/docs/read/web-browsers).
|
||||||
|
|
||||||
|
## Sensors and electricity
|
||||||
|
|
||||||
|
The dashboard displays FC telemetry only when its group is fresh. Missing or stale values show a dash. Demo values are explicitly marked.
|
||||||
|
|
||||||
|
| Reading | Source | Units |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pitch, roll, heading and angular rates | ATTITUDE | degrees, degrees/s |
|
||||||
|
| Depth estimate relative to surface/home | GLOBAL_POSITION_INT.relative_alt | m, positive down |
|
||||||
|
| Acceleration, gyroscope, magnetic field | SCALED_IMU | m/s², rad/s, gauss |
|
||||||
|
| External pressure / temperature | SCALED_PRESSURE2 | hPa, °C |
|
||||||
|
| Battery voltage, current and charge | SYS_STATUS / battery 0 BATTERY_STATUS | V, A, mAh |
|
||||||
|
|
||||||
|
Depth needs a correctly configured and calibrated external pressure sensor. Raw accelerometer values include gravity; they are not speed or a gravity-compensated acceleration estimate. Heading is FC-estimated yaw, not a separately read compass bearing.
|
||||||
|
|
||||||
|
Current is measured in **amps (A)** at the present moment. Power is **watts (W) = V × A**. Consumption is charge integrated over time, displayed as **mAh**; it is not amps per second. Use a correctly sized, calibrated voltage/current power monitor connected to the FC. Voltage alone cannot measure current. The app prefers reported consumption and otherwise integrates fresh current as a session estimate; source information is in Battery settings. A voltage update does not make an old current reading fresh.
|
||||||
|
|
||||||
|
**Log data** writes received normalized sensor values to `logs/sensors_<timestamp>.csv`. Existing raw MAVLink session logs remain in `logs/`. Rates for attitude, depth, IMU and pressure are independently configurable. Begin with low rates on RS-485; use a higher-bandwidth tether for video and increased telemetry.
|
||||||
|
|
||||||
|
Lights and camera power require configured, separate FC relay outputs. Configure relay assignments on the FC, then select their zero-based relay numbers in **Vehicle**. Buttons wait for command acknowledgement and expose timeout/rejection status. An accepted command confirms FC acceptance, not measured physical illumination or camera power.
|
||||||
|
|
||||||
|
Reference: [ArduSub battery monitor calibration](https://ardupilot.org/sub/docs/common-power-module-configuration-in-mission-planner.html), [MAVLink message definitions](https://mavlink.io/en/messages/common.html).
|
||||||
|
|
||||||
|
## Performance and verification
|
||||||
|
|
||||||
|
- The launcher only installs dependencies when the requirements fingerprint changes.
|
||||||
|
- The production path launches `web_ui.py` directly. The old Tk frontend is loaded lazily from `legacy_ui.py`.
|
||||||
|
- The asset server is threaded. Video frames stay in the browser's media pipeline.
|
||||||
|
- Network MAVLink skips the redundant packet-leading heartbeat needed by the existing serial modules.
|
||||||
|
- V2 controller input has one command ramp in the worker, avoiding the previous additional browser smoothing delay.
|
||||||
|
- V2 controller-profile and station files are validated and replaced atomically. Existing input sequence, stale-bridge and heartbeat watchdogs cover all six motion axes.
|
||||||
|
|
||||||
|
Automated checks use synthetic gamepads, mock MAVLink transports, temporary configuration files and telemetry fixtures. Browser checks use Demo Mode and the actual rendered app. No live vehicle has been armed or commanded during development. Real controller hardware, Pi streaming, FC accessories, sensor calibration and water performance still need physical verification.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,814 @@
|
|||||||
|
"""Optional Tk frontend. Imported only when explicitly requested."""
|
||||||
|
import queue
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import tkinter as tk
|
||||||
|
import customtkinter as ctk
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import messagebox, ttk
|
||||||
|
from serial.tools import list_ports
|
||||||
|
from app import Axes, MavlinkWorker, MOTOR_FUNCTIONS, NEUTRAL_Z, normalize_endpoint
|
||||||
|
|
||||||
|
class Joystick(tk.Canvas):
|
||||||
|
def __init__(self, master, callback, **kwargs):
|
||||||
|
self.size = 276
|
||||||
|
self.center_point = self.size / 2
|
||||||
|
self.radius = 108
|
||||||
|
self.knob_radius = 24
|
||||||
|
super().__init__(master, width=self.size, height=self.size, bg="#f5f5f2",
|
||||||
|
highlightthickness=0, bd=0, **kwargs)
|
||||||
|
self.callback = callback
|
||||||
|
self.value = (0.0, 0.0)
|
||||||
|
self.return_job = None
|
||||||
|
c, r, k = self.center_point, self.radius, self.knob_radius
|
||||||
|
self.create_oval(c-r, c-r, c+r, c+r, fill="#e9e9e5", outline="#d8d8d2", width=2)
|
||||||
|
self.create_oval(c-70, c-70, c+70, c+70, outline="#deded8", width=1)
|
||||||
|
self.create_line(c, c-r+12, c, c+r-12, fill="#d2d2cc", dash=(3, 6))
|
||||||
|
self.create_line(c-r+12, c, c+r-12, c, fill="#d2d2cc", dash=(3, 6))
|
||||||
|
self.create_text(c, 13, text="FORWARD", fill="#8a8a84", font=("Helvetica", 8, "bold"))
|
||||||
|
self.create_text(c, self.size-13, text="REVERSE", fill="#8a8a84", font=("Helvetica", 8, "bold"))
|
||||||
|
self.create_text(25, c, text="TURN\nLEFT", fill="#8a8a84", font=("Helvetica", 8, "bold"))
|
||||||
|
self.create_text(self.size-25, c, text="TURN\nRIGHT", fill="#8a8a84", font=("Helvetica", 8, "bold"))
|
||||||
|
self.knob = self.create_oval(c-k, c-k, c+k, c+k,
|
||||||
|
fill="#575c61", outline="#42474b", width=2)
|
||||||
|
self.bind("<Button-1>", self._move)
|
||||||
|
self.bind("<B1-Motion>", self._move)
|
||||||
|
self.bind("<ButtonRelease-1>", self._release)
|
||||||
|
|
||||||
|
def _move(self, event):
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
self.return_job = None
|
||||||
|
dx, dy = event.x - self.center_point, event.y - self.center_point
|
||||||
|
length = max(1.0, (dx * dx + dy * dy) ** 0.5)
|
||||||
|
if length > self.radius:
|
||||||
|
dx, dy = dx * self.radius / length, dy * self.radius / length
|
||||||
|
self.set_immediate(dx / self.radius, -dy / self.radius)
|
||||||
|
|
||||||
|
def set_immediate(self, x: float, y: float):
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
self.return_job = None
|
||||||
|
self.value = (x, y)
|
||||||
|
c, k = self.center_point, self.knob_radius
|
||||||
|
dx, dy = x * self.radius, -y * self.radius
|
||||||
|
self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy)
|
||||||
|
self.callback()
|
||||||
|
|
||||||
|
def _release(self, _event=None):
|
||||||
|
start_x, start_y = self.value
|
||||||
|
started = time.monotonic()
|
||||||
|
duration = 0.40
|
||||||
|
|
||||||
|
def ease_back():
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
progress = min(1.0, elapsed / duration)
|
||||||
|
# Smoothstep has zero slope at both ends, avoiding a motor kick.
|
||||||
|
eased = progress * progress * (3.0 - 2.0 * progress)
|
||||||
|
remaining = 1.0 - eased
|
||||||
|
x, y = start_x * remaining, start_y * remaining
|
||||||
|
self.value = (x, y)
|
||||||
|
c, k = self.center_point, self.knob_radius
|
||||||
|
dx, dy = x * self.radius, -y * self.radius
|
||||||
|
self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy)
|
||||||
|
self.callback()
|
||||||
|
if progress < 1.0:
|
||||||
|
self.return_job = self.after(20, ease_back)
|
||||||
|
else:
|
||||||
|
self.return_job = None
|
||||||
|
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
ease_back()
|
||||||
|
|
||||||
|
def center(self):
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
self.return_job = None
|
||||||
|
self.value = (0.0, 0.0)
|
||||||
|
c, k = self.center_point, self.knob_radius
|
||||||
|
self.coords(self.knob, c-k, c-k, c+k, c+k)
|
||||||
|
self.callback()
|
||||||
|
|
||||||
|
|
||||||
|
class SpringScale(ctk.CTkSlider):
|
||||||
|
"""A Scale that smoothly returns to zero when the pointer is released."""
|
||||||
|
|
||||||
|
def __init__(self, master, **kwargs):
|
||||||
|
self.callback = kwargs.pop("command", None)
|
||||||
|
kwargs.pop("showvalue", None)
|
||||||
|
kwargs.pop("bg", None)
|
||||||
|
kwargs.pop("troughcolor", None)
|
||||||
|
kwargs.pop("activebackground", None)
|
||||||
|
kwargs.pop("highlightthickness", None)
|
||||||
|
kwargs.pop("bd", None)
|
||||||
|
kwargs.pop("sliderrelief", None)
|
||||||
|
if "orient" in kwargs:
|
||||||
|
kwargs["orientation"] = kwargs.pop("orient")
|
||||||
|
length = kwargs.pop("length", 200)
|
||||||
|
if kwargs.get("orientation", "horizontal") == "vertical":
|
||||||
|
kwargs.setdefault("height", length)
|
||||||
|
kwargs.setdefault("width", 18)
|
||||||
|
else:
|
||||||
|
kwargs.setdefault("width", length)
|
||||||
|
kwargs.setdefault("height", 18)
|
||||||
|
super().__init__(master, command=self._changed, corner_radius=10,
|
||||||
|
button_corner_radius=10, button_length=22,
|
||||||
|
fg_color="#dbe3ee", progress_color="#60a5fa",
|
||||||
|
button_color="#2563eb", button_hover_color="#1d4ed8",
|
||||||
|
**kwargs)
|
||||||
|
self.return_job = None
|
||||||
|
self.bind("<ButtonPress-1>", self._cancel_return, add="+")
|
||||||
|
self.bind("<ButtonRelease-1>", self._released, add="+")
|
||||||
|
|
||||||
|
def _changed(self, value):
|
||||||
|
if self.callback:
|
||||||
|
self.callback(value)
|
||||||
|
|
||||||
|
def _set_and_notify(self, value):
|
||||||
|
self.set(value)
|
||||||
|
if self.callback:
|
||||||
|
self.callback(value)
|
||||||
|
|
||||||
|
def _cancel_return(self, _event=None):
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
self.return_job = None
|
||||||
|
|
||||||
|
def _released(self, _event=None):
|
||||||
|
# Wait until Tk's native Scale binding has applied its final value.
|
||||||
|
self.after_idle(self.spring_to_center)
|
||||||
|
|
||||||
|
def set_immediate(self, value):
|
||||||
|
self._cancel_return()
|
||||||
|
self._set_and_notify(value)
|
||||||
|
|
||||||
|
def spring_to_center(self):
|
||||||
|
self._cancel_return()
|
||||||
|
start = float(self.get())
|
||||||
|
if abs(start) < 0.5:
|
||||||
|
self._set_and_notify(0)
|
||||||
|
return
|
||||||
|
started = time.monotonic()
|
||||||
|
duration = 0.40
|
||||||
|
|
||||||
|
def ease_back():
|
||||||
|
progress = min(1.0, (time.monotonic() - started) / duration)
|
||||||
|
eased = progress * progress * (3.0 - 2.0 * progress)
|
||||||
|
self._set_and_notify(start * (1.0 - eased))
|
||||||
|
if progress < 1.0:
|
||||||
|
self.return_job = self.after(20, ease_back)
|
||||||
|
else:
|
||||||
|
self._set_and_notify(0)
|
||||||
|
self.return_job = None
|
||||||
|
|
||||||
|
ease_back()
|
||||||
|
|
||||||
|
|
||||||
|
class App(tk.Tk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("SUB Control")
|
||||||
|
self.geometry("1040x800")
|
||||||
|
self.minsize(920, 720)
|
||||||
|
self.events = queue.Queue()
|
||||||
|
self.worker = MavlinkWorker(self.events)
|
||||||
|
self.keys = set()
|
||||||
|
self.keyboard_yaw = 0
|
||||||
|
self.keyboard_vertical = 0
|
||||||
|
self.keyboard_planar = (0, 0)
|
||||||
|
self.servo_functions = {}
|
||||||
|
self.motor_directions = {}
|
||||||
|
self.battery_config_path = Path(__file__).resolve().parent / "battery_settings.json"
|
||||||
|
self.battery_config = {
|
||||||
|
"cells": 4,
|
||||||
|
"capacity_mah": 5500,
|
||||||
|
"c_rating": 35,
|
||||||
|
"full_v_per_cell": 4.20,
|
||||||
|
"empty_v_per_cell": 3.30,
|
||||||
|
"warning_percent": 20,
|
||||||
|
}
|
||||||
|
self.battery_voltage = None
|
||||||
|
self.battery_voltage_smoothed = None
|
||||||
|
self._load_battery_settings()
|
||||||
|
self._configure_styles()
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self.close)
|
||||||
|
self._build()
|
||||||
|
self.refresh_ports()
|
||||||
|
self.bind_all("<KeyPress>", self._key_down)
|
||||||
|
self.bind_all("<KeyRelease>", self._key_up)
|
||||||
|
self.after(50, self._poll)
|
||||||
|
self.after(500, self._link_watchdog)
|
||||||
|
|
||||||
|
def _configure_styles(self):
|
||||||
|
self.configure(bg="#e8e9e7")
|
||||||
|
style = ttk.Style(self)
|
||||||
|
try:
|
||||||
|
style.theme_use("clam")
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
style.configure("TFrame", background="#fbfbf9")
|
||||||
|
style.configure("TLabel", background="#fbfbf9", foreground="#202224",
|
||||||
|
font=("Helvetica", 9))
|
||||||
|
style.configure("TLabelframe", background="#f4f6f8", bordercolor="#d8dee8",
|
||||||
|
borderwidth=1, relief="solid")
|
||||||
|
style.configure("TLabelframe.Label", background="#f4f6f8", foreground="#475569",
|
||||||
|
font=("Segoe UI", 9, "bold"))
|
||||||
|
style.configure("TButton", font=("Segoe UI", 9), padding=(10, 7),
|
||||||
|
background="#ffffff", foreground="#1e293b", bordercolor="#cbd5e1")
|
||||||
|
style.map("TButton", background=[("active", "#e8eef7")])
|
||||||
|
style.configure("Primary.TButton", background="#2563eb", foreground="#ffffff",
|
||||||
|
bordercolor="#2563eb", font=("Segoe UI", 9, "bold"))
|
||||||
|
style.map("Primary.TButton", background=[("active", "#1d4ed8")])
|
||||||
|
style.configure("Danger.TButton", background="#dc2626", foreground="#ffffff",
|
||||||
|
bordercolor="#dc2626", font=("Segoe UI", 9, "bold"))
|
||||||
|
style.map("Danger.TButton", background=[("active", "#b91c1c")])
|
||||||
|
style.configure("Title.TLabel", font=("Segoe UI", 18, "bold"), foreground="#0f172a")
|
||||||
|
style.configure("Subtitle.TLabel", font=("Segoe UI", 9), foreground="#64748b")
|
||||||
|
style.configure("Status.TLabel", font=("Segoe UI", 9, "bold"))
|
||||||
|
style.configure("Footer.TLabel", background="#e9eef5", foreground="#475569",
|
||||||
|
font=("Segoe UI", 9))
|
||||||
|
style.configure("Treeview", rowheight=28, background="#ffffff", fieldbackground="#ffffff",
|
||||||
|
foreground="#26282a", bordercolor="#e1e1dc", font=("Helvetica", 9))
|
||||||
|
style.configure("Treeview.Heading", font=("Helvetica", 9, "bold"), padding=(6, 6),
|
||||||
|
background="#f0f0ed", foreground="#4c4f52")
|
||||||
|
style.map("Treeview", background=[("selected", "#dbeafe")],
|
||||||
|
foreground=[("selected", "#1e3a8a")])
|
||||||
|
style.configure("Horizontal.TProgressbar", troughcolor="#e2e8f0", background="#22c55e",
|
||||||
|
bordercolor="#e2e8f0", lightcolor="#22c55e", darkcolor="#22c55e")
|
||||||
|
|
||||||
|
def _console_button(self, master, text, command, kind="neutral", width=112):
|
||||||
|
colors = {
|
||||||
|
"neutral": ("#f4f4f1", "#e9e9e4", "#343739", "#deded8"),
|
||||||
|
"primary": ("#dcebd5", "#cde2c4", "#27401f", "#c4dcb9"),
|
||||||
|
"danger": ("#f7dfdc", "#f1cfcb", "#7b3029", "#edcec9"),
|
||||||
|
}
|
||||||
|
color, hover, text_color, border = colors[kind]
|
||||||
|
return ctk.CTkButton(
|
||||||
|
master, text=text, command=command, width=width, height=34,
|
||||||
|
corner_radius=11, fg_color=color, hover_color=hover,
|
||||||
|
text_color=text_color, border_color=border, border_width=1,
|
||||||
|
font=ctk.CTkFont("Helvetica", 12, "bold" if kind != "neutral" else "normal"),
|
||||||
|
bg_color="transparent",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _console_combo(self, master, values, width):
|
||||||
|
return ctk.CTkComboBox(
|
||||||
|
master, values=[str(value) for value in values], width=width, height=34,
|
||||||
|
corner_radius=10, border_width=1, border_color="#cbd5e1",
|
||||||
|
fg_color="#ffffff", button_color="#e8eef7",
|
||||||
|
button_hover_color="#dbeafe", dropdown_fg_color="#ffffff",
|
||||||
|
dropdown_hover_color="#dbeafe", text_color="#1e293b",
|
||||||
|
bg_color="transparent", state="readonly",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
shell = ctk.CTkFrame(self, fg_color="#f8f8f6", corner_radius=24,
|
||||||
|
border_width=1, border_color="#d5d6d3")
|
||||||
|
shell.pack(fill="both", expand=True, padx=18, pady=18)
|
||||||
|
shell.grid_rowconfigure(0, weight=1)
|
||||||
|
shell.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
rail = ctk.CTkFrame(shell, width=248, fg_color="#282d30", corner_radius=22)
|
||||||
|
rail.grid(row=0, column=0, sticky="nsew")
|
||||||
|
rail.grid_propagate(False)
|
||||||
|
workspace = ctk.CTkFrame(shell, fg_color="#fbfbf9", corner_radius=22)
|
||||||
|
workspace.grid(row=0, column=1, sticky="nsew")
|
||||||
|
|
||||||
|
ctk.CTkLabel(rail, text="SUB CONTROL / 03", text_color="#f4f4f1",
|
||||||
|
font=ctk.CTkFont("Helvetica", 13, "bold")).pack(
|
||||||
|
anchor="w", padx=24, pady=(24, 0))
|
||||||
|
ctk.CTkLabel(rail, text="ArduSub vehicle", text_color="#8f9699",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(2, 14))
|
||||||
|
|
||||||
|
vehicle_art = tk.Canvas(rail, width=204, height=250, bg="#282d30",
|
||||||
|
highlightthickness=0, bd=0)
|
||||||
|
vehicle_art.pack(padx=20, pady=(5, 4))
|
||||||
|
vehicle_art.create_oval(47, 27, 157, 223, fill="#353b3e", outline="#555c60", width=2)
|
||||||
|
vehicle_art.create_oval(68, 49, 136, 201, fill="#303538", outline="#444a4e")
|
||||||
|
vehicle_art.create_oval(80, 102, 124, 146, fill="#5e666a", outline="#788084", width=2)
|
||||||
|
vehicle_art.create_oval(21, 57, 62, 100, fill="#202426", outline="#62696d", width=2)
|
||||||
|
vehicle_art.create_oval(142, 57, 183, 100, fill="#202426", outline="#62696d", width=2)
|
||||||
|
vehicle_art.create_line(42, 79, 72, 93, fill="#757d81", width=3)
|
||||||
|
vehicle_art.create_line(162, 79, 132, 93, fill="#757d81", width=3)
|
||||||
|
vehicle_art.create_text(102, 236, text="TOP VIEW • 3 THRUSTERS",
|
||||||
|
fill="#7e8689", font=("Helvetica", 8, "bold"))
|
||||||
|
|
||||||
|
ctk.CTkLabel(rail, text="Vehicle", text_color="#959c9f",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(5, 0))
|
||||||
|
self.vehicle_label = ctk.CTkLabel(rail, text="No heartbeat", text_color="#f4f4f1",
|
||||||
|
font=ctk.CTkFont("Helvetica", 14, "bold"))
|
||||||
|
self.vehicle_label.pack(anchor="w", padx=24)
|
||||||
|
|
||||||
|
rail_bottom = ctk.CTkFrame(rail, fg_color="transparent")
|
||||||
|
rail_bottom.pack(side="bottom", fill="x", padx=24, pady=22)
|
||||||
|
self.battery_summary = ctk.CTkLabel(
|
||||||
|
rail_bottom, text="Battery —", text_color="#f4f4f1",
|
||||||
|
font=ctk.CTkFont("Helvetica", 13, "bold"), anchor="w"
|
||||||
|
)
|
||||||
|
self.battery_summary.pack(fill="x")
|
||||||
|
self.battery_meta = ctk.CTkLabel(
|
||||||
|
rail_bottom, text="Waiting for voltage", text_color="#92999c",
|
||||||
|
font=ctk.CTkFont("Helvetica", 9), anchor="w"
|
||||||
|
)
|
||||||
|
self.battery_meta.pack(fill="x", pady=(0, 5))
|
||||||
|
self.battery_bar = ctk.CTkProgressBar(
|
||||||
|
rail_bottom, height=7, corner_radius=4, fg_color="#454b4e",
|
||||||
|
progress_color="#c4e46c"
|
||||||
|
)
|
||||||
|
self.battery_bar.set(0)
|
||||||
|
self.battery_bar.pack(fill="x", pady=(0, 18))
|
||||||
|
self.link_label = ctk.CTkLabel(
|
||||||
|
rail_bottom, text="● DISCONNECTED", text_color="#f08a82",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10, "bold"), anchor="w"
|
||||||
|
)
|
||||||
|
self.link_label.pack(fill="x")
|
||||||
|
|
||||||
|
header = ctk.CTkFrame(workspace, fg_color="transparent")
|
||||||
|
header.pack(fill="x", padx=28, pady=(24, 14))
|
||||||
|
title = ctk.CTkFrame(header, fg_color="transparent")
|
||||||
|
title.pack(side="left")
|
||||||
|
ctk.CTkLabel(title, text="Drive", text_color="#17191a",
|
||||||
|
font=ctk.CTkFont("Helvetica", 24, "bold")).pack(anchor="w")
|
||||||
|
ctk.CTkLabel(title, text="Differential steering • MANUAL control",
|
||||||
|
text_color="#8a8c8e", font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w")
|
||||||
|
|
||||||
|
top = ctk.CTkFrame(header, fg_color="transparent")
|
||||||
|
top.pack(side="right")
|
||||||
|
self.port = self._console_combo(top, [], 190)
|
||||||
|
self.port.grid(row=0, column=0, padx=4)
|
||||||
|
self._console_button(top, "↻", self.refresh_ports, width=38).grid(row=0, column=1, padx=4)
|
||||||
|
self.baud = self._console_combo(top, (9600, 19200, 38400, 57600, 115200), 105)
|
||||||
|
self.baud.set("57600")
|
||||||
|
self.rate = self._console_combo(top, (10, 20), 72)
|
||||||
|
self.rate.set("20")
|
||||||
|
self.link_settings_button = self._console_button(
|
||||||
|
top, "•••", self._toggle_link_settings, width=38
|
||||||
|
)
|
||||||
|
self.link_settings_button.grid(row=0, column=2, padx=4)
|
||||||
|
self.connect_button = self._console_button(
|
||||||
|
top, "Connect", self.toggle_connect, kind="primary", width=110
|
||||||
|
)
|
||||||
|
self.connect_button.grid(row=0, column=3, padx=(4, 0))
|
||||||
|
|
||||||
|
separator = ctk.CTkFrame(workspace, height=1, fg_color="#e7e7e3")
|
||||||
|
separator.pack(fill="x", padx=28)
|
||||||
|
|
||||||
|
controls = ctk.CTkFrame(workspace, fg_color="transparent")
|
||||||
|
controls.pack(fill="both", expand=True, padx=28, pady=18)
|
||||||
|
controls.grid_rowconfigure(0, weight=1)
|
||||||
|
controls.grid_columnconfigure(0, weight=1)
|
||||||
|
controls.grid_columnconfigure(1, minsize=214)
|
||||||
|
|
||||||
|
drive_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18)
|
||||||
|
drive_card.grid(row=0, column=0, sticky="nsew", padx=(0, 12))
|
||||||
|
ctk.CTkLabel(drive_card, text="STEER + THROTTLE", text_color="#66696b",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 0))
|
||||||
|
self.joystick = Joystick(drive_card, self._axes_changed)
|
||||||
|
self.joystick.pack(expand=True, pady=(4, 0))
|
||||||
|
ctk.CTkLabel(drive_card, text="W / S drive • A / D steer",
|
||||||
|
text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(0, 16))
|
||||||
|
|
||||||
|
heave_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18)
|
||||||
|
heave_card.grid(row=0, column=1, sticky="nsew")
|
||||||
|
ctk.CTkLabel(heave_card, text="VERTICAL", text_color="#66696b",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 2))
|
||||||
|
ctk.CTkLabel(heave_card, text="RISE", text_color="#969896",
|
||||||
|
font=ctk.CTkFont("Helvetica", 8, "bold")).pack()
|
||||||
|
self.vertical = SpringScale(
|
||||||
|
heave_card, from_=100, to=-100, orient="vertical", length=230,
|
||||||
|
command=lambda _: self._axes_changed()
|
||||||
|
)
|
||||||
|
self.vertical.set(0)
|
||||||
|
self.vertical.pack(expand=True, pady=8)
|
||||||
|
ctk.CTkLabel(heave_card, text="DIVE", text_color="#969896",
|
||||||
|
font=ctk.CTkFont("Helvetica", 8, "bold")).pack()
|
||||||
|
ctk.CTkLabel(heave_card, text="R / F • springs to neutral",
|
||||||
|
text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(4, 16))
|
||||||
|
|
||||||
|
self.battery_cells = tk.StringVar(value=str(self.battery_config["cells"]))
|
||||||
|
self.battery_capacity = tk.StringVar(value=str(self.battery_config["capacity_mah"]))
|
||||||
|
self.battery_c_rating = tk.StringVar(value=str(self.battery_config["c_rating"]))
|
||||||
|
self.battery_full_cell = tk.StringVar(value=str(self.battery_config["full_v_per_cell"]))
|
||||||
|
self.battery_empty_cell = tk.StringVar(value=str(self.battery_config["empty_v_per_cell"]))
|
||||||
|
self.battery_warning = tk.StringVar(value=str(self.battery_config["warning_percent"]))
|
||||||
|
fields = (
|
||||||
|
("Cells (S)", self.battery_cells, 5),
|
||||||
|
("Capacity mAh", self.battery_capacity, 8),
|
||||||
|
("C rating", self.battery_c_rating, 5),
|
||||||
|
("Full V/cell", self.battery_full_cell, 6),
|
||||||
|
("Empty V/cell", self.battery_empty_cell, 6),
|
||||||
|
("Warn %", self.battery_warning, 5),
|
||||||
|
)
|
||||||
|
self.battery_settings_frame = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14)
|
||||||
|
for column, (label, variable, width) in enumerate(fields):
|
||||||
|
group = ctk.CTkFrame(self.battery_settings_frame, fg_color="transparent")
|
||||||
|
group.grid(row=0, column=column, padx=(0, 8), sticky="w")
|
||||||
|
ctk.CTkLabel(group, text=label, text_color="#66696b",
|
||||||
|
font=ctk.CTkFont("Helvetica", 9)).pack(anchor="w")
|
||||||
|
ctk.CTkEntry(group, textvariable=variable, width=max(62, width * 9), height=30,
|
||||||
|
corner_radius=8, border_color="#d0d0cb").pack(anchor="w")
|
||||||
|
self._console_button(self.battery_settings_frame, "Apply", self._apply_battery_settings,
|
||||||
|
kind="primary", width=82).grid(
|
||||||
|
row=0, column=6, padx=(8, 0), sticky="sew"
|
||||||
|
)
|
||||||
|
self.battery_settings_frame.pack_forget()
|
||||||
|
|
||||||
|
actions = ctk.CTkFrame(workspace, fg_color="transparent")
|
||||||
|
actions.pack(fill="x", padx=28, pady=(0, 14))
|
||||||
|
self._console_button(actions, "MANUAL", self.set_manual, width=94).pack(side="left", padx=3)
|
||||||
|
self._console_button(actions, "ARM", self.arm, kind="primary", width=82).pack(side="left", padx=3)
|
||||||
|
self._console_button(actions, "DISARM", self.disarm, kind="danger", width=92).pack(side="left", padx=3)
|
||||||
|
ctk.CTkLabel(actions, text="Output", text_color="#777a7c",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10)).pack(side="left", padx=(18, 4))
|
||||||
|
self.gain = ctk.CTkSlider(
|
||||||
|
actions, from_=5, to=50, number_of_steps=45, width=170, height=18,
|
||||||
|
command=self._gain_changed, corner_radius=10, button_corner_radius=10,
|
||||||
|
button_length=22, fg_color="#dbe3ee", progress_color="#60a5fa",
|
||||||
|
button_color="#2563eb", button_hover_color="#1d4ed8",
|
||||||
|
bg_color="transparent",
|
||||||
|
)
|
||||||
|
self.gain.set(20)
|
||||||
|
self.gain.pack(side="left")
|
||||||
|
self.gain_text = ctk.CTkLabel(actions, text="20%", text_color="#55585a",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10, "bold"))
|
||||||
|
self.gain_text.pack(side="left")
|
||||||
|
self._console_button(actions, "Motor setup", self._toggle_motor_setup, width=112).pack(
|
||||||
|
side="right", padx=3
|
||||||
|
)
|
||||||
|
self._console_button(actions, "Battery", self._toggle_battery_settings, width=88).pack(
|
||||||
|
side="right", padx=3
|
||||||
|
)
|
||||||
|
|
||||||
|
assignments = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14)
|
||||||
|
self.assignments_frame = assignments
|
||||||
|
self.servo_tree = ttk.Treeview(
|
||||||
|
assignments, columns=("output", "function", "reversed"),
|
||||||
|
show="headings", height=5, selectmode="browse",
|
||||||
|
)
|
||||||
|
self.servo_tree.heading("output", text="Output")
|
||||||
|
self.servo_tree.heading("function", text="Function")
|
||||||
|
self.servo_tree.heading("reversed", text="Motor reversed")
|
||||||
|
self.servo_tree.column("output", width=90, anchor="center", stretch=False)
|
||||||
|
self.servo_tree.column("function", width=220, anchor="w")
|
||||||
|
self.servo_tree.column("reversed", width=90, anchor="center", stretch=False)
|
||||||
|
for number in range(1, 17):
|
||||||
|
self.servo_tree.insert("", "end", iid=str(number),
|
||||||
|
values=(f"SERVO{number}", "Waiting…", "Waiting…"))
|
||||||
|
self.servo_tree.pack(side="left", fill="x", expand=True)
|
||||||
|
scrollbar = ttk.Scrollbar(assignments, orient="vertical", command=self.servo_tree.yview)
|
||||||
|
scrollbar.pack(side="left", fill="y")
|
||||||
|
self.servo_tree.configure(yscrollcommand=scrollbar.set)
|
||||||
|
|
||||||
|
edit = ctk.CTkFrame(assignments, fg_color="transparent")
|
||||||
|
edit.pack(side="right", fill="y")
|
||||||
|
ctk.CTkLabel(edit, text="Selected output function", text_color="#55585a").pack(anchor="w")
|
||||||
|
motor_choices = ["Unassigned (0)"] + [f"Motor {number}" for number in range(1, 13)]
|
||||||
|
self.motor_function = self._console_combo(edit, motor_choices, 165)
|
||||||
|
self.motor_function.set("Motor 1")
|
||||||
|
self.motor_function.pack(fill="x", pady=(2, 5))
|
||||||
|
self._console_button(edit, "Apply function", self.apply_servo_function, width=165).pack(fill="x", pady=2)
|
||||||
|
self._console_button(edit, "Reverse motor", self.toggle_servo_reversed, width=165).pack(fill="x", pady=2)
|
||||||
|
ctk.CTkLabel(edit, text="Changes require DISARMED", text_color="#a16207").pack(pady=(8, 0))
|
||||||
|
|
||||||
|
log_frame = ctk.CTkFrame(workspace, fg_color="transparent")
|
||||||
|
log_frame.pack(fill="x", padx=28, pady=(0, 12))
|
||||||
|
self.log_frame = log_frame
|
||||||
|
self.log_path_text = tk.StringVar(value="Session log starts when connected")
|
||||||
|
ctk.CTkLabel(log_frame, textvariable=self.log_path_text, anchor="w", text_color="#8b8d8f",
|
||||||
|
font=ctk.CTkFont("Helvetica", 9)).pack(side="left", fill="x", expand=True)
|
||||||
|
self._console_button(log_frame, "Open logs", self.open_logs_folder, width=96).pack(side="right")
|
||||||
|
|
||||||
|
self.status_text = tk.StringVar(value="Ready. Controls are neutral; vehicle is not connected.")
|
||||||
|
ctk.CTkLabel(workspace, textvariable=self.status_text, anchor="w", height=34,
|
||||||
|
corner_radius=12, fg_color="#eeeeea", text_color="#65686a",
|
||||||
|
font=ctk.CTkFont("Helvetica", 9)).pack(fill="x", padx=28, pady=(0, 18))
|
||||||
|
|
||||||
|
def refresh_ports(self):
|
||||||
|
ports = [p.device for p in list_ports.comports()]
|
||||||
|
for sim in ("tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
|
||||||
|
if sim not in ports:
|
||||||
|
ports.append(sim)
|
||||||
|
self.port.configure(values=ports)
|
||||||
|
if ports and self.port.get() not in ports:
|
||||||
|
self.port.set(ports[0])
|
||||||
|
|
||||||
|
def _toggle_battery_settings(self):
|
||||||
|
if self.battery_settings_frame.winfo_ismapped():
|
||||||
|
self.battery_settings_frame.pack_forget()
|
||||||
|
else:
|
||||||
|
self.battery_settings_frame.pack(
|
||||||
|
fill="x", padx=28, pady=(0, 12), before=self.log_frame
|
||||||
|
)
|
||||||
|
|
||||||
|
def _toggle_link_settings(self):
|
||||||
|
if self.baud.winfo_ismapped():
|
||||||
|
self.baud.grid_remove()
|
||||||
|
self.rate.grid_remove()
|
||||||
|
else:
|
||||||
|
self.baud.grid(row=1, column=0, sticky="e", padx=4, pady=(6, 0))
|
||||||
|
self.rate.grid(row=1, column=1, sticky="w", padx=4, pady=(6, 0))
|
||||||
|
|
||||||
|
def _toggle_motor_setup(self):
|
||||||
|
if self.assignments_frame.winfo_ismapped():
|
||||||
|
self.assignments_frame.pack_forget()
|
||||||
|
else:
|
||||||
|
self.assignments_frame.pack(
|
||||||
|
fill="x", padx=28, pady=(0, 12), before=self.log_frame
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_battery_settings(self):
|
||||||
|
try:
|
||||||
|
saved = json.loads(self.battery_config_path.read_text(encoding="utf-8"))
|
||||||
|
for key in self.battery_config:
|
||||||
|
if key in saved:
|
||||||
|
self.battery_config[key] = saved[key]
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _apply_battery_settings(self):
|
||||||
|
try:
|
||||||
|
config = {
|
||||||
|
"cells": int(self.battery_cells.get()),
|
||||||
|
"capacity_mah": int(self.battery_capacity.get()),
|
||||||
|
"c_rating": float(self.battery_c_rating.get()),
|
||||||
|
"full_v_per_cell": float(self.battery_full_cell.get()),
|
||||||
|
"empty_v_per_cell": float(self.battery_empty_cell.get()),
|
||||||
|
"warning_percent": int(self.battery_warning.get()),
|
||||||
|
}
|
||||||
|
if not 1 <= config["cells"] <= 12:
|
||||||
|
raise ValueError("cell count must be between 1 and 12")
|
||||||
|
if config["capacity_mah"] <= 0 or config["c_rating"] <= 0:
|
||||||
|
raise ValueError("capacity and C rating must be positive")
|
||||||
|
if not config["full_v_per_cell"] > config["empty_v_per_cell"] > 0:
|
||||||
|
raise ValueError("full V/cell must be greater than empty V/cell")
|
||||||
|
if not 0 <= config["warning_percent"] <= 100:
|
||||||
|
raise ValueError("warning percentage must be between 0 and 100")
|
||||||
|
self.battery_config = config
|
||||||
|
self.battery_config_path.write_text(
|
||||||
|
json.dumps(config, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
self._update_battery_display()
|
||||||
|
self.status_text.set("Battery settings saved")
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
|
messagebox.showerror("Invalid battery settings", str(exc))
|
||||||
|
|
||||||
|
def _battery_percent(self, voltage):
|
||||||
|
cells = self.battery_config["cells"]
|
||||||
|
per_cell = voltage / cells
|
||||||
|
empty = self.battery_config["empty_v_per_cell"]
|
||||||
|
full = self.battery_config["full_v_per_cell"]
|
||||||
|
return max(0.0, min(100.0, (per_cell - empty) * 100.0 / (full - empty)))
|
||||||
|
|
||||||
|
def _update_battery_display(self):
|
||||||
|
voltage = self.battery_voltage_smoothed
|
||||||
|
if voltage is None:
|
||||||
|
self.battery_summary.configure(text="Battery —", text_color="#f4f4f1")
|
||||||
|
self.battery_meta.configure(text="Waiting for voltage")
|
||||||
|
self.battery_bar.set(0)
|
||||||
|
return
|
||||||
|
cells = self.battery_config["cells"]
|
||||||
|
percent = self._battery_percent(voltage)
|
||||||
|
per_cell = voltage / cells
|
||||||
|
warning = percent <= self.battery_config["warning_percent"]
|
||||||
|
self.battery_summary.configure(
|
||||||
|
text=f"Battery {percent:.0f}%",
|
||||||
|
text_color="#ffaaa2" if warning else "#f4f4f1",
|
||||||
|
)
|
||||||
|
self.battery_meta.configure(
|
||||||
|
text=f"{voltage:.2f} V • {per_cell:.2f} V/cell • {cells}S"
|
||||||
|
+ (" • LOW" if warning else "")
|
||||||
|
)
|
||||||
|
self.battery_bar.configure(progress_color="#f08a82" if warning else "#c4e46c")
|
||||||
|
self.battery_bar.set(percent / 100.0)
|
||||||
|
|
||||||
|
def toggle_connect(self):
|
||||||
|
if self.worker.thread and self.worker.thread.is_alive():
|
||||||
|
self.worker.disconnect()
|
||||||
|
self.connect_button.configure(state="disabled")
|
||||||
|
return
|
||||||
|
if not self.port.get():
|
||||||
|
messagebox.showerror("No port", "Select a USB serial COM port.")
|
||||||
|
return
|
||||||
|
self.worker.rate_hz = int(self.rate.get())
|
||||||
|
self.worker.connect(self.port.get(), int(self.baud.get()))
|
||||||
|
self.connect_button.configure(text="Disconnect")
|
||||||
|
self.status_text.set("Opening serial port…")
|
||||||
|
|
||||||
|
def _axes_changed(self):
|
||||||
|
turn, forward = self.joystick.value
|
||||||
|
self.worker.set_axes(Axes(forward, 0.0,
|
||||||
|
float(self.vertical.get()) / 100,
|
||||||
|
turn))
|
||||||
|
|
||||||
|
def _gain_changed(self, value):
|
||||||
|
value = int(float(value))
|
||||||
|
self.worker.gain = value / 100
|
||||||
|
self.gain_text.configure(text=f"{value}%")
|
||||||
|
|
||||||
|
def _key_down(self, event):
|
||||||
|
key = event.keysym.lower()
|
||||||
|
if key in "wasdqerf":
|
||||||
|
self.keys.add(key)
|
||||||
|
self._apply_keys()
|
||||||
|
|
||||||
|
def _key_up(self, event):
|
||||||
|
key = event.keysym.lower()
|
||||||
|
self.keys.discard(key)
|
||||||
|
self._apply_keys()
|
||||||
|
|
||||||
|
def _apply_keys(self):
|
||||||
|
turn = (1 if ("d" in self.keys or "e" in self.keys) else 0) - (
|
||||||
|
1 if ("a" in self.keys or "q" in self.keys) else 0
|
||||||
|
)
|
||||||
|
forward = (1 if "w" in self.keys else 0) - (1 if "s" in self.keys else 0)
|
||||||
|
vertical = (1 if "r" in self.keys else 0) - (1 if "f" in self.keys else 0)
|
||||||
|
planar = (turn, forward)
|
||||||
|
if planar != self.keyboard_planar:
|
||||||
|
if turn or forward:
|
||||||
|
self.joystick.set_immediate(float(turn), float(forward))
|
||||||
|
else:
|
||||||
|
self.joystick._release()
|
||||||
|
self.keyboard_planar = planar
|
||||||
|
if vertical != self.keyboard_vertical:
|
||||||
|
if vertical:
|
||||||
|
self.vertical.set_immediate(vertical * 100)
|
||||||
|
else:
|
||||||
|
self.vertical.spring_to_center()
|
||||||
|
self.keyboard_vertical = vertical
|
||||||
|
self._axes_changed()
|
||||||
|
|
||||||
|
def arm(self):
|
||||||
|
if not messagebox.askyesno("Arm vehicle", "Propellers removed or vehicle securely restrained?\n\nArm now?"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.worker.log("OPERATOR", "clicked ARM")
|
||||||
|
self.worker.command_arm(True)
|
||||||
|
self.status_text.set("Arm command sent; waiting for ACK/heartbeat…")
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot arm", str(exc))
|
||||||
|
|
||||||
|
def disarm(self):
|
||||||
|
try:
|
||||||
|
self.worker.log("OPERATOR", "clicked DISARM")
|
||||||
|
self.worker.command_arm(False)
|
||||||
|
self.status_text.set("Disarm command sent; neutral sent")
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot disarm", str(exc))
|
||||||
|
|
||||||
|
def set_manual(self):
|
||||||
|
try:
|
||||||
|
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
||||||
|
self.worker.set_manual_mode()
|
||||||
|
self.status_text.set("MANUAL mode request sent")
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot set mode", str(exc))
|
||||||
|
|
||||||
|
def open_logs_folder(self):
|
||||||
|
log_dir = Path(__file__).resolve().parent / "logs"
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
os.startfile(log_dir)
|
||||||
|
|
||||||
|
def _selected_servo(self):
|
||||||
|
selection = self.servo_tree.selection()
|
||||||
|
if not selection:
|
||||||
|
raise RuntimeError("Select a SERVO output in the table first")
|
||||||
|
return int(selection[0])
|
||||||
|
|
||||||
|
def apply_servo_function(self):
|
||||||
|
try:
|
||||||
|
servo = self._selected_servo()
|
||||||
|
choice = self.motor_function.get()
|
||||||
|
if choice.startswith("Motor "):
|
||||||
|
motor = int(choice.split()[1])
|
||||||
|
function = 32 + motor if motor <= 8 else 73 + motor
|
||||||
|
else:
|
||||||
|
function = 0
|
||||||
|
description = MOTOR_FUNCTIONS.get(function, "Unassigned")
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Change output function",
|
||||||
|
f"Set SERVO{servo}_FUNCTION to {description} ({function})?\n\n"
|
||||||
|
"This changes the autopilot configuration.",
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot change function", str(exc))
|
||||||
|
|
||||||
|
def toggle_servo_reversed(self):
|
||||||
|
try:
|
||||||
|
servo = self._selected_servo()
|
||||||
|
function = self.servo_functions.get(servo)
|
||||||
|
motor = motor_number_for_function(function)
|
||||||
|
if motor is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"SERVO{servo} is not assigned to an ArduSub Motor function"
|
||||||
|
)
|
||||||
|
current = self.motor_directions.get(motor)
|
||||||
|
if current is None:
|
||||||
|
raise RuntimeError(f"MOT_{motor}_DIRECTION has not been read yet")
|
||||||
|
new_value = -1 if current == 1 else 1
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Reverse motor output",
|
||||||
|
f"SERVO{servo} is Motor {motor}.\n"
|
||||||
|
f"Set MOT_{motor}_DIRECTION to {new_value}?\n\n"
|
||||||
|
"Test direction with propellers removed after changing this.",
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", new_value)
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot reverse output", str(exc))
|
||||||
|
|
||||||
|
def _refresh_servo_row(self, number):
|
||||||
|
function = self.servo_functions.get(number)
|
||||||
|
motor = motor_number_for_function(function)
|
||||||
|
direction = self.motor_directions.get(motor) if motor is not None else None
|
||||||
|
if function is None:
|
||||||
|
function_text = "Waiting…"
|
||||||
|
else:
|
||||||
|
function_text = f"{MOTOR_FUNCTIONS.get(function, 'Other / unassigned')} ({function})"
|
||||||
|
if motor is None:
|
||||||
|
reversed_text = "—"
|
||||||
|
elif direction is None:
|
||||||
|
reversed_text = "Waiting…"
|
||||||
|
else:
|
||||||
|
reversed_text = "Yes" if direction == -1 else "No"
|
||||||
|
self.servo_tree.item(str(number), values=(f"SERVO{number}", function_text, reversed_text))
|
||||||
|
|
||||||
|
def _poll(self):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
event = self.events.get_nowait()
|
||||||
|
kind = event[0]
|
||||||
|
if kind == "heartbeat":
|
||||||
|
_, system, autopilot, mode, armed = event[:5]
|
||||||
|
self.vehicle_label.configure(
|
||||||
|
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
|
||||||
|
)
|
||||||
|
self.link_label.configure(text="● LINK OK", text_color="#c4e46c")
|
||||||
|
elif kind in ("status", "statustext"):
|
||||||
|
self.status_text.set(event[1])
|
||||||
|
elif kind == "ack":
|
||||||
|
self.status_text.set(f"Command {event[1]} ACK: {event[2]}")
|
||||||
|
elif kind == "servo_param":
|
||||||
|
_, param_id, value = event
|
||||||
|
number = int(param_id[5:param_id.index("_")])
|
||||||
|
self.servo_functions[number] = value
|
||||||
|
self._refresh_servo_row(number)
|
||||||
|
elif kind == "motor_direction":
|
||||||
|
_, motor, value = event
|
||||||
|
self.motor_directions[motor] = value
|
||||||
|
for number in range(1, 17):
|
||||||
|
if motor_number_for_function(self.servo_functions.get(number)) == motor:
|
||||||
|
self._refresh_servo_row(number)
|
||||||
|
elif kind == "battery":
|
||||||
|
self.battery_voltage = event[1]
|
||||||
|
if self.battery_voltage_smoothed is None:
|
||||||
|
self.battery_voltage_smoothed = self.battery_voltage
|
||||||
|
else:
|
||||||
|
self.battery_voltage_smoothed = (
|
||||||
|
0.75 * self.battery_voltage_smoothed + 0.25 * self.battery_voltage
|
||||||
|
)
|
||||||
|
self._update_battery_display()
|
||||||
|
elif kind == "param_saved":
|
||||||
|
self.status_text.set(f"Confirmed: {event[1]}={event[2]}")
|
||||||
|
elif kind == "param_missing":
|
||||||
|
self.status_text.set(f"No response for {event[1]}; continuing scan…")
|
||||||
|
elif kind == "param_scan_complete":
|
||||||
|
count = sum(value in MOTOR_FUNCTIONS for value in self.servo_functions.values())
|
||||||
|
self.status_text.set(f"Servo scan complete: {count} motor output(s) assigned")
|
||||||
|
elif kind == "log_path":
|
||||||
|
self.log_path_text.set(f"Log: {event[1]}")
|
||||||
|
elif kind == "error":
|
||||||
|
self.status_text.set(event[1])
|
||||||
|
messagebox.showerror("MAVLink", event[1])
|
||||||
|
elif kind == "disconnected":
|
||||||
|
self.connect_button.configure(text="Connect", state="normal")
|
||||||
|
self.link_label.configure(text="● DISCONNECTED", text_color="#f08a82")
|
||||||
|
self.vehicle_label.configure(text="No heartbeat")
|
||||||
|
self.battery_voltage = None
|
||||||
|
self.battery_voltage_smoothed = None
|
||||||
|
self._update_battery_display()
|
||||||
|
self.servo_functions.clear()
|
||||||
|
self.motor_directions.clear()
|
||||||
|
for number in range(1, 17):
|
||||||
|
self.servo_tree.item(str(number), values=(f"SERVO{number}", "Waiting…", "Waiting…"))
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
self.after(50, self._poll)
|
||||||
|
|
||||||
|
def _link_watchdog(self):
|
||||||
|
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5.0:
|
||||||
|
if time.monotonic() - self.worker.last_rx < 2.0:
|
||||||
|
text = "● DATA OK / HEARTBEAT DELAYED"
|
||||||
|
else:
|
||||||
|
text = "● ALL LINK DATA LOST"
|
||||||
|
self.link_label.configure(text=text, text_color="#f08a82")
|
||||||
|
self.worker._send_control(force_neutral=True)
|
||||||
|
self.after(500, self._link_watchdog)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.worker.disconnect()
|
||||||
|
self.after(150, self.destroy)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Validated V2 settings and telemetry, independent of the desktop renderer."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
AXES = ("forward", "lateral", "vertical", "turn", "pitch", "roll")
|
||||||
|
FRAMES = {
|
||||||
|
0: ("BlueROV1", 6, AXES),
|
||||||
|
1: ("Vectored", 6, tuple(axis for axis in AXES if axis != "pitch")),
|
||||||
|
2: ("Vectored 6DOF", 8, AXES),
|
||||||
|
3: ("Vectored 90", 8, AXES),
|
||||||
|
4: ("SimpleROV 3", 3, ("forward", "vertical", "turn")),
|
||||||
|
5: ("SimpleROV 4", 4, ("forward", "vertical", "turn", "roll")),
|
||||||
|
6: ("SimpleROV 5", 5, ("forward", "lateral", "vertical", "turn", "roll")),
|
||||||
|
}
|
||||||
|
INPUT = re.compile(r"^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$")
|
||||||
|
|
||||||
|
|
||||||
|
def number(value, minimum, maximum):
|
||||||
|
result = float(value)
|
||||||
|
if not math.isfinite(result) or not minimum <= result <= maximum:
|
||||||
|
raise ValueError(f"Value must be between {minimum} and {maximum}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def clean_profile(config):
|
||||||
|
if not isinstance(config, dict) or config.get("version") != 2:
|
||||||
|
raise ValueError("Invalid controller profile")
|
||||||
|
if not isinstance(config.get("bindings", {}), dict) or not isinstance(config.get("actions", {}), dict):
|
||||||
|
raise ValueError("Invalid controller bindings")
|
||||||
|
bindings = {}
|
||||||
|
for axis in AXES:
|
||||||
|
item = config.get("bindings", {}).get(axis, {})
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("Invalid motion binding")
|
||||||
|
negative, positive = item.get("negative", "off"), item.get("positive", "off")
|
||||||
|
if not isinstance(negative, str) or not INPUT.fullmatch(negative):
|
||||||
|
raise ValueError("Invalid negative input")
|
||||||
|
if not isinstance(positive, str) or not INPUT.fullmatch(positive):
|
||||||
|
raise ValueError("Invalid positive input")
|
||||||
|
if negative == positive and positive != "off":
|
||||||
|
raise ValueError("Choose different inputs for each direction")
|
||||||
|
bindings[axis] = {"negative": negative, "positive": positive,
|
||||||
|
"scale": number(item.get("scale", 1), 0, 1)}
|
||||||
|
actions = {}
|
||||||
|
for action in ("lights", "stream", "record"):
|
||||||
|
source = config.get("actions", {}).get(action, "off")
|
||||||
|
if source != "off" and (not isinstance(source, str) or not re.fullmatch(r"button:(?:[0-9]|[12][0-9]|3[01])", source)):
|
||||||
|
raise ValueError("Actions require a button")
|
||||||
|
actions[action] = source
|
||||||
|
return {"version": 2, "preset": str(config.get("preset", "custom"))[:24],
|
||||||
|
"deadZone": number(config.get("deadZone", .08), 0, .3),
|
||||||
|
"expo": number(config.get("expo", .15), 0, 1),
|
||||||
|
"bindings": bindings, "actions": actions}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_station(config):
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValueError("Invalid station settings")
|
||||||
|
frame_value = number(config.get("frame", 4), 0, 6)
|
||||||
|
if not frame_value.is_integer():
|
||||||
|
raise ValueError("Invalid frame")
|
||||||
|
frame = int(frame_value)
|
||||||
|
if frame not in FRAMES:
|
||||||
|
raise ValueError("Choose an ArduSub frame")
|
||||||
|
mode = config.get("video_mode", "whep")
|
||||||
|
if mode not in ("whep", "video", "mjpeg"):
|
||||||
|
raise ValueError("Unsupported stream type")
|
||||||
|
url = str(config.get("video_url", "")).strip()
|
||||||
|
if url:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
|
||||||
|
raise ValueError("Use an HTTP stream URL without embedded credentials")
|
||||||
|
relays = {}
|
||||||
|
for name in ("lights_relay", "camera_relay"):
|
||||||
|
value = number(config.get(name, -1), -1, 15)
|
||||||
|
if not value.is_integer():
|
||||||
|
raise ValueError("Invalid relay")
|
||||||
|
relays[name] = int(value)
|
||||||
|
if relays["lights_relay"] >= 0 and relays["lights_relay"] == relays["camera_relay"]:
|
||||||
|
raise ValueError("Lights and camera need separate relays")
|
||||||
|
return {"frame": frame, "video_mode": mode, "video_url": url[:2048], **relays}
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path: Path, data):
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetry:
|
||||||
|
"""Timestamp every group; missing or stale measurements stay unavailable."""
|
||||||
|
def __init__(self, clock=time.monotonic):
|
||||||
|
self.clock = clock
|
||||||
|
self.groups = {}
|
||||||
|
|
||||||
|
def update(self, group, values, received_at=None):
|
||||||
|
clean = {key: value if isinstance(value, (int, float)) and math.isfinite(value) else None
|
||||||
|
for key, value in values.items()}
|
||||||
|
self.groups[group] = {"values": clean, "at": self.clock() if received_at is None else received_at}
|
||||||
|
|
||||||
|
def snapshot(self, connected=True):
|
||||||
|
now = self.clock()
|
||||||
|
return {name: {"values": item["values"], "age_s": max(0, now - item["at"]),
|
||||||
|
"stale": not connected or now - item["at"] > 3}
|
||||||
|
for name, item in self.groups.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_telemetry(msg):
|
||||||
|
kind = msg.get_type()
|
||||||
|
if kind == "ATTITUDE":
|
||||||
|
return "attitude", {"roll": math.degrees(msg.roll), "pitch": math.degrees(msg.pitch),
|
||||||
|
"heading": math.degrees(msg.yaw) % 360,
|
||||||
|
"roll_rate": math.degrees(msg.rollspeed),
|
||||||
|
"pitch_rate": math.degrees(msg.pitchspeed),
|
||||||
|
"yaw_rate": math.degrees(msg.yawspeed)}
|
||||||
|
if kind == "GLOBAL_POSITION_INT":
|
||||||
|
# ArduSub reports height relative to its surface/home origin, positive up.
|
||||||
|
return "depth", {"depth_m": -msg.relative_alt / 1000.0, "vertical_ms": msg.vz / 100.0}
|
||||||
|
if kind in ("SCALED_IMU", "SCALED_IMU2", "SCALED_IMU3"):
|
||||||
|
return "imu", {"ax": msg.xacc * .00980665, "ay": msg.yacc * .00980665,
|
||||||
|
"az": msg.zacc * .00980665, "gx": msg.xgyro / 1000.0,
|
||||||
|
"gy": msg.ygyro / 1000.0, "gz": msg.zgyro / 1000.0,
|
||||||
|
"mx": msg.xmag / 1000.0, "my": msg.ymag / 1000.0, "mz": msg.zmag / 1000.0}
|
||||||
|
if kind == "SCALED_PRESSURE2":
|
||||||
|
return "pressure", {"pressure_hpa": msg.press_abs, "temperature_c": msg.temperature / 100.0}
|
||||||
|
return None
|
||||||
+13
-6
@@ -5,13 +5,20 @@ Set-Location -LiteralPath $projectRoot
|
|||||||
|
|
||||||
& (Join-Path $projectRoot 'create_shortcut.ps1')
|
& (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."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) {
|
if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) {
|
||||||
|
if (-not (Get-Command py -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again."
|
||||||
|
}
|
||||||
& py -3 -m venv .venv
|
& py -3 -m venv .venv
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Virtual environment setup failed.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt
|
$dependencyHash = (Get-FileHash -LiteralPath 'requirements.txt' -Algorithm SHA256).Hash
|
||||||
& .venv\Scripts\python.exe app.py
|
$dependencyMarker = Join-Path $projectRoot '.venv\requirements.sha256'
|
||||||
|
$installedHash = if (Test-Path -LiteralPath $dependencyMarker) { (Get-Content -LiteralPath $dependencyMarker -Raw).Trim() } else { '' }
|
||||||
|
if ($installedHash -ne $dependencyHash) {
|
||||||
|
& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' }
|
||||||
|
Set-Content -LiteralPath $dependencyMarker -Value $dependencyHash
|
||||||
|
}
|
||||||
|
& .venv\Scripts\python.exe web_ui.py
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* Synthetic Xbox input: no device, serial port, or browser is opened. */
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const input=require('../web/controller-input.js');
|
||||||
|
const pad=()=>({connected:true,mapping:'standard',axes:[0,0,0,0],buttons:Array.from({length:17},()=>({value:0,pressed:false}))});
|
||||||
|
const press=(pad,index,value=1)=>{pad.buttons[index]={value,pressed:value>.5};return pad};
|
||||||
|
const three=input.preset(),six=input.preset('sixaxis');
|
||||||
|
assert(input.validProfile(three));assert(input.validProfile(six));
|
||||||
|
assert.deepEqual(input.read(pad(),three),Object.fromEntries(input.names.map(name=>[name,0])));
|
||||||
|
assert.equal(input.read(press(pad(),1),three).vertical,1);
|
||||||
|
assert.equal(input.read(press(pad(),0),three).vertical,-1);
|
||||||
|
assert.equal(input.read(press(press(pad(),0),1),three).vertical,0);
|
||||||
|
assert.equal(input.read(press(pad(),7),three).forward,1);
|
||||||
|
assert.equal(input.read(press(pad(),6),three).forward,-1);
|
||||||
|
assert.equal(input.read(press(press(pad(),6),7),three).forward,0);
|
||||||
|
assert(input.read(press(pad(),7,.5),three).forward>0);
|
||||||
|
assert(input.read(press(pad(),7,.5),three).forward<.5);
|
||||||
|
let p=pad();p.axes[0]=1;p.axes[1]=-1;p.axes[2]=1;press(p,4);
|
||||||
|
assert.deepEqual(input.read(p,six),{forward:0,lateral:1,vertical:0,turn:1,pitch:-1,roll:-1});
|
||||||
|
let custom=structuredClone(six);custom.bindings.vertical={negative:'button:12',positive:'button:13',scale:.25};
|
||||||
|
assert.equal(input.read(press(pad(),13),custom).vertical,.25);
|
||||||
|
custom.bindings.turn.scale=0;assert.equal(input.read(p,custom).turn,0);
|
||||||
|
custom.bindings.pitch.negative='axis:90:-';assert.equal(input.read(p,custom),null);
|
||||||
|
assert.equal(input.read({...pad(),connected:false},three),null);
|
||||||
|
p=pad();p.axes[0]=NaN;assert.equal(input.read(p,three),null);
|
||||||
|
p=pad();p.buttons=[];assert.equal(input.read(p,three),null);
|
||||||
|
assert.equal(input.actions(press(pad(),2),three).lights,true);
|
||||||
|
assert.equal(input.migrate({turn:0,forward:1,vertical:3,invertForward:true,invertVertical:true,deadZone:.1}).bindings.forward.positive,'axis:1:-');
|
||||||
|
const duplicate=structuredClone(three);duplicate.bindings.forward.negative='button:7';assert.equal(input.validProfile(duplicate),false);
|
||||||
|
console.log('PASS: Xbox triggers, B/A, cancellation, six axes, custom buttons, limits, invalid devices, migration and actions.');
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""V2 protocol and persistence checks. Never opens a vehicle connection."""
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from app import Axes, MavlinkWorker, normalize_endpoint
|
||||||
|
from rov_v2 import Telemetry, clean_station, clean_profile, decode_telemetry, save_json
|
||||||
|
from web_ui import ControlApi
|
||||||
|
|
||||||
|
|
||||||
|
class V2Tests(unittest.TestCase):
|
||||||
|
def worker(self, frame=0, version=(4, 5, 3)):
|
||||||
|
worker = MavlinkWorker(queue.Queue())
|
||||||
|
worker.connection = Mock()
|
||||||
|
worker.target_system = 1
|
||||||
|
worker.frame_type = frame
|
||||||
|
worker.firmware_version = version
|
||||||
|
worker.gain = .5
|
||||||
|
worker.last_control_at = time.monotonic() - 1
|
||||||
|
return worker
|
||||||
|
|
||||||
|
def test_six_axis_mapping_and_envelope(self):
|
||||||
|
worker = self.worker()
|
||||||
|
worker.set_axes(Axes(forward=1, lateral=1, vertical=1, yaw=1, pitch=1, roll=1))
|
||||||
|
worker._send_control()
|
||||||
|
args = worker.connection.mav.manual_control_send.call_args.args
|
||||||
|
self.assertEqual(args[6:8], (0, 3))
|
||||||
|
self.assertEqual(args[8:], (33, 33)) # S=pitch, T=roll; ArduSub 4.5 ±400.
|
||||||
|
self.assertEqual(args[1:5], (83, 83, 542, 83))
|
||||||
|
|
||||||
|
def test_modern_normalized_attitude(self):
|
||||||
|
worker = self.worker(version=(4, 8, 0))
|
||||||
|
worker.set_axes(Axes(pitch=.5, roll=-.5))
|
||||||
|
worker._send_control()
|
||||||
|
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args[8:], (250, -250))
|
||||||
|
|
||||||
|
def test_unsupported_frame_and_old_firmware(self):
|
||||||
|
worker = self.worker(frame=1)
|
||||||
|
worker.set_axes(Axes(pitch=1, roll=.2))
|
||||||
|
worker._send_control()
|
||||||
|
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args[8], 0)
|
||||||
|
worker.firmware_version = (4, 1, 0)
|
||||||
|
worker._send_control()
|
||||||
|
self.assertEqual(len(worker.connection.mav.manual_control_send.call_args.args), 6)
|
||||||
|
|
||||||
|
def test_watchdog_neutralizes_extended_axes(self):
|
||||||
|
worker = self.worker()
|
||||||
|
worker.require_input_lease = True
|
||||||
|
worker.armed = True
|
||||||
|
worker.last_heartbeat = time.monotonic()
|
||||||
|
worker.set_axes(Axes(pitch=1, roll=-1), 1)
|
||||||
|
worker.last_input_at -= 2
|
||||||
|
worker._send_control()
|
||||||
|
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args, (1, 0, 0, 500, 0, 0, 0, 3, 0, 0))
|
||||||
|
self.assertEqual(worker.axes, Axes())
|
||||||
|
|
||||||
|
def test_endpoints_preserve_configured_network_host(self):
|
||||||
|
for endpoint in ('udpin:0.0.0.0:14550', 'udpout:192.168.2.2:14550', 'tcp:192.168.2.2:5760'):
|
||||||
|
self.assertEqual(normalize_endpoint(endpoint), endpoint)
|
||||||
|
|
||||||
|
def test_sensor_units_and_staleness(self):
|
||||||
|
msg = SimpleNamespace(get_type=lambda: 'ATTITUDE', roll=math.pi/2, pitch=0, yaw=-math.pi/2, rollspeed=1, pitchspeed=0, yawspeed=0)
|
||||||
|
group, values = decode_telemetry(msg)
|
||||||
|
self.assertEqual(values['roll'], 90)
|
||||||
|
self.assertEqual(values['heading'], 270)
|
||||||
|
clock = [10]
|
||||||
|
store = Telemetry(lambda: clock[0])
|
||||||
|
store.update(group, values)
|
||||||
|
clock[0] = 14
|
||||||
|
self.assertTrue(store.snapshot()[group]['stale'])
|
||||||
|
msg = SimpleNamespace(get_type=lambda: 'GLOBAL_POSITION_INT', relative_alt=-1200, vz=25)
|
||||||
|
self.assertEqual(decode_telemetry(msg)[1], {'depth_m': 1.2, 'vertical_ms': .25})
|
||||||
|
|
||||||
|
def test_config_validation_and_atomic_roundtrip(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
clean_station({'video_url': 'javascript:alert(1)'})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
clean_station({'lights_relay': 1, 'camera_relay': 1})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
clean_profile({'version': 2, 'expo': float('nan')})
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
target = Path(folder) / 'station.json'
|
||||||
|
save_json(target, clean_station({'frame': 2}))
|
||||||
|
import json
|
||||||
|
self.assertEqual(json.loads(target.read_text())['frame'], 2)
|
||||||
|
self.assertFalse(target.with_suffix('.json.tmp').exists())
|
||||||
|
|
||||||
|
def test_stale_current_is_not_refreshed_by_voltage(self):
|
||||||
|
api = ControlApi()
|
||||||
|
api.state['connected'] = True
|
||||||
|
with patch('web_ui.time.monotonic', return_value=10):
|
||||||
|
api._consume_battery({'current_a': 4, 'voltage': 16})
|
||||||
|
with patch('web_ui.time.monotonic', return_value=16):
|
||||||
|
api._consume_battery({'voltage': 15.9})
|
||||||
|
self.assertIsNone(api._snapshot()['battery']['current_a'])
|
||||||
|
with patch('web_ui.time.monotonic', return_value=17):
|
||||||
|
api._consume_battery({'current_a': None})
|
||||||
|
self.assertIsNone(api.state['battery']['current_a'])
|
||||||
|
|
||||||
|
def test_attitude_stream_is_never_disabled_when_requested(self):
|
||||||
|
worker = self.worker()
|
||||||
|
worker._configure_low_bandwidth_streams()
|
||||||
|
rates = [(name, interval) for name, _, interval in worker.stream_config]
|
||||||
|
self.assertIn(('ATTITUDE', 500000), rates)
|
||||||
|
self.assertNotIn(('ATTITUDE', -1), rates)
|
||||||
|
|
||||||
|
def test_network_control_omits_serial_priming_heartbeat(self):
|
||||||
|
worker = self.worker()
|
||||||
|
worker.network_link = True
|
||||||
|
worker._send_control()
|
||||||
|
worker.connection.mav.heartbeat_send.assert_not_called()
|
||||||
|
|
||||||
|
def test_profile_saves_atomically_and_cannot_change_while_armed(self):
|
||||||
|
api = ControlApi()
|
||||||
|
profile = {'version': 2, 'bindings': {}, 'actions': {}}
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
api.profiles_path = Path(folder) / 'profiles.json'
|
||||||
|
api.save_controller_profile('$xbox', profile)
|
||||||
|
self.assertTrue(api.profiles_path.is_file())
|
||||||
|
before = api.profiles_path.read_bytes()
|
||||||
|
api.state.update(connected=True, armed=True)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
api.save_controller_profile('$xbox', profile)
|
||||||
|
self.assertEqual(before, api.profiles_path.read_bytes())
|
||||||
|
|
||||||
|
def test_native_recording_chunk_roundtrip_and_expired_token(self):
|
||||||
|
import base64
|
||||||
|
api = ControlApi()
|
||||||
|
with tempfile.TemporaryDirectory() as folder, patch('web_ui.__file__', str(Path(folder) / 'web_ui.py')):
|
||||||
|
token = api.recording_begin()
|
||||||
|
api.recording_chunk(token, base64.b64encode(b'video chunk').decode())
|
||||||
|
path = api.recording_end(token)
|
||||||
|
self.assertEqual(Path(path).read_bytes(), b'video chunk')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
api.recording_chunk(token, 'AA==')
|
||||||
|
|
||||||
|
def test_relay_action_waits_for_ack(self):
|
||||||
|
api = ControlApi()
|
||||||
|
api.station_settings['lights_relay'] = 0
|
||||||
|
worker = api.worker
|
||||||
|
worker.connection = Mock()
|
||||||
|
worker.target_system = worker.target_component = 1
|
||||||
|
worker.last_heartbeat = time.monotonic()
|
||||||
|
api.set_accessory('lights', True)
|
||||||
|
self.assertIsNone(api.state['accessories']['lights']['accepted'])
|
||||||
|
api._consume(('accessory_ack', 'lights', True, 2))
|
||||||
|
self.assertFalse(api.state['accessories']['lights']['accepted'])
|
||||||
|
self.assertEqual(api.state['accessories']['lights']['error'], 'Rejected')
|
||||||
|
|
||||||
|
def test_motor_names_survive_app_restart(self):
|
||||||
|
with tempfile.TemporaryDirectory() as folder, patch('web_ui.__file__', str(Path(folder) / 'web_ui.py')):
|
||||||
|
first = ControlApi()
|
||||||
|
settings = first.motor_settings
|
||||||
|
settings['motors']['1']['name'] = 'Port'
|
||||||
|
first.save_motor_settings(settings)
|
||||||
|
self.assertEqual(ControlApi().motor_settings['motors']['1']['name'], 'Port')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -227,9 +227,11 @@ class WorkerWatchdogTests(unittest.TestCase):
|
|||||||
self.worker.armed = False
|
self.worker.armed = False
|
||||||
with patch("app.time.monotonic", return_value=10):
|
with patch("app.time.monotonic", return_value=10):
|
||||||
self.worker._configure_low_bandwidth_streams()
|
self.worker._configure_low_bandwidth_streams()
|
||||||
self.assertEqual(len(self.worker.stream_config), 10)
|
configured = {name: interval for name, _, interval in self.worker.stream_config}
|
||||||
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
|
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 1_000_000)
|
||||||
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
|
self.assertEqual(configured["ATTITUDE"], 500_000)
|
||||||
|
self.assertEqual(configured["GLOBAL_POSITION_INT"], 500_000)
|
||||||
|
self.assertNotIn(("ATTITUDE", 30, -1), self.worker.stream_config)
|
||||||
self.worker.connection.mav.command_long_send.assert_not_called()
|
self.worker.connection.mav.command_long_send.assert_not_called()
|
||||||
self.worker._tick_stream_config(10)
|
self.worker._tick_stream_config(10)
|
||||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||||
|
|||||||
+107
-91
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
|
|||||||
link_degraded: 'Signal lost — check tether',
|
link_degraded: 'Signal lost — check tether',
|
||||||
};
|
};
|
||||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||||
const control={forward:0,turn:0,vertical:0}, 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;
|
const control={forward:0,turn:0,vertical:0,lateral:0,pitch:0,roll:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,battery_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||||
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
|
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
|
||||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||||
const activity=[];
|
const activity=[];
|
||||||
@@ -25,126 +25,132 @@ let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehi
|
|||||||
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
|
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
|
||||||
|
|
||||||
window.canDrive=canDrive;
|
window.canDrive=canDrive;
|
||||||
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
|
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerDraft=null,controllerNeedCenter=true,controllerWasDriving=false;
|
||||||
|
let controllerProfiles={};try{controllerProfiles=JSON.parse(localStorage.getItem('sub-v2-profiles'))||{}}catch(_){}
|
||||||
const controllerMotion=new ControllerInput.Motion();
|
const controllerMotion=new ControllerInput.Motion();
|
||||||
|
let controllerActionState={},controllerActionsReady=false;
|
||||||
const controllerId=pad=>pad.index+':'+pad.id;
|
const controllerId=pad=>pad.index+':'+pad.id;
|
||||||
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
|
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
|
||||||
function loadControllerConfig(pad){
|
function loadControllerConfig(pad){
|
||||||
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
|
let saved=controllerProfiles[pad?.id||'$xbox']||controllerProfiles.$xbox;
|
||||||
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
|
if(!saved&&pad)try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
|
||||||
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
|
const config=ControllerInput.migrate(saved,pad);
|
||||||
|
return ControllerInput.validProfile(config)?config:ControllerInput.preset();
|
||||||
}
|
}
|
||||||
function selectController(pad){
|
function selectController(pad){
|
||||||
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
|
resetInputs();controllerKey=pad?controllerId(pad):null;controllerConfig=loadControllerConfig(pad);
|
||||||
}
|
}
|
||||||
function setInputMode(mode){
|
function setInputMode(mode){
|
||||||
if(mode===inputMode||inputConfigurationLocked())return false;
|
if(mode===inputMode||inputConfigurationLocked())return false;
|
||||||
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
|
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
|
||||||
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
|
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
|
||||||
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
|
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
|
||||||
$('#controller-settings').hidden=mode!=='joystick';
|
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller output':'Steering and throttle joystick; W A S D keys');
|
||||||
$('#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':'Vertical thrust control');
|
||||||
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function inputConfigurationLocked(){
|
function inputConfigurationLocked(){return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known))}
|
||||||
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
|
|
||||||
}
|
|
||||||
function syncInputModeControls(){
|
function syncInputModeControls(){
|
||||||
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
const locked=inputConfigurationLocked();
|
||||||
$('.input-mode-switch').dataset.locked=String(locked);
|
$('.input-mode-switch').dataset.locked=String(locked);
|
||||||
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
|
for(const button of [$('#input-keyboard'),$('#input-joystick'),$('#controller-settings')])button.disabled=locked;
|
||||||
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
|
if($('#controller-modal').open)for(const field of $('#controller-form').querySelectorAll('input,select,button[type=submit],button[data-swap]'))field.disabled=locked;
|
||||||
}
|
|
||||||
$('#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();
|
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
|
||||||
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
|
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
|
||||||
$('#input-joystick').onclick=()=>setInputMode('joystick');
|
$('#input-joystick').onclick=()=>setInputMode('joystick');
|
||||||
function fillControllerForm(){
|
const axisLabels={forward:'Forward',lateral:'Strafe',vertical:'Vertical',turn:'Turn',pitch:'Pitch',roll:'Roll'};
|
||||||
|
function fillControllerForm(keepDraft=false){
|
||||||
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
|
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
|
||||||
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
|
device.replaceChildren(new Option('Xbox · offline',''),...pads.map(pad=>new Option(pad.id,controllerId(pad))));
|
||||||
if(!pads.length)device.add(new Option('No controller detected',''));
|
|
||||||
device.value=controllerKey||'';
|
device.value=controllerKey||'';
|
||||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
||||||
const config=controllerConfig||ControllerInput.defaults(pad);
|
if(!keepDraft)controllerDraft=structuredClone(controllerConfig||loadControllerConfig(pad));
|
||||||
for(const name of ['turn','forward','vertical']){
|
const config=controllerDraft;
|
||||||
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
|
$('#controller-preset').value=['triggers3','sixaxis','classic'].includes(config.preset)?config.preset:'custom';
|
||||||
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
|
const rows=$('#controller-bindings');rows.replaceChildren();
|
||||||
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
|
for(const name of ControllerInput.names){
|
||||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);
|
const row=document.createElement('div');row.className='binding-row';row.dataset.axis=name;
|
||||||
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
|
const label=document.createElement('span');label.textContent=axisLabels[name];
|
||||||
|
const meter=document.createElement('meter');meter.min=-1;meter.max=1;meter.value=0;meter.setAttribute('aria-label',axisLabels[name]+' demand');label.append(meter);row.append(label);
|
||||||
|
for(const direction of ['negative','positive']){
|
||||||
|
const select=document.createElement('select');select.name=name+'_'+direction;select.setAttribute('aria-label',axisLabels[name]+' '+direction);
|
||||||
|
for(const [value,text] of ControllerInput.sources(pad))select.add(new Option(text,value));
|
||||||
|
select.value=config.bindings[name][direction];
|
||||||
|
select.onchange=()=>{config.bindings[name][direction]=select.value;config.preset='custom';$('#controller-preset').value='custom'};
|
||||||
|
row.append(select);
|
||||||
|
}
|
||||||
|
const swap=document.createElement('button');swap.type='button';swap.dataset.swap=name;swap.className='binding-swap';swap.textContent='⇄';swap.title='Reverse '+axisLabels[name];swap.setAttribute('aria-label','Reverse '+axisLabels[name]);
|
||||||
|
swap.onclick=()=>{const item=config.bindings[name];[item.negative,item.positive]=[item.positive,item.negative];config.preset='custom';fillControllerForm(true)};row.append(swap);
|
||||||
|
const scale=document.createElement('input');scale.type='number';scale.min=0;scale.max=100;scale.step=1;scale.value=Math.round(config.bindings[name].scale*100);scale.setAttribute('aria-label',axisLabels[name]+' limit');
|
||||||
|
scale.oninput=()=>{config.bindings[name].scale=Number(scale.value)/100;config.preset='custom';$('#controller-preset').value='custom'};row.append(scale);rows.append(row);
|
||||||
}
|
}
|
||||||
form.elements.deadZone.value=Math.round(config.deadZone*100);
|
for(const action of ['lights','stream','record']){
|
||||||
device.disabled=inputConfigurationLocked()||!pads.length;
|
const select=form.elements[action];select.replaceChildren(...ControllerInput.sources(pad).filter(([value])=>!value.startsWith('axis:')).map(([value,text])=>new Option(text,value)));
|
||||||
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
|
select.value=config.actions[action];select.onchange=()=>config.actions[action]=select.value;
|
||||||
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
|
}
|
||||||
|
form.elements.deadZone.value=Math.round(config.deadZone*100);form.elements.expo.value=Math.round(config.expo*100);
|
||||||
|
form.elements.deadZone.oninput=()=>config.deadZone=Number(form.elements.deadZone.value)/100;
|
||||||
|
form.elements.expo.oninput=()=>config.expo=Number(form.elements.expo.value)/100;
|
||||||
|
syncInputModeControls();
|
||||||
}
|
}
|
||||||
$('#controller-device').onchange=e=>{
|
$('#controller-device').onchange=e=>{
|
||||||
if(inputConfigurationLocked())return;
|
if(inputConfigurationLocked())return;
|
||||||
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
|
selectController(getControllers().find(pad=>controllerId(pad)===e.target.value));fillControllerForm();
|
||||||
if(pad){selectController(pad);fillControllerForm()}
|
|
||||||
};
|
};
|
||||||
$('#controller-form').onsubmit=e=>{
|
$('#controller-preset').onchange=e=>{if(e.target.value==='custom')return;controllerDraft=ControllerInput.preset(e.target.value);fillControllerForm(true)};
|
||||||
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
|
$('#controller-form').onsubmit=async e=>{
|
||||||
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
|
e.preventDefault();const form=e.currentTarget,feedback=form.querySelector('.form-feedback');
|
||||||
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
|
if(inputConfigurationLocked()){setFeedback(feedback,'Disengage first');return}
|
||||||
const config={deadZone:Number(form.elements.deadZone.value)/100};
|
if(!ControllerInput.validProfile(controllerDraft)){setFeedback(feedback,'Check inputs');return}
|
||||||
for(const name of ['turn','forward','vertical']){
|
const pad=getControllers().find(pad=>controllerId(pad)===controllerKey),device=pad?.id||'$xbox';
|
||||||
config[name]=Number(form.elements[name].value);
|
try{
|
||||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
|
const clean=bridgeReady?await api('save_controller_profile',device,controllerDraft):structuredClone(controllerDraft);
|
||||||
}
|
controllerProfiles[device]=clean;localStorage.setItem('sub-v2-profiles',JSON.stringify(controllerProfiles));controllerConfig=clean;
|
||||||
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
|
resetInputs();closeModal($('#controller-modal'));toast('Mapping saved');
|
||||||
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
|
}catch(error){setFeedback(feedback,error.message||String(error))}
|
||||||
}
|
|
||||||
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
|
|
||||||
resetInputs();closeModal($('#controller-modal'));
|
|
||||||
};
|
};
|
||||||
function pollController(now){
|
function pollController(now){
|
||||||
try{
|
try{
|
||||||
if(inputMode==='joystick'){
|
if(inputMode==='joystick'||$('#controller-modal').open){
|
||||||
const pads=getControllers();
|
const pads=getControllers();
|
||||||
if(controllerKey===null&&pads.length)selectController(pads[0]);
|
if(controllerKey===null&&pads.length&&!$('#controller-modal').open)selectController(pads[0]);
|
||||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
|
const pad=pads.find(pad=>controllerId(pad)===controllerKey),raw=pad&&ControllerInput.read(pad,controllerConfig||loadControllerConfig(pad));
|
||||||
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;
|
const target=raw&&(window.V2?.filterDemand(raw)||raw);
|
||||||
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
|
let status=!pad?'No controller':!raw?'Check mapping':controllerNeedCenter&&canDrive()?'Center controls':'Controller ready';
|
||||||
if(!target||!canDrive()){
|
if(inputMode==='joystick'){
|
||||||
if(controllerWasDriving)resetInputs();
|
if(!target||!canDrive()){
|
||||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
if(controllerWasDriving)resetInputs();controllerNeedCenter=true;controllerWasDriving=false;
|
||||||
}else{
|
}else{
|
||||||
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
|
if(controllerNeedCenter&&!Object.values(raw).some(value=>value!==0)){controllerNeedCenter=false;status='Controller ready'}
|
||||||
if(!controllerNeedCenter){
|
if(!controllerNeedCenter){
|
||||||
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
|
controllerWasDriving=true;
|
||||||
control.turn=demand.turn;control.forward=demand.forward;
|
Object.assign(control,target);window.joystick3D?.setVisual(target.turn,-target.forward);
|
||||||
window.joystick3D?.setVisual(demand.turn,-demand.forward);
|
setHeave(target.vertical,false);updateReadouts();sendAxes();
|
||||||
setHeave(demand.vertical,false);updateReadouts();sendAxes();
|
}
|
||||||
}
|
}
|
||||||
|
const active=ControllerInput.actions(pad,controllerConfig);
|
||||||
|
if(!Object.values(active).some(Boolean))controllerActionsReady=true;
|
||||||
|
if(controllerActionsReady&&inputFocused&&!document.hidden&&!document.querySelector('dialog[open]'))for(const [action,pressed] of Object.entries(active))
|
||||||
|
if(pressed&&!controllerActionState[action])window.V2?.action(action);
|
||||||
|
controllerActionState=active;
|
||||||
|
}
|
||||||
|
if($('#controller-modal').open){
|
||||||
|
const demand=pad&&ControllerInput.read(pad,controllerDraft);
|
||||||
|
for(const meter of $('#controller-bindings').querySelectorAll('meter'))meter.value=demand?.[meter.closest('[data-axis]').dataset.axis]||0;
|
||||||
|
$('#controller-axes').textContent=pad?.mapping==='standard'?'Xbox layout':pad?'Raw inputs':'Xbox · offline';
|
||||||
}
|
}
|
||||||
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
|
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
|
||||||
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
|
$('#controller-status').dataset.quiet=String(!!pad&&!!raw);
|
||||||
if($('#controller-modal').open){
|
}else{$('#controller-status').dataset.quiet='true';$('#controller-status').textContent='Keyboard + touch'}
|
||||||
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
|
}catch(error){if(controllerWasDriving)resetInputs();$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable'}
|
||||||
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);
|
requestAnimationFrame(pollController);
|
||||||
}
|
}
|
||||||
document.body.dataset.inputMode=inputMode;
|
document.body.dataset.inputMode=inputMode;
|
||||||
|
requestAnimationFrame(pollController);
|
||||||
|
|
||||||
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 makeDemoState(){const frame=window.V2?.station().frame??4,count=({0:6,1:6,2:8,3:8,4:3,5:4,6:5})[frame],servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false,current_a:3.2,consumed_mah:320,remaining_mah:5180,consumed_source:'Demo'},capabilities:{frame,firmware:[4,5,3]},accessories:{},link_quality:null,servos}}
|
||||||
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
||||||
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||||
@@ -156,7 +162,7 @@ function populatePorts(ports){
|
|||||||
const old=p.value||'';
|
const old=p.value||'';
|
||||||
p.innerHTML='';
|
p.innerHTML='';
|
||||||
|
|
||||||
const allList=[];
|
const allList=[{device:'udpin:0.0.0.0:14550',label:'Network · UDP 14550',type:'network'}];
|
||||||
const seen=new Set();
|
const seen=new Set();
|
||||||
|
|
||||||
const addPort=item=>{
|
const addPort=item=>{
|
||||||
@@ -194,7 +200,7 @@ function sendAxes(){
|
|||||||
axisTimer=0;
|
axisTimer=0;
|
||||||
if(!canDrive()||demoActive||axisBusy)return;
|
if(!canDrive()||demoActive||axisBusy)return;
|
||||||
lastAxisSend=performance.now();axisBusy=true;
|
lastAxisSend=performance.now();axisBusy=true;
|
||||||
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence))
|
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence,control.lateral,control.pitch,control.roll))
|
||||||
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
|
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
|
||||||
};
|
};
|
||||||
const wait=50-(performance.now()-lastAxisSend);
|
const wait=50-(performance.now()-lastAxisSend);
|
||||||
@@ -203,7 +209,7 @@ function sendAxes(){
|
|||||||
// This is a local bridge lease, not extra MAVLink traffic. The worker retains
|
// 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.
|
// the configured wire rate and neutralizes if the UI stops renewing its input.
|
||||||
setInterval(()=>{if(canDrive())sendAxes()},250);
|
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);}
|
function updateReadouts(){ window.V2?.updateDemand(control);if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||||
|
|
||||||
window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||||
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||||
@@ -216,8 +222,8 @@ heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||heavePoin
|
|||||||
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)}
|
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})};
|
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
|
||||||
|
|
||||||
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)}
|
function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const extra=window.V2?.filterDemand({lateral:(pressedKeys.has('l')?1:0)-(pressedKeys.has('j')?1:0),pitch:(pressedKeys.has('k')?1:0)-(pressedKeys.has('i')?1:0),roll:(pressedKeys.has('o')?1:0)-(pressedKeys.has('u')?1:0)});Object.assign(control,extra);updateReadouts();sendAxes();const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
|
||||||
addEventListener('keydown',e=>{if(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();});
|
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerfjlikuo'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||||
function resetInputs(){
|
function resetInputs(){
|
||||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
||||||
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||||
@@ -226,7 +232,7 @@ function resetInputs(){
|
|||||||
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
|
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
|
||||||
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
|
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
|
||||||
hknob.classList.remove('pressed','released');
|
hknob.classList.remove('pressed','released');
|
||||||
control.forward=control.turn=control.vertical=0;setHeave(0,false);
|
for(const axis of Object.keys(control))control[axis]=0;setHeave(0,false);window.V2?.updateDemand(control);controllerActionState={};controllerActionsReady=false;
|
||||||
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
|
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
|
||||||
}
|
}
|
||||||
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
|
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
|
||||||
@@ -750,6 +756,7 @@ function render(s,confirmed=false){
|
|||||||
const previous=view;
|
const previous=view;
|
||||||
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||||
syncInputModeControls();
|
syncInputModeControls();
|
||||||
|
window.V2?.render(s);
|
||||||
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||||
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
||||||
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
|
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
|
||||||
@@ -964,6 +971,9 @@ async function boot(){
|
|||||||
batteryConfig=init.battery_config||{};
|
batteryConfig=init.battery_config||{};
|
||||||
linkConfig=init.link_config||{};
|
linkConfig=init.link_config||{};
|
||||||
motorSettings=init.motor_settings||motorSettings;
|
motorSettings=init.motor_settings||motorSettings;
|
||||||
|
controllerProfiles=init.controller_profiles||controllerProfiles;
|
||||||
|
controllerConfig=null;
|
||||||
|
window.V2?.setStation(init.station_settings||{});
|
||||||
syncMotorScalingUI();
|
syncMotorScalingUI();
|
||||||
fillBatteryForm();
|
fillBatteryForm();
|
||||||
fillLinkForm();
|
fillLinkForm();
|
||||||
@@ -996,6 +1006,15 @@ function waitForBridge(){
|
|||||||
if(hasDesktopApi())boot();
|
if(hasDesktopApi())boot();
|
||||||
setTimeout(waitForBridge,250);
|
setTimeout(waitForBridge,250);
|
||||||
}
|
}
|
||||||
|
window.V2.init({
|
||||||
|
api,bridge:()=>bridgeReady,demo:()=>demoActive,toast,open:openModal,close:closeModal,
|
||||||
|
locked:inputConfigurationLocked,reset:resetInputs,touch:()=>window.canTouchControl(),demand:()=>control,
|
||||||
|
axis:(name,value)=>{control[name]=value;updateReadouts();sendAxes()},
|
||||||
|
scaling:allowed=>{const toggle=$('#motor-scale-independent');toggle.disabled=!allowed||inputConfigurationLocked();toggle.title=allowed?'Independent scales':'SimpleROV 3 only';if(!allowed&&motorSettings.independent){motorSettings.independent=false;syncMotorScalingUI();servoSignature=''}},
|
||||||
|
demoAccessory:(name,enabled)=>{if(demoState){demoState.accessories??={};demoState.accessories[name]={requested:enabled,accepted:true,demo:true};render(demoState)}},
|
||||||
|
previewFrame:(frame,count)=>{if(demoState){demoState.capabilities={frame,firmware:[4,5,3]};for(let i=1;i<=16;i++)demoState.servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?1:null};servoSignature='';render(demoState)}}
|
||||||
|
});
|
||||||
|
|
||||||
addEventListener('pywebviewready',boot);
|
addEventListener('pywebviewready',boot);
|
||||||
render(vehicleSnapshot);
|
render(vehicleSnapshot);
|
||||||
if(document.readyState==='loading'){
|
if(document.readyState==='loading'){
|
||||||
@@ -1003,6 +1022,3 @@ if(document.readyState==='loading'){
|
|||||||
}else{
|
}else{
|
||||||
waitForBridge();
|
waitForBridge();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
requestAnimationFrame(pollController);
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
|
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
|
||||||
}
|
}
|
||||||
function read(pad,config){
|
function read(pad,config){
|
||||||
|
if(config?.version===2)return readProfile(pad,config);
|
||||||
if(!pad?.connected||!pad.axes)return null;
|
if(!pad?.connected||!pad.axes)return null;
|
||||||
const axis=(index,invert)=>{
|
const axis=(index,invert)=>{
|
||||||
if(index===-1)return 0;
|
if(index===-1)return 0;
|
||||||
@@ -48,6 +49,77 @@
|
|||||||
return demand;
|
return demand;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
root.ControllerInput={deadZone,defaults,read,Motion};
|
const names=['forward','lateral','vertical','turn','pitch','roll'];
|
||||||
|
const pair=(negative='off',positive='off')=>({negative,positive,scale:1});
|
||||||
|
function preset(name='triggers3'){
|
||||||
|
const bindings=Object.fromEntries(names.map(axis=>[axis,pair()]));
|
||||||
|
bindings.forward=pair('button:6','button:7');
|
||||||
|
bindings.vertical=pair('button:0','button:1');
|
||||||
|
bindings.turn=pair('axis:0:-','axis:0:+');
|
||||||
|
if(name==='sixaxis'){
|
||||||
|
bindings.lateral=pair('axis:2:-','axis:2:+');
|
||||||
|
bindings.pitch=pair('axis:1:-','axis:1:+');
|
||||||
|
bindings.roll=pair('button:4','button:5');
|
||||||
|
}else if(name==='classic'){
|
||||||
|
bindings.forward=pair('axis:1:+','axis:1:-');
|
||||||
|
bindings.vertical=pair('axis:3:+','axis:3:-');
|
||||||
|
}
|
||||||
|
return {version:2,preset:name,deadZone:.08,expo:.15,bindings,
|
||||||
|
actions:{lights:'button:2',stream:'button:3',record:'button:8'}};
|
||||||
|
}
|
||||||
|
function migrate(config,pad){
|
||||||
|
if(config?.version===2)return structuredClone(config);
|
||||||
|
if(!config)return preset(pad&&pad.mapping!=='standard'?'classic':'triggers3');
|
||||||
|
const result=preset('classic');
|
||||||
|
for(const axis of ['forward','vertical','turn']){
|
||||||
|
const index=config[axis],invert=config['invert'+axis[0].toUpperCase()+axis.slice(1)];
|
||||||
|
result.bindings[axis]=Number.isInteger(index)&&index>=0?
|
||||||
|
pair(`axis:${index}:${invert?'+':'-'}`,`axis:${index}:${invert?'-':'+'}`):pair();
|
||||||
|
}
|
||||||
|
result.deadZone=config.deadZone??.08;
|
||||||
|
result.actions={lights:'off',stream:'off',record:'off'};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function validProfile(config){
|
||||||
|
const source=value=>typeof value==='string'&&/^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$/.test(value);
|
||||||
|
return config?.version===2&&Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&
|
||||||
|
Number.isFinite(config.expo)&&config.expo>=0&&config.expo<=1&&names.every(axis=>{
|
||||||
|
const item=config.bindings?.[axis];return item&&source(item.negative)&&source(item.positive)&&
|
||||||
|
(item.negative==='off'||item.negative!==item.positive)&&Number.isFinite(item.scale)&&item.scale>=0&&item.scale<=1;
|
||||||
|
})&&['lights','stream','record'].every(action=>source(config.actions?.[action])&&!config.actions[action].startsWith('axis:'));
|
||||||
|
}
|
||||||
|
function inputValue(pad,source,zone){
|
||||||
|
if(source==='off')return 0;
|
||||||
|
const parts=source.split(':'),index=Number(parts[1]);
|
||||||
|
if(parts[0]==='button'){
|
||||||
|
const button=pad.buttons?.[index];if(!button||!Number.isFinite(button.value))return null;
|
||||||
|
return index===6||index===7?Math.max(0,deadZone(button.value,zone)):(button.pressed?1:0);
|
||||||
|
}
|
||||||
|
const value=pad.axes?.[index];
|
||||||
|
return Number.isFinite(value)?Math.max(0,deadZone(value,zone)*(parts[2]==='-'?-1:1)):null;
|
||||||
|
}
|
||||||
|
function readProfile(pad,config){
|
||||||
|
if(!pad?.connected||!validProfile(config))return null;
|
||||||
|
const result={};
|
||||||
|
for(const name of names){
|
||||||
|
const item=config.bindings[name],negative=inputValue(pad,item.negative,config.deadZone),positive=inputValue(pad,item.positive,config.deadZone);
|
||||||
|
if(negative===null||positive===null)return null;
|
||||||
|
const value=clamp(positive-negative);
|
||||||
|
result[name]=((1-config.expo)*value+config.expo*value**3)*item.scale;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function actions(pad,config){
|
||||||
|
return Object.fromEntries(Object.entries(config?.actions||{}).map(([name,source])=>[name,!!pad&&inputValue(pad,source,0)>.5]));
|
||||||
|
}
|
||||||
|
function sources(pad){
|
||||||
|
const choices=[['off','Off']],axes=['Left X','Left Y','Right X','Right Y'];
|
||||||
|
for(let index=0;index<Math.max(4,pad?.axes?.length||0);index++)for(const sign of ['-','+'])
|
||||||
|
choices.push([`axis:${index}:${sign}`,`${axes[index]||'Axis '+index} ${sign==='-'?'−':'+'}`]);
|
||||||
|
const buttons=['A','B','X','Y','LB','RB','LT','RT','View','Menu','Left click','Right click','D-pad up','D-pad down','D-pad left','D-pad right','Guide'];
|
||||||
|
for(let index=0;index<Math.max(17,pad?.buttons?.length||0);index++)choices.push([`button:${index}`,buttons[index]||'Button '+index]);
|
||||||
|
return choices;
|
||||||
|
}
|
||||||
|
root.ControllerInput={deadZone,defaults,read,Motion,names,preset,migrate,validProfile,actions,sources};
|
||||||
if(typeof module!=='undefined')module.exports=root.ControllerInput;
|
if(typeof module!=='undefined')module.exports=root.ControllerInput;
|
||||||
})(typeof window!=='undefined'?window:globalThis);
|
})(typeof window!=='undefined'?window:globalThis);
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/* Video stays in the browser's media pipeline; no frames cross the control bridge. */
|
||||||
|
(()=>{
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const frames={0:['BlueROV1',6,['forward','lateral','vertical','turn','pitch','roll']],1:['Vectored',6,['forward','lateral','vertical','turn','roll']],2:['Full 6DOF',8,['forward','lateral','vertical','turn','pitch','roll']],3:['6DOF 90°',8,['forward','lateral','vertical','turn','pitch','roll']],4:['SimpleROV 3',3,['forward','vertical','turn']],5:['SimpleROV 4',4,['forward','vertical','turn','roll']],6:['SimpleROV 5',5,['forward','lateral','vertical','turn','roll']]};
|
||||||
|
let station={frame:4,video_mode:'whep',video_url:'',lights_relay:-1,camera_relay:-1};
|
||||||
|
try{station={...station,...JSON.parse(localStorage.getItem('sub-v2-station'))}}catch(_){}
|
||||||
|
let host,snapshot={},workspace='drive',streaming=false,connecting=false,generation=0,peer=null,sessionUrl=null,abort=null;
|
||||||
|
let recorder=null,recordToken=null,recordQueue=Promise.resolve(),recordChunks=[],recordBytes=0,ownedStream=null,recordError=null,saving=false;
|
||||||
|
let demoFrame=0,lastPaint=0,streamStarted=0,auxBusy=false,streamTimer=0,capabilitySignature='',demoLastClock=-1;
|
||||||
|
const notify=(message,error=false)=>host?.toast(message,error);
|
||||||
|
const call=(name,...args)=>host.api(name,...args);
|
||||||
|
const demo=()=>!!host?.demo();
|
||||||
|
const frame=()=>frames[(demo()||!snapshot.connected)?station.frame:snapshot.capabilities?.frame]||frames[4];
|
||||||
|
function supported(){
|
||||||
|
let axes=frame()[2];
|
||||||
|
const firmware=snapshot.capabilities?.firmware;
|
||||||
|
if(snapshot.connected&&!demo()&&!(firmware&&(firmware[0]>4||firmware[0]===4&&(firmware[1]===5||firmware[1]>=8))))axes=axes.filter(axis=>axis!=='pitch'&&axis!=='roll');
|
||||||
|
return axes;
|
||||||
|
}
|
||||||
|
function filterDemand(demand){return Object.fromEntries(Object.entries(demand).map(([name,value])=>[name,supported().includes(name)?value:0]))}
|
||||||
|
function view(name){
|
||||||
|
if(name===workspace)return;
|
||||||
|
host.reset();workspace=name;document.body.dataset.view=name;
|
||||||
|
$('dashboard').hidden=name!=='dashboard';document.querySelector('.primary-control-zone').hidden=name!=='drive';
|
||||||
|
$('view-drive').setAttribute('aria-pressed',String(name==='drive'));$('view-dashboard').setAttribute('aria-pressed',String(name==='dashboard'));
|
||||||
|
}
|
||||||
|
function fillStation(){
|
||||||
|
const form=$('station-form');
|
||||||
|
for(const [name,value] of Object.entries(station))if(form.elements[name])form.elements[name].value=value;
|
||||||
|
for(const field of form.querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
|
||||||
|
}
|
||||||
|
function state(text){$('camera-state').textContent=text;$('camera-empty').hidden=false}
|
||||||
|
function showStream(){
|
||||||
|
clearTimeout(streamTimer);connecting=false;
|
||||||
|
$('camera-empty').hidden=true;$('camera-record').disabled=station.video_mode==='mjpeg'&&!demo();
|
||||||
|
$('camera-badge').textContent=demo()?'Demo':'Stream';streamStarted=performance.now();demoLastClock=-1;$('stream-clock').textContent='00:00';
|
||||||
|
}
|
||||||
|
async function startStream(){
|
||||||
|
if(streaming||connecting)return;
|
||||||
|
if(!demo()&&!station.video_url){host.open('station-modal');return}
|
||||||
|
const current=++generation;connecting=true;state('Connecting');$('camera-toggle').textContent='Stop';
|
||||||
|
clearTimeout(streamTimer);streamTimer=setTimeout(()=>{if(current===generation)void failStream('Stream timeout')},10000);
|
||||||
|
try{
|
||||||
|
if(demo()){
|
||||||
|
$('camera-demo').hidden=false;streaming=true;showStream();demoFrame=requestAnimationFrame(drawDemo);
|
||||||
|
}else if(station.video_mode==='mjpeg'){
|
||||||
|
const img=$('camera-image');img.onload=()=>{if(current!==generation)return;streaming=true;img.hidden=false;showStream()};
|
||||||
|
img.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};img.src=station.video_url;
|
||||||
|
}else if(station.video_mode==='video'){
|
||||||
|
const video=$('camera-video');video.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};video.onended=()=>{if(current===generation)void stopStream()};video.src=station.video_url;video.hidden=false;await video.play();
|
||||||
|
if(current!==generation)return;streaming=true;showStream();
|
||||||
|
}else{
|
||||||
|
abort=new AbortController();const timeout=setTimeout(()=>abort?.abort(),10000);
|
||||||
|
try{
|
||||||
|
peer=new RTCPeerConnection({iceServers:[]});const connection=peer;
|
||||||
|
connection.addTransceiver('video',{direction:'recvonly'});
|
||||||
|
connection.ontrack=event=>{if(current!==generation)return;$('camera-video').srcObject=event.streams[0]||new MediaStream([event.track]);$('camera-video').hidden=false;void $('camera-video').play().catch(()=>{});streaming=true;showStream()};
|
||||||
|
connection.onconnectionstatechange=()=>{if(current===generation&&connection.connectionState==='failed')void failStream('Camera disconnected')};
|
||||||
|
await connection.setLocalDescription(await connection.createOffer());
|
||||||
|
if(connection.iceGatheringState!=='complete')await new Promise(resolve=>{
|
||||||
|
const timer=setTimeout(resolve,2000);connection.addEventListener('icegatheringstatechange',()=>{if(connection.iceGatheringState==='complete'){clearTimeout(timer);resolve()}},{once:true});
|
||||||
|
});
|
||||||
|
if(current!==generation)return;
|
||||||
|
const response=await fetch(station.video_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:connection.localDescription.sdp,signal:abort.signal});
|
||||||
|
if(!response.ok)throw new Error('WHEP '+response.status);
|
||||||
|
const location=response.headers.get('Location');let resource=location?new URL(location,station.video_url).href:null;
|
||||||
|
if(resource&&new URL(resource).origin!==new URL(station.video_url).origin)resource=null;
|
||||||
|
if(current!==generation){if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});return}
|
||||||
|
sessionUrl=resource;
|
||||||
|
const sdp=await response.text();if(current!==generation)return;
|
||||||
|
await connection.setRemoteDescription({type:'answer',sdp});
|
||||||
|
}finally{clearTimeout(timeout)}
|
||||||
|
}
|
||||||
|
}catch(error){if(current===generation){await stopStream();state('Unavailable');notify(error.message||'Stream unavailable',true)}}
|
||||||
|
finally{if(current===generation&&streaming)connecting=false}
|
||||||
|
}
|
||||||
|
async function failStream(message){await stopStream();state(message);notify(message,true)}
|
||||||
|
async function stopStream(){
|
||||||
|
++generation;connecting=false;streaming=false;abort?.abort();abort=null;
|
||||||
|
clearTimeout(streamTimer);
|
||||||
|
if(recorder&&recorder.state!=='inactive')recorder.stop();
|
||||||
|
peer?.close();peer=null;
|
||||||
|
if(sessionUrl){void fetch(sessionUrl,{method:'DELETE'}).catch(()=>{});sessionUrl=null}
|
||||||
|
cancelAnimationFrame(demoFrame);demoFrame=0;
|
||||||
|
const video=$('camera-video');video.onerror=video.onended=null;video.pause();video.srcObject=null;video.removeAttribute('src');video.load();video.hidden=true;
|
||||||
|
const img=$('camera-image');img.onload=img.onerror=null;img.removeAttribute('src');img.hidden=true;
|
||||||
|
$('camera-demo').hidden=true;$('camera-record').disabled=true;$('camera-toggle').textContent='Start';$('stream-clock').textContent='';state('Camera off');
|
||||||
|
}
|
||||||
|
function drawDemo(now){
|
||||||
|
if(!streaming||!demo())return;
|
||||||
|
demoFrame=requestAnimationFrame(drawDemo);
|
||||||
|
if(workspace!=='dashboard'||document.hidden||now-lastPaint<40)return;lastPaint=now;
|
||||||
|
const seconds=Math.floor((now-streamStarted)/1000);
|
||||||
|
if(seconds!==demoLastClock){demoLastClock=seconds;$('stream-clock').textContent=String(Math.floor(seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0')}
|
||||||
|
const canvas=$('camera-demo'),rect=canvas.getBoundingClientRect();
|
||||||
|
const width=Math.max(320,Math.round(rect.width)),height=Math.max(180,Math.round(rect.height));
|
||||||
|
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
|
||||||
|
const c=canvas.getContext('2d'),gradient=c.createLinearGradient(0,0,0,height);gradient.addColorStop(0,'#28444a');gradient.addColorStop(1,'#101e25');c.fillStyle=gradient;c.fillRect(0,0,width,height);
|
||||||
|
const light=c.createRadialGradient(width*.52,height*.62,4,width*.52,height*.62,width*.6);light.addColorStop(0,'#90aaa324');light.addColorStop(1,'#00000000');c.fillStyle=light;c.fillRect(0,0,width,height);
|
||||||
|
c.strokeStyle='#9eb2a92a';c.lineWidth=1;
|
||||||
|
for(let i=-4;i<8;i++){c.beginPath();c.moveTo(width*.5+i*16,height*.45);c.lineTo(width*.5+i*110,height);c.stroke()}
|
||||||
|
for(let i=0;i<8;i++){const y=height*.45+(i/8)**2*height*.55;c.beginPath();c.moveTo(0,y);c.lineTo(width,y);c.stroke()}
|
||||||
|
const pipe=c.createLinearGradient(0,height*.58,0,height*.76);pipe.addColorStop(0,'#a1afa0');pipe.addColorStop(.4,'#6e8980');pipe.addColorStop(1,'#263e3d');c.fillStyle=pipe;c.fillRect(width*.16,height*.58,width*.72,height*.18);
|
||||||
|
c.strokeStyle='#b3c2b166';c.strokeRect(width*.16,height*.58,width*.72,height*.18);
|
||||||
|
c.fillStyle='#c3d7c977';for(let i=0;i<32;i++){const x=(i*89.7+Math.sin(now/5000+i)*9)%width,y=(i*37.9+now*.004)%height;c.fillRect(x,y,1,1)}
|
||||||
|
c.strokeStyle='#e1f0e477';c.beginPath();c.moveTo(width/2-10,height/2);c.lineTo(width/2+10,height/2);c.moveTo(width/2,height/2-10);c.lineTo(width/2,height/2+10);c.stroke();
|
||||||
|
}
|
||||||
|
async function toggleRecord(){
|
||||||
|
if(saving)return;
|
||||||
|
if(recorder&&recorder.state!=='inactive'){recorder.stop();return}
|
||||||
|
if(!streaming)return;
|
||||||
|
try{
|
||||||
|
const stream=demo()?$('camera-demo').captureStream(24):$('camera-video').srcObject||$('camera-video').captureStream?.();
|
||||||
|
if(!stream?.getVideoTracks().length)throw new Error('Recording unavailable');
|
||||||
|
ownedStream=demo()?stream:null;recordToken=host.bridge()?await call('recording_begin'):null;recordQueue=Promise.resolve();recordChunks=[];recordBytes=0;recordError=null;
|
||||||
|
const mime=['video/webm;codecs=vp8','video/webm'].find(type=>MediaRecorder.isTypeSupported(type));
|
||||||
|
recorder=new MediaRecorder(stream,mime?{mimeType:mime,videoBitsPerSecond:4000000}:undefined);
|
||||||
|
recorder.ondataavailable=event=>{
|
||||||
|
if(!event.data.size)return;
|
||||||
|
if(recordToken){const token=recordToken;recordQueue=recordQueue.then(async()=>{
|
||||||
|
if(recordError)return;
|
||||||
|
const data=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=reject;reader.readAsDataURL(event.data)});
|
||||||
|
await call('recording_chunk',token,data);
|
||||||
|
}).catch(error=>{recordError=error;if(recorder?.state==='recording')recorder.stop()})}
|
||||||
|
else{recordChunks.push(event.data);recordBytes+=event.data.size;if(recordBytes>256*1024*1024&&recorder.state==='recording')recorder.stop()}
|
||||||
|
};
|
||||||
|
recorder.onstop=async()=>{
|
||||||
|
saving=true;$('camera-record').textContent='Saving';$('recording-badge').hidden=true;
|
||||||
|
try{
|
||||||
|
await recordQueue;
|
||||||
|
if(recordToken){const path=await call('recording_end',recordToken);notify(recordError?'Recording interrupted':'Video saved',!!recordError);if(path)$('camera-record').title=path}
|
||||||
|
else{const blob=new Blob(recordChunks,{type:recorder.mimeType}),url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download='argo-'+Date.now()+'.webm';link.click();setTimeout(()=>URL.revokeObjectURL(url),60000);notify('Video saved')}
|
||||||
|
}catch(error){notify(error.message||'Save failed',true)}
|
||||||
|
finally{ownedStream?.getTracks().forEach(track=>track.stop());ownedStream=null;recordToken=null;recordChunks=[];saving=false;$('camera-record').textContent='Record';$('camera-record').setAttribute('aria-pressed','false');recorder=null}
|
||||||
|
};
|
||||||
|
recorder.start(1000);$('camera-record').textContent='Stop record';$('camera-record').setAttribute('aria-pressed','true');$('recording-badge').hidden=false;
|
||||||
|
}catch(error){if(recordToken)await call('recording_end',recordToken).catch(()=>{});recordToken=null;notify(error.message||'Recording unavailable',true)}
|
||||||
|
}
|
||||||
|
async function accessory(name){
|
||||||
|
if(auxBusy||!snapshot.connected)return;
|
||||||
|
const relay=station[name+'_relay'];if(relay<0){host.open('station-modal');return}
|
||||||
|
auxBusy=true;
|
||||||
|
try{
|
||||||
|
const previous=snapshot.accessories?.[name],enabled=!(previous?.accepted&&previous.requested);
|
||||||
|
if(demo())host.demoAccessory(name,enabled);else await call('set_accessory',name,enabled);
|
||||||
|
if(name==='camera'&&!enabled)await stopStream();
|
||||||
|
}catch(error){notify(error.message||'Accessory unavailable',true)}finally{auxBusy=false}
|
||||||
|
}
|
||||||
|
function updateDemand(demand){
|
||||||
|
if(!host)return;
|
||||||
|
const axes=supported();$('extra-controls').hidden=!axes.some(axis=>['lateral','pitch','roll'].includes(axis));
|
||||||
|
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
|
||||||
|
const axis=label.dataset.motion;label.hidden=!axes.includes(axis);label.querySelector('input').value=Math.round((demand[axis]||0)*100);
|
||||||
|
label.querySelector('input').disabled=!host.touch();label.querySelector('output').value=Math.round((demand[axis]||0)*100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function render(s){
|
||||||
|
snapshot=s;
|
||||||
|
if(!host)return;
|
||||||
|
const signature=String(!!s.connected)+':'+supported().join(',');
|
||||||
|
if(capabilitySignature&&signature!==capabilitySignature)host.reset();
|
||||||
|
capabilitySignature=signature;
|
||||||
|
host.scaling((demo()||!s.connected)?station.frame===4:s.capabilities?.frame===4);
|
||||||
|
if($('station-modal').open)for(const field of $('station-form').querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
|
||||||
|
const telemetry=demo()&&s.connected?{
|
||||||
|
attitude:{values:{heading:124,pitch:4.2,roll:-2.1},stale:false},depth:{values:{depth_m:1.8},stale:false},
|
||||||
|
imu:{values:{ax:.12,ay:-.08,az:9.78,gx:.01,gy:.02,gz:0,mx:.21,my:.04,mz:.43},stale:false},pressure:{values:{pressure_hpa:1190,temperature_c:18.6},stale:false}
|
||||||
|
}:s.telemetry||{};
|
||||||
|
const value=(group,key)=>s.connected&&!telemetry[group]?.stale?telemetry[group]?.values?.[key]:null;
|
||||||
|
const format=(value,unit,digits=1)=>Number.isFinite(value)?value.toFixed(digits)+unit:'—';
|
||||||
|
const show=(id,number,unit,digits)=>{$(id).textContent=format(number,unit,digits);$(id).parentElement.dataset.stale=String(!Number.isFinite(number))};
|
||||||
|
show('live-depth',value('depth','depth_m'),' m',2);show('live-heading',value('attitude','heading'),'°',0);show('live-pitch',value('attitude','pitch'),'°',1);show('live-roll',value('attitude','roll'),'°',1);
|
||||||
|
const battery=s.battery||{},fresh=s.connected&&!s.battery_stale;
|
||||||
|
const current=fresh&&!battery.current_stale?battery.current_a:null;
|
||||||
|
show('live-current',current,' A',1);show('live-power',fresh&&Number.isFinite(current)&&Number.isFinite(battery.voltage)?current*battery.voltage:null,' W',1);
|
||||||
|
show('live-charge',fresh?battery.consumed_mah:null,' mAh',0);
|
||||||
|
for(const [id,keys,unit] of [['live-accel',['ax','ay','az'],'m/s²'],['live-gyro',['gx','gy','gz'],'rad/s'],['live-mag',['mx','my','mz'],'G']]){
|
||||||
|
const values=keys.map(key=>value('imu',key));$(id).textContent=values.every(Number.isFinite)?values.map(value=>value.toFixed(2)).join(' / ')+' '+unit:'—';
|
||||||
|
}
|
||||||
|
show('live-temperature',value('pressure','temperature_c'),' °C',1);show('live-pressure',value('pressure','pressure_hpa'),' hPa',1);
|
||||||
|
$('sensor-state').textContent=demo()&&s.connected?'Demo':!s.connected?'Offline':Object.values(telemetry).some(group=>!group.stale)?'Receiving':'No data';
|
||||||
|
$('detected-frame').textContent=s.connected&&!demo()?(frames[s.capabilities?.frame]?.[0]||'Detecting'):demo()?'Demo':'Offline';
|
||||||
|
for(const [name,id] of [['lights','lights-toggle'],['camera','camera-power']]){
|
||||||
|
const item=s.accessories?.[name],pending=item&&item.accepted===null&&!item.error;
|
||||||
|
$(id).disabled=!s.connected||station[name+'_relay']<0||!!pending;$(id).setAttribute('aria-pressed',String(!!(item?.accepted&&item.requested)));
|
||||||
|
$(id).title=item?.error||(pending?'Awaiting ACK':station[name+'_relay']<0?'Configure relay':'Relay '+station[name+'_relay']);
|
||||||
|
}
|
||||||
|
if(streaming)$('stream-clock').textContent=new Date(performance.now()-streamStarted).toISOString().slice(14,19);
|
||||||
|
$('sensor-record').setAttribute('aria-pressed',String(!!s.sensor_recording));$('sensor-record').textContent=s.sensor_recording?'Stop log':'Log data';
|
||||||
|
$('sensor-record').disabled=!s.connected||demo();
|
||||||
|
updateDemand(host.demand());
|
||||||
|
}
|
||||||
|
function action(name){if(name==='stream')void(streaming||connecting?stopStream():startStream());else if(name==='record')void toggleRecord();else if(name==='lights')void accessory('lights')}
|
||||||
|
function init(callbacks){
|
||||||
|
host=callbacks;
|
||||||
|
$('view-drive').onclick=()=>view('drive');$('view-dashboard').onclick=()=>view('dashboard');
|
||||||
|
$('camera-toggle').onclick=()=>action('stream');$('camera-record').onclick=()=>action('record');$('lights-toggle').onclick=()=>accessory('lights');$('camera-power').onclick=()=>accessory('camera');
|
||||||
|
$('sensor-record').onclick=async()=>{try{if(demo())throw new Error('Live telemetry required');if(!host.bridge())throw new Error('Connect desktop');const path=await call('sensor_record',!snapshot.sensor_recording);if(path)$('sensor-record').title=path}catch(error){notify(error.message,true)}};
|
||||||
|
for(const name of ['lights_relay','camera_relay']){
|
||||||
|
const select=$('station-form').elements[name];select.add(new Option('Off','-1'));for(let index=0;index<16;index++)select.add(new Option('Relay '+index,String(index)));
|
||||||
|
}
|
||||||
|
const stationObserver=new MutationObserver(()=>{if($('station-modal').open)fillStation()});stationObserver.observe($('station-modal'),{attributes:true,attributeFilter:['open']});
|
||||||
|
document.querySelector('[data-open=station-modal]').addEventListener('click',fillStation);
|
||||||
|
$('station-form').onsubmit=async event=>{
|
||||||
|
event.preventDefault();const form=event.currentTarget,feedback=form.querySelector('.form-feedback');
|
||||||
|
if(host.locked()){feedback.textContent='Disengage first';feedback.hidden=false;return}
|
||||||
|
const config=Object.fromEntries(new FormData(form));for(const name of ['frame','lights_relay','camera_relay'])config[name]=Number(config[name]);
|
||||||
|
try{
|
||||||
|
if(config.video_url){const url=new URL(config.video_url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw new Error('Use an HTTP stream URL')}
|
||||||
|
if(config.lights_relay>=0&&config.lights_relay===config.camera_relay)throw new Error('Use separate relays');
|
||||||
|
if(streaming||connecting)await stopStream();
|
||||||
|
station=host.bridge()?await call('save_station_settings',config):config;localStorage.setItem('sub-v2-station',JSON.stringify(station));
|
||||||
|
host.previewFrame(station.frame,frames[station.frame][1]);host.reset();host.close($('station-modal'));notify('Vehicle saved');render(snapshot);
|
||||||
|
}catch(error){feedback.textContent=error.message||String(error);feedback.hidden=false}
|
||||||
|
};
|
||||||
|
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
|
||||||
|
const input=label.querySelector('input'),axis=label.dataset.motion;
|
||||||
|
input.oninput=()=>{if(host.touch())host.axis(axis,Number(input.value)/100)};
|
||||||
|
const release=()=>{if(host.touch())host.axis(axis,0);input.value=0};
|
||||||
|
input.onpointerup=release;input.onpointercancel=release;input.onblur=release;input.onkeyup=release;
|
||||||
|
}
|
||||||
|
for(const [name,text,value] of [['attitude_hz','Attitude · Hz',2],['depth_hz','Depth · Hz',2],['imu_hz','IMU · Hz',1],['pressure_hz','Pressure · Hz',1]]){
|
||||||
|
const label=document.createElement('label'),span=document.createElement('span'),select=document.createElement('select');span.textContent=text;select.name=name;
|
||||||
|
for(const rate of [0,1,2,5,10])select.add(new Option(rate===0?'Off':String(rate),String(rate)));select.value=String(value);label.append(span,select);$('link-form').querySelectorAll('.form-grid')[1].append(label);
|
||||||
|
}
|
||||||
|
addEventListener('pagehide',()=>void stopStream());document.body.dataset.view='drive';render(snapshot);
|
||||||
|
}
|
||||||
|
window.V2={init,render,filterDemand,action,updateDemand,station:()=>station,setStation:config=>{station={...station,...config};render(snapshot)}};
|
||||||
|
})();
|
||||||
+35
-15
@@ -3,8 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Sub Control</title>
|
<title>Argo V2</title>
|
||||||
<link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
|
<link rel="stylesheet" href="styles.css?v=20261022-v2">
|
||||||
|
<link rel="stylesheet" href="v2.css?v=20261022-v2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
<!-- Telemetry / Status Zone -->
|
<!-- Telemetry / Status Zone -->
|
||||||
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
|
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
|
||||||
<div class="deck-card model-card">
|
<div class="deck-card model-card">
|
||||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
<div class="rov-stage" aria-label="Vehicle model">
|
||||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||||
<div id="model-status" class="model-status">Initializing…</div>
|
<div id="model-status" class="model-status">Initializing…</div>
|
||||||
<div class="model-tools">
|
<div class="model-tools">
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
|
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
|
||||||
<!-- Frameless Drag & Window Controls Header -->
|
<!-- Frameless Drag & Window Controls Header -->
|
||||||
<div class="workspace-topbar">
|
<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="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal">Mapping</button></div>
|
||||||
<div class="workspace-drag-area pywebview-drag-region"></div>
|
<div class="workspace-drag-area pywebview-drag-region"></div>
|
||||||
<div class="window-actions">
|
<div class="window-actions">
|
||||||
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
||||||
@@ -80,7 +81,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="workspace-nav"><div class="view-switch" role="group" aria-label="Workspace"><button id="view-drive" type="button" aria-pressed="true">Drive</button><button id="view-dashboard" type="button" aria-pressed="false">Dashboard</button></div><span class="version-mark">V2</span><button class="quiet-button" data-open="station-modal" type="button">Vehicle</button></div>
|
||||||
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
|
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
|
||||||
|
<section id="dashboard" class="dashboard" aria-label="Vehicle dashboard" hidden>
|
||||||
|
<div class="camera-stage">
|
||||||
|
<video id="camera-video" muted autoplay playsinline hidden></video><img id="camera-image" alt="Camera stream" hidden><canvas id="camera-demo" aria-label="Simulated inspection camera" hidden></canvas>
|
||||||
|
<div id="camera-empty" class="camera-empty"><svg viewBox="0 0 48 48" aria-hidden="true"><rect x="5" y="12" width="27" height="24" rx="6"/><path d="m32 20 11-6v20l-11-6"/></svg><span id="camera-state">Camera off</span></div>
|
||||||
|
<div class="camera-overlay"><span id="camera-badge">Preview</span><span id="recording-badge" hidden>● REC</span><span id="stream-clock"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-actions"><button id="camera-toggle" type="button">Start</button><button id="camera-record" type="button" aria-pressed="false" disabled>Record</button><button id="lights-toggle" type="button" aria-pressed="false" disabled>Lights</button><button id="camera-power" type="button" aria-pressed="false" disabled>Camera power</button><button id="sensor-record" type="button" aria-pressed="false">Log data</button></div>
|
||||||
|
<div class="telemetry-strip" aria-label="Live telemetry"><div><span>Depth</span><strong id="live-depth">—</strong></div><div><span>Heading</span><strong id="live-heading">—</strong></div><div><span>Current</span><strong id="live-current">—</strong></div><div><span>Pitch</span><strong id="live-pitch">—</strong></div><div><span>Roll</span><strong id="live-roll">—</strong></div><div><span>Power</span><strong id="live-power">—</strong></div></div>
|
||||||
|
<details class="sensor-details"><summary>Sensors <span id="sensor-state">Offline</span></summary><div class="sensor-values"><div><span>Acceleration</span><strong id="live-accel">—</strong></div><div><span>Gyroscope</span><strong id="live-gyro">—</strong></div><div><span>Magnetometer</span><strong id="live-mag">—</strong></div><div><span>Temperature</span><strong id="live-temperature">—</strong></div><div><span>Pressure</span><strong id="live-pressure">—</strong></div><div><span>Charge used</span><strong id="live-charge">—</strong></div></div></details>
|
||||||
|
</section>
|
||||||
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
||||||
<!-- Reserved Engage / Disarm row -->
|
<!-- Reserved Engage / Disarm row -->
|
||||||
<div class="engage-header-zone">
|
<div class="engage-header-zone">
|
||||||
@@ -146,6 +158,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="extra-controls" class="extra-controls" hidden><label data-motion="lateral"><span>Strafe</span><input type="range" min="-100" max="100" value="0" aria-label="Strafe demand"><output>0</output></label><label data-motion="pitch"><span>Pitch</span><input type="range" min="-100" max="100" value="0" aria-label="Pitch demand"><output>0</output></label><label data-motion="roll"><span>Roll</span><input type="range" min="-100" max="100" value="0" aria-label="Roll demand"><output>0</output></label></div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
|
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
|
||||||
@@ -218,28 +231,35 @@
|
|||||||
<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 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>
|
<div id="motor-list" class="motor-list"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
|
<dialog id="controller-modal" class="modal controller-modal" aria-labelledby="controller-title">
|
||||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
|
<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.">
|
<form id="controller-form">
|
||||||
<label><span>Controller</span><select id="controller-device"></select></label>
|
<div class="form-grid two"><label><span>Controller</span><select id="controller-device"></select></label><label><span>Preset</span><select id="controller-preset"><option value="triggers3">Triggers · 3</option><option value="sixaxis">Six axes</option><option value="classic">Classic sticks</option><option value="custom">Custom</option></select></label></div>
|
||||||
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
|
<p id="controller-axes" class="note" hidden>Xbox · offline</p>
|
||||||
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
|
<div class="binding-head"><span>Motion</span><span>Negative</span><span>Positive</span><span></span><span>Limit %</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 id="controller-bindings"></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="form-grid two mapping-tuning"><label><span>Dead zone %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label><label><span>Expo %</span><input name="expo" type="number" min="0" max="100" step="1" required></label></div>
|
||||||
<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>
|
<details class="mapping-actions"><summary>Buttons</summary><div class="form-grid three"><label><span>Lights</span><select name="lights"></select></label><label><span>Stream</span><select name="stream"></select></label><label><span>Record</span><select name="record"></select></label></div></details>
|
||||||
<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>
|
<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>
|
<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>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="station-modal" class="modal station-modal" aria-labelledby="station-title"><button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="station-title">Vehicle</h2><form id="station-form">
|
||||||
|
<div class="form-grid two"><label><span>Preview frame</span><select name="frame"><option value="4">SimpleROV · 3</option><option value="0">BlueROV1 · 6</option><option value="1">Vectored · 6</option><option value="2">Full 6DOF · 8</option><option value="3">6DOF 90° · 8</option><option value="5">SimpleROV · 4</option><option value="6">SimpleROV · 5</option></select></label><div class="detected-frame"><span>FC frame</span><strong id="detected-frame">Offline</strong></div></div>
|
||||||
|
<section class="settings-section"><h3>Camera</h3><label><span>Stream type</span><select name="video_mode"><option value="whep">WebRTC · WHEP</option><option value="video">HTTP video</option><option value="mjpeg">MJPEG</option></select></label><label><span>Stream URL</span><input name="video_url" type="url" placeholder="http://192.168.2.2:8889/cam/whep" autocomplete="off"></label></section>
|
||||||
|
<section class="settings-section"><h3>Accessories</h3><div class="form-grid two"><label><span>Lights relay</span><select name="lights_relay"></select></label><label><span>Camera relay</span><select name="camera_relay"></select></label></div></section>
|
||||||
|
<p class="form-feedback" role="alert" hidden></p><div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save</button></div>
|
||||||
|
</form></dialog>
|
||||||
|
|
||||||
<script src="model-viewer.js?v=20260826-controls-v2"></script>
|
<script src="model-viewer.js?v=20260826-controls-v2"></script>
|
||||||
<script src="joystick-3d.js?v=20261002-spacing-motion-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="control-state.js?v=20260827-state-v1"></script>
|
||||||
<script src="controller-input.js?v=20261001-controller-v1"></script>
|
<script src="controller-input.js?v=20261022-v2"></script>
|
||||||
|
<script src="dashboard.js?v=20261022-v2"></script>
|
||||||
<script src="console-view.js?v=20261002-console-v2"></script>
|
<script src="console-view.js?v=20261002-console-v2"></script>
|
||||||
<script src="app.js?v=20261017-engage-center-v2"></script>
|
<script src="app.js?v=20261022-v2"></script>
|
||||||
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/* V2 uses the existing deck, type scale, and flat settings rows. */
|
||||||
|
:root { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||||
|
[hidden] { display:none !important; }
|
||||||
|
.workspace-nav { display:flex; align-items:center; gap:12px; margin:0 0 10px; min-height:32px; }
|
||||||
|
.view-switch { display:flex; gap:2px; padding:3px; background:#e1e1e1; border-radius:20px; }
|
||||||
|
.view-switch button,.quiet-button { border:0; background:transparent; color:#737575; border-radius:18px; padding:6px 14px; cursor:pointer; font-size:12px; }
|
||||||
|
.view-switch [aria-pressed=true] { background:#fafafa; color:#242929; box-shadow:0 1px 3px #0000000c; }
|
||||||
|
.workspace-nav .quiet-button { margin-left:auto; }
|
||||||
|
.version-mark { color:#9a9e9d; font-size:10px; letter-spacing:.08em; }
|
||||||
|
#controller-settings { display:block; }
|
||||||
|
.controller-modal { width:min(730px,calc(100vw - 28px)); max-height:calc(100dvh - 28px); overflow:auto; }
|
||||||
|
.binding-head,.binding-row { display:grid; grid-template-columns:80px minmax(100px,1fr) minmax(100px,1fr) 28px 54px; gap:8px; align-items:center; }
|
||||||
|
.binding-head { font-size:10px; color:#858b89; padding:8px 0; }
|
||||||
|
.binding-row { padding:9px 0; border-top:1px solid #dedfdd; }
|
||||||
|
.binding-row > span { font-size:12px; font-weight:500; }
|
||||||
|
.binding-row meter { display:block; width:50px; height:3px; margin-top:7px; accent-color:#6d9c8b; }
|
||||||
|
.binding-row select,.binding-row input { height:34px; min-height:34px; padding:0 8px; border-radius:8px; font-size:12px; min-width:0; }
|
||||||
|
.binding-row input { text-align:center; padding:0 2px; font-variant-numeric:tabular-nums; }
|
||||||
|
.binding-swap { background:transparent; border:0; height:32px; color:#6a7470; font-size:19px; cursor:pointer; border-radius:7px; }
|
||||||
|
.binding-swap:hover { background:#e0e5e1; }
|
||||||
|
.mapping-tuning { margin-top:14px; }
|
||||||
|
.mapping-actions { margin-top:16px; font-size:12px; }
|
||||||
|
.mapping-actions summary { padding:8px 0; cursor:pointer; color:#727b76; }
|
||||||
|
.form-grid.three { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
|
||||||
|
.dashboard { display:flex; flex:1; flex-direction:column; min-height:0; gap:0; overflow:auto; }
|
||||||
|
.camera-stage { flex:1 1 220px; min-height:150px; position:relative; border-radius:14px; overflow:hidden; background:#18282d; color:#f2f6f5; isolation:isolate; }
|
||||||
|
.camera-stage video,.camera-stage img,.camera-stage canvas { display:block; width:100%; height:100%; position:absolute; inset:0; object-fit:contain; }
|
||||||
|
.camera-empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:#8fa5aa; font-size:12px; }
|
||||||
|
.camera-empty svg { width:40px; fill:none; stroke:currentColor; stroke-width:1.4; opacity:.65; }
|
||||||
|
.camera-overlay { pointer-events:none; position:absolute; top:14px; left:16px; right:16px; display:flex; align-items:center; gap:10px; font-size:10px; text-shadow:0 1px 3px #000; color:#d7e5e4; }
|
||||||
|
#camera-badge { border:1px solid #a8c1c245; border-radius:10px; padding:3px 8px; background:#16282c55; }
|
||||||
|
#recording-badge { color:#ff9e93; font-weight:600; }
|
||||||
|
#stream-clock { margin-left:auto; font-variant-numeric:tabular-nums; }
|
||||||
|
.dashboard-actions { display:flex; align-items:center; gap:5px; padding:10px 0; flex-wrap:wrap; }
|
||||||
|
.dashboard-actions button { min-height:32px; padding:6px 12px; border-radius:18px; border:0; background:#e0e2df; color:#46524b; font-size:11px; cursor:pointer; white-space:nowrap; }
|
||||||
|
.dashboard-actions button[aria-pressed=true] { background:#d0ddd5; color:#345844; }
|
||||||
|
.dashboard-actions button:disabled { opacity:.4; cursor:default; }
|
||||||
|
.telemetry-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); row-gap:12px; border-top:1px solid #d9deda; padding:14px 0; }
|
||||||
|
.telemetry-strip > div { min-width:0; display:flex; flex-direction:column; gap:3px; }
|
||||||
|
.telemetry-strip span { font-size:10px; color:#7b8480; }
|
||||||
|
.telemetry-strip strong { font-weight:400; font-size:clamp(18px,2.2vw,26px); color:#36463d; font-variant-numeric:tabular-nums; letter-spacing:-.035em; white-space:nowrap; }
|
||||||
|
.telemetry-strip [data-stale=true] strong { color:#a4aaa5; }
|
||||||
|
.sensor-details { border-top:1px solid #d9deda; padding:10px 0 0; font-size:11px; }
|
||||||
|
.sensor-details summary { cursor:pointer; color:#58665d; }
|
||||||
|
.sensor-details summary span { float:right; color:#939c96; font-size:10px; }
|
||||||
|
.sensor-values { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; padding:14px 0; }
|
||||||
|
.sensor-values div { min-width:0; display:flex; flex-direction:column; gap:4px; }
|
||||||
|
.sensor-values span { color:#87908a; font-size:10px; }
|
||||||
|
.sensor-values strong { font-size:11px; font-weight:500; color:#4b5c50; font-variant-numeric:tabular-nums; }
|
||||||
|
.detected-frame { display:flex; flex-direction:column; gap:9px; color:#859086; font-size:12px; padding-top:4px; }
|
||||||
|
.detected-frame strong { color:#4b5c50; font-weight:500; }
|
||||||
|
.extra-controls { display:flex; gap:16px; margin-top:8px; padding:10px 0 0; border-top:1px solid #d9deda; }
|
||||||
|
.extra-controls label { display:grid; flex:1; min-width:0; grid-template-columns:1fr 24px; gap:4px; color:#7e8981; font-size:10px; }
|
||||||
|
.extra-controls label input { width:100%; min-width:0; grid-row:2; grid-column:1/-1; accent-color:#668c78; height:18px; }
|
||||||
|
.extra-controls output { text-align:right; font-variant-numeric:tabular-nums; }
|
||||||
|
.primary-control-zone { grid-template-rows:clamp(64px,11vh,90px) minmax(0,1fr) auto auto; }
|
||||||
|
.controls-grid { --joy-size:min(34vh,calc(100cqw - 146px),370px); }
|
||||||
|
.workspace-topbar { margin-bottom:8px; }
|
||||||
|
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):active { transform:scale(.97); }
|
||||||
|
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):focus-visible { outline:2px solid #6b9a84; outline-offset:2px; }
|
||||||
|
.station-modal { width:min(560px,calc(100vw - 28px)); }
|
||||||
|
body[data-view=dashboard] #controller-status { position:absolute; clip:rect(0,0,0,0); }
|
||||||
|
@media (max-width:760px) {
|
||||||
|
.binding-head,.binding-row { grid-template-columns:66px minmax(0,1fr) minmax(0,1fr) 24px 46px; gap:6px; }
|
||||||
|
.binding-row select { padding:0 4px; font-size:11px; }
|
||||||
|
.controller-modal { padding:22px 18px; }
|
||||||
|
.controls-workspace { padding:16px; }
|
||||||
|
.dashboard-actions { gap:4px; }.dashboard-actions button { padding:6px 9px; }
|
||||||
|
}
|
||||||
|
@media (max-width:600px) {
|
||||||
|
.app-shell { grid-template-columns:170px minmax(0,1fr); }
|
||||||
|
.sidebar-deck { padding:14px; }
|
||||||
|
.input-mode-switch button { padding:6px 8px; }
|
||||||
|
.controls-grid { --joy-size:min(30vh,calc(100cqw - 95px),300px); grid-template-columns:minmax(0,1fr) 70px; gap:6px; }
|
||||||
|
.control-toolbar { flex-wrap:wrap; gap:8px; }.control-toolbar .range-pill { width:100px; }
|
||||||
|
.control-toolbar .output-limit > span { width:auto; flex-basis:auto; }
|
||||||
|
.window-actions { gap:0; }.window-actions button { width:24px; }
|
||||||
|
.extra-controls { gap:8px; }.workspace-nav { gap:6px; }
|
||||||
|
.view-switch button { padding:6px 9px; }.workspace-nav .quiet-button { padding:6px; }
|
||||||
|
.version-mark { display:none; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion:reduce) { :is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button) { transition:none; transform:none !important; } }
|
||||||
+192
-12
@@ -8,8 +8,10 @@ import time
|
|||||||
import base64
|
import base64
|
||||||
import shutil
|
import shutil
|
||||||
import http.server
|
import http.server
|
||||||
import socketserver
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import csv
|
||||||
|
import uuid
|
||||||
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import webview
|
import webview
|
||||||
@@ -17,6 +19,7 @@ from serial.tools import list_ports
|
|||||||
|
|
||||||
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
||||||
from vehicle_state import VehicleState
|
from vehicle_state import VehicleState
|
||||||
|
from rov_v2 import Telemetry, clean_profile, clean_station, save_json, number
|
||||||
|
|
||||||
|
|
||||||
class ControlApi:
|
class ControlApi:
|
||||||
@@ -31,9 +34,28 @@ class ControlApi:
|
|||||||
self.battery_last_current = None
|
self.battery_last_current = None
|
||||||
self.battery_last_current_at = None
|
self.battery_last_current_at = None
|
||||||
self.battery_session_consumed_mah = 0.0
|
self.battery_session_consumed_mah = 0.0
|
||||||
|
self.battery_fields_at = {}
|
||||||
self.demo_mode = False
|
self.demo_mode = False
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self._poll_lock = threading.Lock()
|
self._poll_lock = threading.Lock()
|
||||||
|
self.telemetry = Telemetry()
|
||||||
|
self.station_path = Path(__file__).resolve().parent / "station_settings.json"
|
||||||
|
self.profiles_path = self.station_path.with_name("controller_profiles.json")
|
||||||
|
self.station_settings = clean_station({})
|
||||||
|
self.controller_profiles = {}
|
||||||
|
try:
|
||||||
|
self.station_settings = clean_station(json.loads(self.station_path.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.controller_profiles = {str(key)[:256]: clean_profile(value) for key, value in
|
||||||
|
json.loads(self.profiles_path.read_text(encoding="utf-8")).items()}
|
||||||
|
except (OSError, ValueError, TypeError, AttributeError):
|
||||||
|
pass
|
||||||
|
self._recording_lock = threading.Lock()
|
||||||
|
self._recording = None
|
||||||
|
self._sensor_file = None
|
||||||
|
self._sensor_writer = None
|
||||||
self.state = {
|
self.state = {
|
||||||
"connected": False,
|
"connected": False,
|
||||||
"link": "DISCONNECTED",
|
"link": "DISCONNECTED",
|
||||||
@@ -47,6 +69,9 @@ class ControlApi:
|
|||||||
"heavy_quality": None,
|
"heavy_quality": None,
|
||||||
"log_path": None,
|
"log_path": None,
|
||||||
"motor_test_active": False,
|
"motor_test_active": False,
|
||||||
|
"capabilities": {"frame": None, "firmware": None},
|
||||||
|
"accessories": {},
|
||||||
|
"sensor_recording": False,
|
||||||
"servos": {str(i): {"function": None, "direction": None, "output_us": None} for i in range(1, 17)},
|
"servos": {str(i): {"function": None, "direction": None, "output_us": None} for i in range(1, 17)},
|
||||||
}
|
}
|
||||||
self.servo_functions: dict[int, int] = {}
|
self.servo_functions: dict[int, int] = {}
|
||||||
@@ -62,6 +87,8 @@ class ControlApi:
|
|||||||
self.motor_settings = self._clean_motor_settings(saved_motor_settings)
|
self.motor_settings = self._clean_motor_settings(saved_motor_settings)
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
if self.station_settings["frame"] != 4:
|
||||||
|
self.motor_settings["independent"] = False
|
||||||
self.worker.set_motor_scaling(
|
self.worker.set_motor_scaling(
|
||||||
self.motor_settings["independent"],
|
self.motor_settings["independent"],
|
||||||
{int(i): entry["scale"] for i, entry in self.motor_settings["motors"].items()},
|
{int(i): entry["scale"] for i, entry in self.motor_settings["motors"].items()},
|
||||||
@@ -88,6 +115,7 @@ class ControlApi:
|
|||||||
"baud": 57600, "control_hz": 20,
|
"baud": 57600, "control_hz": 20,
|
||||||
"ping_hz": 1, "system_hz": 1, "battery_hz": 1, "servo_hz": 2,
|
"ping_hz": 1, "system_hz": 1, "battery_hz": 1, "servo_hz": 2,
|
||||||
"rc_hz": 2, "esc_hz": 1,
|
"rc_hz": 2, "esc_hz": 1,
|
||||||
|
"attitude_hz": 2, "depth_hz": 2, "imu_hz": 1, "pressure_hz": 1,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
|
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
|
||||||
@@ -100,6 +128,8 @@ class ControlApi:
|
|||||||
"battery_config": self.battery_config,
|
"battery_config": self.battery_config,
|
||||||
"link_config": self.link_config,
|
"link_config": self.link_config,
|
||||||
"motor_settings": self.motor_settings,
|
"motor_settings": self.motor_settings,
|
||||||
|
"station_settings": self.station_settings,
|
||||||
|
"controller_profiles": self.controller_profiles,
|
||||||
"state": self._snapshot(),
|
"state": self._snapshot(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +287,12 @@ class ControlApi:
|
|||||||
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
|
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
|
||||||
if not port:
|
if not port:
|
||||||
raise ValueError("Select a USB serial port")
|
raise ValueError("Select a USB serial port")
|
||||||
if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
|
self.telemetry = Telemetry()
|
||||||
|
self.state["capabilities"] = {"frame": None, "firmware": None}
|
||||||
|
self.state["accessories"] = {}
|
||||||
|
self.state["battery"] = None
|
||||||
|
self.battery_fields_at = {}
|
||||||
|
if str(port).lower() == "demo":
|
||||||
self.demo_mode = True
|
self.demo_mode = True
|
||||||
self.servo_functions = {1: 33, 2: 34, 3: 35}
|
self.servo_functions = {1: 33, 2: 34, 3: 35}
|
||||||
self.motor_directions = {1: 1, 2: 1, 3: 1}
|
self.motor_directions = {1: 1, 2: 1, 3: 1}
|
||||||
@@ -307,6 +342,7 @@ class ControlApi:
|
|||||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
|
self.sensor_record(False)
|
||||||
self.worker.stop_motor_test()
|
self.worker.stop_motor_test()
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.state["motor_test_active"] = False
|
self.state["motor_test_active"] = False
|
||||||
@@ -358,7 +394,8 @@ class ControlApi:
|
|||||||
self.state["status"] = "Motor test released; waiting for flight-controller stop"
|
self.state["status"] = "Motor test released; waiting for flight-controller stop"
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_axes(self, forward, turn, vertical, sequence=None):
|
def set_axes(self, forward, turn, vertical, sequence=None, lateral=0, pitch=0, roll=0):
|
||||||
|
values = [number(value, -1, 1) for value in (forward, lateral, vertical, turn, pitch, roll)]
|
||||||
if self.demo_mode:
|
if self.demo_mode:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.state["status"] = (
|
self.state["status"] = (
|
||||||
@@ -368,13 +405,95 @@ class ControlApi:
|
|||||||
return True
|
return True
|
||||||
with self.lock:
|
with self.lock:
|
||||||
allowed = self.vehicle_state.snapshot()["controls_allowed"]
|
allowed = self.vehicle_state.snapshot()["controls_allowed"]
|
||||||
self.worker.set_axes(Axes(
|
self.worker.set_axes(Axes(*values) if allowed else Axes(), sequence)
|
||||||
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
|
return True
|
||||||
|
|
||||||
|
def _require_settings(self):
|
||||||
|
if self.state.get("connected") and (self.state.get("armed") or
|
||||||
|
(not self.demo_mode and not self.vehicle_state.snapshot()["armed_known"])):
|
||||||
|
raise RuntimeError("Disengage first")
|
||||||
|
|
||||||
|
def save_controller_profile(self, device, profile):
|
||||||
|
with self.lock:
|
||||||
|
self._require_settings()
|
||||||
|
clean = clean_profile(profile)
|
||||||
|
updated = {**self.controller_profiles, str(device)[:256]: clean}
|
||||||
|
save_json(self.profiles_path, updated)
|
||||||
|
self.controller_profiles = updated
|
||||||
|
self.worker.set_axes(Axes())
|
||||||
|
return clean
|
||||||
|
|
||||||
|
def save_station_settings(self, config):
|
||||||
|
with self.lock:
|
||||||
|
self._require_settings()
|
||||||
|
clean = clean_station(config)
|
||||||
|
save_json(self.station_path, clean)
|
||||||
|
self.station_settings = clean
|
||||||
|
if clean["frame"] != 4 and (self.demo_mode or not self.state.get("connected")):
|
||||||
|
self.motor_settings["independent"] = False
|
||||||
|
self.worker.set_motor_scaling(False, {int(key): item["scale"] for key, item in self.motor_settings["motors"].items()})
|
||||||
|
# Frame selection describes the preview; actual FRAME_TYPE is read
|
||||||
|
# from the connected autopilot and never silently overwritten.
|
||||||
|
return clean
|
||||||
|
|
||||||
|
def set_accessory(self, name, enabled):
|
||||||
|
if name not in ("lights", "camera") or not isinstance(enabled, bool):
|
||||||
|
raise ValueError("Invalid accessory")
|
||||||
|
relay = self.station_settings[name + "_relay"]
|
||||||
|
if relay < 0:
|
||||||
|
raise RuntimeError("Set relay first")
|
||||||
|
if self.demo_mode:
|
||||||
|
self.state["accessories"][name] = {"requested": enabled, "accepted": True, "demo": True}
|
||||||
|
return True
|
||||||
|
self.worker.accessory(name, relay, enabled)
|
||||||
|
with self.lock:
|
||||||
|
self.state["accessories"][name] = {"requested": enabled, "accepted": None, "at": time.monotonic()}
|
||||||
|
return True
|
||||||
|
|
||||||
|
def sensor_record(self, enabled):
|
||||||
|
with self.lock:
|
||||||
|
if enabled and self._sensor_file is None:
|
||||||
|
folder = Path(__file__).resolve().parent / "logs"
|
||||||
|
folder.mkdir(exist_ok=True)
|
||||||
|
self._sensor_file = (folder / f"sensors_{time.time_ns()}.csv").open("w", newline="", encoding="utf-8")
|
||||||
|
self._sensor_writer = csv.writer(self._sensor_file)
|
||||||
|
self._sensor_writer.writerow(("monotonic_s", "group", "field", "value"))
|
||||||
|
elif not enabled and self._sensor_file is not None:
|
||||||
|
self._sensor_file.close()
|
||||||
|
self._sensor_file = self._sensor_writer = None
|
||||||
|
self.state["sensor_recording"] = bool(enabled)
|
||||||
|
return self._sensor_file.name if self._sensor_file else None
|
||||||
|
|
||||||
|
def recording_begin(self):
|
||||||
|
with self._recording_lock:
|
||||||
|
if self._recording:
|
||||||
|
raise RuntimeError("Already recording")
|
||||||
|
folder = Path(__file__).resolve().parent / "runtime" / "recordings"
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = folder / f"rov_{time.time_ns()}.webm"
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
self._recording = (token, path.open("wb"), path)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def recording_chunk(self, token, data):
|
||||||
|
if not isinstance(data, str) or len(data) > 12 * 1024 * 1024:
|
||||||
|
raise ValueError("Invalid video chunk")
|
||||||
|
chunk = base64.b64decode(data, validate=True)
|
||||||
|
with self._recording_lock:
|
||||||
|
if not self._recording or token != self._recording[0]:
|
||||||
|
raise ValueError("Recording expired")
|
||||||
|
self._recording[1].write(chunk)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def recording_end(self, token):
|
||||||
|
with self._recording_lock:
|
||||||
|
if not self._recording or token != self._recording[0]:
|
||||||
|
return None
|
||||||
|
_, handle, path = self._recording
|
||||||
|
handle.close()
|
||||||
|
self._recording = None
|
||||||
|
return str(path)
|
||||||
|
|
||||||
def stop_controls(self, sequence=None):
|
def stop_controls(self, sequence=None):
|
||||||
"""Invalidate queued input without claiming that the vehicle disarmed."""
|
"""Invalidate queued input without claiming that the vehicle disarmed."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -412,6 +531,10 @@ class ControlApi:
|
|||||||
|
|
||||||
def save_motor_settings(self, settings, persist=True):
|
def save_motor_settings(self, settings, persist=True):
|
||||||
clean = self._clean_motor_settings(settings)
|
clean = self._clean_motor_settings(settings)
|
||||||
|
if clean["independent"] != self.motor_settings["independent"]:
|
||||||
|
self._require_settings()
|
||||||
|
if clean["independent"] and self.worker.frame_type not in (None, 4):
|
||||||
|
raise ValueError("Independent scales require SimpleROV 3")
|
||||||
if persist:
|
if persist:
|
||||||
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||||
self.motor_settings = clean
|
self.motor_settings = clean
|
||||||
@@ -516,6 +639,10 @@ class ControlApi:
|
|||||||
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
|
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
|
||||||
"RC_CHANNELS": self.link_config["rc_hz"],
|
"RC_CHANNELS": self.link_config["rc_hz"],
|
||||||
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
|
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
|
||||||
|
"ATTITUDE": self.link_config["attitude_hz"],
|
||||||
|
"GLOBAL_POSITION_INT": self.link_config["depth_hz"],
|
||||||
|
"SCALED_IMU": self.link_config["imu_hz"],
|
||||||
|
"SCALED_PRESSURE2": self.link_config["pressure_hz"],
|
||||||
})
|
})
|
||||||
|
|
||||||
def save_link_settings(self, config):
|
def save_link_settings(self, config):
|
||||||
@@ -527,7 +654,7 @@ class ControlApi:
|
|||||||
raise ValueError("Control rate must be 10 or 20 Hz")
|
raise ValueError("Control rate must be 10 or 20 Hz")
|
||||||
if clean["ping_hz"] not in {1, 2, 5, 10}:
|
if clean["ping_hz"] not in {1, 2, 5, 10}:
|
||||||
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
|
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")):
|
if any(clean[key] not in allowed for key in ("system_hz", "battery_hz", "servo_hz", "rc_hz", "esc_hz", "attitude_hz", "depth_hz", "imu_hz", "pressure_hz")):
|
||||||
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
|
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_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||||
self.link_config = clean
|
self.link_config = clean
|
||||||
@@ -577,6 +704,31 @@ class ControlApi:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
snap = json.loads(json.dumps(self.state))
|
snap = json.loads(json.dumps(self.state))
|
||||||
snap["motor_settings"] = self.motor_settings
|
snap["motor_settings"] = self.motor_settings
|
||||||
|
snap["telemetry"] = self.telemetry.snapshot(bool(snap.get("connected")))
|
||||||
|
if snap.get("battery") and not self.demo_mode:
|
||||||
|
now = time.monotonic()
|
||||||
|
snap["battery"]["current_stale"] = now - self.battery_fields_at.get("current_a", 0) > 5
|
||||||
|
if snap["battery"]["current_stale"]:
|
||||||
|
snap["battery"]["current_a"] = None
|
||||||
|
for field in ("voltage", "per_cell", "consumed_mah", "remaining_percent"):
|
||||||
|
key = "voltage" if field == "per_cell" else field
|
||||||
|
if now - self.battery_fields_at.get(key, 0) > 5:
|
||||||
|
snap["battery"][field] = None
|
||||||
|
battery = snap["battery"]
|
||||||
|
if battery.get("remaining_percent") is not None:
|
||||||
|
battery["percent"] = battery["remaining_percent"]
|
||||||
|
elif battery.get("per_cell") is not None:
|
||||||
|
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
|
||||||
|
battery["percent"] = max(0, min(100, (battery["per_cell"] - low) * 100 / (high - low)))
|
||||||
|
battery["charge_source"] = "Voltage estimate"
|
||||||
|
else:
|
||||||
|
battery["percent"] = None
|
||||||
|
capacity = self.battery_config["capacity_mah"]
|
||||||
|
battery["remaining_mah"] = (max(0, capacity - battery["consumed_mah"]) if battery.get("consumed_mah") is not None else
|
||||||
|
round(capacity * battery["remaining_percent"] / 100) if battery.get("remaining_percent") is not None else None)
|
||||||
|
for accessory in snap["accessories"].values():
|
||||||
|
if accessory.get("accepted") is None and time.monotonic() - accessory.get("at", 0) > 3:
|
||||||
|
accessory["error"] = "No ACK"
|
||||||
snap["app_state"] = self._derive_app_state()
|
snap["app_state"] = self._derive_app_state()
|
||||||
if not self.demo_mode:
|
if not self.demo_mode:
|
||||||
snap.update(self.vehicle_state.snapshot())
|
snap.update(self.vehicle_state.snapshot())
|
||||||
@@ -608,6 +760,19 @@ class ControlApi:
|
|||||||
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
||||||
elif kind == "battery":
|
elif kind == "battery":
|
||||||
self._consume_battery(event[1])
|
self._consume_battery(event[1])
|
||||||
|
elif kind == "capabilities":
|
||||||
|
self.state["capabilities"].update(event[1])
|
||||||
|
if event[1].get("frame", 4) != 4:
|
||||||
|
self.motor_settings["independent"] = False
|
||||||
|
self.worker.set_motor_scaling(False, {int(key): item["scale"] for key, item in self.motor_settings["motors"].items()})
|
||||||
|
elif kind == "telemetry":
|
||||||
|
self.telemetry.update(event[1], event[2], event[3])
|
||||||
|
if self._sensor_writer:
|
||||||
|
self._sensor_writer.writerows((event[3], event[1], key, value) for key, value in event[2].items())
|
||||||
|
self._sensor_file.flush()
|
||||||
|
elif kind == "accessory_ack":
|
||||||
|
self.state["accessories"][event[1]] = {"requested": event[2], "accepted": event[3] == 0,
|
||||||
|
"error": None if event[3] == 0 else "Rejected"}
|
||||||
elif kind == "servo_output":
|
elif kind == "servo_output":
|
||||||
for servo, output_us in event[1].items():
|
for servo, output_us in event[1].items():
|
||||||
if servo in self.state["servos"]:
|
if servo in self.state["servos"]:
|
||||||
@@ -655,6 +820,14 @@ class ControlApi:
|
|||||||
previous = self.state.get("battery") or {}
|
previous = self.state.get("battery") or {}
|
||||||
battery = dict(previous)
|
battery = dict(previous)
|
||||||
battery["cells"] = self.battery_config["cells"]
|
battery["cells"] = self.battery_config["cells"]
|
||||||
|
for field in ("voltage", "current_a", "consumed_mah", "remaining_percent"):
|
||||||
|
if field in data:
|
||||||
|
value = data[field]
|
||||||
|
if isinstance(value, (float, int)) and math.isfinite(value) and value >= 0:
|
||||||
|
self.battery_fields_at[field] = now
|
||||||
|
else:
|
||||||
|
battery[field] = None
|
||||||
|
self.battery_fields_at.pop(field, None)
|
||||||
voltage = data.get("voltage")
|
voltage = data.get("voltage")
|
||||||
if voltage is not None and float(voltage) > 0:
|
if voltage is not None and float(voltage) > 0:
|
||||||
battery["voltage"] = float(voltage)
|
battery["voltage"] = float(voltage)
|
||||||
@@ -670,6 +843,9 @@ class ControlApi:
|
|||||||
self.battery_last_current = current
|
self.battery_last_current = current
|
||||||
self.battery_last_current_at = now
|
self.battery_last_current_at = now
|
||||||
battery["current_a"] = current
|
battery["current_a"] = current
|
||||||
|
elif "current_a" in data:
|
||||||
|
self.battery_last_current = None
|
||||||
|
self.battery_last_current_at = None
|
||||||
consumed = data.get("consumed_mah")
|
consumed = data.get("consumed_mah")
|
||||||
if consumed is not None and int(consumed) >= 0:
|
if consumed is not None and int(consumed) >= 0:
|
||||||
battery["consumed_mah"] = int(consumed)
|
battery["consumed_mah"] = int(consumed)
|
||||||
@@ -677,6 +853,7 @@ class ControlApi:
|
|||||||
elif self.battery_last_current is not None:
|
elif self.battery_last_current is not None:
|
||||||
battery["consumed_mah"] = round(self.battery_session_consumed_mah)
|
battery["consumed_mah"] = round(self.battery_session_consumed_mah)
|
||||||
battery["consumed_source"] = "Session estimate"
|
battery["consumed_source"] = "Session estimate"
|
||||||
|
self.battery_fields_at["consumed_mah"] = now
|
||||||
remaining = data.get("remaining_percent")
|
remaining = data.get("remaining_percent")
|
||||||
if remaining is not None and 0 <= int(remaining) <= 100:
|
if remaining is not None and 0 <= int(remaining) <= 100:
|
||||||
battery["remaining_percent"] = int(remaining)
|
battery["remaining_percent"] = int(remaining)
|
||||||
@@ -711,6 +888,9 @@ class ControlApi:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
self.sensor_record(False)
|
||||||
|
if self._recording:
|
||||||
|
self.recording_end(self._recording[0])
|
||||||
self.worker.disconnect()
|
self.worker.disconnect()
|
||||||
|
|
||||||
|
|
||||||
@@ -785,7 +965,7 @@ class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
def start_local_server(directory: Path) -> int:
|
def start_local_server(directory: Path) -> int:
|
||||||
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
|
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
|
||||||
server = socketserver.TCPServer(("127.0.0.1", 0), handler)
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
port = server.server_address[1]
|
port = server.server_address[1]
|
||||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
return port
|
return port
|
||||||
@@ -804,7 +984,7 @@ def main():
|
|||||||
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
|
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()
|
win_w, win_h, min_dims = _get_window_size()
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
"SUB Control", url=app_url, js_api=api,
|
"Argo V2", url=app_url, js_api=api,
|
||||||
width=win_w, height=win_h, min_size=min_dims,
|
width=win_w, height=win_h, min_size=min_dims,
|
||||||
background_color="#111719", frameless=True, easy_drag=False,
|
background_color="#111719", frameless=True, easy_drag=False,
|
||||||
shadow=True,
|
shadow=True,
|
||||||
@@ -815,7 +995,7 @@ def main():
|
|||||||
window.events.restored += lambda: setattr(api, "maximized", False)
|
window.events.restored += lambda: setattr(api, "maximized", False)
|
||||||
try:
|
try:
|
||||||
icon_path = project_root / "assets" / "sub-control.ico"
|
icon_path = project_root / "assets" / "sub-control.ico"
|
||||||
webview.start(debug=True, icon=str(icon_path) if icon_path.is_file() else None)
|
webview.start(debug=os.environ.get("SUB_CONTROL_DEBUG") == "1", icon=str(icon_path) if icon_path.is_file() else None)
|
||||||
finally:
|
finally:
|
||||||
api.close()
|
api.close()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# V2 verification — 22 October 2026
|
||||||
|
|
||||||
|
## Passing checks
|
||||||
|
|
||||||
|
- 41 Python tests: vehicle-state confirmation, control leases/watchdogs, six-axis packet values, unsupported firmware/frame gating, endpoint handling, telemetry conversion/staleness, persisted names/profiles, recording chunks, and relay acknowledgement.
|
||||||
|
- `node tests/controller_v2.cjs`: synthetic Xbox triggers, B/A direction, opposing input cancellation, six axes, custom buttons, limits, invalid/missing inputs, migration, and actions.
|
||||||
|
- `node tests/controller_input.cjs`: existing stick conditioning and console parser checks.
|
||||||
|
- Python compilation, JavaScript syntax checks, and `git diff --check`.
|
||||||
|
|
||||||
|
No tests opened a real COM port or armed a physical vehicle.
|
||||||
|
|
||||||
|
## Rendered browser checks
|
||||||
|
|
||||||
|
Used the supported local in-app browser with the real app assets in Demo Mode:
|
||||||
|
|
||||||
|
- Mapping dialog: three-axis and six-axis presets, saving, reopening after a full page reload, and retained LB/RB roll bindings.
|
||||||
|
- Vehicle settings: saved BlueROV1 six-thruster preview, independent accessory relay selectors, and consistent FC/demo status.
|
||||||
|
- Drive: all six command controls at 1024×768, 960×580 (the native window minimum), and 700×572; no overlap with the output toolbar.
|
||||||
|
- Active-output list: six rows for the six-thruster preview, with scrolling and reversal buttons. General frames disable the SimpleROV-3-specific independent-scale mode.
|
||||||
|
- Engagement: held F2 in Demo Mode, inspected rendered **engaging**, then **disengaging** and their settled states. Both words are spelled correctly, centered, and have no trailing dots.
|
||||||
|
- Dashboard: camera-off state, active simulated inspection view, missing/offline readings, explicit Demo labeling, sensor values and video controls at desktop and compact sizes.
|
||||||
|
- Recording: started/stopped a canvas preview recording; the browser produced a 1,386,065-byte WebM file.
|
||||||
|
- Accessories: demo lights toggle, camera power toggle, and viewing stops after demo power-off.
|
||||||
|
|
||||||
|
Reduced-motion CSS is included. The connected browser offers viewport testing but no media-preference emulation, so a rendered reduced-motion override was not verified.
|
||||||
|
|
||||||
|
## Existing harness limitation
|
||||||
|
|
||||||
|
The older `tests/control_ui.test.cjs` fake-DOM harness is not passing. It reports 23 failures on the saved pre-V2 checkpoint and 21 on V2. Most fail before app initialization because the harness lacks `ResizeObserver`; it also has obsolete markup/animation assertions and omits browser dependencies. It is not counted as a passing check. Actual browser checks above establish the rendered behavior of this implementation.
|
||||||
|
|
||||||
|
## Startup measurement
|
||||||
|
|
||||||
|
Three fresh-process worker-module imports on this laptop:
|
||||||
|
|
||||||
|
| Build | Samples | Median |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pre-V2 checkpoint | 781.8 / 616.3 / 655.3 ms | 655.3 ms |
|
||||||
|
| V2 | 402.8 / 362.4 / 399.1 ms | 399.1 ms |
|
||||||
|
|
||||||
|
This measures the module import, not total app startup. The launcher also avoids pip on subsequent launches when requirements have not changed, and starts the web interface directly.
|
||||||
|
|
||||||
|
## Physical verification still required
|
||||||
|
|
||||||
|
Real Xbox hardware, live six-axis FC motor allocation, live sensor calibration, WHEP video from a Raspberry Pi, native WebView full-video recording, physical relay outputs, and tether/underwater behavior were not verified. The browser recording and native chunk-writing path were checked separately. BlueOS native camera discovery/signaling is not integrated; this release uses an explicit WHEP, HTTP video, or MJPEG URL.
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
# Argo V2
|
||||||
|
|
||||||
|
V2 separates controller mapping, vehicle motion, ArduSub motor allocation, and camera transport. The pre-V2 app is preserved in commit `5e91c28` on `main`; development is on `v2`. A portable Git bundle was also saved locally. The checkpoint is also uploaded as `checkpoint/pre-v2-20261022`.
|
||||||
|
|
||||||
|
## Try it
|
||||||
|
|
||||||
|
1. Run `run.ps1`, select **Demo Mode**, and connect.
|
||||||
|
2. Open **Vehicle**, select a **Preview frame**, and save. This changes the demo, never the connected FC's `FRAME_TYPE`.
|
||||||
|
3. Open **Mapping**, choose **Triggers · 3** or **Six axes**, and save.
|
||||||
|
4. Open **Dashboard** and press **Start** to see the simulated inspection camera. **Record** produces a WebM clip. The camera badge and sensor status identify demo data.
|
||||||
|
|
||||||
|
No physical port is opened in Demo Mode. Real TCP and UDP endpoints now connect to MAVLink instead of silently entering the demo.
|
||||||
|
|
||||||
|
## Xbox controls
|
||||||
|
|
||||||
|
| Motion | Triggers · 3 | Six axes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Forward / back | RT / LT | RT / LT |
|
||||||
|
| Rise / dive | B / A | B / A |
|
||||||
|
| Turn | Left stick X | Left stick X |
|
||||||
|
| Sideways | Unassigned | Right stick X |
|
||||||
|
| Pitch | Unassigned | Left stick Y |
|
||||||
|
| Roll | Unassigned | RB / LB |
|
||||||
|
| Lights | X | X |
|
||||||
|
| View stream | Y | Y |
|
||||||
|
| Record | View | View |
|
||||||
|
|
||||||
|
Each motion has separate negative and positive bindings. Either direction can use a stick half, an analog trigger, a D-pad direction, or a button. Opposite commands cancel. Each motion has a limit; the profile also has dead zone and exponential response. The reverse arrow swaps its bindings. The **Buttons** disclosure configures optional accessory and media actions. Buttons do not arm the ROV.
|
||||||
|
|
||||||
|
Profiles are stored by controller ID in `controller_profiles.json`; **Xbox · offline** supplies a default before connecting a controller. Browser preview uses local storage. Existing stick mappings are migrated. Configuration is locked while armed or while the FC state is uncertain. Controls must return to neutral after input mode changes, reconnects, focus changes, or interruption. Accessory buttons also have to be released before they can trigger again.
|
||||||
|
|
||||||
|
Keyboard movement remains W/S, A/D (or Q/E), R/F. Additional axes use J/L for strafe, I/K for pitch, U/O for roll. The extra on-screen sliders return to center when released. F2 retains the 0.6-second engage hold; F3 requests disarm.
|
||||||
|
|
||||||
|
## Six thrusters and six axes
|
||||||
|
|
||||||
|
Your proposed arrangement can provide full control when the paired thrusters have useful moment arms:
|
||||||
|
|
||||||
|
| Pair | Shared thrust | Differential thrust |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Port / starboard, pointing forward | Surge | Yaw |
|
||||||
|
| Front / rear, pointing vertically | Heave | Pitch |
|
||||||
|
| Top / bottom, pointing sideways | Sway | Roll |
|
||||||
|
|
||||||
|
Translation uses the pair in the same direction; rotation uses opposite directions. Thruster directions, lever arms, balance and saturation still determine the result. The allocation matrix, whose columns are force direction and its moment `r × f`, must have rank six. Motor count alone does not establish this.
|
||||||
|
|
||||||
|
V2 sends body-motion demands to the FC. The autopilot owns its mixer and stabilization. Select/configure a firmware frame that matches the physical vehicle before using it. The app reads `FRAME_TYPE`; it does not infer a mixer from connected outputs or write a new frame automatically. Your orthogonal-pair design may require a custom firmware mixer if no stock frame matches its geometry.
|
||||||
|
|
||||||
|
| ArduSub frame | Motors | Available motion |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SimpleROV-3 (4) | 3 | Forward, vertical, yaw |
|
||||||
|
| SimpleROV-4 (5) | 4 | Forward, vertical, yaw, roll |
|
||||||
|
| SimpleROV-5 (6) | 5 | Forward, lateral, vertical, yaw, roll |
|
||||||
|
| BlueROV1 (0) | 6 | All six axes |
|
||||||
|
| Vectored (1) | 6 | All except pitch |
|
||||||
|
| Vectored 6DOF (2, 3) | 8 | All six axes |
|
||||||
|
|
||||||
|
The verified six-axis wire mapping is ArduSub's `MANUAL_CONTROL`: X forward, Y lateral, Z vertical (500 neutral), R yaw, S pitch, T roll. Some generic joystick documentation describes S/T differently; this implementation follows ArduSub source. Firmware version is requested at connection; pitch/roll remain unavailable until compatible firmware is identified. ArduSub 4.5 uses S/T as ±400 PWM offsets; the current 4.8 development implementation scales normalized S/T. These encodings have automated checks; physical behavior is unverified.
|
||||||
|
|
||||||
|
The shared output limit applies to all motion axes. Full-frame demands use a conservative shared envelope before the FC mixer. This is an input-demand limit, not a calibrated thrust or electrical-current limit. The earlier independent motor-scale reconstruction is specific to SimpleROV-3 and is unavailable on other frames; applying it to a general mixer would be incorrect. Motor names, assignments, and direction controls remain available for active outputs.
|
||||||
|
|
||||||
|
References: [ArduSub frame configurations](https://ardupilot.org/sub/docs/sub-frames.html), [ArduSub 4.5 joystick source](https://github.com/ArduPilot/ardupilot/blob/Sub-4.5/ArduSub/joystick.cpp), [current joystick source](https://github.com/ArduPilot/ardupilot/blob/master/ArduSub/joystick.cpp), [Cockpit mapping and vehicle-version support](https://blueos.cloud/cockpit/docs/latest/usage/advanced/).
|
||||||
|
|
||||||
|
## Companion computer and tether
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
PC[Argo V2 on Windows] <-- Ethernet --> TOP[Topside media converter]
|
||||||
|
TOP <-- Fibre tether --> ROV[Onboard media converter]
|
||||||
|
ROV <-- Ethernet --> PI[Raspberry Pi / BlueOS]
|
||||||
|
PI <-- USB or UART / MAVLink --> FC[ArduSub FC]
|
||||||
|
CAM[USB or CSI camera] --> PI
|
||||||
|
SENSOR[Pressure and power sensors] --> FC
|
||||||
|
FC --> ESC[ESCs / thrusters]
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a proposed architecture, not installed hardware. Use a compatible Ethernet-to-fibre interface at each end, with optics, cable, terminations and housings selected for the tether length and environment. Fibre carries the data link; onboard power or a separate power path is still required. Ethernet lets telemetry and compressed video share a network while remaining separate application streams.
|
||||||
|
|
||||||
|
BlueOS is the researched companion option. It routes the FC's MAVLink traffic and manages cameras on supported Raspberry Pi hardware. In BlueOS, configure a MAVLink UDP output to the topside computer's tether-interface IP on port 14550. In Argo, choose **Network · UDP 14550**, or enter an explicit endpoint in Link settings. The listener uses `udpin:0.0.0.0:14550`; choose an interface-specific address with a custom endpoint if needed. The PC and Pi must be reachable on the same tether network.
|
||||||
|
|
||||||
|
Video uses an independent browser media connection. This release accepts **WebRTC · WHEP**, a browser-playable **HTTP video** URL, or **MJPEG**. It does not directly decode RTSP or raw UDP H.264. BlueOS's camera service has its own signaling interface, so entering its normal web-dashboard address as a WHEP URL will not work. Use a compatible WHEP endpoint, such as MediaMTX connected to the Pi camera or an RTSP source. Integrating BlueOS's native camera discovery/signaling is a separate follow-up.
|
||||||
|
|
||||||
|
Example MediaMTX path for a supported Pi camera:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
paths:
|
||||||
|
cam:
|
||||||
|
source: rpiCamera
|
||||||
|
```
|
||||||
|
|
||||||
|
With the server configured on the tether network, a typical WHEP endpoint is `http://<pi-address>:8889/cam/whep`. Verify it against your MediaMTX configuration. The browser needs CORS permission for the local Argo origin, and the negotiated WebRTC media ports must be reachable. Use a browser-compatible codec. No firewall, authentication, or origin policy is changed automatically by Argo.
|
||||||
|
|
||||||
|
**Start/Stop** controls viewing. **Camera power** is a separate optional relay action. **Record** records the incoming browser video; desktop clips are written incrementally to `runtime/recordings/`, and browser-preview clips download as WebM. MJPEG playback has no video recording control. For recordings that must survive tether loss, configure recording on the Pi as well.
|
||||||
|
|
||||||
|
References: [BlueOS companion hardware](https://blueos.cloud/docs/latest/integrations/hardware/required/onboard-computer/), [tether overview](https://blueos.cloud/docs/latest/integrations/hardware/required/tether/), [BlueOS endpoints and video services](https://blueos.cloud/docs/latest/usage/advanced/), [MediaMTX Pi cameras](https://mediamtx.org/docs/static-sources/raspberry-pi-cameras), [browser playback](https://mediamtx.org/docs/read/web-browsers).
|
||||||
|
|
||||||
|
## Sensors and electricity
|
||||||
|
|
||||||
|
The dashboard displays FC telemetry only when its group is fresh. Missing or stale values show a dash. Demo values are explicitly marked.
|
||||||
|
|
||||||
|
| Reading | Source | Units |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pitch, roll, heading and angular rates | ATTITUDE | degrees, degrees/s |
|
||||||
|
| Depth estimate relative to surface/home | GLOBAL_POSITION_INT.relative_alt | m, positive down |
|
||||||
|
| Acceleration, gyroscope, magnetic field | SCALED_IMU | m/s², rad/s, gauss |
|
||||||
|
| External pressure / temperature | SCALED_PRESSURE2 | hPa, °C |
|
||||||
|
| Battery voltage, current and charge | SYS_STATUS / battery 0 BATTERY_STATUS | V, A, mAh |
|
||||||
|
|
||||||
|
Depth needs a correctly configured and calibrated external pressure sensor. Raw accelerometer values include gravity; they are not speed or a gravity-compensated acceleration estimate. Heading is FC-estimated yaw, not a separately read compass bearing.
|
||||||
|
|
||||||
|
Current is measured in **amps (A)** at the present moment. Power is **watts (W) = V × A**. Consumption is charge integrated over time, displayed as **mAh**; it is not amps per second. Use a correctly sized, calibrated voltage/current power monitor connected to the FC. Voltage alone cannot measure current. The app prefers reported consumption and otherwise integrates fresh current as a session estimate; source information is in Battery settings. A voltage update does not make an old current reading fresh.
|
||||||
|
|
||||||
|
**Log data** writes received normalized sensor values to `logs/sensors_<timestamp>.csv`. Existing raw MAVLink session logs remain in `logs/`. Rates for attitude, depth, IMU and pressure are independently configurable. Begin with low rates on RS-485; use a higher-bandwidth tether for video and increased telemetry.
|
||||||
|
|
||||||
|
Lights and camera power require configured, separate FC relay outputs. Configure relay assignments on the FC, then select their zero-based relay numbers in **Vehicle**. Buttons wait for command acknowledgement and expose timeout/rejection status. An accepted command confirms FC acceptance, not measured physical illumination or camera power.
|
||||||
|
|
||||||
|
Reference: [ArduSub battery monitor calibration](https://ardupilot.org/sub/docs/common-power-module-configuration-in-mission-planner.html), [MAVLink message definitions](https://mavlink.io/en/messages/common.html).
|
||||||
|
|
||||||
|
## Performance and verification
|
||||||
|
|
||||||
|
- The launcher only installs dependencies when the requirements fingerprint changes.
|
||||||
|
- The production path launches `web_ui.py` directly. The old Tk frontend is loaded lazily from `legacy_ui.py`.
|
||||||
|
- The asset server is threaded. Video frames stay in the browser's media pipeline.
|
||||||
|
- Network MAVLink skips the redundant packet-leading heartbeat needed by the existing serial modules.
|
||||||
|
- V2 controller input has one command ramp in the worker, avoiding the previous additional browser smoothing delay.
|
||||||
|
- V2 controller-profile and station files are validated and replaced atomically. Existing input sequence, stale-bridge and heartbeat watchdogs cover all six motion axes.
|
||||||
|
|
||||||
|
Automated checks use synthetic gamepads, mock MAVLink transports, temporary configuration files and telemetry fixtures. Browser checks use Demo Mode and the actual rendered app. No live vehicle has been armed or commanded during development. Real controller hardware, Pi streaming, FC accessories, sensor calibration and water performance still need physical verification.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
+814
@@ -0,0 +1,814 @@
|
|||||||
|
"""Optional Tk frontend. Imported only when explicitly requested."""
|
||||||
|
import queue
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import tkinter as tk
|
||||||
|
import customtkinter as ctk
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import messagebox, ttk
|
||||||
|
from serial.tools import list_ports
|
||||||
|
from app import Axes, MavlinkWorker, MOTOR_FUNCTIONS, NEUTRAL_Z, normalize_endpoint
|
||||||
|
|
||||||
|
class Joystick(tk.Canvas):
|
||||||
|
def __init__(self, master, callback, **kwargs):
|
||||||
|
self.size = 276
|
||||||
|
self.center_point = self.size / 2
|
||||||
|
self.radius = 108
|
||||||
|
self.knob_radius = 24
|
||||||
|
super().__init__(master, width=self.size, height=self.size, bg="#f5f5f2",
|
||||||
|
highlightthickness=0, bd=0, **kwargs)
|
||||||
|
self.callback = callback
|
||||||
|
self.value = (0.0, 0.0)
|
||||||
|
self.return_job = None
|
||||||
|
c, r, k = self.center_point, self.radius, self.knob_radius
|
||||||
|
self.create_oval(c-r, c-r, c+r, c+r, fill="#e9e9e5", outline="#d8d8d2", width=2)
|
||||||
|
self.create_oval(c-70, c-70, c+70, c+70, outline="#deded8", width=1)
|
||||||
|
self.create_line(c, c-r+12, c, c+r-12, fill="#d2d2cc", dash=(3, 6))
|
||||||
|
self.create_line(c-r+12, c, c+r-12, c, fill="#d2d2cc", dash=(3, 6))
|
||||||
|
self.create_text(c, 13, text="FORWARD", fill="#8a8a84", font=("Helvetica", 8, "bold"))
|
||||||
|
self.create_text(c, self.size-13, text="REVERSE", fill="#8a8a84", font=("Helvetica", 8, "bold"))
|
||||||
|
self.create_text(25, c, text="TURN\nLEFT", fill="#8a8a84", font=("Helvetica", 8, "bold"))
|
||||||
|
self.create_text(self.size-25, c, text="TURN\nRIGHT", fill="#8a8a84", font=("Helvetica", 8, "bold"))
|
||||||
|
self.knob = self.create_oval(c-k, c-k, c+k, c+k,
|
||||||
|
fill="#575c61", outline="#42474b", width=2)
|
||||||
|
self.bind("<Button-1>", self._move)
|
||||||
|
self.bind("<B1-Motion>", self._move)
|
||||||
|
self.bind("<ButtonRelease-1>", self._release)
|
||||||
|
|
||||||
|
def _move(self, event):
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
self.return_job = None
|
||||||
|
dx, dy = event.x - self.center_point, event.y - self.center_point
|
||||||
|
length = max(1.0, (dx * dx + dy * dy) ** 0.5)
|
||||||
|
if length > self.radius:
|
||||||
|
dx, dy = dx * self.radius / length, dy * self.radius / length
|
||||||
|
self.set_immediate(dx / self.radius, -dy / self.radius)
|
||||||
|
|
||||||
|
def set_immediate(self, x: float, y: float):
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
self.return_job = None
|
||||||
|
self.value = (x, y)
|
||||||
|
c, k = self.center_point, self.knob_radius
|
||||||
|
dx, dy = x * self.radius, -y * self.radius
|
||||||
|
self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy)
|
||||||
|
self.callback()
|
||||||
|
|
||||||
|
def _release(self, _event=None):
|
||||||
|
start_x, start_y = self.value
|
||||||
|
started = time.monotonic()
|
||||||
|
duration = 0.40
|
||||||
|
|
||||||
|
def ease_back():
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
progress = min(1.0, elapsed / duration)
|
||||||
|
# Smoothstep has zero slope at both ends, avoiding a motor kick.
|
||||||
|
eased = progress * progress * (3.0 - 2.0 * progress)
|
||||||
|
remaining = 1.0 - eased
|
||||||
|
x, y = start_x * remaining, start_y * remaining
|
||||||
|
self.value = (x, y)
|
||||||
|
c, k = self.center_point, self.knob_radius
|
||||||
|
dx, dy = x * self.radius, -y * self.radius
|
||||||
|
self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy)
|
||||||
|
self.callback()
|
||||||
|
if progress < 1.0:
|
||||||
|
self.return_job = self.after(20, ease_back)
|
||||||
|
else:
|
||||||
|
self.return_job = None
|
||||||
|
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
ease_back()
|
||||||
|
|
||||||
|
def center(self):
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
self.return_job = None
|
||||||
|
self.value = (0.0, 0.0)
|
||||||
|
c, k = self.center_point, self.knob_radius
|
||||||
|
self.coords(self.knob, c-k, c-k, c+k, c+k)
|
||||||
|
self.callback()
|
||||||
|
|
||||||
|
|
||||||
|
class SpringScale(ctk.CTkSlider):
|
||||||
|
"""A Scale that smoothly returns to zero when the pointer is released."""
|
||||||
|
|
||||||
|
def __init__(self, master, **kwargs):
|
||||||
|
self.callback = kwargs.pop("command", None)
|
||||||
|
kwargs.pop("showvalue", None)
|
||||||
|
kwargs.pop("bg", None)
|
||||||
|
kwargs.pop("troughcolor", None)
|
||||||
|
kwargs.pop("activebackground", None)
|
||||||
|
kwargs.pop("highlightthickness", None)
|
||||||
|
kwargs.pop("bd", None)
|
||||||
|
kwargs.pop("sliderrelief", None)
|
||||||
|
if "orient" in kwargs:
|
||||||
|
kwargs["orientation"] = kwargs.pop("orient")
|
||||||
|
length = kwargs.pop("length", 200)
|
||||||
|
if kwargs.get("orientation", "horizontal") == "vertical":
|
||||||
|
kwargs.setdefault("height", length)
|
||||||
|
kwargs.setdefault("width", 18)
|
||||||
|
else:
|
||||||
|
kwargs.setdefault("width", length)
|
||||||
|
kwargs.setdefault("height", 18)
|
||||||
|
super().__init__(master, command=self._changed, corner_radius=10,
|
||||||
|
button_corner_radius=10, button_length=22,
|
||||||
|
fg_color="#dbe3ee", progress_color="#60a5fa",
|
||||||
|
button_color="#2563eb", button_hover_color="#1d4ed8",
|
||||||
|
**kwargs)
|
||||||
|
self.return_job = None
|
||||||
|
self.bind("<ButtonPress-1>", self._cancel_return, add="+")
|
||||||
|
self.bind("<ButtonRelease-1>", self._released, add="+")
|
||||||
|
|
||||||
|
def _changed(self, value):
|
||||||
|
if self.callback:
|
||||||
|
self.callback(value)
|
||||||
|
|
||||||
|
def _set_and_notify(self, value):
|
||||||
|
self.set(value)
|
||||||
|
if self.callback:
|
||||||
|
self.callback(value)
|
||||||
|
|
||||||
|
def _cancel_return(self, _event=None):
|
||||||
|
if self.return_job is not None:
|
||||||
|
self.after_cancel(self.return_job)
|
||||||
|
self.return_job = None
|
||||||
|
|
||||||
|
def _released(self, _event=None):
|
||||||
|
# Wait until Tk's native Scale binding has applied its final value.
|
||||||
|
self.after_idle(self.spring_to_center)
|
||||||
|
|
||||||
|
def set_immediate(self, value):
|
||||||
|
self._cancel_return()
|
||||||
|
self._set_and_notify(value)
|
||||||
|
|
||||||
|
def spring_to_center(self):
|
||||||
|
self._cancel_return()
|
||||||
|
start = float(self.get())
|
||||||
|
if abs(start) < 0.5:
|
||||||
|
self._set_and_notify(0)
|
||||||
|
return
|
||||||
|
started = time.monotonic()
|
||||||
|
duration = 0.40
|
||||||
|
|
||||||
|
def ease_back():
|
||||||
|
progress = min(1.0, (time.monotonic() - started) / duration)
|
||||||
|
eased = progress * progress * (3.0 - 2.0 * progress)
|
||||||
|
self._set_and_notify(start * (1.0 - eased))
|
||||||
|
if progress < 1.0:
|
||||||
|
self.return_job = self.after(20, ease_back)
|
||||||
|
else:
|
||||||
|
self._set_and_notify(0)
|
||||||
|
self.return_job = None
|
||||||
|
|
||||||
|
ease_back()
|
||||||
|
|
||||||
|
|
||||||
|
class App(tk.Tk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("SUB Control")
|
||||||
|
self.geometry("1040x800")
|
||||||
|
self.minsize(920, 720)
|
||||||
|
self.events = queue.Queue()
|
||||||
|
self.worker = MavlinkWorker(self.events)
|
||||||
|
self.keys = set()
|
||||||
|
self.keyboard_yaw = 0
|
||||||
|
self.keyboard_vertical = 0
|
||||||
|
self.keyboard_planar = (0, 0)
|
||||||
|
self.servo_functions = {}
|
||||||
|
self.motor_directions = {}
|
||||||
|
self.battery_config_path = Path(__file__).resolve().parent / "battery_settings.json"
|
||||||
|
self.battery_config = {
|
||||||
|
"cells": 4,
|
||||||
|
"capacity_mah": 5500,
|
||||||
|
"c_rating": 35,
|
||||||
|
"full_v_per_cell": 4.20,
|
||||||
|
"empty_v_per_cell": 3.30,
|
||||||
|
"warning_percent": 20,
|
||||||
|
}
|
||||||
|
self.battery_voltage = None
|
||||||
|
self.battery_voltage_smoothed = None
|
||||||
|
self._load_battery_settings()
|
||||||
|
self._configure_styles()
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self.close)
|
||||||
|
self._build()
|
||||||
|
self.refresh_ports()
|
||||||
|
self.bind_all("<KeyPress>", self._key_down)
|
||||||
|
self.bind_all("<KeyRelease>", self._key_up)
|
||||||
|
self.after(50, self._poll)
|
||||||
|
self.after(500, self._link_watchdog)
|
||||||
|
|
||||||
|
def _configure_styles(self):
|
||||||
|
self.configure(bg="#e8e9e7")
|
||||||
|
style = ttk.Style(self)
|
||||||
|
try:
|
||||||
|
style.theme_use("clam")
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
style.configure("TFrame", background="#fbfbf9")
|
||||||
|
style.configure("TLabel", background="#fbfbf9", foreground="#202224",
|
||||||
|
font=("Helvetica", 9))
|
||||||
|
style.configure("TLabelframe", background="#f4f6f8", bordercolor="#d8dee8",
|
||||||
|
borderwidth=1, relief="solid")
|
||||||
|
style.configure("TLabelframe.Label", background="#f4f6f8", foreground="#475569",
|
||||||
|
font=("Segoe UI", 9, "bold"))
|
||||||
|
style.configure("TButton", font=("Segoe UI", 9), padding=(10, 7),
|
||||||
|
background="#ffffff", foreground="#1e293b", bordercolor="#cbd5e1")
|
||||||
|
style.map("TButton", background=[("active", "#e8eef7")])
|
||||||
|
style.configure("Primary.TButton", background="#2563eb", foreground="#ffffff",
|
||||||
|
bordercolor="#2563eb", font=("Segoe UI", 9, "bold"))
|
||||||
|
style.map("Primary.TButton", background=[("active", "#1d4ed8")])
|
||||||
|
style.configure("Danger.TButton", background="#dc2626", foreground="#ffffff",
|
||||||
|
bordercolor="#dc2626", font=("Segoe UI", 9, "bold"))
|
||||||
|
style.map("Danger.TButton", background=[("active", "#b91c1c")])
|
||||||
|
style.configure("Title.TLabel", font=("Segoe UI", 18, "bold"), foreground="#0f172a")
|
||||||
|
style.configure("Subtitle.TLabel", font=("Segoe UI", 9), foreground="#64748b")
|
||||||
|
style.configure("Status.TLabel", font=("Segoe UI", 9, "bold"))
|
||||||
|
style.configure("Footer.TLabel", background="#e9eef5", foreground="#475569",
|
||||||
|
font=("Segoe UI", 9))
|
||||||
|
style.configure("Treeview", rowheight=28, background="#ffffff", fieldbackground="#ffffff",
|
||||||
|
foreground="#26282a", bordercolor="#e1e1dc", font=("Helvetica", 9))
|
||||||
|
style.configure("Treeview.Heading", font=("Helvetica", 9, "bold"), padding=(6, 6),
|
||||||
|
background="#f0f0ed", foreground="#4c4f52")
|
||||||
|
style.map("Treeview", background=[("selected", "#dbeafe")],
|
||||||
|
foreground=[("selected", "#1e3a8a")])
|
||||||
|
style.configure("Horizontal.TProgressbar", troughcolor="#e2e8f0", background="#22c55e",
|
||||||
|
bordercolor="#e2e8f0", lightcolor="#22c55e", darkcolor="#22c55e")
|
||||||
|
|
||||||
|
def _console_button(self, master, text, command, kind="neutral", width=112):
|
||||||
|
colors = {
|
||||||
|
"neutral": ("#f4f4f1", "#e9e9e4", "#343739", "#deded8"),
|
||||||
|
"primary": ("#dcebd5", "#cde2c4", "#27401f", "#c4dcb9"),
|
||||||
|
"danger": ("#f7dfdc", "#f1cfcb", "#7b3029", "#edcec9"),
|
||||||
|
}
|
||||||
|
color, hover, text_color, border = colors[kind]
|
||||||
|
return ctk.CTkButton(
|
||||||
|
master, text=text, command=command, width=width, height=34,
|
||||||
|
corner_radius=11, fg_color=color, hover_color=hover,
|
||||||
|
text_color=text_color, border_color=border, border_width=1,
|
||||||
|
font=ctk.CTkFont("Helvetica", 12, "bold" if kind != "neutral" else "normal"),
|
||||||
|
bg_color="transparent",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _console_combo(self, master, values, width):
|
||||||
|
return ctk.CTkComboBox(
|
||||||
|
master, values=[str(value) for value in values], width=width, height=34,
|
||||||
|
corner_radius=10, border_width=1, border_color="#cbd5e1",
|
||||||
|
fg_color="#ffffff", button_color="#e8eef7",
|
||||||
|
button_hover_color="#dbeafe", dropdown_fg_color="#ffffff",
|
||||||
|
dropdown_hover_color="#dbeafe", text_color="#1e293b",
|
||||||
|
bg_color="transparent", state="readonly",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
shell = ctk.CTkFrame(self, fg_color="#f8f8f6", corner_radius=24,
|
||||||
|
border_width=1, border_color="#d5d6d3")
|
||||||
|
shell.pack(fill="both", expand=True, padx=18, pady=18)
|
||||||
|
shell.grid_rowconfigure(0, weight=1)
|
||||||
|
shell.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
rail = ctk.CTkFrame(shell, width=248, fg_color="#282d30", corner_radius=22)
|
||||||
|
rail.grid(row=0, column=0, sticky="nsew")
|
||||||
|
rail.grid_propagate(False)
|
||||||
|
workspace = ctk.CTkFrame(shell, fg_color="#fbfbf9", corner_radius=22)
|
||||||
|
workspace.grid(row=0, column=1, sticky="nsew")
|
||||||
|
|
||||||
|
ctk.CTkLabel(rail, text="SUB CONTROL / 03", text_color="#f4f4f1",
|
||||||
|
font=ctk.CTkFont("Helvetica", 13, "bold")).pack(
|
||||||
|
anchor="w", padx=24, pady=(24, 0))
|
||||||
|
ctk.CTkLabel(rail, text="ArduSub vehicle", text_color="#8f9699",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(2, 14))
|
||||||
|
|
||||||
|
vehicle_art = tk.Canvas(rail, width=204, height=250, bg="#282d30",
|
||||||
|
highlightthickness=0, bd=0)
|
||||||
|
vehicle_art.pack(padx=20, pady=(5, 4))
|
||||||
|
vehicle_art.create_oval(47, 27, 157, 223, fill="#353b3e", outline="#555c60", width=2)
|
||||||
|
vehicle_art.create_oval(68, 49, 136, 201, fill="#303538", outline="#444a4e")
|
||||||
|
vehicle_art.create_oval(80, 102, 124, 146, fill="#5e666a", outline="#788084", width=2)
|
||||||
|
vehicle_art.create_oval(21, 57, 62, 100, fill="#202426", outline="#62696d", width=2)
|
||||||
|
vehicle_art.create_oval(142, 57, 183, 100, fill="#202426", outline="#62696d", width=2)
|
||||||
|
vehicle_art.create_line(42, 79, 72, 93, fill="#757d81", width=3)
|
||||||
|
vehicle_art.create_line(162, 79, 132, 93, fill="#757d81", width=3)
|
||||||
|
vehicle_art.create_text(102, 236, text="TOP VIEW • 3 THRUSTERS",
|
||||||
|
fill="#7e8689", font=("Helvetica", 8, "bold"))
|
||||||
|
|
||||||
|
ctk.CTkLabel(rail, text="Vehicle", text_color="#959c9f",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(5, 0))
|
||||||
|
self.vehicle_label = ctk.CTkLabel(rail, text="No heartbeat", text_color="#f4f4f1",
|
||||||
|
font=ctk.CTkFont("Helvetica", 14, "bold"))
|
||||||
|
self.vehicle_label.pack(anchor="w", padx=24)
|
||||||
|
|
||||||
|
rail_bottom = ctk.CTkFrame(rail, fg_color="transparent")
|
||||||
|
rail_bottom.pack(side="bottom", fill="x", padx=24, pady=22)
|
||||||
|
self.battery_summary = ctk.CTkLabel(
|
||||||
|
rail_bottom, text="Battery —", text_color="#f4f4f1",
|
||||||
|
font=ctk.CTkFont("Helvetica", 13, "bold"), anchor="w"
|
||||||
|
)
|
||||||
|
self.battery_summary.pack(fill="x")
|
||||||
|
self.battery_meta = ctk.CTkLabel(
|
||||||
|
rail_bottom, text="Waiting for voltage", text_color="#92999c",
|
||||||
|
font=ctk.CTkFont("Helvetica", 9), anchor="w"
|
||||||
|
)
|
||||||
|
self.battery_meta.pack(fill="x", pady=(0, 5))
|
||||||
|
self.battery_bar = ctk.CTkProgressBar(
|
||||||
|
rail_bottom, height=7, corner_radius=4, fg_color="#454b4e",
|
||||||
|
progress_color="#c4e46c"
|
||||||
|
)
|
||||||
|
self.battery_bar.set(0)
|
||||||
|
self.battery_bar.pack(fill="x", pady=(0, 18))
|
||||||
|
self.link_label = ctk.CTkLabel(
|
||||||
|
rail_bottom, text="● DISCONNECTED", text_color="#f08a82",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10, "bold"), anchor="w"
|
||||||
|
)
|
||||||
|
self.link_label.pack(fill="x")
|
||||||
|
|
||||||
|
header = ctk.CTkFrame(workspace, fg_color="transparent")
|
||||||
|
header.pack(fill="x", padx=28, pady=(24, 14))
|
||||||
|
title = ctk.CTkFrame(header, fg_color="transparent")
|
||||||
|
title.pack(side="left")
|
||||||
|
ctk.CTkLabel(title, text="Drive", text_color="#17191a",
|
||||||
|
font=ctk.CTkFont("Helvetica", 24, "bold")).pack(anchor="w")
|
||||||
|
ctk.CTkLabel(title, text="Differential steering • MANUAL control",
|
||||||
|
text_color="#8a8c8e", font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w")
|
||||||
|
|
||||||
|
top = ctk.CTkFrame(header, fg_color="transparent")
|
||||||
|
top.pack(side="right")
|
||||||
|
self.port = self._console_combo(top, [], 190)
|
||||||
|
self.port.grid(row=0, column=0, padx=4)
|
||||||
|
self._console_button(top, "↻", self.refresh_ports, width=38).grid(row=0, column=1, padx=4)
|
||||||
|
self.baud = self._console_combo(top, (9600, 19200, 38400, 57600, 115200), 105)
|
||||||
|
self.baud.set("57600")
|
||||||
|
self.rate = self._console_combo(top, (10, 20), 72)
|
||||||
|
self.rate.set("20")
|
||||||
|
self.link_settings_button = self._console_button(
|
||||||
|
top, "•••", self._toggle_link_settings, width=38
|
||||||
|
)
|
||||||
|
self.link_settings_button.grid(row=0, column=2, padx=4)
|
||||||
|
self.connect_button = self._console_button(
|
||||||
|
top, "Connect", self.toggle_connect, kind="primary", width=110
|
||||||
|
)
|
||||||
|
self.connect_button.grid(row=0, column=3, padx=(4, 0))
|
||||||
|
|
||||||
|
separator = ctk.CTkFrame(workspace, height=1, fg_color="#e7e7e3")
|
||||||
|
separator.pack(fill="x", padx=28)
|
||||||
|
|
||||||
|
controls = ctk.CTkFrame(workspace, fg_color="transparent")
|
||||||
|
controls.pack(fill="both", expand=True, padx=28, pady=18)
|
||||||
|
controls.grid_rowconfigure(0, weight=1)
|
||||||
|
controls.grid_columnconfigure(0, weight=1)
|
||||||
|
controls.grid_columnconfigure(1, minsize=214)
|
||||||
|
|
||||||
|
drive_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18)
|
||||||
|
drive_card.grid(row=0, column=0, sticky="nsew", padx=(0, 12))
|
||||||
|
ctk.CTkLabel(drive_card, text="STEER + THROTTLE", text_color="#66696b",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 0))
|
||||||
|
self.joystick = Joystick(drive_card, self._axes_changed)
|
||||||
|
self.joystick.pack(expand=True, pady=(4, 0))
|
||||||
|
ctk.CTkLabel(drive_card, text="W / S drive • A / D steer",
|
||||||
|
text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(0, 16))
|
||||||
|
|
||||||
|
heave_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18)
|
||||||
|
heave_card.grid(row=0, column=1, sticky="nsew")
|
||||||
|
ctk.CTkLabel(heave_card, text="VERTICAL", text_color="#66696b",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 2))
|
||||||
|
ctk.CTkLabel(heave_card, text="RISE", text_color="#969896",
|
||||||
|
font=ctk.CTkFont("Helvetica", 8, "bold")).pack()
|
||||||
|
self.vertical = SpringScale(
|
||||||
|
heave_card, from_=100, to=-100, orient="vertical", length=230,
|
||||||
|
command=lambda _: self._axes_changed()
|
||||||
|
)
|
||||||
|
self.vertical.set(0)
|
||||||
|
self.vertical.pack(expand=True, pady=8)
|
||||||
|
ctk.CTkLabel(heave_card, text="DIVE", text_color="#969896",
|
||||||
|
font=ctk.CTkFont("Helvetica", 8, "bold")).pack()
|
||||||
|
ctk.CTkLabel(heave_card, text="R / F • springs to neutral",
|
||||||
|
text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(4, 16))
|
||||||
|
|
||||||
|
self.battery_cells = tk.StringVar(value=str(self.battery_config["cells"]))
|
||||||
|
self.battery_capacity = tk.StringVar(value=str(self.battery_config["capacity_mah"]))
|
||||||
|
self.battery_c_rating = tk.StringVar(value=str(self.battery_config["c_rating"]))
|
||||||
|
self.battery_full_cell = tk.StringVar(value=str(self.battery_config["full_v_per_cell"]))
|
||||||
|
self.battery_empty_cell = tk.StringVar(value=str(self.battery_config["empty_v_per_cell"]))
|
||||||
|
self.battery_warning = tk.StringVar(value=str(self.battery_config["warning_percent"]))
|
||||||
|
fields = (
|
||||||
|
("Cells (S)", self.battery_cells, 5),
|
||||||
|
("Capacity mAh", self.battery_capacity, 8),
|
||||||
|
("C rating", self.battery_c_rating, 5),
|
||||||
|
("Full V/cell", self.battery_full_cell, 6),
|
||||||
|
("Empty V/cell", self.battery_empty_cell, 6),
|
||||||
|
("Warn %", self.battery_warning, 5),
|
||||||
|
)
|
||||||
|
self.battery_settings_frame = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14)
|
||||||
|
for column, (label, variable, width) in enumerate(fields):
|
||||||
|
group = ctk.CTkFrame(self.battery_settings_frame, fg_color="transparent")
|
||||||
|
group.grid(row=0, column=column, padx=(0, 8), sticky="w")
|
||||||
|
ctk.CTkLabel(group, text=label, text_color="#66696b",
|
||||||
|
font=ctk.CTkFont("Helvetica", 9)).pack(anchor="w")
|
||||||
|
ctk.CTkEntry(group, textvariable=variable, width=max(62, width * 9), height=30,
|
||||||
|
corner_radius=8, border_color="#d0d0cb").pack(anchor="w")
|
||||||
|
self._console_button(self.battery_settings_frame, "Apply", self._apply_battery_settings,
|
||||||
|
kind="primary", width=82).grid(
|
||||||
|
row=0, column=6, padx=(8, 0), sticky="sew"
|
||||||
|
)
|
||||||
|
self.battery_settings_frame.pack_forget()
|
||||||
|
|
||||||
|
actions = ctk.CTkFrame(workspace, fg_color="transparent")
|
||||||
|
actions.pack(fill="x", padx=28, pady=(0, 14))
|
||||||
|
self._console_button(actions, "MANUAL", self.set_manual, width=94).pack(side="left", padx=3)
|
||||||
|
self._console_button(actions, "ARM", self.arm, kind="primary", width=82).pack(side="left", padx=3)
|
||||||
|
self._console_button(actions, "DISARM", self.disarm, kind="danger", width=92).pack(side="left", padx=3)
|
||||||
|
ctk.CTkLabel(actions, text="Output", text_color="#777a7c",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10)).pack(side="left", padx=(18, 4))
|
||||||
|
self.gain = ctk.CTkSlider(
|
||||||
|
actions, from_=5, to=50, number_of_steps=45, width=170, height=18,
|
||||||
|
command=self._gain_changed, corner_radius=10, button_corner_radius=10,
|
||||||
|
button_length=22, fg_color="#dbe3ee", progress_color="#60a5fa",
|
||||||
|
button_color="#2563eb", button_hover_color="#1d4ed8",
|
||||||
|
bg_color="transparent",
|
||||||
|
)
|
||||||
|
self.gain.set(20)
|
||||||
|
self.gain.pack(side="left")
|
||||||
|
self.gain_text = ctk.CTkLabel(actions, text="20%", text_color="#55585a",
|
||||||
|
font=ctk.CTkFont("Helvetica", 10, "bold"))
|
||||||
|
self.gain_text.pack(side="left")
|
||||||
|
self._console_button(actions, "Motor setup", self._toggle_motor_setup, width=112).pack(
|
||||||
|
side="right", padx=3
|
||||||
|
)
|
||||||
|
self._console_button(actions, "Battery", self._toggle_battery_settings, width=88).pack(
|
||||||
|
side="right", padx=3
|
||||||
|
)
|
||||||
|
|
||||||
|
assignments = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14)
|
||||||
|
self.assignments_frame = assignments
|
||||||
|
self.servo_tree = ttk.Treeview(
|
||||||
|
assignments, columns=("output", "function", "reversed"),
|
||||||
|
show="headings", height=5, selectmode="browse",
|
||||||
|
)
|
||||||
|
self.servo_tree.heading("output", text="Output")
|
||||||
|
self.servo_tree.heading("function", text="Function")
|
||||||
|
self.servo_tree.heading("reversed", text="Motor reversed")
|
||||||
|
self.servo_tree.column("output", width=90, anchor="center", stretch=False)
|
||||||
|
self.servo_tree.column("function", width=220, anchor="w")
|
||||||
|
self.servo_tree.column("reversed", width=90, anchor="center", stretch=False)
|
||||||
|
for number in range(1, 17):
|
||||||
|
self.servo_tree.insert("", "end", iid=str(number),
|
||||||
|
values=(f"SERVO{number}", "Waiting…", "Waiting…"))
|
||||||
|
self.servo_tree.pack(side="left", fill="x", expand=True)
|
||||||
|
scrollbar = ttk.Scrollbar(assignments, orient="vertical", command=self.servo_tree.yview)
|
||||||
|
scrollbar.pack(side="left", fill="y")
|
||||||
|
self.servo_tree.configure(yscrollcommand=scrollbar.set)
|
||||||
|
|
||||||
|
edit = ctk.CTkFrame(assignments, fg_color="transparent")
|
||||||
|
edit.pack(side="right", fill="y")
|
||||||
|
ctk.CTkLabel(edit, text="Selected output function", text_color="#55585a").pack(anchor="w")
|
||||||
|
motor_choices = ["Unassigned (0)"] + [f"Motor {number}" for number in range(1, 13)]
|
||||||
|
self.motor_function = self._console_combo(edit, motor_choices, 165)
|
||||||
|
self.motor_function.set("Motor 1")
|
||||||
|
self.motor_function.pack(fill="x", pady=(2, 5))
|
||||||
|
self._console_button(edit, "Apply function", self.apply_servo_function, width=165).pack(fill="x", pady=2)
|
||||||
|
self._console_button(edit, "Reverse motor", self.toggle_servo_reversed, width=165).pack(fill="x", pady=2)
|
||||||
|
ctk.CTkLabel(edit, text="Changes require DISARMED", text_color="#a16207").pack(pady=(8, 0))
|
||||||
|
|
||||||
|
log_frame = ctk.CTkFrame(workspace, fg_color="transparent")
|
||||||
|
log_frame.pack(fill="x", padx=28, pady=(0, 12))
|
||||||
|
self.log_frame = log_frame
|
||||||
|
self.log_path_text = tk.StringVar(value="Session log starts when connected")
|
||||||
|
ctk.CTkLabel(log_frame, textvariable=self.log_path_text, anchor="w", text_color="#8b8d8f",
|
||||||
|
font=ctk.CTkFont("Helvetica", 9)).pack(side="left", fill="x", expand=True)
|
||||||
|
self._console_button(log_frame, "Open logs", self.open_logs_folder, width=96).pack(side="right")
|
||||||
|
|
||||||
|
self.status_text = tk.StringVar(value="Ready. Controls are neutral; vehicle is not connected.")
|
||||||
|
ctk.CTkLabel(workspace, textvariable=self.status_text, anchor="w", height=34,
|
||||||
|
corner_radius=12, fg_color="#eeeeea", text_color="#65686a",
|
||||||
|
font=ctk.CTkFont("Helvetica", 9)).pack(fill="x", padx=28, pady=(0, 18))
|
||||||
|
|
||||||
|
def refresh_ports(self):
|
||||||
|
ports = [p.device for p in list_ports.comports()]
|
||||||
|
for sim in ("tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
|
||||||
|
if sim not in ports:
|
||||||
|
ports.append(sim)
|
||||||
|
self.port.configure(values=ports)
|
||||||
|
if ports and self.port.get() not in ports:
|
||||||
|
self.port.set(ports[0])
|
||||||
|
|
||||||
|
def _toggle_battery_settings(self):
|
||||||
|
if self.battery_settings_frame.winfo_ismapped():
|
||||||
|
self.battery_settings_frame.pack_forget()
|
||||||
|
else:
|
||||||
|
self.battery_settings_frame.pack(
|
||||||
|
fill="x", padx=28, pady=(0, 12), before=self.log_frame
|
||||||
|
)
|
||||||
|
|
||||||
|
def _toggle_link_settings(self):
|
||||||
|
if self.baud.winfo_ismapped():
|
||||||
|
self.baud.grid_remove()
|
||||||
|
self.rate.grid_remove()
|
||||||
|
else:
|
||||||
|
self.baud.grid(row=1, column=0, sticky="e", padx=4, pady=(6, 0))
|
||||||
|
self.rate.grid(row=1, column=1, sticky="w", padx=4, pady=(6, 0))
|
||||||
|
|
||||||
|
def _toggle_motor_setup(self):
|
||||||
|
if self.assignments_frame.winfo_ismapped():
|
||||||
|
self.assignments_frame.pack_forget()
|
||||||
|
else:
|
||||||
|
self.assignments_frame.pack(
|
||||||
|
fill="x", padx=28, pady=(0, 12), before=self.log_frame
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_battery_settings(self):
|
||||||
|
try:
|
||||||
|
saved = json.loads(self.battery_config_path.read_text(encoding="utf-8"))
|
||||||
|
for key in self.battery_config:
|
||||||
|
if key in saved:
|
||||||
|
self.battery_config[key] = saved[key]
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _apply_battery_settings(self):
|
||||||
|
try:
|
||||||
|
config = {
|
||||||
|
"cells": int(self.battery_cells.get()),
|
||||||
|
"capacity_mah": int(self.battery_capacity.get()),
|
||||||
|
"c_rating": float(self.battery_c_rating.get()),
|
||||||
|
"full_v_per_cell": float(self.battery_full_cell.get()),
|
||||||
|
"empty_v_per_cell": float(self.battery_empty_cell.get()),
|
||||||
|
"warning_percent": int(self.battery_warning.get()),
|
||||||
|
}
|
||||||
|
if not 1 <= config["cells"] <= 12:
|
||||||
|
raise ValueError("cell count must be between 1 and 12")
|
||||||
|
if config["capacity_mah"] <= 0 or config["c_rating"] <= 0:
|
||||||
|
raise ValueError("capacity and C rating must be positive")
|
||||||
|
if not config["full_v_per_cell"] > config["empty_v_per_cell"] > 0:
|
||||||
|
raise ValueError("full V/cell must be greater than empty V/cell")
|
||||||
|
if not 0 <= config["warning_percent"] <= 100:
|
||||||
|
raise ValueError("warning percentage must be between 0 and 100")
|
||||||
|
self.battery_config = config
|
||||||
|
self.battery_config_path.write_text(
|
||||||
|
json.dumps(config, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
self._update_battery_display()
|
||||||
|
self.status_text.set("Battery settings saved")
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
|
messagebox.showerror("Invalid battery settings", str(exc))
|
||||||
|
|
||||||
|
def _battery_percent(self, voltage):
|
||||||
|
cells = self.battery_config["cells"]
|
||||||
|
per_cell = voltage / cells
|
||||||
|
empty = self.battery_config["empty_v_per_cell"]
|
||||||
|
full = self.battery_config["full_v_per_cell"]
|
||||||
|
return max(0.0, min(100.0, (per_cell - empty) * 100.0 / (full - empty)))
|
||||||
|
|
||||||
|
def _update_battery_display(self):
|
||||||
|
voltage = self.battery_voltage_smoothed
|
||||||
|
if voltage is None:
|
||||||
|
self.battery_summary.configure(text="Battery —", text_color="#f4f4f1")
|
||||||
|
self.battery_meta.configure(text="Waiting for voltage")
|
||||||
|
self.battery_bar.set(0)
|
||||||
|
return
|
||||||
|
cells = self.battery_config["cells"]
|
||||||
|
percent = self._battery_percent(voltage)
|
||||||
|
per_cell = voltage / cells
|
||||||
|
warning = percent <= self.battery_config["warning_percent"]
|
||||||
|
self.battery_summary.configure(
|
||||||
|
text=f"Battery {percent:.0f}%",
|
||||||
|
text_color="#ffaaa2" if warning else "#f4f4f1",
|
||||||
|
)
|
||||||
|
self.battery_meta.configure(
|
||||||
|
text=f"{voltage:.2f} V • {per_cell:.2f} V/cell • {cells}S"
|
||||||
|
+ (" • LOW" if warning else "")
|
||||||
|
)
|
||||||
|
self.battery_bar.configure(progress_color="#f08a82" if warning else "#c4e46c")
|
||||||
|
self.battery_bar.set(percent / 100.0)
|
||||||
|
|
||||||
|
def toggle_connect(self):
|
||||||
|
if self.worker.thread and self.worker.thread.is_alive():
|
||||||
|
self.worker.disconnect()
|
||||||
|
self.connect_button.configure(state="disabled")
|
||||||
|
return
|
||||||
|
if not self.port.get():
|
||||||
|
messagebox.showerror("No port", "Select a USB serial COM port.")
|
||||||
|
return
|
||||||
|
self.worker.rate_hz = int(self.rate.get())
|
||||||
|
self.worker.connect(self.port.get(), int(self.baud.get()))
|
||||||
|
self.connect_button.configure(text="Disconnect")
|
||||||
|
self.status_text.set("Opening serial port…")
|
||||||
|
|
||||||
|
def _axes_changed(self):
|
||||||
|
turn, forward = self.joystick.value
|
||||||
|
self.worker.set_axes(Axes(forward, 0.0,
|
||||||
|
float(self.vertical.get()) / 100,
|
||||||
|
turn))
|
||||||
|
|
||||||
|
def _gain_changed(self, value):
|
||||||
|
value = int(float(value))
|
||||||
|
self.worker.gain = value / 100
|
||||||
|
self.gain_text.configure(text=f"{value}%")
|
||||||
|
|
||||||
|
def _key_down(self, event):
|
||||||
|
key = event.keysym.lower()
|
||||||
|
if key in "wasdqerf":
|
||||||
|
self.keys.add(key)
|
||||||
|
self._apply_keys()
|
||||||
|
|
||||||
|
def _key_up(self, event):
|
||||||
|
key = event.keysym.lower()
|
||||||
|
self.keys.discard(key)
|
||||||
|
self._apply_keys()
|
||||||
|
|
||||||
|
def _apply_keys(self):
|
||||||
|
turn = (1 if ("d" in self.keys or "e" in self.keys) else 0) - (
|
||||||
|
1 if ("a" in self.keys or "q" in self.keys) else 0
|
||||||
|
)
|
||||||
|
forward = (1 if "w" in self.keys else 0) - (1 if "s" in self.keys else 0)
|
||||||
|
vertical = (1 if "r" in self.keys else 0) - (1 if "f" in self.keys else 0)
|
||||||
|
planar = (turn, forward)
|
||||||
|
if planar != self.keyboard_planar:
|
||||||
|
if turn or forward:
|
||||||
|
self.joystick.set_immediate(float(turn), float(forward))
|
||||||
|
else:
|
||||||
|
self.joystick._release()
|
||||||
|
self.keyboard_planar = planar
|
||||||
|
if vertical != self.keyboard_vertical:
|
||||||
|
if vertical:
|
||||||
|
self.vertical.set_immediate(vertical * 100)
|
||||||
|
else:
|
||||||
|
self.vertical.spring_to_center()
|
||||||
|
self.keyboard_vertical = vertical
|
||||||
|
self._axes_changed()
|
||||||
|
|
||||||
|
def arm(self):
|
||||||
|
if not messagebox.askyesno("Arm vehicle", "Propellers removed or vehicle securely restrained?\n\nArm now?"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.worker.log("OPERATOR", "clicked ARM")
|
||||||
|
self.worker.command_arm(True)
|
||||||
|
self.status_text.set("Arm command sent; waiting for ACK/heartbeat…")
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot arm", str(exc))
|
||||||
|
|
||||||
|
def disarm(self):
|
||||||
|
try:
|
||||||
|
self.worker.log("OPERATOR", "clicked DISARM")
|
||||||
|
self.worker.command_arm(False)
|
||||||
|
self.status_text.set("Disarm command sent; neutral sent")
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot disarm", str(exc))
|
||||||
|
|
||||||
|
def set_manual(self):
|
||||||
|
try:
|
||||||
|
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
||||||
|
self.worker.set_manual_mode()
|
||||||
|
self.status_text.set("MANUAL mode request sent")
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot set mode", str(exc))
|
||||||
|
|
||||||
|
def open_logs_folder(self):
|
||||||
|
log_dir = Path(__file__).resolve().parent / "logs"
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
os.startfile(log_dir)
|
||||||
|
|
||||||
|
def _selected_servo(self):
|
||||||
|
selection = self.servo_tree.selection()
|
||||||
|
if not selection:
|
||||||
|
raise RuntimeError("Select a SERVO output in the table first")
|
||||||
|
return int(selection[0])
|
||||||
|
|
||||||
|
def apply_servo_function(self):
|
||||||
|
try:
|
||||||
|
servo = self._selected_servo()
|
||||||
|
choice = self.motor_function.get()
|
||||||
|
if choice.startswith("Motor "):
|
||||||
|
motor = int(choice.split()[1])
|
||||||
|
function = 32 + motor if motor <= 8 else 73 + motor
|
||||||
|
else:
|
||||||
|
function = 0
|
||||||
|
description = MOTOR_FUNCTIONS.get(function, "Unassigned")
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Change output function",
|
||||||
|
f"Set SERVO{servo}_FUNCTION to {description} ({function})?\n\n"
|
||||||
|
"This changes the autopilot configuration.",
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot change function", str(exc))
|
||||||
|
|
||||||
|
def toggle_servo_reversed(self):
|
||||||
|
try:
|
||||||
|
servo = self._selected_servo()
|
||||||
|
function = self.servo_functions.get(servo)
|
||||||
|
motor = motor_number_for_function(function)
|
||||||
|
if motor is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"SERVO{servo} is not assigned to an ArduSub Motor function"
|
||||||
|
)
|
||||||
|
current = self.motor_directions.get(motor)
|
||||||
|
if current is None:
|
||||||
|
raise RuntimeError(f"MOT_{motor}_DIRECTION has not been read yet")
|
||||||
|
new_value = -1 if current == 1 else 1
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Reverse motor output",
|
||||||
|
f"SERVO{servo} is Motor {motor}.\n"
|
||||||
|
f"Set MOT_{motor}_DIRECTION to {new_value}?\n\n"
|
||||||
|
"Test direction with propellers removed after changing this.",
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", new_value)
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("Cannot reverse output", str(exc))
|
||||||
|
|
||||||
|
def _refresh_servo_row(self, number):
|
||||||
|
function = self.servo_functions.get(number)
|
||||||
|
motor = motor_number_for_function(function)
|
||||||
|
direction = self.motor_directions.get(motor) if motor is not None else None
|
||||||
|
if function is None:
|
||||||
|
function_text = "Waiting…"
|
||||||
|
else:
|
||||||
|
function_text = f"{MOTOR_FUNCTIONS.get(function, 'Other / unassigned')} ({function})"
|
||||||
|
if motor is None:
|
||||||
|
reversed_text = "—"
|
||||||
|
elif direction is None:
|
||||||
|
reversed_text = "Waiting…"
|
||||||
|
else:
|
||||||
|
reversed_text = "Yes" if direction == -1 else "No"
|
||||||
|
self.servo_tree.item(str(number), values=(f"SERVO{number}", function_text, reversed_text))
|
||||||
|
|
||||||
|
def _poll(self):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
event = self.events.get_nowait()
|
||||||
|
kind = event[0]
|
||||||
|
if kind == "heartbeat":
|
||||||
|
_, system, autopilot, mode, armed = event[:5]
|
||||||
|
self.vehicle_label.configure(
|
||||||
|
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
|
||||||
|
)
|
||||||
|
self.link_label.configure(text="● LINK OK", text_color="#c4e46c")
|
||||||
|
elif kind in ("status", "statustext"):
|
||||||
|
self.status_text.set(event[1])
|
||||||
|
elif kind == "ack":
|
||||||
|
self.status_text.set(f"Command {event[1]} ACK: {event[2]}")
|
||||||
|
elif kind == "servo_param":
|
||||||
|
_, param_id, value = event
|
||||||
|
number = int(param_id[5:param_id.index("_")])
|
||||||
|
self.servo_functions[number] = value
|
||||||
|
self._refresh_servo_row(number)
|
||||||
|
elif kind == "motor_direction":
|
||||||
|
_, motor, value = event
|
||||||
|
self.motor_directions[motor] = value
|
||||||
|
for number in range(1, 17):
|
||||||
|
if motor_number_for_function(self.servo_functions.get(number)) == motor:
|
||||||
|
self._refresh_servo_row(number)
|
||||||
|
elif kind == "battery":
|
||||||
|
self.battery_voltage = event[1]
|
||||||
|
if self.battery_voltage_smoothed is None:
|
||||||
|
self.battery_voltage_smoothed = self.battery_voltage
|
||||||
|
else:
|
||||||
|
self.battery_voltage_smoothed = (
|
||||||
|
0.75 * self.battery_voltage_smoothed + 0.25 * self.battery_voltage
|
||||||
|
)
|
||||||
|
self._update_battery_display()
|
||||||
|
elif kind == "param_saved":
|
||||||
|
self.status_text.set(f"Confirmed: {event[1]}={event[2]}")
|
||||||
|
elif kind == "param_missing":
|
||||||
|
self.status_text.set(f"No response for {event[1]}; continuing scan…")
|
||||||
|
elif kind == "param_scan_complete":
|
||||||
|
count = sum(value in MOTOR_FUNCTIONS for value in self.servo_functions.values())
|
||||||
|
self.status_text.set(f"Servo scan complete: {count} motor output(s) assigned")
|
||||||
|
elif kind == "log_path":
|
||||||
|
self.log_path_text.set(f"Log: {event[1]}")
|
||||||
|
elif kind == "error":
|
||||||
|
self.status_text.set(event[1])
|
||||||
|
messagebox.showerror("MAVLink", event[1])
|
||||||
|
elif kind == "disconnected":
|
||||||
|
self.connect_button.configure(text="Connect", state="normal")
|
||||||
|
self.link_label.configure(text="● DISCONNECTED", text_color="#f08a82")
|
||||||
|
self.vehicle_label.configure(text="No heartbeat")
|
||||||
|
self.battery_voltage = None
|
||||||
|
self.battery_voltage_smoothed = None
|
||||||
|
self._update_battery_display()
|
||||||
|
self.servo_functions.clear()
|
||||||
|
self.motor_directions.clear()
|
||||||
|
for number in range(1, 17):
|
||||||
|
self.servo_tree.item(str(number), values=(f"SERVO{number}", "Waiting…", "Waiting…"))
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
self.after(50, self._poll)
|
||||||
|
|
||||||
|
def _link_watchdog(self):
|
||||||
|
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5.0:
|
||||||
|
if time.monotonic() - self.worker.last_rx < 2.0:
|
||||||
|
text = "● DATA OK / HEARTBEAT DELAYED"
|
||||||
|
else:
|
||||||
|
text = "● ALL LINK DATA LOST"
|
||||||
|
self.link_label.configure(text=text, text_color="#f08a82")
|
||||||
|
self.worker._send_control(force_neutral=True)
|
||||||
|
self.after(500, self._link_watchdog)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.worker.disconnect()
|
||||||
|
self.after(150, self.destroy)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Validated V2 settings and telemetry, independent of the desktop renderer."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
AXES = ("forward", "lateral", "vertical", "turn", "pitch", "roll")
|
||||||
|
FRAMES = {
|
||||||
|
0: ("BlueROV1", 6, AXES),
|
||||||
|
1: ("Vectored", 6, tuple(axis for axis in AXES if axis != "pitch")),
|
||||||
|
2: ("Vectored 6DOF", 8, AXES),
|
||||||
|
3: ("Vectored 90", 8, AXES),
|
||||||
|
4: ("SimpleROV 3", 3, ("forward", "vertical", "turn")),
|
||||||
|
5: ("SimpleROV 4", 4, ("forward", "vertical", "turn", "roll")),
|
||||||
|
6: ("SimpleROV 5", 5, ("forward", "lateral", "vertical", "turn", "roll")),
|
||||||
|
}
|
||||||
|
INPUT = re.compile(r"^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$")
|
||||||
|
|
||||||
|
|
||||||
|
def number(value, minimum, maximum):
|
||||||
|
result = float(value)
|
||||||
|
if not math.isfinite(result) or not minimum <= result <= maximum:
|
||||||
|
raise ValueError(f"Value must be between {minimum} and {maximum}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def clean_profile(config):
|
||||||
|
if not isinstance(config, dict) or config.get("version") != 2:
|
||||||
|
raise ValueError("Invalid controller profile")
|
||||||
|
if not isinstance(config.get("bindings", {}), dict) or not isinstance(config.get("actions", {}), dict):
|
||||||
|
raise ValueError("Invalid controller bindings")
|
||||||
|
bindings = {}
|
||||||
|
for axis in AXES:
|
||||||
|
item = config.get("bindings", {}).get(axis, {})
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("Invalid motion binding")
|
||||||
|
negative, positive = item.get("negative", "off"), item.get("positive", "off")
|
||||||
|
if not isinstance(negative, str) or not INPUT.fullmatch(negative):
|
||||||
|
raise ValueError("Invalid negative input")
|
||||||
|
if not isinstance(positive, str) or not INPUT.fullmatch(positive):
|
||||||
|
raise ValueError("Invalid positive input")
|
||||||
|
if negative == positive and positive != "off":
|
||||||
|
raise ValueError("Choose different inputs for each direction")
|
||||||
|
bindings[axis] = {"negative": negative, "positive": positive,
|
||||||
|
"scale": number(item.get("scale", 1), 0, 1)}
|
||||||
|
actions = {}
|
||||||
|
for action in ("lights", "stream", "record"):
|
||||||
|
source = config.get("actions", {}).get(action, "off")
|
||||||
|
if source != "off" and (not isinstance(source, str) or not re.fullmatch(r"button:(?:[0-9]|[12][0-9]|3[01])", source)):
|
||||||
|
raise ValueError("Actions require a button")
|
||||||
|
actions[action] = source
|
||||||
|
return {"version": 2, "preset": str(config.get("preset", "custom"))[:24],
|
||||||
|
"deadZone": number(config.get("deadZone", .08), 0, .3),
|
||||||
|
"expo": number(config.get("expo", .15), 0, 1),
|
||||||
|
"bindings": bindings, "actions": actions}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_station(config):
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValueError("Invalid station settings")
|
||||||
|
frame_value = number(config.get("frame", 4), 0, 6)
|
||||||
|
if not frame_value.is_integer():
|
||||||
|
raise ValueError("Invalid frame")
|
||||||
|
frame = int(frame_value)
|
||||||
|
if frame not in FRAMES:
|
||||||
|
raise ValueError("Choose an ArduSub frame")
|
||||||
|
mode = config.get("video_mode", "whep")
|
||||||
|
if mode not in ("whep", "video", "mjpeg"):
|
||||||
|
raise ValueError("Unsupported stream type")
|
||||||
|
url = str(config.get("video_url", "")).strip()
|
||||||
|
if url:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
|
||||||
|
raise ValueError("Use an HTTP stream URL without embedded credentials")
|
||||||
|
relays = {}
|
||||||
|
for name in ("lights_relay", "camera_relay"):
|
||||||
|
value = number(config.get(name, -1), -1, 15)
|
||||||
|
if not value.is_integer():
|
||||||
|
raise ValueError("Invalid relay")
|
||||||
|
relays[name] = int(value)
|
||||||
|
if relays["lights_relay"] >= 0 and relays["lights_relay"] == relays["camera_relay"]:
|
||||||
|
raise ValueError("Lights and camera need separate relays")
|
||||||
|
return {"frame": frame, "video_mode": mode, "video_url": url[:2048], **relays}
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path: Path, data):
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetry:
|
||||||
|
"""Timestamp every group; missing or stale measurements stay unavailable."""
|
||||||
|
def __init__(self, clock=time.monotonic):
|
||||||
|
self.clock = clock
|
||||||
|
self.groups = {}
|
||||||
|
|
||||||
|
def update(self, group, values, received_at=None):
|
||||||
|
clean = {key: value if isinstance(value, (int, float)) and math.isfinite(value) else None
|
||||||
|
for key, value in values.items()}
|
||||||
|
self.groups[group] = {"values": clean, "at": self.clock() if received_at is None else received_at}
|
||||||
|
|
||||||
|
def snapshot(self, connected=True):
|
||||||
|
now = self.clock()
|
||||||
|
return {name: {"values": item["values"], "age_s": max(0, now - item["at"]),
|
||||||
|
"stale": not connected or now - item["at"] > 3}
|
||||||
|
for name, item in self.groups.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_telemetry(msg):
|
||||||
|
kind = msg.get_type()
|
||||||
|
if kind == "ATTITUDE":
|
||||||
|
return "attitude", {"roll": math.degrees(msg.roll), "pitch": math.degrees(msg.pitch),
|
||||||
|
"heading": math.degrees(msg.yaw) % 360,
|
||||||
|
"roll_rate": math.degrees(msg.rollspeed),
|
||||||
|
"pitch_rate": math.degrees(msg.pitchspeed),
|
||||||
|
"yaw_rate": math.degrees(msg.yawspeed)}
|
||||||
|
if kind == "GLOBAL_POSITION_INT":
|
||||||
|
# ArduSub reports height relative to its surface/home origin, positive up.
|
||||||
|
return "depth", {"depth_m": -msg.relative_alt / 1000.0, "vertical_ms": msg.vz / 100.0}
|
||||||
|
if kind in ("SCALED_IMU", "SCALED_IMU2", "SCALED_IMU3"):
|
||||||
|
return "imu", {"ax": msg.xacc * .00980665, "ay": msg.yacc * .00980665,
|
||||||
|
"az": msg.zacc * .00980665, "gx": msg.xgyro / 1000.0,
|
||||||
|
"gy": msg.ygyro / 1000.0, "gz": msg.zgyro / 1000.0,
|
||||||
|
"mx": msg.xmag / 1000.0, "my": msg.ymag / 1000.0, "mz": msg.zmag / 1000.0}
|
||||||
|
if kind == "SCALED_PRESSURE2":
|
||||||
|
return "pressure", {"pressure_hpa": msg.press_abs, "temperature_c": msg.temperature / 100.0}
|
||||||
|
return None
|
||||||
@@ -5,13 +5,20 @@ Set-Location -LiteralPath $projectRoot
|
|||||||
|
|
||||||
& (Join-Path $projectRoot 'create_shortcut.ps1')
|
& (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."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) {
|
if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) {
|
||||||
|
if (-not (Get-Command py -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again."
|
||||||
|
}
|
||||||
& py -3 -m venv .venv
|
& py -3 -m venv .venv
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Virtual environment setup failed.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt
|
$dependencyHash = (Get-FileHash -LiteralPath 'requirements.txt' -Algorithm SHA256).Hash
|
||||||
& .venv\Scripts\python.exe app.py
|
$dependencyMarker = Join-Path $projectRoot '.venv\requirements.sha256'
|
||||||
|
$installedHash = if (Test-Path -LiteralPath $dependencyMarker) { (Get-Content -LiteralPath $dependencyMarker -Raw).Trim() } else { '' }
|
||||||
|
if ($installedHash -ne $dependencyHash) {
|
||||||
|
& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' }
|
||||||
|
Set-Content -LiteralPath $dependencyMarker -Value $dependencyHash
|
||||||
|
}
|
||||||
|
& .venv\Scripts\python.exe web_ui.py
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* Synthetic Xbox input: no device, serial port, or browser is opened. */
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const input=require('../web/controller-input.js');
|
||||||
|
const pad=()=>({connected:true,mapping:'standard',axes:[0,0,0,0],buttons:Array.from({length:17},()=>({value:0,pressed:false}))});
|
||||||
|
const press=(pad,index,value=1)=>{pad.buttons[index]={value,pressed:value>.5};return pad};
|
||||||
|
const three=input.preset(),six=input.preset('sixaxis');
|
||||||
|
assert(input.validProfile(three));assert(input.validProfile(six));
|
||||||
|
assert.deepEqual(input.read(pad(),three),Object.fromEntries(input.names.map(name=>[name,0])));
|
||||||
|
assert.equal(input.read(press(pad(),1),three).vertical,1);
|
||||||
|
assert.equal(input.read(press(pad(),0),three).vertical,-1);
|
||||||
|
assert.equal(input.read(press(press(pad(),0),1),three).vertical,0);
|
||||||
|
assert.equal(input.read(press(pad(),7),three).forward,1);
|
||||||
|
assert.equal(input.read(press(pad(),6),three).forward,-1);
|
||||||
|
assert.equal(input.read(press(press(pad(),6),7),three).forward,0);
|
||||||
|
assert(input.read(press(pad(),7,.5),three).forward>0);
|
||||||
|
assert(input.read(press(pad(),7,.5),three).forward<.5);
|
||||||
|
let p=pad();p.axes[0]=1;p.axes[1]=-1;p.axes[2]=1;press(p,4);
|
||||||
|
assert.deepEqual(input.read(p,six),{forward:0,lateral:1,vertical:0,turn:1,pitch:-1,roll:-1});
|
||||||
|
let custom=structuredClone(six);custom.bindings.vertical={negative:'button:12',positive:'button:13',scale:.25};
|
||||||
|
assert.equal(input.read(press(pad(),13),custom).vertical,.25);
|
||||||
|
custom.bindings.turn.scale=0;assert.equal(input.read(p,custom).turn,0);
|
||||||
|
custom.bindings.pitch.negative='axis:90:-';assert.equal(input.read(p,custom),null);
|
||||||
|
assert.equal(input.read({...pad(),connected:false},three),null);
|
||||||
|
p=pad();p.axes[0]=NaN;assert.equal(input.read(p,three),null);
|
||||||
|
p=pad();p.buttons=[];assert.equal(input.read(p,three),null);
|
||||||
|
assert.equal(input.actions(press(pad(),2),three).lights,true);
|
||||||
|
assert.equal(input.migrate({turn:0,forward:1,vertical:3,invertForward:true,invertVertical:true,deadZone:.1}).bindings.forward.positive,'axis:1:-');
|
||||||
|
const duplicate=structuredClone(three);duplicate.bindings.forward.negative='button:7';assert.equal(input.validProfile(duplicate),false);
|
||||||
|
console.log('PASS: Xbox triggers, B/A, cancellation, six axes, custom buttons, limits, invalid devices, migration and actions.');
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""V2 protocol and persistence checks. Never opens a vehicle connection."""
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from app import Axes, MavlinkWorker, normalize_endpoint
|
||||||
|
from rov_v2 import Telemetry, clean_station, clean_profile, decode_telemetry, save_json
|
||||||
|
from web_ui import ControlApi
|
||||||
|
|
||||||
|
|
||||||
|
class V2Tests(unittest.TestCase):
|
||||||
|
def worker(self, frame=0, version=(4, 5, 3)):
|
||||||
|
worker = MavlinkWorker(queue.Queue())
|
||||||
|
worker.connection = Mock()
|
||||||
|
worker.target_system = 1
|
||||||
|
worker.frame_type = frame
|
||||||
|
worker.firmware_version = version
|
||||||
|
worker.gain = .5
|
||||||
|
worker.last_control_at = time.monotonic() - 1
|
||||||
|
return worker
|
||||||
|
|
||||||
|
def test_six_axis_mapping_and_envelope(self):
|
||||||
|
worker = self.worker()
|
||||||
|
worker.set_axes(Axes(forward=1, lateral=1, vertical=1, yaw=1, pitch=1, roll=1))
|
||||||
|
worker._send_control()
|
||||||
|
args = worker.connection.mav.manual_control_send.call_args.args
|
||||||
|
self.assertEqual(args[6:8], (0, 3))
|
||||||
|
self.assertEqual(args[8:], (33, 33)) # S=pitch, T=roll; ArduSub 4.5 ±400.
|
||||||
|
self.assertEqual(args[1:5], (83, 83, 542, 83))
|
||||||
|
|
||||||
|
def test_modern_normalized_attitude(self):
|
||||||
|
worker = self.worker(version=(4, 8, 0))
|
||||||
|
worker.set_axes(Axes(pitch=.5, roll=-.5))
|
||||||
|
worker._send_control()
|
||||||
|
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args[8:], (250, -250))
|
||||||
|
|
||||||
|
def test_unsupported_frame_and_old_firmware(self):
|
||||||
|
worker = self.worker(frame=1)
|
||||||
|
worker.set_axes(Axes(pitch=1, roll=.2))
|
||||||
|
worker._send_control()
|
||||||
|
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args[8], 0)
|
||||||
|
worker.firmware_version = (4, 1, 0)
|
||||||
|
worker._send_control()
|
||||||
|
self.assertEqual(len(worker.connection.mav.manual_control_send.call_args.args), 6)
|
||||||
|
|
||||||
|
def test_watchdog_neutralizes_extended_axes(self):
|
||||||
|
worker = self.worker()
|
||||||
|
worker.require_input_lease = True
|
||||||
|
worker.armed = True
|
||||||
|
worker.last_heartbeat = time.monotonic()
|
||||||
|
worker.set_axes(Axes(pitch=1, roll=-1), 1)
|
||||||
|
worker.last_input_at -= 2
|
||||||
|
worker._send_control()
|
||||||
|
self.assertEqual(worker.connection.mav.manual_control_send.call_args.args, (1, 0, 0, 500, 0, 0, 0, 3, 0, 0))
|
||||||
|
self.assertEqual(worker.axes, Axes())
|
||||||
|
|
||||||
|
def test_endpoints_preserve_configured_network_host(self):
|
||||||
|
for endpoint in ('udpin:0.0.0.0:14550', 'udpout:192.168.2.2:14550', 'tcp:192.168.2.2:5760'):
|
||||||
|
self.assertEqual(normalize_endpoint(endpoint), endpoint)
|
||||||
|
|
||||||
|
def test_sensor_units_and_staleness(self):
|
||||||
|
msg = SimpleNamespace(get_type=lambda: 'ATTITUDE', roll=math.pi/2, pitch=0, yaw=-math.pi/2, rollspeed=1, pitchspeed=0, yawspeed=0)
|
||||||
|
group, values = decode_telemetry(msg)
|
||||||
|
self.assertEqual(values['roll'], 90)
|
||||||
|
self.assertEqual(values['heading'], 270)
|
||||||
|
clock = [10]
|
||||||
|
store = Telemetry(lambda: clock[0])
|
||||||
|
store.update(group, values)
|
||||||
|
clock[0] = 14
|
||||||
|
self.assertTrue(store.snapshot()[group]['stale'])
|
||||||
|
msg = SimpleNamespace(get_type=lambda: 'GLOBAL_POSITION_INT', relative_alt=-1200, vz=25)
|
||||||
|
self.assertEqual(decode_telemetry(msg)[1], {'depth_m': 1.2, 'vertical_ms': .25})
|
||||||
|
|
||||||
|
def test_config_validation_and_atomic_roundtrip(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
clean_station({'video_url': 'javascript:alert(1)'})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
clean_station({'lights_relay': 1, 'camera_relay': 1})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
clean_profile({'version': 2, 'expo': float('nan')})
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
target = Path(folder) / 'station.json'
|
||||||
|
save_json(target, clean_station({'frame': 2}))
|
||||||
|
import json
|
||||||
|
self.assertEqual(json.loads(target.read_text())['frame'], 2)
|
||||||
|
self.assertFalse(target.with_suffix('.json.tmp').exists())
|
||||||
|
|
||||||
|
def test_stale_current_is_not_refreshed_by_voltage(self):
|
||||||
|
api = ControlApi()
|
||||||
|
api.state['connected'] = True
|
||||||
|
with patch('web_ui.time.monotonic', return_value=10):
|
||||||
|
api._consume_battery({'current_a': 4, 'voltage': 16})
|
||||||
|
with patch('web_ui.time.monotonic', return_value=16):
|
||||||
|
api._consume_battery({'voltage': 15.9})
|
||||||
|
self.assertIsNone(api._snapshot()['battery']['current_a'])
|
||||||
|
with patch('web_ui.time.monotonic', return_value=17):
|
||||||
|
api._consume_battery({'current_a': None})
|
||||||
|
self.assertIsNone(api.state['battery']['current_a'])
|
||||||
|
|
||||||
|
def test_attitude_stream_is_never_disabled_when_requested(self):
|
||||||
|
worker = self.worker()
|
||||||
|
worker._configure_low_bandwidth_streams()
|
||||||
|
rates = [(name, interval) for name, _, interval in worker.stream_config]
|
||||||
|
self.assertIn(('ATTITUDE', 500000), rates)
|
||||||
|
self.assertNotIn(('ATTITUDE', -1), rates)
|
||||||
|
|
||||||
|
def test_network_control_omits_serial_priming_heartbeat(self):
|
||||||
|
worker = self.worker()
|
||||||
|
worker.network_link = True
|
||||||
|
worker._send_control()
|
||||||
|
worker.connection.mav.heartbeat_send.assert_not_called()
|
||||||
|
|
||||||
|
def test_profile_saves_atomically_and_cannot_change_while_armed(self):
|
||||||
|
api = ControlApi()
|
||||||
|
profile = {'version': 2, 'bindings': {}, 'actions': {}}
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
api.profiles_path = Path(folder) / 'profiles.json'
|
||||||
|
api.save_controller_profile('$xbox', profile)
|
||||||
|
self.assertTrue(api.profiles_path.is_file())
|
||||||
|
before = api.profiles_path.read_bytes()
|
||||||
|
api.state.update(connected=True, armed=True)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
api.save_controller_profile('$xbox', profile)
|
||||||
|
self.assertEqual(before, api.profiles_path.read_bytes())
|
||||||
|
|
||||||
|
def test_native_recording_chunk_roundtrip_and_expired_token(self):
|
||||||
|
import base64
|
||||||
|
api = ControlApi()
|
||||||
|
with tempfile.TemporaryDirectory() as folder, patch('web_ui.__file__', str(Path(folder) / 'web_ui.py')):
|
||||||
|
token = api.recording_begin()
|
||||||
|
api.recording_chunk(token, base64.b64encode(b'video chunk').decode())
|
||||||
|
path = api.recording_end(token)
|
||||||
|
self.assertEqual(Path(path).read_bytes(), b'video chunk')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
api.recording_chunk(token, 'AA==')
|
||||||
|
|
||||||
|
def test_relay_action_waits_for_ack(self):
|
||||||
|
api = ControlApi()
|
||||||
|
api.station_settings['lights_relay'] = 0
|
||||||
|
worker = api.worker
|
||||||
|
worker.connection = Mock()
|
||||||
|
worker.target_system = worker.target_component = 1
|
||||||
|
worker.last_heartbeat = time.monotonic()
|
||||||
|
api.set_accessory('lights', True)
|
||||||
|
self.assertIsNone(api.state['accessories']['lights']['accepted'])
|
||||||
|
api._consume(('accessory_ack', 'lights', True, 2))
|
||||||
|
self.assertFalse(api.state['accessories']['lights']['accepted'])
|
||||||
|
self.assertEqual(api.state['accessories']['lights']['error'], 'Rejected')
|
||||||
|
|
||||||
|
def test_motor_names_survive_app_restart(self):
|
||||||
|
with tempfile.TemporaryDirectory() as folder, patch('web_ui.__file__', str(Path(folder) / 'web_ui.py')):
|
||||||
|
first = ControlApi()
|
||||||
|
settings = first.motor_settings
|
||||||
|
settings['motors']['1']['name'] = 'Port'
|
||||||
|
first.save_motor_settings(settings)
|
||||||
|
self.assertEqual(ControlApi().motor_settings['motors']['1']['name'], 'Port')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -227,9 +227,11 @@ class WorkerWatchdogTests(unittest.TestCase):
|
|||||||
self.worker.armed = False
|
self.worker.armed = False
|
||||||
with patch("app.time.monotonic", return_value=10):
|
with patch("app.time.monotonic", return_value=10):
|
||||||
self.worker._configure_low_bandwidth_streams()
|
self.worker._configure_low_bandwidth_streams()
|
||||||
self.assertEqual(len(self.worker.stream_config), 10)
|
configured = {name: interval for name, _, interval in self.worker.stream_config}
|
||||||
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
|
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 1_000_000)
|
||||||
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
|
self.assertEqual(configured["ATTITUDE"], 500_000)
|
||||||
|
self.assertEqual(configured["GLOBAL_POSITION_INT"], 500_000)
|
||||||
|
self.assertNotIn(("ATTITUDE", 30, -1), self.worker.stream_config)
|
||||||
self.worker.connection.mav.command_long_send.assert_not_called()
|
self.worker.connection.mav.command_long_send.assert_not_called()
|
||||||
self.worker._tick_stream_config(10)
|
self.worker._tick_stream_config(10)
|
||||||
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
|
||||||
|
|||||||
+107
-91
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
|
|||||||
link_degraded: 'Signal lost — check tether',
|
link_degraded: 'Signal lost — check tether',
|
||||||
};
|
};
|
||||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||||
const control={forward:0,turn:0,vertical:0}, 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;
|
const control={forward:0,turn:0,vertical:0,lateral:0,pitch:0,roll:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,battery_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||||
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
|
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
|
||||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||||
const activity=[];
|
const activity=[];
|
||||||
@@ -25,126 +25,132 @@ let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehi
|
|||||||
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
|
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
|
||||||
|
|
||||||
window.canDrive=canDrive;
|
window.canDrive=canDrive;
|
||||||
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
|
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerDraft=null,controllerNeedCenter=true,controllerWasDriving=false;
|
||||||
|
let controllerProfiles={};try{controllerProfiles=JSON.parse(localStorage.getItem('sub-v2-profiles'))||{}}catch(_){}
|
||||||
const controllerMotion=new ControllerInput.Motion();
|
const controllerMotion=new ControllerInput.Motion();
|
||||||
|
let controllerActionState={},controllerActionsReady=false;
|
||||||
const controllerId=pad=>pad.index+':'+pad.id;
|
const controllerId=pad=>pad.index+':'+pad.id;
|
||||||
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
|
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
|
||||||
function loadControllerConfig(pad){
|
function loadControllerConfig(pad){
|
||||||
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
|
let saved=controllerProfiles[pad?.id||'$xbox']||controllerProfiles.$xbox;
|
||||||
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
|
if(!saved&&pad)try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
|
||||||
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
|
const config=ControllerInput.migrate(saved,pad);
|
||||||
|
return ControllerInput.validProfile(config)?config:ControllerInput.preset();
|
||||||
}
|
}
|
||||||
function selectController(pad){
|
function selectController(pad){
|
||||||
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
|
resetInputs();controllerKey=pad?controllerId(pad):null;controllerConfig=loadControllerConfig(pad);
|
||||||
}
|
}
|
||||||
function setInputMode(mode){
|
function setInputMode(mode){
|
||||||
if(mode===inputMode||inputConfigurationLocked())return false;
|
if(mode===inputMode||inputConfigurationLocked())return false;
|
||||||
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
|
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
|
||||||
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
|
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
|
||||||
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
|
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
|
||||||
$('#controller-settings').hidden=mode!=='joystick';
|
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller output':'Steering and throttle joystick; W A S D keys');
|
||||||
$('#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':'Vertical thrust control');
|
||||||
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function inputConfigurationLocked(){
|
function inputConfigurationLocked(){return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known))}
|
||||||
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
|
|
||||||
}
|
|
||||||
function syncInputModeControls(){
|
function syncInputModeControls(){
|
||||||
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
const locked=inputConfigurationLocked();
|
||||||
$('.input-mode-switch').dataset.locked=String(locked);
|
$('.input-mode-switch').dataset.locked=String(locked);
|
||||||
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
|
for(const button of [$('#input-keyboard'),$('#input-joystick'),$('#controller-settings')])button.disabled=locked;
|
||||||
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
|
if($('#controller-modal').open)for(const field of $('#controller-form').querySelectorAll('input,select,button[type=submit],button[data-swap]'))field.disabled=locked;
|
||||||
}
|
|
||||||
$('#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();
|
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
|
||||||
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
|
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
|
||||||
$('#input-joystick').onclick=()=>setInputMode('joystick');
|
$('#input-joystick').onclick=()=>setInputMode('joystick');
|
||||||
function fillControllerForm(){
|
const axisLabels={forward:'Forward',lateral:'Strafe',vertical:'Vertical',turn:'Turn',pitch:'Pitch',roll:'Roll'};
|
||||||
|
function fillControllerForm(keepDraft=false){
|
||||||
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
|
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
|
||||||
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
|
device.replaceChildren(new Option('Xbox · offline',''),...pads.map(pad=>new Option(pad.id,controllerId(pad))));
|
||||||
if(!pads.length)device.add(new Option('No controller detected',''));
|
|
||||||
device.value=controllerKey||'';
|
device.value=controllerKey||'';
|
||||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
||||||
const config=controllerConfig||ControllerInput.defaults(pad);
|
if(!keepDraft)controllerDraft=structuredClone(controllerConfig||loadControllerConfig(pad));
|
||||||
for(const name of ['turn','forward','vertical']){
|
const config=controllerDraft;
|
||||||
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
|
$('#controller-preset').value=['triggers3','sixaxis','classic'].includes(config.preset)?config.preset:'custom';
|
||||||
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
|
const rows=$('#controller-bindings');rows.replaceChildren();
|
||||||
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
|
for(const name of ControllerInput.names){
|
||||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);
|
const row=document.createElement('div');row.className='binding-row';row.dataset.axis=name;
|
||||||
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
|
const label=document.createElement('span');label.textContent=axisLabels[name];
|
||||||
|
const meter=document.createElement('meter');meter.min=-1;meter.max=1;meter.value=0;meter.setAttribute('aria-label',axisLabels[name]+' demand');label.append(meter);row.append(label);
|
||||||
|
for(const direction of ['negative','positive']){
|
||||||
|
const select=document.createElement('select');select.name=name+'_'+direction;select.setAttribute('aria-label',axisLabels[name]+' '+direction);
|
||||||
|
for(const [value,text] of ControllerInput.sources(pad))select.add(new Option(text,value));
|
||||||
|
select.value=config.bindings[name][direction];
|
||||||
|
select.onchange=()=>{config.bindings[name][direction]=select.value;config.preset='custom';$('#controller-preset').value='custom'};
|
||||||
|
row.append(select);
|
||||||
|
}
|
||||||
|
const swap=document.createElement('button');swap.type='button';swap.dataset.swap=name;swap.className='binding-swap';swap.textContent='⇄';swap.title='Reverse '+axisLabels[name];swap.setAttribute('aria-label','Reverse '+axisLabels[name]);
|
||||||
|
swap.onclick=()=>{const item=config.bindings[name];[item.negative,item.positive]=[item.positive,item.negative];config.preset='custom';fillControllerForm(true)};row.append(swap);
|
||||||
|
const scale=document.createElement('input');scale.type='number';scale.min=0;scale.max=100;scale.step=1;scale.value=Math.round(config.bindings[name].scale*100);scale.setAttribute('aria-label',axisLabels[name]+' limit');
|
||||||
|
scale.oninput=()=>{config.bindings[name].scale=Number(scale.value)/100;config.preset='custom';$('#controller-preset').value='custom'};row.append(scale);rows.append(row);
|
||||||
}
|
}
|
||||||
form.elements.deadZone.value=Math.round(config.deadZone*100);
|
for(const action of ['lights','stream','record']){
|
||||||
device.disabled=inputConfigurationLocked()||!pads.length;
|
const select=form.elements[action];select.replaceChildren(...ControllerInput.sources(pad).filter(([value])=>!value.startsWith('axis:')).map(([value,text])=>new Option(text,value)));
|
||||||
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
|
select.value=config.actions[action];select.onchange=()=>config.actions[action]=select.value;
|
||||||
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
|
}
|
||||||
|
form.elements.deadZone.value=Math.round(config.deadZone*100);form.elements.expo.value=Math.round(config.expo*100);
|
||||||
|
form.elements.deadZone.oninput=()=>config.deadZone=Number(form.elements.deadZone.value)/100;
|
||||||
|
form.elements.expo.oninput=()=>config.expo=Number(form.elements.expo.value)/100;
|
||||||
|
syncInputModeControls();
|
||||||
}
|
}
|
||||||
$('#controller-device').onchange=e=>{
|
$('#controller-device').onchange=e=>{
|
||||||
if(inputConfigurationLocked())return;
|
if(inputConfigurationLocked())return;
|
||||||
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
|
selectController(getControllers().find(pad=>controllerId(pad)===e.target.value));fillControllerForm();
|
||||||
if(pad){selectController(pad);fillControllerForm()}
|
|
||||||
};
|
};
|
||||||
$('#controller-form').onsubmit=e=>{
|
$('#controller-preset').onchange=e=>{if(e.target.value==='custom')return;controllerDraft=ControllerInput.preset(e.target.value);fillControllerForm(true)};
|
||||||
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
|
$('#controller-form').onsubmit=async e=>{
|
||||||
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
|
e.preventDefault();const form=e.currentTarget,feedback=form.querySelector('.form-feedback');
|
||||||
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
|
if(inputConfigurationLocked()){setFeedback(feedback,'Disengage first');return}
|
||||||
const config={deadZone:Number(form.elements.deadZone.value)/100};
|
if(!ControllerInput.validProfile(controllerDraft)){setFeedback(feedback,'Check inputs');return}
|
||||||
for(const name of ['turn','forward','vertical']){
|
const pad=getControllers().find(pad=>controllerId(pad)===controllerKey),device=pad?.id||'$xbox';
|
||||||
config[name]=Number(form.elements[name].value);
|
try{
|
||||||
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
|
const clean=bridgeReady?await api('save_controller_profile',device,controllerDraft):structuredClone(controllerDraft);
|
||||||
}
|
controllerProfiles[device]=clean;localStorage.setItem('sub-v2-profiles',JSON.stringify(controllerProfiles));controllerConfig=clean;
|
||||||
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
|
resetInputs();closeModal($('#controller-modal'));toast('Mapping saved');
|
||||||
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
|
}catch(error){setFeedback(feedback,error.message||String(error))}
|
||||||
}
|
|
||||||
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
|
|
||||||
resetInputs();closeModal($('#controller-modal'));
|
|
||||||
};
|
};
|
||||||
function pollController(now){
|
function pollController(now){
|
||||||
try{
|
try{
|
||||||
if(inputMode==='joystick'){
|
if(inputMode==='joystick'||$('#controller-modal').open){
|
||||||
const pads=getControllers();
|
const pads=getControllers();
|
||||||
if(controllerKey===null&&pads.length)selectController(pads[0]);
|
if(controllerKey===null&&pads.length&&!$('#controller-modal').open)selectController(pads[0]);
|
||||||
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
|
const pad=pads.find(pad=>controllerId(pad)===controllerKey),raw=pad&&ControllerInput.read(pad,controllerConfig||loadControllerConfig(pad));
|
||||||
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;
|
const target=raw&&(window.V2?.filterDemand(raw)||raw);
|
||||||
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
|
let status=!pad?'No controller':!raw?'Check mapping':controllerNeedCenter&&canDrive()?'Center controls':'Controller ready';
|
||||||
if(!target||!canDrive()){
|
if(inputMode==='joystick'){
|
||||||
if(controllerWasDriving)resetInputs();
|
if(!target||!canDrive()){
|
||||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
if(controllerWasDriving)resetInputs();controllerNeedCenter=true;controllerWasDriving=false;
|
||||||
}else{
|
}else{
|
||||||
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
|
if(controllerNeedCenter&&!Object.values(raw).some(value=>value!==0)){controllerNeedCenter=false;status='Controller ready'}
|
||||||
if(!controllerNeedCenter){
|
if(!controllerNeedCenter){
|
||||||
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
|
controllerWasDriving=true;
|
||||||
control.turn=demand.turn;control.forward=demand.forward;
|
Object.assign(control,target);window.joystick3D?.setVisual(target.turn,-target.forward);
|
||||||
window.joystick3D?.setVisual(demand.turn,-demand.forward);
|
setHeave(target.vertical,false);updateReadouts();sendAxes();
|
||||||
setHeave(demand.vertical,false);updateReadouts();sendAxes();
|
}
|
||||||
}
|
}
|
||||||
|
const active=ControllerInput.actions(pad,controllerConfig);
|
||||||
|
if(!Object.values(active).some(Boolean))controllerActionsReady=true;
|
||||||
|
if(controllerActionsReady&&inputFocused&&!document.hidden&&!document.querySelector('dialog[open]'))for(const [action,pressed] of Object.entries(active))
|
||||||
|
if(pressed&&!controllerActionState[action])window.V2?.action(action);
|
||||||
|
controllerActionState=active;
|
||||||
|
}
|
||||||
|
if($('#controller-modal').open){
|
||||||
|
const demand=pad&&ControllerInput.read(pad,controllerDraft);
|
||||||
|
for(const meter of $('#controller-bindings').querySelectorAll('meter'))meter.value=demand?.[meter.closest('[data-axis]').dataset.axis]||0;
|
||||||
|
$('#controller-axes').textContent=pad?.mapping==='standard'?'Xbox layout':pad?'Raw inputs':'Xbox · offline';
|
||||||
}
|
}
|
||||||
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
|
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
|
||||||
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
|
$('#controller-status').dataset.quiet=String(!!pad&&!!raw);
|
||||||
if($('#controller-modal').open){
|
}else{$('#controller-status').dataset.quiet='true';$('#controller-status').textContent='Keyboard + touch'}
|
||||||
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
|
}catch(error){if(controllerWasDriving)resetInputs();$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable'}
|
||||||
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);
|
requestAnimationFrame(pollController);
|
||||||
}
|
}
|
||||||
document.body.dataset.inputMode=inputMode;
|
document.body.dataset.inputMode=inputMode;
|
||||||
|
requestAnimationFrame(pollController);
|
||||||
|
|
||||||
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 makeDemoState(){const frame=window.V2?.station().frame??4,count=({0:6,1:6,2:8,3:8,4:3,5:4,6:5})[frame],servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false,current_a:3.2,consumed_mah:320,remaining_mah:5180,consumed_source:'Demo'},capabilities:{frame,firmware:[4,5,3]},accessories:{},link_quality:null,servos}}
|
||||||
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
||||||
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||||
@@ -156,7 +162,7 @@ function populatePorts(ports){
|
|||||||
const old=p.value||'';
|
const old=p.value||'';
|
||||||
p.innerHTML='';
|
p.innerHTML='';
|
||||||
|
|
||||||
const allList=[];
|
const allList=[{device:'udpin:0.0.0.0:14550',label:'Network · UDP 14550',type:'network'}];
|
||||||
const seen=new Set();
|
const seen=new Set();
|
||||||
|
|
||||||
const addPort=item=>{
|
const addPort=item=>{
|
||||||
@@ -194,7 +200,7 @@ function sendAxes(){
|
|||||||
axisTimer=0;
|
axisTimer=0;
|
||||||
if(!canDrive()||demoActive||axisBusy)return;
|
if(!canDrive()||demoActive||axisBusy)return;
|
||||||
lastAxisSend=performance.now();axisBusy=true;
|
lastAxisSend=performance.now();axisBusy=true;
|
||||||
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence))
|
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence,control.lateral,control.pitch,control.roll))
|
||||||
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
|
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
|
||||||
};
|
};
|
||||||
const wait=50-(performance.now()-lastAxisSend);
|
const wait=50-(performance.now()-lastAxisSend);
|
||||||
@@ -203,7 +209,7 @@ function sendAxes(){
|
|||||||
// This is a local bridge lease, not extra MAVLink traffic. The worker retains
|
// 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.
|
// the configured wire rate and neutralizes if the UI stops renewing its input.
|
||||||
setInterval(()=>{if(canDrive())sendAxes()},250);
|
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);}
|
function updateReadouts(){ window.V2?.updateDemand(control);if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||||
|
|
||||||
window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||||
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||||
@@ -216,8 +222,8 @@ heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||heavePoin
|
|||||||
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)}
|
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})};
|
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
|
||||||
|
|
||||||
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)}
|
function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const extra=window.V2?.filterDemand({lateral:(pressedKeys.has('l')?1:0)-(pressedKeys.has('j')?1:0),pitch:(pressedKeys.has('k')?1:0)-(pressedKeys.has('i')?1:0),roll:(pressedKeys.has('o')?1:0)-(pressedKeys.has('u')?1:0)});Object.assign(control,extra);updateReadouts();sendAxes();const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
|
||||||
addEventListener('keydown',e=>{if(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();});
|
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerfjlikuo'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||||
function resetInputs(){
|
function resetInputs(){
|
||||||
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
|
||||||
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||||
@@ -226,7 +232,7 @@ function resetInputs(){
|
|||||||
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
|
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
|
||||||
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
|
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
|
||||||
hknob.classList.remove('pressed','released');
|
hknob.classList.remove('pressed','released');
|
||||||
control.forward=control.turn=control.vertical=0;setHeave(0,false);
|
for(const axis of Object.keys(control))control[axis]=0;setHeave(0,false);window.V2?.updateDemand(control);controllerActionState={};controllerActionsReady=false;
|
||||||
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
|
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
|
||||||
}
|
}
|
||||||
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
|
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
|
||||||
@@ -750,6 +756,7 @@ function render(s,confirmed=false){
|
|||||||
const previous=view;
|
const previous=view;
|
||||||
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||||
syncInputModeControls();
|
syncInputModeControls();
|
||||||
|
window.V2?.render(s);
|
||||||
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||||
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
||||||
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
|
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
|
||||||
@@ -964,6 +971,9 @@ async function boot(){
|
|||||||
batteryConfig=init.battery_config||{};
|
batteryConfig=init.battery_config||{};
|
||||||
linkConfig=init.link_config||{};
|
linkConfig=init.link_config||{};
|
||||||
motorSettings=init.motor_settings||motorSettings;
|
motorSettings=init.motor_settings||motorSettings;
|
||||||
|
controllerProfiles=init.controller_profiles||controllerProfiles;
|
||||||
|
controllerConfig=null;
|
||||||
|
window.V2?.setStation(init.station_settings||{});
|
||||||
syncMotorScalingUI();
|
syncMotorScalingUI();
|
||||||
fillBatteryForm();
|
fillBatteryForm();
|
||||||
fillLinkForm();
|
fillLinkForm();
|
||||||
@@ -996,6 +1006,15 @@ function waitForBridge(){
|
|||||||
if(hasDesktopApi())boot();
|
if(hasDesktopApi())boot();
|
||||||
setTimeout(waitForBridge,250);
|
setTimeout(waitForBridge,250);
|
||||||
}
|
}
|
||||||
|
window.V2.init({
|
||||||
|
api,bridge:()=>bridgeReady,demo:()=>demoActive,toast,open:openModal,close:closeModal,
|
||||||
|
locked:inputConfigurationLocked,reset:resetInputs,touch:()=>window.canTouchControl(),demand:()=>control,
|
||||||
|
axis:(name,value)=>{control[name]=value;updateReadouts();sendAxes()},
|
||||||
|
scaling:allowed=>{const toggle=$('#motor-scale-independent');toggle.disabled=!allowed||inputConfigurationLocked();toggle.title=allowed?'Independent scales':'SimpleROV 3 only';if(!allowed&&motorSettings.independent){motorSettings.independent=false;syncMotorScalingUI();servoSignature=''}},
|
||||||
|
demoAccessory:(name,enabled)=>{if(demoState){demoState.accessories??={};demoState.accessories[name]={requested:enabled,accepted:true,demo:true};render(demoState)}},
|
||||||
|
previewFrame:(frame,count)=>{if(demoState){demoState.capabilities={frame,firmware:[4,5,3]};for(let i=1;i<=16;i++)demoState.servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?1:null};servoSignature='';render(demoState)}}
|
||||||
|
});
|
||||||
|
|
||||||
addEventListener('pywebviewready',boot);
|
addEventListener('pywebviewready',boot);
|
||||||
render(vehicleSnapshot);
|
render(vehicleSnapshot);
|
||||||
if(document.readyState==='loading'){
|
if(document.readyState==='loading'){
|
||||||
@@ -1003,6 +1022,3 @@ if(document.readyState==='loading'){
|
|||||||
}else{
|
}else{
|
||||||
waitForBridge();
|
waitForBridge();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
requestAnimationFrame(pollController);
|
|
||||||
|
|||||||
+73
-1
@@ -10,6 +10,7 @@
|
|||||||
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
|
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
|
||||||
}
|
}
|
||||||
function read(pad,config){
|
function read(pad,config){
|
||||||
|
if(config?.version===2)return readProfile(pad,config);
|
||||||
if(!pad?.connected||!pad.axes)return null;
|
if(!pad?.connected||!pad.axes)return null;
|
||||||
const axis=(index,invert)=>{
|
const axis=(index,invert)=>{
|
||||||
if(index===-1)return 0;
|
if(index===-1)return 0;
|
||||||
@@ -48,6 +49,77 @@
|
|||||||
return demand;
|
return demand;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
root.ControllerInput={deadZone,defaults,read,Motion};
|
const names=['forward','lateral','vertical','turn','pitch','roll'];
|
||||||
|
const pair=(negative='off',positive='off')=>({negative,positive,scale:1});
|
||||||
|
function preset(name='triggers3'){
|
||||||
|
const bindings=Object.fromEntries(names.map(axis=>[axis,pair()]));
|
||||||
|
bindings.forward=pair('button:6','button:7');
|
||||||
|
bindings.vertical=pair('button:0','button:1');
|
||||||
|
bindings.turn=pair('axis:0:-','axis:0:+');
|
||||||
|
if(name==='sixaxis'){
|
||||||
|
bindings.lateral=pair('axis:2:-','axis:2:+');
|
||||||
|
bindings.pitch=pair('axis:1:-','axis:1:+');
|
||||||
|
bindings.roll=pair('button:4','button:5');
|
||||||
|
}else if(name==='classic'){
|
||||||
|
bindings.forward=pair('axis:1:+','axis:1:-');
|
||||||
|
bindings.vertical=pair('axis:3:+','axis:3:-');
|
||||||
|
}
|
||||||
|
return {version:2,preset:name,deadZone:.08,expo:.15,bindings,
|
||||||
|
actions:{lights:'button:2',stream:'button:3',record:'button:8'}};
|
||||||
|
}
|
||||||
|
function migrate(config,pad){
|
||||||
|
if(config?.version===2)return structuredClone(config);
|
||||||
|
if(!config)return preset(pad&&pad.mapping!=='standard'?'classic':'triggers3');
|
||||||
|
const result=preset('classic');
|
||||||
|
for(const axis of ['forward','vertical','turn']){
|
||||||
|
const index=config[axis],invert=config['invert'+axis[0].toUpperCase()+axis.slice(1)];
|
||||||
|
result.bindings[axis]=Number.isInteger(index)&&index>=0?
|
||||||
|
pair(`axis:${index}:${invert?'+':'-'}`,`axis:${index}:${invert?'-':'+'}`):pair();
|
||||||
|
}
|
||||||
|
result.deadZone=config.deadZone??.08;
|
||||||
|
result.actions={lights:'off',stream:'off',record:'off'};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function validProfile(config){
|
||||||
|
const source=value=>typeof value==='string'&&/^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$/.test(value);
|
||||||
|
return config?.version===2&&Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&
|
||||||
|
Number.isFinite(config.expo)&&config.expo>=0&&config.expo<=1&&names.every(axis=>{
|
||||||
|
const item=config.bindings?.[axis];return item&&source(item.negative)&&source(item.positive)&&
|
||||||
|
(item.negative==='off'||item.negative!==item.positive)&&Number.isFinite(item.scale)&&item.scale>=0&&item.scale<=1;
|
||||||
|
})&&['lights','stream','record'].every(action=>source(config.actions?.[action])&&!config.actions[action].startsWith('axis:'));
|
||||||
|
}
|
||||||
|
function inputValue(pad,source,zone){
|
||||||
|
if(source==='off')return 0;
|
||||||
|
const parts=source.split(':'),index=Number(parts[1]);
|
||||||
|
if(parts[0]==='button'){
|
||||||
|
const button=pad.buttons?.[index];if(!button||!Number.isFinite(button.value))return null;
|
||||||
|
return index===6||index===7?Math.max(0,deadZone(button.value,zone)):(button.pressed?1:0);
|
||||||
|
}
|
||||||
|
const value=pad.axes?.[index];
|
||||||
|
return Number.isFinite(value)?Math.max(0,deadZone(value,zone)*(parts[2]==='-'?-1:1)):null;
|
||||||
|
}
|
||||||
|
function readProfile(pad,config){
|
||||||
|
if(!pad?.connected||!validProfile(config))return null;
|
||||||
|
const result={};
|
||||||
|
for(const name of names){
|
||||||
|
const item=config.bindings[name],negative=inputValue(pad,item.negative,config.deadZone),positive=inputValue(pad,item.positive,config.deadZone);
|
||||||
|
if(negative===null||positive===null)return null;
|
||||||
|
const value=clamp(positive-negative);
|
||||||
|
result[name]=((1-config.expo)*value+config.expo*value**3)*item.scale;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function actions(pad,config){
|
||||||
|
return Object.fromEntries(Object.entries(config?.actions||{}).map(([name,source])=>[name,!!pad&&inputValue(pad,source,0)>.5]));
|
||||||
|
}
|
||||||
|
function sources(pad){
|
||||||
|
const choices=[['off','Off']],axes=['Left X','Left Y','Right X','Right Y'];
|
||||||
|
for(let index=0;index<Math.max(4,pad?.axes?.length||0);index++)for(const sign of ['-','+'])
|
||||||
|
choices.push([`axis:${index}:${sign}`,`${axes[index]||'Axis '+index} ${sign==='-'?'−':'+'}`]);
|
||||||
|
const buttons=['A','B','X','Y','LB','RB','LT','RT','View','Menu','Left click','Right click','D-pad up','D-pad down','D-pad left','D-pad right','Guide'];
|
||||||
|
for(let index=0;index<Math.max(17,pad?.buttons?.length||0);index++)choices.push([`button:${index}`,buttons[index]||'Button '+index]);
|
||||||
|
return choices;
|
||||||
|
}
|
||||||
|
root.ControllerInput={deadZone,defaults,read,Motion,names,preset,migrate,validProfile,actions,sources};
|
||||||
if(typeof module!=='undefined')module.exports=root.ControllerInput;
|
if(typeof module!=='undefined')module.exports=root.ControllerInput;
|
||||||
})(typeof window!=='undefined'?window:globalThis);
|
})(typeof window!=='undefined'?window:globalThis);
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/* Video stays in the browser's media pipeline; no frames cross the control bridge. */
|
||||||
|
(()=>{
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const frames={0:['BlueROV1',6,['forward','lateral','vertical','turn','pitch','roll']],1:['Vectored',6,['forward','lateral','vertical','turn','roll']],2:['Full 6DOF',8,['forward','lateral','vertical','turn','pitch','roll']],3:['6DOF 90°',8,['forward','lateral','vertical','turn','pitch','roll']],4:['SimpleROV 3',3,['forward','vertical','turn']],5:['SimpleROV 4',4,['forward','vertical','turn','roll']],6:['SimpleROV 5',5,['forward','lateral','vertical','turn','roll']]};
|
||||||
|
let station={frame:4,video_mode:'whep',video_url:'',lights_relay:-1,camera_relay:-1};
|
||||||
|
try{station={...station,...JSON.parse(localStorage.getItem('sub-v2-station'))}}catch(_){}
|
||||||
|
let host,snapshot={},workspace='drive',streaming=false,connecting=false,generation=0,peer=null,sessionUrl=null,abort=null;
|
||||||
|
let recorder=null,recordToken=null,recordQueue=Promise.resolve(),recordChunks=[],recordBytes=0,ownedStream=null,recordError=null,saving=false;
|
||||||
|
let demoFrame=0,lastPaint=0,streamStarted=0,auxBusy=false,streamTimer=0,capabilitySignature='',demoLastClock=-1;
|
||||||
|
const notify=(message,error=false)=>host?.toast(message,error);
|
||||||
|
const call=(name,...args)=>host.api(name,...args);
|
||||||
|
const demo=()=>!!host?.demo();
|
||||||
|
const frame=()=>frames[(demo()||!snapshot.connected)?station.frame:snapshot.capabilities?.frame]||frames[4];
|
||||||
|
function supported(){
|
||||||
|
let axes=frame()[2];
|
||||||
|
const firmware=snapshot.capabilities?.firmware;
|
||||||
|
if(snapshot.connected&&!demo()&&!(firmware&&(firmware[0]>4||firmware[0]===4&&(firmware[1]===5||firmware[1]>=8))))axes=axes.filter(axis=>axis!=='pitch'&&axis!=='roll');
|
||||||
|
return axes;
|
||||||
|
}
|
||||||
|
function filterDemand(demand){return Object.fromEntries(Object.entries(demand).map(([name,value])=>[name,supported().includes(name)?value:0]))}
|
||||||
|
function view(name){
|
||||||
|
if(name===workspace)return;
|
||||||
|
host.reset();workspace=name;document.body.dataset.view=name;
|
||||||
|
$('dashboard').hidden=name!=='dashboard';document.querySelector('.primary-control-zone').hidden=name!=='drive';
|
||||||
|
$('view-drive').setAttribute('aria-pressed',String(name==='drive'));$('view-dashboard').setAttribute('aria-pressed',String(name==='dashboard'));
|
||||||
|
}
|
||||||
|
function fillStation(){
|
||||||
|
const form=$('station-form');
|
||||||
|
for(const [name,value] of Object.entries(station))if(form.elements[name])form.elements[name].value=value;
|
||||||
|
for(const field of form.querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
|
||||||
|
}
|
||||||
|
function state(text){$('camera-state').textContent=text;$('camera-empty').hidden=false}
|
||||||
|
function showStream(){
|
||||||
|
clearTimeout(streamTimer);connecting=false;
|
||||||
|
$('camera-empty').hidden=true;$('camera-record').disabled=station.video_mode==='mjpeg'&&!demo();
|
||||||
|
$('camera-badge').textContent=demo()?'Demo':'Stream';streamStarted=performance.now();demoLastClock=-1;$('stream-clock').textContent='00:00';
|
||||||
|
}
|
||||||
|
async function startStream(){
|
||||||
|
if(streaming||connecting)return;
|
||||||
|
if(!demo()&&!station.video_url){host.open('station-modal');return}
|
||||||
|
const current=++generation;connecting=true;state('Connecting');$('camera-toggle').textContent='Stop';
|
||||||
|
clearTimeout(streamTimer);streamTimer=setTimeout(()=>{if(current===generation)void failStream('Stream timeout')},10000);
|
||||||
|
try{
|
||||||
|
if(demo()){
|
||||||
|
$('camera-demo').hidden=false;streaming=true;showStream();demoFrame=requestAnimationFrame(drawDemo);
|
||||||
|
}else if(station.video_mode==='mjpeg'){
|
||||||
|
const img=$('camera-image');img.onload=()=>{if(current!==generation)return;streaming=true;img.hidden=false;showStream()};
|
||||||
|
img.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};img.src=station.video_url;
|
||||||
|
}else if(station.video_mode==='video'){
|
||||||
|
const video=$('camera-video');video.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};video.onended=()=>{if(current===generation)void stopStream()};video.src=station.video_url;video.hidden=false;await video.play();
|
||||||
|
if(current!==generation)return;streaming=true;showStream();
|
||||||
|
}else{
|
||||||
|
abort=new AbortController();const timeout=setTimeout(()=>abort?.abort(),10000);
|
||||||
|
try{
|
||||||
|
peer=new RTCPeerConnection({iceServers:[]});const connection=peer;
|
||||||
|
connection.addTransceiver('video',{direction:'recvonly'});
|
||||||
|
connection.ontrack=event=>{if(current!==generation)return;$('camera-video').srcObject=event.streams[0]||new MediaStream([event.track]);$('camera-video').hidden=false;void $('camera-video').play().catch(()=>{});streaming=true;showStream()};
|
||||||
|
connection.onconnectionstatechange=()=>{if(current===generation&&connection.connectionState==='failed')void failStream('Camera disconnected')};
|
||||||
|
await connection.setLocalDescription(await connection.createOffer());
|
||||||
|
if(connection.iceGatheringState!=='complete')await new Promise(resolve=>{
|
||||||
|
const timer=setTimeout(resolve,2000);connection.addEventListener('icegatheringstatechange',()=>{if(connection.iceGatheringState==='complete'){clearTimeout(timer);resolve()}},{once:true});
|
||||||
|
});
|
||||||
|
if(current!==generation)return;
|
||||||
|
const response=await fetch(station.video_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:connection.localDescription.sdp,signal:abort.signal});
|
||||||
|
if(!response.ok)throw new Error('WHEP '+response.status);
|
||||||
|
const location=response.headers.get('Location');let resource=location?new URL(location,station.video_url).href:null;
|
||||||
|
if(resource&&new URL(resource).origin!==new URL(station.video_url).origin)resource=null;
|
||||||
|
if(current!==generation){if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});return}
|
||||||
|
sessionUrl=resource;
|
||||||
|
const sdp=await response.text();if(current!==generation)return;
|
||||||
|
await connection.setRemoteDescription({type:'answer',sdp});
|
||||||
|
}finally{clearTimeout(timeout)}
|
||||||
|
}
|
||||||
|
}catch(error){if(current===generation){await stopStream();state('Unavailable');notify(error.message||'Stream unavailable',true)}}
|
||||||
|
finally{if(current===generation&&streaming)connecting=false}
|
||||||
|
}
|
||||||
|
async function failStream(message){await stopStream();state(message);notify(message,true)}
|
||||||
|
async function stopStream(){
|
||||||
|
++generation;connecting=false;streaming=false;abort?.abort();abort=null;
|
||||||
|
clearTimeout(streamTimer);
|
||||||
|
if(recorder&&recorder.state!=='inactive')recorder.stop();
|
||||||
|
peer?.close();peer=null;
|
||||||
|
if(sessionUrl){void fetch(sessionUrl,{method:'DELETE'}).catch(()=>{});sessionUrl=null}
|
||||||
|
cancelAnimationFrame(demoFrame);demoFrame=0;
|
||||||
|
const video=$('camera-video');video.onerror=video.onended=null;video.pause();video.srcObject=null;video.removeAttribute('src');video.load();video.hidden=true;
|
||||||
|
const img=$('camera-image');img.onload=img.onerror=null;img.removeAttribute('src');img.hidden=true;
|
||||||
|
$('camera-demo').hidden=true;$('camera-record').disabled=true;$('camera-toggle').textContent='Start';$('stream-clock').textContent='';state('Camera off');
|
||||||
|
}
|
||||||
|
function drawDemo(now){
|
||||||
|
if(!streaming||!demo())return;
|
||||||
|
demoFrame=requestAnimationFrame(drawDemo);
|
||||||
|
if(workspace!=='dashboard'||document.hidden||now-lastPaint<40)return;lastPaint=now;
|
||||||
|
const seconds=Math.floor((now-streamStarted)/1000);
|
||||||
|
if(seconds!==demoLastClock){demoLastClock=seconds;$('stream-clock').textContent=String(Math.floor(seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0')}
|
||||||
|
const canvas=$('camera-demo'),rect=canvas.getBoundingClientRect();
|
||||||
|
const width=Math.max(320,Math.round(rect.width)),height=Math.max(180,Math.round(rect.height));
|
||||||
|
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
|
||||||
|
const c=canvas.getContext('2d'),gradient=c.createLinearGradient(0,0,0,height);gradient.addColorStop(0,'#28444a');gradient.addColorStop(1,'#101e25');c.fillStyle=gradient;c.fillRect(0,0,width,height);
|
||||||
|
const light=c.createRadialGradient(width*.52,height*.62,4,width*.52,height*.62,width*.6);light.addColorStop(0,'#90aaa324');light.addColorStop(1,'#00000000');c.fillStyle=light;c.fillRect(0,0,width,height);
|
||||||
|
c.strokeStyle='#9eb2a92a';c.lineWidth=1;
|
||||||
|
for(let i=-4;i<8;i++){c.beginPath();c.moveTo(width*.5+i*16,height*.45);c.lineTo(width*.5+i*110,height);c.stroke()}
|
||||||
|
for(let i=0;i<8;i++){const y=height*.45+(i/8)**2*height*.55;c.beginPath();c.moveTo(0,y);c.lineTo(width,y);c.stroke()}
|
||||||
|
const pipe=c.createLinearGradient(0,height*.58,0,height*.76);pipe.addColorStop(0,'#a1afa0');pipe.addColorStop(.4,'#6e8980');pipe.addColorStop(1,'#263e3d');c.fillStyle=pipe;c.fillRect(width*.16,height*.58,width*.72,height*.18);
|
||||||
|
c.strokeStyle='#b3c2b166';c.strokeRect(width*.16,height*.58,width*.72,height*.18);
|
||||||
|
c.fillStyle='#c3d7c977';for(let i=0;i<32;i++){const x=(i*89.7+Math.sin(now/5000+i)*9)%width,y=(i*37.9+now*.004)%height;c.fillRect(x,y,1,1)}
|
||||||
|
c.strokeStyle='#e1f0e477';c.beginPath();c.moveTo(width/2-10,height/2);c.lineTo(width/2+10,height/2);c.moveTo(width/2,height/2-10);c.lineTo(width/2,height/2+10);c.stroke();
|
||||||
|
}
|
||||||
|
async function toggleRecord(){
|
||||||
|
if(saving)return;
|
||||||
|
if(recorder&&recorder.state!=='inactive'){recorder.stop();return}
|
||||||
|
if(!streaming)return;
|
||||||
|
try{
|
||||||
|
const stream=demo()?$('camera-demo').captureStream(24):$('camera-video').srcObject||$('camera-video').captureStream?.();
|
||||||
|
if(!stream?.getVideoTracks().length)throw new Error('Recording unavailable');
|
||||||
|
ownedStream=demo()?stream:null;recordToken=host.bridge()?await call('recording_begin'):null;recordQueue=Promise.resolve();recordChunks=[];recordBytes=0;recordError=null;
|
||||||
|
const mime=['video/webm;codecs=vp8','video/webm'].find(type=>MediaRecorder.isTypeSupported(type));
|
||||||
|
recorder=new MediaRecorder(stream,mime?{mimeType:mime,videoBitsPerSecond:4000000}:undefined);
|
||||||
|
recorder.ondataavailable=event=>{
|
||||||
|
if(!event.data.size)return;
|
||||||
|
if(recordToken){const token=recordToken;recordQueue=recordQueue.then(async()=>{
|
||||||
|
if(recordError)return;
|
||||||
|
const data=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=reject;reader.readAsDataURL(event.data)});
|
||||||
|
await call('recording_chunk',token,data);
|
||||||
|
}).catch(error=>{recordError=error;if(recorder?.state==='recording')recorder.stop()})}
|
||||||
|
else{recordChunks.push(event.data);recordBytes+=event.data.size;if(recordBytes>256*1024*1024&&recorder.state==='recording')recorder.stop()}
|
||||||
|
};
|
||||||
|
recorder.onstop=async()=>{
|
||||||
|
saving=true;$('camera-record').textContent='Saving';$('recording-badge').hidden=true;
|
||||||
|
try{
|
||||||
|
await recordQueue;
|
||||||
|
if(recordToken){const path=await call('recording_end',recordToken);notify(recordError?'Recording interrupted':'Video saved',!!recordError);if(path)$('camera-record').title=path}
|
||||||
|
else{const blob=new Blob(recordChunks,{type:recorder.mimeType}),url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download='argo-'+Date.now()+'.webm';link.click();setTimeout(()=>URL.revokeObjectURL(url),60000);notify('Video saved')}
|
||||||
|
}catch(error){notify(error.message||'Save failed',true)}
|
||||||
|
finally{ownedStream?.getTracks().forEach(track=>track.stop());ownedStream=null;recordToken=null;recordChunks=[];saving=false;$('camera-record').textContent='Record';$('camera-record').setAttribute('aria-pressed','false');recorder=null}
|
||||||
|
};
|
||||||
|
recorder.start(1000);$('camera-record').textContent='Stop record';$('camera-record').setAttribute('aria-pressed','true');$('recording-badge').hidden=false;
|
||||||
|
}catch(error){if(recordToken)await call('recording_end',recordToken).catch(()=>{});recordToken=null;notify(error.message||'Recording unavailable',true)}
|
||||||
|
}
|
||||||
|
async function accessory(name){
|
||||||
|
if(auxBusy||!snapshot.connected)return;
|
||||||
|
const relay=station[name+'_relay'];if(relay<0){host.open('station-modal');return}
|
||||||
|
auxBusy=true;
|
||||||
|
try{
|
||||||
|
const previous=snapshot.accessories?.[name],enabled=!(previous?.accepted&&previous.requested);
|
||||||
|
if(demo())host.demoAccessory(name,enabled);else await call('set_accessory',name,enabled);
|
||||||
|
if(name==='camera'&&!enabled)await stopStream();
|
||||||
|
}catch(error){notify(error.message||'Accessory unavailable',true)}finally{auxBusy=false}
|
||||||
|
}
|
||||||
|
function updateDemand(demand){
|
||||||
|
if(!host)return;
|
||||||
|
const axes=supported();$('extra-controls').hidden=!axes.some(axis=>['lateral','pitch','roll'].includes(axis));
|
||||||
|
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
|
||||||
|
const axis=label.dataset.motion;label.hidden=!axes.includes(axis);label.querySelector('input').value=Math.round((demand[axis]||0)*100);
|
||||||
|
label.querySelector('input').disabled=!host.touch();label.querySelector('output').value=Math.round((demand[axis]||0)*100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function render(s){
|
||||||
|
snapshot=s;
|
||||||
|
if(!host)return;
|
||||||
|
const signature=String(!!s.connected)+':'+supported().join(',');
|
||||||
|
if(capabilitySignature&&signature!==capabilitySignature)host.reset();
|
||||||
|
capabilitySignature=signature;
|
||||||
|
host.scaling((demo()||!s.connected)?station.frame===4:s.capabilities?.frame===4);
|
||||||
|
if($('station-modal').open)for(const field of $('station-form').querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
|
||||||
|
const telemetry=demo()&&s.connected?{
|
||||||
|
attitude:{values:{heading:124,pitch:4.2,roll:-2.1},stale:false},depth:{values:{depth_m:1.8},stale:false},
|
||||||
|
imu:{values:{ax:.12,ay:-.08,az:9.78,gx:.01,gy:.02,gz:0,mx:.21,my:.04,mz:.43},stale:false},pressure:{values:{pressure_hpa:1190,temperature_c:18.6},stale:false}
|
||||||
|
}:s.telemetry||{};
|
||||||
|
const value=(group,key)=>s.connected&&!telemetry[group]?.stale?telemetry[group]?.values?.[key]:null;
|
||||||
|
const format=(value,unit,digits=1)=>Number.isFinite(value)?value.toFixed(digits)+unit:'—';
|
||||||
|
const show=(id,number,unit,digits)=>{$(id).textContent=format(number,unit,digits);$(id).parentElement.dataset.stale=String(!Number.isFinite(number))};
|
||||||
|
show('live-depth',value('depth','depth_m'),' m',2);show('live-heading',value('attitude','heading'),'°',0);show('live-pitch',value('attitude','pitch'),'°',1);show('live-roll',value('attitude','roll'),'°',1);
|
||||||
|
const battery=s.battery||{},fresh=s.connected&&!s.battery_stale;
|
||||||
|
const current=fresh&&!battery.current_stale?battery.current_a:null;
|
||||||
|
show('live-current',current,' A',1);show('live-power',fresh&&Number.isFinite(current)&&Number.isFinite(battery.voltage)?current*battery.voltage:null,' W',1);
|
||||||
|
show('live-charge',fresh?battery.consumed_mah:null,' mAh',0);
|
||||||
|
for(const [id,keys,unit] of [['live-accel',['ax','ay','az'],'m/s²'],['live-gyro',['gx','gy','gz'],'rad/s'],['live-mag',['mx','my','mz'],'G']]){
|
||||||
|
const values=keys.map(key=>value('imu',key));$(id).textContent=values.every(Number.isFinite)?values.map(value=>value.toFixed(2)).join(' / ')+' '+unit:'—';
|
||||||
|
}
|
||||||
|
show('live-temperature',value('pressure','temperature_c'),' °C',1);show('live-pressure',value('pressure','pressure_hpa'),' hPa',1);
|
||||||
|
$('sensor-state').textContent=demo()&&s.connected?'Demo':!s.connected?'Offline':Object.values(telemetry).some(group=>!group.stale)?'Receiving':'No data';
|
||||||
|
$('detected-frame').textContent=s.connected&&!demo()?(frames[s.capabilities?.frame]?.[0]||'Detecting'):demo()?'Demo':'Offline';
|
||||||
|
for(const [name,id] of [['lights','lights-toggle'],['camera','camera-power']]){
|
||||||
|
const item=s.accessories?.[name],pending=item&&item.accepted===null&&!item.error;
|
||||||
|
$(id).disabled=!s.connected||station[name+'_relay']<0||!!pending;$(id).setAttribute('aria-pressed',String(!!(item?.accepted&&item.requested)));
|
||||||
|
$(id).title=item?.error||(pending?'Awaiting ACK':station[name+'_relay']<0?'Configure relay':'Relay '+station[name+'_relay']);
|
||||||
|
}
|
||||||
|
if(streaming)$('stream-clock').textContent=new Date(performance.now()-streamStarted).toISOString().slice(14,19);
|
||||||
|
$('sensor-record').setAttribute('aria-pressed',String(!!s.sensor_recording));$('sensor-record').textContent=s.sensor_recording?'Stop log':'Log data';
|
||||||
|
$('sensor-record').disabled=!s.connected||demo();
|
||||||
|
updateDemand(host.demand());
|
||||||
|
}
|
||||||
|
function action(name){if(name==='stream')void(streaming||connecting?stopStream():startStream());else if(name==='record')void toggleRecord();else if(name==='lights')void accessory('lights')}
|
||||||
|
function init(callbacks){
|
||||||
|
host=callbacks;
|
||||||
|
$('view-drive').onclick=()=>view('drive');$('view-dashboard').onclick=()=>view('dashboard');
|
||||||
|
$('camera-toggle').onclick=()=>action('stream');$('camera-record').onclick=()=>action('record');$('lights-toggle').onclick=()=>accessory('lights');$('camera-power').onclick=()=>accessory('camera');
|
||||||
|
$('sensor-record').onclick=async()=>{try{if(demo())throw new Error('Live telemetry required');if(!host.bridge())throw new Error('Connect desktop');const path=await call('sensor_record',!snapshot.sensor_recording);if(path)$('sensor-record').title=path}catch(error){notify(error.message,true)}};
|
||||||
|
for(const name of ['lights_relay','camera_relay']){
|
||||||
|
const select=$('station-form').elements[name];select.add(new Option('Off','-1'));for(let index=0;index<16;index++)select.add(new Option('Relay '+index,String(index)));
|
||||||
|
}
|
||||||
|
const stationObserver=new MutationObserver(()=>{if($('station-modal').open)fillStation()});stationObserver.observe($('station-modal'),{attributes:true,attributeFilter:['open']});
|
||||||
|
document.querySelector('[data-open=station-modal]').addEventListener('click',fillStation);
|
||||||
|
$('station-form').onsubmit=async event=>{
|
||||||
|
event.preventDefault();const form=event.currentTarget,feedback=form.querySelector('.form-feedback');
|
||||||
|
if(host.locked()){feedback.textContent='Disengage first';feedback.hidden=false;return}
|
||||||
|
const config=Object.fromEntries(new FormData(form));for(const name of ['frame','lights_relay','camera_relay'])config[name]=Number(config[name]);
|
||||||
|
try{
|
||||||
|
if(config.video_url){const url=new URL(config.video_url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw new Error('Use an HTTP stream URL')}
|
||||||
|
if(config.lights_relay>=0&&config.lights_relay===config.camera_relay)throw new Error('Use separate relays');
|
||||||
|
if(streaming||connecting)await stopStream();
|
||||||
|
station=host.bridge()?await call('save_station_settings',config):config;localStorage.setItem('sub-v2-station',JSON.stringify(station));
|
||||||
|
host.previewFrame(station.frame,frames[station.frame][1]);host.reset();host.close($('station-modal'));notify('Vehicle saved');render(snapshot);
|
||||||
|
}catch(error){feedback.textContent=error.message||String(error);feedback.hidden=false}
|
||||||
|
};
|
||||||
|
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
|
||||||
|
const input=label.querySelector('input'),axis=label.dataset.motion;
|
||||||
|
input.oninput=()=>{if(host.touch())host.axis(axis,Number(input.value)/100)};
|
||||||
|
const release=()=>{if(host.touch())host.axis(axis,0);input.value=0};
|
||||||
|
input.onpointerup=release;input.onpointercancel=release;input.onblur=release;input.onkeyup=release;
|
||||||
|
}
|
||||||
|
for(const [name,text,value] of [['attitude_hz','Attitude · Hz',2],['depth_hz','Depth · Hz',2],['imu_hz','IMU · Hz',1],['pressure_hz','Pressure · Hz',1]]){
|
||||||
|
const label=document.createElement('label'),span=document.createElement('span'),select=document.createElement('select');span.textContent=text;select.name=name;
|
||||||
|
for(const rate of [0,1,2,5,10])select.add(new Option(rate===0?'Off':String(rate),String(rate)));select.value=String(value);label.append(span,select);$('link-form').querySelectorAll('.form-grid')[1].append(label);
|
||||||
|
}
|
||||||
|
addEventListener('pagehide',()=>void stopStream());document.body.dataset.view='drive';render(snapshot);
|
||||||
|
}
|
||||||
|
window.V2={init,render,filterDemand,action,updateDemand,station:()=>station,setStation:config=>{station={...station,...config};render(snapshot)}};
|
||||||
|
})();
|
||||||
+35
-15
@@ -3,8 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Sub Control</title>
|
<title>Argo V2</title>
|
||||||
<link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
|
<link rel="stylesheet" href="styles.css?v=20261022-v2">
|
||||||
|
<link rel="stylesheet" href="v2.css?v=20261022-v2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
<!-- Telemetry / Status Zone -->
|
<!-- Telemetry / Status Zone -->
|
||||||
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
|
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
|
||||||
<div class="deck-card model-card">
|
<div class="deck-card model-card">
|
||||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
<div class="rov-stage" aria-label="Vehicle model">
|
||||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||||
<div id="model-status" class="model-status">Initializing…</div>
|
<div id="model-status" class="model-status">Initializing…</div>
|
||||||
<div class="model-tools">
|
<div class="model-tools">
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
|
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
|
||||||
<!-- Frameless Drag & Window Controls Header -->
|
<!-- Frameless Drag & Window Controls Header -->
|
||||||
<div class="workspace-topbar">
|
<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="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal">Mapping</button></div>
|
||||||
<div class="workspace-drag-area pywebview-drag-region"></div>
|
<div class="workspace-drag-area pywebview-drag-region"></div>
|
||||||
<div class="window-actions">
|
<div class="window-actions">
|
||||||
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
||||||
@@ -80,7 +81,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="workspace-nav"><div class="view-switch" role="group" aria-label="Workspace"><button id="view-drive" type="button" aria-pressed="true">Drive</button><button id="view-dashboard" type="button" aria-pressed="false">Dashboard</button></div><span class="version-mark">V2</span><button class="quiet-button" data-open="station-modal" type="button">Vehicle</button></div>
|
||||||
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
|
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
|
||||||
|
<section id="dashboard" class="dashboard" aria-label="Vehicle dashboard" hidden>
|
||||||
|
<div class="camera-stage">
|
||||||
|
<video id="camera-video" muted autoplay playsinline hidden></video><img id="camera-image" alt="Camera stream" hidden><canvas id="camera-demo" aria-label="Simulated inspection camera" hidden></canvas>
|
||||||
|
<div id="camera-empty" class="camera-empty"><svg viewBox="0 0 48 48" aria-hidden="true"><rect x="5" y="12" width="27" height="24" rx="6"/><path d="m32 20 11-6v20l-11-6"/></svg><span id="camera-state">Camera off</span></div>
|
||||||
|
<div class="camera-overlay"><span id="camera-badge">Preview</span><span id="recording-badge" hidden>● REC</span><span id="stream-clock"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-actions"><button id="camera-toggle" type="button">Start</button><button id="camera-record" type="button" aria-pressed="false" disabled>Record</button><button id="lights-toggle" type="button" aria-pressed="false" disabled>Lights</button><button id="camera-power" type="button" aria-pressed="false" disabled>Camera power</button><button id="sensor-record" type="button" aria-pressed="false">Log data</button></div>
|
||||||
|
<div class="telemetry-strip" aria-label="Live telemetry"><div><span>Depth</span><strong id="live-depth">—</strong></div><div><span>Heading</span><strong id="live-heading">—</strong></div><div><span>Current</span><strong id="live-current">—</strong></div><div><span>Pitch</span><strong id="live-pitch">—</strong></div><div><span>Roll</span><strong id="live-roll">—</strong></div><div><span>Power</span><strong id="live-power">—</strong></div></div>
|
||||||
|
<details class="sensor-details"><summary>Sensors <span id="sensor-state">Offline</span></summary><div class="sensor-values"><div><span>Acceleration</span><strong id="live-accel">—</strong></div><div><span>Gyroscope</span><strong id="live-gyro">—</strong></div><div><span>Magnetometer</span><strong id="live-mag">—</strong></div><div><span>Temperature</span><strong id="live-temperature">—</strong></div><div><span>Pressure</span><strong id="live-pressure">—</strong></div><div><span>Charge used</span><strong id="live-charge">—</strong></div></div></details>
|
||||||
|
</section>
|
||||||
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
||||||
<!-- Reserved Engage / Disarm row -->
|
<!-- Reserved Engage / Disarm row -->
|
||||||
<div class="engage-header-zone">
|
<div class="engage-header-zone">
|
||||||
@@ -146,6 +158,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="extra-controls" class="extra-controls" hidden><label data-motion="lateral"><span>Strafe</span><input type="range" min="-100" max="100" value="0" aria-label="Strafe demand"><output>0</output></label><label data-motion="pitch"><span>Pitch</span><input type="range" min="-100" max="100" value="0" aria-label="Pitch demand"><output>0</output></label><label data-motion="roll"><span>Roll</span><input type="range" min="-100" max="100" value="0" aria-label="Roll demand"><output>0</output></label></div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
|
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
|
||||||
@@ -218,28 +231,35 @@
|
|||||||
<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 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>
|
<div id="motor-list" class="motor-list"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
|
<dialog id="controller-modal" class="modal controller-modal" aria-labelledby="controller-title">
|
||||||
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
|
<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.">
|
<form id="controller-form">
|
||||||
<label><span>Controller</span><select id="controller-device"></select></label>
|
<div class="form-grid two"><label><span>Controller</span><select id="controller-device"></select></label><label><span>Preset</span><select id="controller-preset"><option value="triggers3">Triggers · 3</option><option value="sixaxis">Six axes</option><option value="classic">Classic sticks</option><option value="custom">Custom</option></select></label></div>
|
||||||
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
|
<p id="controller-axes" class="note" hidden>Xbox · offline</p>
|
||||||
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
|
<div class="binding-head"><span>Motion</span><span>Negative</span><span>Positive</span><span></span><span>Limit %</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 id="controller-bindings"></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="form-grid two mapping-tuning"><label><span>Dead zone %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label><label><span>Expo %</span><input name="expo" type="number" min="0" max="100" step="1" required></label></div>
|
||||||
<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>
|
<details class="mapping-actions"><summary>Buttons</summary><div class="form-grid three"><label><span>Lights</span><select name="lights"></select></label><label><span>Stream</span><select name="stream"></select></label><label><span>Record</span><select name="record"></select></label></div></details>
|
||||||
<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>
|
<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>
|
<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>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="station-modal" class="modal station-modal" aria-labelledby="station-title"><button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="station-title">Vehicle</h2><form id="station-form">
|
||||||
|
<div class="form-grid two"><label><span>Preview frame</span><select name="frame"><option value="4">SimpleROV · 3</option><option value="0">BlueROV1 · 6</option><option value="1">Vectored · 6</option><option value="2">Full 6DOF · 8</option><option value="3">6DOF 90° · 8</option><option value="5">SimpleROV · 4</option><option value="6">SimpleROV · 5</option></select></label><div class="detected-frame"><span>FC frame</span><strong id="detected-frame">Offline</strong></div></div>
|
||||||
|
<section class="settings-section"><h3>Camera</h3><label><span>Stream type</span><select name="video_mode"><option value="whep">WebRTC · WHEP</option><option value="video">HTTP video</option><option value="mjpeg">MJPEG</option></select></label><label><span>Stream URL</span><input name="video_url" type="url" placeholder="http://192.168.2.2:8889/cam/whep" autocomplete="off"></label></section>
|
||||||
|
<section class="settings-section"><h3>Accessories</h3><div class="form-grid two"><label><span>Lights relay</span><select name="lights_relay"></select></label><label><span>Camera relay</span><select name="camera_relay"></select></label></div></section>
|
||||||
|
<p class="form-feedback" role="alert" hidden></p><div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save</button></div>
|
||||||
|
</form></dialog>
|
||||||
|
|
||||||
<script src="model-viewer.js?v=20260826-controls-v2"></script>
|
<script src="model-viewer.js?v=20260826-controls-v2"></script>
|
||||||
<script src="joystick-3d.js?v=20261002-spacing-motion-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="control-state.js?v=20260827-state-v1"></script>
|
||||||
<script src="controller-input.js?v=20261001-controller-v1"></script>
|
<script src="controller-input.js?v=20261022-v2"></script>
|
||||||
|
<script src="dashboard.js?v=20261022-v2"></script>
|
||||||
<script src="console-view.js?v=20261002-console-v2"></script>
|
<script src="console-view.js?v=20261002-console-v2"></script>
|
||||||
<script src="app.js?v=20261017-engage-center-v2"></script>
|
<script src="app.js?v=20261022-v2"></script>
|
||||||
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
<script src="cursor-light.js?v=20260827-directional-v10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/* V2 uses the existing deck, type scale, and flat settings rows. */
|
||||||
|
:root { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||||
|
[hidden] { display:none !important; }
|
||||||
|
.workspace-nav { display:flex; align-items:center; gap:12px; margin:0 0 10px; min-height:32px; }
|
||||||
|
.view-switch { display:flex; gap:2px; padding:3px; background:#e1e1e1; border-radius:20px; }
|
||||||
|
.view-switch button,.quiet-button { border:0; background:transparent; color:#737575; border-radius:18px; padding:6px 14px; cursor:pointer; font-size:12px; }
|
||||||
|
.view-switch [aria-pressed=true] { background:#fafafa; color:#242929; box-shadow:0 1px 3px #0000000c; }
|
||||||
|
.workspace-nav .quiet-button { margin-left:auto; }
|
||||||
|
.version-mark { color:#9a9e9d; font-size:10px; letter-spacing:.08em; }
|
||||||
|
#controller-settings { display:block; }
|
||||||
|
.controller-modal { width:min(730px,calc(100vw - 28px)); max-height:calc(100dvh - 28px); overflow:auto; }
|
||||||
|
.binding-head,.binding-row { display:grid; grid-template-columns:80px minmax(100px,1fr) minmax(100px,1fr) 28px 54px; gap:8px; align-items:center; }
|
||||||
|
.binding-head { font-size:10px; color:#858b89; padding:8px 0; }
|
||||||
|
.binding-row { padding:9px 0; border-top:1px solid #dedfdd; }
|
||||||
|
.binding-row > span { font-size:12px; font-weight:500; }
|
||||||
|
.binding-row meter { display:block; width:50px; height:3px; margin-top:7px; accent-color:#6d9c8b; }
|
||||||
|
.binding-row select,.binding-row input { height:34px; min-height:34px; padding:0 8px; border-radius:8px; font-size:12px; min-width:0; }
|
||||||
|
.binding-row input { text-align:center; padding:0 2px; font-variant-numeric:tabular-nums; }
|
||||||
|
.binding-swap { background:transparent; border:0; height:32px; color:#6a7470; font-size:19px; cursor:pointer; border-radius:7px; }
|
||||||
|
.binding-swap:hover { background:#e0e5e1; }
|
||||||
|
.mapping-tuning { margin-top:14px; }
|
||||||
|
.mapping-actions { margin-top:16px; font-size:12px; }
|
||||||
|
.mapping-actions summary { padding:8px 0; cursor:pointer; color:#727b76; }
|
||||||
|
.form-grid.three { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
|
||||||
|
.dashboard { display:flex; flex:1; flex-direction:column; min-height:0; gap:0; overflow:auto; }
|
||||||
|
.camera-stage { flex:1 1 220px; min-height:150px; position:relative; border-radius:14px; overflow:hidden; background:#18282d; color:#f2f6f5; isolation:isolate; }
|
||||||
|
.camera-stage video,.camera-stage img,.camera-stage canvas { display:block; width:100%; height:100%; position:absolute; inset:0; object-fit:contain; }
|
||||||
|
.camera-empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:#8fa5aa; font-size:12px; }
|
||||||
|
.camera-empty svg { width:40px; fill:none; stroke:currentColor; stroke-width:1.4; opacity:.65; }
|
||||||
|
.camera-overlay { pointer-events:none; position:absolute; top:14px; left:16px; right:16px; display:flex; align-items:center; gap:10px; font-size:10px; text-shadow:0 1px 3px #000; color:#d7e5e4; }
|
||||||
|
#camera-badge { border:1px solid #a8c1c245; border-radius:10px; padding:3px 8px; background:#16282c55; }
|
||||||
|
#recording-badge { color:#ff9e93; font-weight:600; }
|
||||||
|
#stream-clock { margin-left:auto; font-variant-numeric:tabular-nums; }
|
||||||
|
.dashboard-actions { display:flex; align-items:center; gap:5px; padding:10px 0; flex-wrap:wrap; }
|
||||||
|
.dashboard-actions button { min-height:32px; padding:6px 12px; border-radius:18px; border:0; background:#e0e2df; color:#46524b; font-size:11px; cursor:pointer; white-space:nowrap; }
|
||||||
|
.dashboard-actions button[aria-pressed=true] { background:#d0ddd5; color:#345844; }
|
||||||
|
.dashboard-actions button:disabled { opacity:.4; cursor:default; }
|
||||||
|
.telemetry-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); row-gap:12px; border-top:1px solid #d9deda; padding:14px 0; }
|
||||||
|
.telemetry-strip > div { min-width:0; display:flex; flex-direction:column; gap:3px; }
|
||||||
|
.telemetry-strip span { font-size:10px; color:#7b8480; }
|
||||||
|
.telemetry-strip strong { font-weight:400; font-size:clamp(18px,2.2vw,26px); color:#36463d; font-variant-numeric:tabular-nums; letter-spacing:-.035em; white-space:nowrap; }
|
||||||
|
.telemetry-strip [data-stale=true] strong { color:#a4aaa5; }
|
||||||
|
.sensor-details { border-top:1px solid #d9deda; padding:10px 0 0; font-size:11px; }
|
||||||
|
.sensor-details summary { cursor:pointer; color:#58665d; }
|
||||||
|
.sensor-details summary span { float:right; color:#939c96; font-size:10px; }
|
||||||
|
.sensor-values { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; padding:14px 0; }
|
||||||
|
.sensor-values div { min-width:0; display:flex; flex-direction:column; gap:4px; }
|
||||||
|
.sensor-values span { color:#87908a; font-size:10px; }
|
||||||
|
.sensor-values strong { font-size:11px; font-weight:500; color:#4b5c50; font-variant-numeric:tabular-nums; }
|
||||||
|
.detected-frame { display:flex; flex-direction:column; gap:9px; color:#859086; font-size:12px; padding-top:4px; }
|
||||||
|
.detected-frame strong { color:#4b5c50; font-weight:500; }
|
||||||
|
.extra-controls { display:flex; gap:16px; margin-top:8px; padding:10px 0 0; border-top:1px solid #d9deda; }
|
||||||
|
.extra-controls label { display:grid; flex:1; min-width:0; grid-template-columns:1fr 24px; gap:4px; color:#7e8981; font-size:10px; }
|
||||||
|
.extra-controls label input { width:100%; min-width:0; grid-row:2; grid-column:1/-1; accent-color:#668c78; height:18px; }
|
||||||
|
.extra-controls output { text-align:right; font-variant-numeric:tabular-nums; }
|
||||||
|
.primary-control-zone { grid-template-rows:clamp(64px,11vh,90px) minmax(0,1fr) auto auto; }
|
||||||
|
.controls-grid { --joy-size:min(34vh,calc(100cqw - 146px),370px); }
|
||||||
|
.workspace-topbar { margin-bottom:8px; }
|
||||||
|
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):active { transform:scale(.97); }
|
||||||
|
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):focus-visible { outline:2px solid #6b9a84; outline-offset:2px; }
|
||||||
|
.station-modal { width:min(560px,calc(100vw - 28px)); }
|
||||||
|
body[data-view=dashboard] #controller-status { position:absolute; clip:rect(0,0,0,0); }
|
||||||
|
@media (max-width:760px) {
|
||||||
|
.binding-head,.binding-row { grid-template-columns:66px minmax(0,1fr) minmax(0,1fr) 24px 46px; gap:6px; }
|
||||||
|
.binding-row select { padding:0 4px; font-size:11px; }
|
||||||
|
.controller-modal { padding:22px 18px; }
|
||||||
|
.controls-workspace { padding:16px; }
|
||||||
|
.dashboard-actions { gap:4px; }.dashboard-actions button { padding:6px 9px; }
|
||||||
|
}
|
||||||
|
@media (max-width:600px) {
|
||||||
|
.app-shell { grid-template-columns:170px minmax(0,1fr); }
|
||||||
|
.sidebar-deck { padding:14px; }
|
||||||
|
.input-mode-switch button { padding:6px 8px; }
|
||||||
|
.controls-grid { --joy-size:min(30vh,calc(100cqw - 95px),300px); grid-template-columns:minmax(0,1fr) 70px; gap:6px; }
|
||||||
|
.control-toolbar { flex-wrap:wrap; gap:8px; }.control-toolbar .range-pill { width:100px; }
|
||||||
|
.control-toolbar .output-limit > span { width:auto; flex-basis:auto; }
|
||||||
|
.window-actions { gap:0; }.window-actions button { width:24px; }
|
||||||
|
.extra-controls { gap:8px; }.workspace-nav { gap:6px; }
|
||||||
|
.view-switch button { padding:6px 9px; }.workspace-nav .quiet-button { padding:6px; }
|
||||||
|
.version-mark { display:none; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion:reduce) { :is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button) { transition:none; transform:none !important; } }
|
||||||
@@ -8,8 +8,10 @@ import time
|
|||||||
import base64
|
import base64
|
||||||
import shutil
|
import shutil
|
||||||
import http.server
|
import http.server
|
||||||
import socketserver
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import csv
|
||||||
|
import uuid
|
||||||
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import webview
|
import webview
|
||||||
@@ -17,6 +19,7 @@ from serial.tools import list_ports
|
|||||||
|
|
||||||
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
||||||
from vehicle_state import VehicleState
|
from vehicle_state import VehicleState
|
||||||
|
from rov_v2 import Telemetry, clean_profile, clean_station, save_json, number
|
||||||
|
|
||||||
|
|
||||||
class ControlApi:
|
class ControlApi:
|
||||||
@@ -31,9 +34,28 @@ class ControlApi:
|
|||||||
self.battery_last_current = None
|
self.battery_last_current = None
|
||||||
self.battery_last_current_at = None
|
self.battery_last_current_at = None
|
||||||
self.battery_session_consumed_mah = 0.0
|
self.battery_session_consumed_mah = 0.0
|
||||||
|
self.battery_fields_at = {}
|
||||||
self.demo_mode = False
|
self.demo_mode = False
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self._poll_lock = threading.Lock()
|
self._poll_lock = threading.Lock()
|
||||||
|
self.telemetry = Telemetry()
|
||||||
|
self.station_path = Path(__file__).resolve().parent / "station_settings.json"
|
||||||
|
self.profiles_path = self.station_path.with_name("controller_profiles.json")
|
||||||
|
self.station_settings = clean_station({})
|
||||||
|
self.controller_profiles = {}
|
||||||
|
try:
|
||||||
|
self.station_settings = clean_station(json.loads(self.station_path.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.controller_profiles = {str(key)[:256]: clean_profile(value) for key, value in
|
||||||
|
json.loads(self.profiles_path.read_text(encoding="utf-8")).items()}
|
||||||
|
except (OSError, ValueError, TypeError, AttributeError):
|
||||||
|
pass
|
||||||
|
self._recording_lock = threading.Lock()
|
||||||
|
self._recording = None
|
||||||
|
self._sensor_file = None
|
||||||
|
self._sensor_writer = None
|
||||||
self.state = {
|
self.state = {
|
||||||
"connected": False,
|
"connected": False,
|
||||||
"link": "DISCONNECTED",
|
"link": "DISCONNECTED",
|
||||||
@@ -47,6 +69,9 @@ class ControlApi:
|
|||||||
"heavy_quality": None,
|
"heavy_quality": None,
|
||||||
"log_path": None,
|
"log_path": None,
|
||||||
"motor_test_active": False,
|
"motor_test_active": False,
|
||||||
|
"capabilities": {"frame": None, "firmware": None},
|
||||||
|
"accessories": {},
|
||||||
|
"sensor_recording": False,
|
||||||
"servos": {str(i): {"function": None, "direction": None, "output_us": None} for i in range(1, 17)},
|
"servos": {str(i): {"function": None, "direction": None, "output_us": None} for i in range(1, 17)},
|
||||||
}
|
}
|
||||||
self.servo_functions: dict[int, int] = {}
|
self.servo_functions: dict[int, int] = {}
|
||||||
@@ -62,6 +87,8 @@ class ControlApi:
|
|||||||
self.motor_settings = self._clean_motor_settings(saved_motor_settings)
|
self.motor_settings = self._clean_motor_settings(saved_motor_settings)
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
if self.station_settings["frame"] != 4:
|
||||||
|
self.motor_settings["independent"] = False
|
||||||
self.worker.set_motor_scaling(
|
self.worker.set_motor_scaling(
|
||||||
self.motor_settings["independent"],
|
self.motor_settings["independent"],
|
||||||
{int(i): entry["scale"] for i, entry in self.motor_settings["motors"].items()},
|
{int(i): entry["scale"] for i, entry in self.motor_settings["motors"].items()},
|
||||||
@@ -88,6 +115,7 @@ class ControlApi:
|
|||||||
"baud": 57600, "control_hz": 20,
|
"baud": 57600, "control_hz": 20,
|
||||||
"ping_hz": 1, "system_hz": 1, "battery_hz": 1, "servo_hz": 2,
|
"ping_hz": 1, "system_hz": 1, "battery_hz": 1, "servo_hz": 2,
|
||||||
"rc_hz": 2, "esc_hz": 1,
|
"rc_hz": 2, "esc_hz": 1,
|
||||||
|
"attitude_hz": 2, "depth_hz": 2, "imu_hz": 1, "pressure_hz": 1,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
|
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
|
||||||
@@ -100,6 +128,8 @@ class ControlApi:
|
|||||||
"battery_config": self.battery_config,
|
"battery_config": self.battery_config,
|
||||||
"link_config": self.link_config,
|
"link_config": self.link_config,
|
||||||
"motor_settings": self.motor_settings,
|
"motor_settings": self.motor_settings,
|
||||||
|
"station_settings": self.station_settings,
|
||||||
|
"controller_profiles": self.controller_profiles,
|
||||||
"state": self._snapshot(),
|
"state": self._snapshot(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +287,12 @@ class ControlApi:
|
|||||||
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
|
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
|
||||||
if not port:
|
if not port:
|
||||||
raise ValueError("Select a USB serial port")
|
raise ValueError("Select a USB serial port")
|
||||||
if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
|
self.telemetry = Telemetry()
|
||||||
|
self.state["capabilities"] = {"frame": None, "firmware": None}
|
||||||
|
self.state["accessories"] = {}
|
||||||
|
self.state["battery"] = None
|
||||||
|
self.battery_fields_at = {}
|
||||||
|
if str(port).lower() == "demo":
|
||||||
self.demo_mode = True
|
self.demo_mode = True
|
||||||
self.servo_functions = {1: 33, 2: 34, 3: 35}
|
self.servo_functions = {1: 33, 2: 34, 3: 35}
|
||||||
self.motor_directions = {1: 1, 2: 1, 3: 1}
|
self.motor_directions = {1: 1, 2: 1, 3: 1}
|
||||||
@@ -307,6 +342,7 @@ class ControlApi:
|
|||||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
|
self.sensor_record(False)
|
||||||
self.worker.stop_motor_test()
|
self.worker.stop_motor_test()
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.state["motor_test_active"] = False
|
self.state["motor_test_active"] = False
|
||||||
@@ -358,7 +394,8 @@ class ControlApi:
|
|||||||
self.state["status"] = "Motor test released; waiting for flight-controller stop"
|
self.state["status"] = "Motor test released; waiting for flight-controller stop"
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_axes(self, forward, turn, vertical, sequence=None):
|
def set_axes(self, forward, turn, vertical, sequence=None, lateral=0, pitch=0, roll=0):
|
||||||
|
values = [number(value, -1, 1) for value in (forward, lateral, vertical, turn, pitch, roll)]
|
||||||
if self.demo_mode:
|
if self.demo_mode:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.state["status"] = (
|
self.state["status"] = (
|
||||||
@@ -368,13 +405,95 @@ class ControlApi:
|
|||||||
return True
|
return True
|
||||||
with self.lock:
|
with self.lock:
|
||||||
allowed = self.vehicle_state.snapshot()["controls_allowed"]
|
allowed = self.vehicle_state.snapshot()["controls_allowed"]
|
||||||
self.worker.set_axes(Axes(
|
self.worker.set_axes(Axes(*values) if allowed else Axes(), sequence)
|
||||||
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
|
return True
|
||||||
|
|
||||||
|
def _require_settings(self):
|
||||||
|
if self.state.get("connected") and (self.state.get("armed") or
|
||||||
|
(not self.demo_mode and not self.vehicle_state.snapshot()["armed_known"])):
|
||||||
|
raise RuntimeError("Disengage first")
|
||||||
|
|
||||||
|
def save_controller_profile(self, device, profile):
|
||||||
|
with self.lock:
|
||||||
|
self._require_settings()
|
||||||
|
clean = clean_profile(profile)
|
||||||
|
updated = {**self.controller_profiles, str(device)[:256]: clean}
|
||||||
|
save_json(self.profiles_path, updated)
|
||||||
|
self.controller_profiles = updated
|
||||||
|
self.worker.set_axes(Axes())
|
||||||
|
return clean
|
||||||
|
|
||||||
|
def save_station_settings(self, config):
|
||||||
|
with self.lock:
|
||||||
|
self._require_settings()
|
||||||
|
clean = clean_station(config)
|
||||||
|
save_json(self.station_path, clean)
|
||||||
|
self.station_settings = clean
|
||||||
|
if clean["frame"] != 4 and (self.demo_mode or not self.state.get("connected")):
|
||||||
|
self.motor_settings["independent"] = False
|
||||||
|
self.worker.set_motor_scaling(False, {int(key): item["scale"] for key, item in self.motor_settings["motors"].items()})
|
||||||
|
# Frame selection describes the preview; actual FRAME_TYPE is read
|
||||||
|
# from the connected autopilot and never silently overwritten.
|
||||||
|
return clean
|
||||||
|
|
||||||
|
def set_accessory(self, name, enabled):
|
||||||
|
if name not in ("lights", "camera") or not isinstance(enabled, bool):
|
||||||
|
raise ValueError("Invalid accessory")
|
||||||
|
relay = self.station_settings[name + "_relay"]
|
||||||
|
if relay < 0:
|
||||||
|
raise RuntimeError("Set relay first")
|
||||||
|
if self.demo_mode:
|
||||||
|
self.state["accessories"][name] = {"requested": enabled, "accepted": True, "demo": True}
|
||||||
|
return True
|
||||||
|
self.worker.accessory(name, relay, enabled)
|
||||||
|
with self.lock:
|
||||||
|
self.state["accessories"][name] = {"requested": enabled, "accepted": None, "at": time.monotonic()}
|
||||||
|
return True
|
||||||
|
|
||||||
|
def sensor_record(self, enabled):
|
||||||
|
with self.lock:
|
||||||
|
if enabled and self._sensor_file is None:
|
||||||
|
folder = Path(__file__).resolve().parent / "logs"
|
||||||
|
folder.mkdir(exist_ok=True)
|
||||||
|
self._sensor_file = (folder / f"sensors_{time.time_ns()}.csv").open("w", newline="", encoding="utf-8")
|
||||||
|
self._sensor_writer = csv.writer(self._sensor_file)
|
||||||
|
self._sensor_writer.writerow(("monotonic_s", "group", "field", "value"))
|
||||||
|
elif not enabled and self._sensor_file is not None:
|
||||||
|
self._sensor_file.close()
|
||||||
|
self._sensor_file = self._sensor_writer = None
|
||||||
|
self.state["sensor_recording"] = bool(enabled)
|
||||||
|
return self._sensor_file.name if self._sensor_file else None
|
||||||
|
|
||||||
|
def recording_begin(self):
|
||||||
|
with self._recording_lock:
|
||||||
|
if self._recording:
|
||||||
|
raise RuntimeError("Already recording")
|
||||||
|
folder = Path(__file__).resolve().parent / "runtime" / "recordings"
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = folder / f"rov_{time.time_ns()}.webm"
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
self._recording = (token, path.open("wb"), path)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def recording_chunk(self, token, data):
|
||||||
|
if not isinstance(data, str) or len(data) > 12 * 1024 * 1024:
|
||||||
|
raise ValueError("Invalid video chunk")
|
||||||
|
chunk = base64.b64decode(data, validate=True)
|
||||||
|
with self._recording_lock:
|
||||||
|
if not self._recording or token != self._recording[0]:
|
||||||
|
raise ValueError("Recording expired")
|
||||||
|
self._recording[1].write(chunk)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def recording_end(self, token):
|
||||||
|
with self._recording_lock:
|
||||||
|
if not self._recording or token != self._recording[0]:
|
||||||
|
return None
|
||||||
|
_, handle, path = self._recording
|
||||||
|
handle.close()
|
||||||
|
self._recording = None
|
||||||
|
return str(path)
|
||||||
|
|
||||||
def stop_controls(self, sequence=None):
|
def stop_controls(self, sequence=None):
|
||||||
"""Invalidate queued input without claiming that the vehicle disarmed."""
|
"""Invalidate queued input without claiming that the vehicle disarmed."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -412,6 +531,10 @@ class ControlApi:
|
|||||||
|
|
||||||
def save_motor_settings(self, settings, persist=True):
|
def save_motor_settings(self, settings, persist=True):
|
||||||
clean = self._clean_motor_settings(settings)
|
clean = self._clean_motor_settings(settings)
|
||||||
|
if clean["independent"] != self.motor_settings["independent"]:
|
||||||
|
self._require_settings()
|
||||||
|
if clean["independent"] and self.worker.frame_type not in (None, 4):
|
||||||
|
raise ValueError("Independent scales require SimpleROV 3")
|
||||||
if persist:
|
if persist:
|
||||||
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||||
self.motor_settings = clean
|
self.motor_settings = clean
|
||||||
@@ -516,6 +639,10 @@ class ControlApi:
|
|||||||
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
|
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
|
||||||
"RC_CHANNELS": self.link_config["rc_hz"],
|
"RC_CHANNELS": self.link_config["rc_hz"],
|
||||||
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
|
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
|
||||||
|
"ATTITUDE": self.link_config["attitude_hz"],
|
||||||
|
"GLOBAL_POSITION_INT": self.link_config["depth_hz"],
|
||||||
|
"SCALED_IMU": self.link_config["imu_hz"],
|
||||||
|
"SCALED_PRESSURE2": self.link_config["pressure_hz"],
|
||||||
})
|
})
|
||||||
|
|
||||||
def save_link_settings(self, config):
|
def save_link_settings(self, config):
|
||||||
@@ -527,7 +654,7 @@ class ControlApi:
|
|||||||
raise ValueError("Control rate must be 10 or 20 Hz")
|
raise ValueError("Control rate must be 10 or 20 Hz")
|
||||||
if clean["ping_hz"] not in {1, 2, 5, 10}:
|
if clean["ping_hz"] not in {1, 2, 5, 10}:
|
||||||
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
|
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")):
|
if any(clean[key] not in allowed for key in ("system_hz", "battery_hz", "servo_hz", "rc_hz", "esc_hz", "attitude_hz", "depth_hz", "imu_hz", "pressure_hz")):
|
||||||
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
|
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_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
|
||||||
self.link_config = clean
|
self.link_config = clean
|
||||||
@@ -577,6 +704,31 @@ class ControlApi:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
snap = json.loads(json.dumps(self.state))
|
snap = json.loads(json.dumps(self.state))
|
||||||
snap["motor_settings"] = self.motor_settings
|
snap["motor_settings"] = self.motor_settings
|
||||||
|
snap["telemetry"] = self.telemetry.snapshot(bool(snap.get("connected")))
|
||||||
|
if snap.get("battery") and not self.demo_mode:
|
||||||
|
now = time.monotonic()
|
||||||
|
snap["battery"]["current_stale"] = now - self.battery_fields_at.get("current_a", 0) > 5
|
||||||
|
if snap["battery"]["current_stale"]:
|
||||||
|
snap["battery"]["current_a"] = None
|
||||||
|
for field in ("voltage", "per_cell", "consumed_mah", "remaining_percent"):
|
||||||
|
key = "voltage" if field == "per_cell" else field
|
||||||
|
if now - self.battery_fields_at.get(key, 0) > 5:
|
||||||
|
snap["battery"][field] = None
|
||||||
|
battery = snap["battery"]
|
||||||
|
if battery.get("remaining_percent") is not None:
|
||||||
|
battery["percent"] = battery["remaining_percent"]
|
||||||
|
elif battery.get("per_cell") is not None:
|
||||||
|
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
|
||||||
|
battery["percent"] = max(0, min(100, (battery["per_cell"] - low) * 100 / (high - low)))
|
||||||
|
battery["charge_source"] = "Voltage estimate"
|
||||||
|
else:
|
||||||
|
battery["percent"] = None
|
||||||
|
capacity = self.battery_config["capacity_mah"]
|
||||||
|
battery["remaining_mah"] = (max(0, capacity - battery["consumed_mah"]) if battery.get("consumed_mah") is not None else
|
||||||
|
round(capacity * battery["remaining_percent"] / 100) if battery.get("remaining_percent") is not None else None)
|
||||||
|
for accessory in snap["accessories"].values():
|
||||||
|
if accessory.get("accepted") is None and time.monotonic() - accessory.get("at", 0) > 3:
|
||||||
|
accessory["error"] = "No ACK"
|
||||||
snap["app_state"] = self._derive_app_state()
|
snap["app_state"] = self._derive_app_state()
|
||||||
if not self.demo_mode:
|
if not self.demo_mode:
|
||||||
snap.update(self.vehicle_state.snapshot())
|
snap.update(self.vehicle_state.snapshot())
|
||||||
@@ -608,6 +760,19 @@ class ControlApi:
|
|||||||
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
||||||
elif kind == "battery":
|
elif kind == "battery":
|
||||||
self._consume_battery(event[1])
|
self._consume_battery(event[1])
|
||||||
|
elif kind == "capabilities":
|
||||||
|
self.state["capabilities"].update(event[1])
|
||||||
|
if event[1].get("frame", 4) != 4:
|
||||||
|
self.motor_settings["independent"] = False
|
||||||
|
self.worker.set_motor_scaling(False, {int(key): item["scale"] for key, item in self.motor_settings["motors"].items()})
|
||||||
|
elif kind == "telemetry":
|
||||||
|
self.telemetry.update(event[1], event[2], event[3])
|
||||||
|
if self._sensor_writer:
|
||||||
|
self._sensor_writer.writerows((event[3], event[1], key, value) for key, value in event[2].items())
|
||||||
|
self._sensor_file.flush()
|
||||||
|
elif kind == "accessory_ack":
|
||||||
|
self.state["accessories"][event[1]] = {"requested": event[2], "accepted": event[3] == 0,
|
||||||
|
"error": None if event[3] == 0 else "Rejected"}
|
||||||
elif kind == "servo_output":
|
elif kind == "servo_output":
|
||||||
for servo, output_us in event[1].items():
|
for servo, output_us in event[1].items():
|
||||||
if servo in self.state["servos"]:
|
if servo in self.state["servos"]:
|
||||||
@@ -655,6 +820,14 @@ class ControlApi:
|
|||||||
previous = self.state.get("battery") or {}
|
previous = self.state.get("battery") or {}
|
||||||
battery = dict(previous)
|
battery = dict(previous)
|
||||||
battery["cells"] = self.battery_config["cells"]
|
battery["cells"] = self.battery_config["cells"]
|
||||||
|
for field in ("voltage", "current_a", "consumed_mah", "remaining_percent"):
|
||||||
|
if field in data:
|
||||||
|
value = data[field]
|
||||||
|
if isinstance(value, (float, int)) and math.isfinite(value) and value >= 0:
|
||||||
|
self.battery_fields_at[field] = now
|
||||||
|
else:
|
||||||
|
battery[field] = None
|
||||||
|
self.battery_fields_at.pop(field, None)
|
||||||
voltage = data.get("voltage")
|
voltage = data.get("voltage")
|
||||||
if voltage is not None and float(voltage) > 0:
|
if voltage is not None and float(voltage) > 0:
|
||||||
battery["voltage"] = float(voltage)
|
battery["voltage"] = float(voltage)
|
||||||
@@ -670,6 +843,9 @@ class ControlApi:
|
|||||||
self.battery_last_current = current
|
self.battery_last_current = current
|
||||||
self.battery_last_current_at = now
|
self.battery_last_current_at = now
|
||||||
battery["current_a"] = current
|
battery["current_a"] = current
|
||||||
|
elif "current_a" in data:
|
||||||
|
self.battery_last_current = None
|
||||||
|
self.battery_last_current_at = None
|
||||||
consumed = data.get("consumed_mah")
|
consumed = data.get("consumed_mah")
|
||||||
if consumed is not None and int(consumed) >= 0:
|
if consumed is not None and int(consumed) >= 0:
|
||||||
battery["consumed_mah"] = int(consumed)
|
battery["consumed_mah"] = int(consumed)
|
||||||
@@ -677,6 +853,7 @@ class ControlApi:
|
|||||||
elif self.battery_last_current is not None:
|
elif self.battery_last_current is not None:
|
||||||
battery["consumed_mah"] = round(self.battery_session_consumed_mah)
|
battery["consumed_mah"] = round(self.battery_session_consumed_mah)
|
||||||
battery["consumed_source"] = "Session estimate"
|
battery["consumed_source"] = "Session estimate"
|
||||||
|
self.battery_fields_at["consumed_mah"] = now
|
||||||
remaining = data.get("remaining_percent")
|
remaining = data.get("remaining_percent")
|
||||||
if remaining is not None and 0 <= int(remaining) <= 100:
|
if remaining is not None and 0 <= int(remaining) <= 100:
|
||||||
battery["remaining_percent"] = int(remaining)
|
battery["remaining_percent"] = int(remaining)
|
||||||
@@ -711,6 +888,9 @@ class ControlApi:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
self.sensor_record(False)
|
||||||
|
if self._recording:
|
||||||
|
self.recording_end(self._recording[0])
|
||||||
self.worker.disconnect()
|
self.worker.disconnect()
|
||||||
|
|
||||||
|
|
||||||
@@ -785,7 +965,7 @@ class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
def start_local_server(directory: Path) -> int:
|
def start_local_server(directory: Path) -> int:
|
||||||
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
|
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
|
||||||
server = socketserver.TCPServer(("127.0.0.1", 0), handler)
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
port = server.server_address[1]
|
port = server.server_address[1]
|
||||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
return port
|
return port
|
||||||
@@ -804,7 +984,7 @@ def main():
|
|||||||
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
|
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()
|
win_w, win_h, min_dims = _get_window_size()
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
"SUB Control", url=app_url, js_api=api,
|
"Argo V2", url=app_url, js_api=api,
|
||||||
width=win_w, height=win_h, min_size=min_dims,
|
width=win_w, height=win_h, min_size=min_dims,
|
||||||
background_color="#111719", frameless=True, easy_drag=False,
|
background_color="#111719", frameless=True, easy_drag=False,
|
||||||
shadow=True,
|
shadow=True,
|
||||||
@@ -815,7 +995,7 @@ def main():
|
|||||||
window.events.restored += lambda: setattr(api, "maximized", False)
|
window.events.restored += lambda: setattr(api, "maximized", False)
|
||||||
try:
|
try:
|
||||||
icon_path = project_root / "assets" / "sub-control.ico"
|
icon_path = project_root / "assets" / "sub-control.ico"
|
||||||
webview.start(debug=True, icon=str(icon_path) if icon_path.is_file() else None)
|
webview.start(debug=os.environ.get("SUB_CONTROL_DEBUG") == "1", icon=str(icon_path) if icon_path.is_file() else None)
|
||||||
finally:
|
finally:
|
||||||
api.close()
|
api.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user