V2
This commit is contained in:
@@ -109,11 +109,12 @@ The car-style virtual joystick maps only to forward/reverse and continuous
|
|||||||
differential turning; it never sends lateral motion. The joystick and vertical
|
differential turning; it never sends lateral motion. The joystick and vertical
|
||||||
slider ease back to center over 400 ms after mouse/key release, so neither can
|
slider ease back to center over 400 ms after mouse/key release, so neither can
|
||||||
be left commanding motion.
|
be left commanding motion.
|
||||||
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are
|
Focus loss, stale telemetry, disconnect, and disarm clear commanded inputs
|
||||||
intentionally not eased.
|
without easing. This is a neutral-command request, **not proof that motors have
|
||||||
|
stopped** when the communication link is unavailable.
|
||||||
|
|
||||||
In addition, transmitted pilot demands have a small 4% input deadband and a
|
In addition, transmitted pilot demands have a small 4% input deadband and a
|
||||||
0.8-second full-range slew limit. This makes both acceleration and deceleration
|
0.25-second full-range slew limit. This makes both acceleration and deceleration
|
||||||
arrive at the flight controller as a sequence of stable intermediate values.
|
arrive at the flight controller as a sequence of stable intermediate values.
|
||||||
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
|
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
|
||||||
limit is applied, preventing their mixer contributions from adding beyond the
|
limit is applied, preventing their mixer contributions from adding beyond the
|
||||||
@@ -122,3 +123,51 @@ selected horizontal limit.
|
|||||||
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
|
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
|
||||||
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
|
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
|
||||||
MAVLink input. Those parameters must already be correctly configured.
|
MAVLink input. Those parameters must already be correctly configured.
|
||||||
|
|
||||||
|
## Engage, confirmation, and interruption handling
|
||||||
|
|
||||||
|
The compact window opens at 1100 × 650. Empty sidebar areas drag the window;
|
||||||
|
the STL viewer, buttons, and sliders are separate interactive regions. Output
|
||||||
|
limit and Motor setup sit below the main controls. Recent activity retains the
|
||||||
|
last three events, oldest at the top and newest emphasized at the bottom.
|
||||||
|
Routine hold instructions are kept in tooltips;
|
||||||
|
pending confirmations and safety warnings still appear above the controls.
|
||||||
|
The full-width text control displays `engage` / `disengage`; disengage sends the
|
||||||
|
same immediate disarm request described below.
|
||||||
|
|
||||||
|
- Hold **ENGAGE** for 1.4 seconds (or hold F2). Early release, leaving the
|
||||||
|
button, interrupted touch, and focus loss cancel the hold without sending arm.
|
||||||
|
- **ENGAGING…** means a request is pending. A send result or accepted command
|
||||||
|
ACK alone does not prove that the vehicle armed: a fresh autopilot heartbeat
|
||||||
|
reporting the requested state is the final confirmation. A heartbeat can
|
||||||
|
confirm state even if the ACK packet was lost.
|
||||||
|
- **DISARM** is one tap or F3, with no long hold. While confirmation is pending,
|
||||||
|
**DISARMING…** stays red and driving is disabled. Disarm can also interrupt a
|
||||||
|
pending Engage request. No forced-arm/disarm bypass is used.
|
||||||
|
- Confirmation timeout: 5 seconds in the backend (6-second UI fallback).
|
||||||
|
Rejection and timeout remain visible. Late heartbeat updates still determine
|
||||||
|
the displayed state; the UI never substitutes an animation state for telemetry.
|
||||||
|
- A heartbeat older than 3 seconds is stale. A missing bridge reply after
|
||||||
|
1.5 seconds shows **STATE UNKNOWN**, preserving the last-known state without
|
||||||
|
claiming disarm. Battery readings older than 5 seconds are marked stale.
|
||||||
|
- The web frontend renews an input lease locally every 250 ms. If it stops for
|
||||||
|
1 second, the worker clears stored demands and sends neutral at its configured
|
||||||
|
control rate. This adds no MAVLink stream or parameter download. Focus loss
|
||||||
|
clears both captured touch pointers and queued input; reconnect never replays
|
||||||
|
the old joystick position. Normal release still eases back to center.
|
||||||
|
- Link loss cannot deliver a stop over a broken tether. Verify the vehicle's
|
||||||
|
own failsafe and use independent physical power isolation for bench tests.
|
||||||
|
|
||||||
|
### Regression checks (no hardware)
|
||||||
|
|
||||||
|
Run from this project folder after setting up the Python environment:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe -m unittest discover -s tests -v
|
||||||
|
node --test tests/control_ui.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Node is needed only for the frontend regression tests, not to run the app.
|
||||||
|
The tests use mocked transport and synthetic pointer events, including independent
|
||||||
|
joystick/vertical pointers. Validate physical multi-touch on the intended screen
|
||||||
|
and FC failsafes on a restrained, propeller-free setup before powered operation.
|
||||||
|
|||||||
+52
-3
@@ -109,11 +109,12 @@ The car-style virtual joystick maps only to forward/reverse and continuous
|
|||||||
differential turning; it never sends lateral motion. The joystick and vertical
|
differential turning; it never sends lateral motion. The joystick and vertical
|
||||||
slider ease back to center over 400 ms after mouse/key release, so neither can
|
slider ease back to center over 400 ms after mouse/key release, so neither can
|
||||||
be left commanding motion.
|
be left commanding motion.
|
||||||
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are
|
Focus loss, stale telemetry, disconnect, and disarm clear commanded inputs
|
||||||
intentionally not eased.
|
without easing. This is a neutral-command request, **not proof that motors have
|
||||||
|
stopped** when the communication link is unavailable.
|
||||||
|
|
||||||
In addition, transmitted pilot demands have a small 4% input deadband and a
|
In addition, transmitted pilot demands have a small 4% input deadband and a
|
||||||
0.8-second full-range slew limit. This makes both acceleration and deceleration
|
0.25-second full-range slew limit. This makes both acceleration and deceleration
|
||||||
arrive at the flight controller as a sequence of stable intermediate values.
|
arrive at the flight controller as a sequence of stable intermediate values.
|
||||||
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
|
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
|
||||||
limit is applied, preventing their mixer contributions from adding beyond the
|
limit is applied, preventing their mixer contributions from adding beyond the
|
||||||
@@ -122,3 +123,51 @@ selected horizontal limit.
|
|||||||
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
|
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
|
||||||
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
|
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
|
||||||
MAVLink input. Those parameters must already be correctly configured.
|
MAVLink input. Those parameters must already be correctly configured.
|
||||||
|
|
||||||
|
## Engage, confirmation, and interruption handling
|
||||||
|
|
||||||
|
The compact window opens at 1100 × 650. Empty sidebar areas drag the window;
|
||||||
|
the STL viewer, buttons, and sliders are separate interactive regions. Output
|
||||||
|
limit and Motor setup sit below the main controls. Recent activity retains the
|
||||||
|
last three events, oldest at the top and newest emphasized at the bottom.
|
||||||
|
Routine hold instructions are kept in tooltips;
|
||||||
|
pending confirmations and safety warnings still appear above the controls.
|
||||||
|
The full-width text control displays `engage` / `disengage`; disengage sends the
|
||||||
|
same immediate disarm request described below.
|
||||||
|
|
||||||
|
- Hold **ENGAGE** for 1.4 seconds (or hold F2). Early release, leaving the
|
||||||
|
button, interrupted touch, and focus loss cancel the hold without sending arm.
|
||||||
|
- **ENGAGING…** means a request is pending. A send result or accepted command
|
||||||
|
ACK alone does not prove that the vehicle armed: a fresh autopilot heartbeat
|
||||||
|
reporting the requested state is the final confirmation. A heartbeat can
|
||||||
|
confirm state even if the ACK packet was lost.
|
||||||
|
- **DISARM** is one tap or F3, with no long hold. While confirmation is pending,
|
||||||
|
**DISARMING…** stays red and driving is disabled. Disarm can also interrupt a
|
||||||
|
pending Engage request. No forced-arm/disarm bypass is used.
|
||||||
|
- Confirmation timeout: 5 seconds in the backend (6-second UI fallback).
|
||||||
|
Rejection and timeout remain visible. Late heartbeat updates still determine
|
||||||
|
the displayed state; the UI never substitutes an animation state for telemetry.
|
||||||
|
- A heartbeat older than 3 seconds is stale. A missing bridge reply after
|
||||||
|
1.5 seconds shows **STATE UNKNOWN**, preserving the last-known state without
|
||||||
|
claiming disarm. Battery readings older than 5 seconds are marked stale.
|
||||||
|
- The web frontend renews an input lease locally every 250 ms. If it stops for
|
||||||
|
1 second, the worker clears stored demands and sends neutral at its configured
|
||||||
|
control rate. This adds no MAVLink stream or parameter download. Focus loss
|
||||||
|
clears both captured touch pointers and queued input; reconnect never replays
|
||||||
|
the old joystick position. Normal release still eases back to center.
|
||||||
|
- Link loss cannot deliver a stop over a broken tether. Verify the vehicle's
|
||||||
|
own failsafe and use independent physical power isolation for bench tests.
|
||||||
|
|
||||||
|
### Regression checks (no hardware)
|
||||||
|
|
||||||
|
Run from this project folder after setting up the Python environment:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe -m unittest discover -s tests -v
|
||||||
|
node --test tests/control_ui.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Node is needed only for the frontend regression tests, not to run the app.
|
||||||
|
The tests use mocked transport and synthetic pointer events, including independent
|
||||||
|
joystick/vertical pointers. Validate physical multi-touch on the intended screen
|
||||||
|
and FC failsafes on a restrained, propeller-free setup before powered operation.
|
||||||
|
|||||||
+29
-6
@@ -71,6 +71,11 @@ class MavlinkWorker:
|
|||||||
self.axes = Axes()
|
self.axes = Axes()
|
||||||
self.output_axes = Axes()
|
self.output_axes = Axes()
|
||||||
self.last_control_at = 0.0
|
self.last_control_at = 0.0
|
||||||
|
# Enabled by the web bridge only; the legacy Tk frontend is unchanged.
|
||||||
|
self.require_input_lease = False
|
||||||
|
self.last_input_at = 0.0
|
||||||
|
self.last_input_sequence = -1
|
||||||
|
self.input_timeout = 1.0
|
||||||
# Smooth ESC commands without adding a perceptible one-second lag.
|
# Smooth ESC commands without adding a perceptible one-second lag.
|
||||||
# At 20 Hz this produces five small steps from neutral to full command.
|
# At 20 Hz this produces five small steps from neutral to full command.
|
||||||
self.ramp_seconds = 0.25
|
self.ramp_seconds = 0.25
|
||||||
@@ -200,6 +205,10 @@ class MavlinkWorker:
|
|||||||
payload = str(msg)
|
payload = str(msg)
|
||||||
self.log("RX", payload)
|
self.log("RX", payload)
|
||||||
if msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
|
if msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
|
||||||
|
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
|
||||||
|
return
|
||||||
|
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||||
|
return
|
||||||
first = not self.target_system
|
first = not self.target_system
|
||||||
self.target_system = msg.get_srcSystem()
|
self.target_system = msg.get_srcSystem()
|
||||||
self.target_component = msg.get_srcComponent()
|
self.target_component = msg.get_srcComponent()
|
||||||
@@ -213,15 +222,17 @@ class MavlinkWorker:
|
|||||||
mode = str(msg.custom_mode)
|
mode = str(msg.custom_mode)
|
||||||
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
|
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
|
||||||
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
||||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed))
|
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
|
||||||
if first:
|
if first:
|
||||||
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
||||||
self._configure_low_bandwidth_streams()
|
self._configure_low_bandwidth_streams()
|
||||||
self._start_servo_scan()
|
self._start_servo_scan()
|
||||||
elif msg_type == "COMMAND_ACK":
|
elif msg_type == "COMMAND_ACK":
|
||||||
|
if (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||||
|
return
|
||||||
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
|
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
|
||||||
result_name = result.name if result else str(msg.result)
|
result_name = result.name if result else str(msg.result)
|
||||||
self.events.put(("ack", int(msg.command), result_name))
|
self.events.put(("ack", int(msg.command), result_name, self.last_rx))
|
||||||
elif msg_type == "STATUSTEXT":
|
elif msg_type == "STATUSTEXT":
|
||||||
text = msg.text
|
text = msg.text
|
||||||
if isinstance(text, bytes):
|
if isinstance(text, bytes):
|
||||||
@@ -343,10 +354,17 @@ class MavlinkWorker:
|
|||||||
if not conn or not self.target_system:
|
if not conn or not self.target_system:
|
||||||
return
|
return
|
||||||
with self.lock:
|
with self.lock:
|
||||||
active = not force_neutral
|
now = time.monotonic()
|
||||||
|
expired = self.require_input_lease and (
|
||||||
|
not self.armed or now - self.last_heartbeat > 3.0
|
||||||
|
or now - self.last_input_at > self.input_timeout)
|
||||||
|
if expired or force_neutral:
|
||||||
|
# Never replay a stored command after telemetry/UI recovery.
|
||||||
|
self.axes = Axes()
|
||||||
|
self.output_axes = Axes()
|
||||||
|
active = not (force_neutral or expired)
|
||||||
target = self.axes if active else Axes()
|
target = self.axes if active else Axes()
|
||||||
gain = self.gain
|
gain = self.gain
|
||||||
now = time.monotonic()
|
|
||||||
if force_neutral:
|
if force_neutral:
|
||||||
self.output_axes = Axes()
|
self.output_axes = Axes()
|
||||||
else:
|
else:
|
||||||
@@ -418,7 +436,7 @@ class MavlinkWorker:
|
|||||||
manual,
|
manual,
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_axes(self, axes: Axes):
|
def set_axes(self, axes: Axes, sequence=None):
|
||||||
def conditioned(value):
|
def conditioned(value):
|
||||||
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
|
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
|
||||||
if abs(value) < 0.04:
|
if abs(value) < 0.04:
|
||||||
@@ -426,6 +444,11 @@ class MavlinkWorker:
|
|||||||
return round(max(-1.0, min(1.0, value)), 2)
|
return round(max(-1.0, min(1.0, value)), 2)
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
if sequence is not None:
|
||||||
|
if sequence <= self.last_input_sequence:
|
||||||
|
return
|
||||||
|
self.last_input_sequence = sequence
|
||||||
|
self.last_input_at = time.monotonic()
|
||||||
self.axes = Axes(
|
self.axes = Axes(
|
||||||
conditioned(axes.forward), conditioned(axes.lateral),
|
conditioned(axes.forward), conditioned(axes.lateral),
|
||||||
conditioned(axes.vertical), conditioned(axes.yaw),
|
conditioned(axes.vertical), conditioned(axes.yaw),
|
||||||
@@ -1185,7 +1208,7 @@ class App(tk.Tk):
|
|||||||
event = self.events.get_nowait()
|
event = self.events.get_nowait()
|
||||||
kind = event[0]
|
kind = event[0]
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
_, system, autopilot, mode, armed = event
|
_, system, autopilot, mode, armed = event[:5]
|
||||||
self.vehicle_label.configure(
|
self.vehicle_label.configure(
|
||||||
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
|
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Deterministic DOM/pointer harness. No browser, vehicle, or serial port involved.
|
||||||
|
const {test}=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
|
||||||
|
const {present}=require('../web/control-state.js');
|
||||||
|
const armed={connected:true,armed_known:true,armed:true,app_state:'armed',servos:{}};
|
||||||
|
|
||||||
|
test('native pywebview result wrapper can execute alongside app globals',()=>{
|
||||||
|
const h=harness();
|
||||||
|
// Window.evaluate_js declares these variables in the page's global scope.
|
||||||
|
// A global const/let collision prevents every Python reply from executing.
|
||||||
|
assert.doesNotThrow(()=>h.run(`var value; try { value = ['COM3','COM4']; }
|
||||||
|
catch(e) { value = {name:e.name}; var keys = Object.getOwnPropertyNames(e);
|
||||||
|
keys.forEach(function(key) { value[key] = e[key]; }); }
|
||||||
|
populatePorts(value);`));
|
||||||
|
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty pywebview API cannot permanently latch startup as completed',async()=>{
|
||||||
|
const h=harness();h.context.pywebview.api={};await h.run('boot()');
|
||||||
|
assert.equal(h.run('booted'),false);assert.equal(h.run('bridgeReady'),false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop startup retries after failure and populates detected COM ports',async()=>{
|
||||||
|
const h=harness();let attempts=0;
|
||||||
|
h.run('loadDefaultModel=()=>Promise.resolve(false)');
|
||||||
|
h.context.pywebview.api.refresh_ports=()=>Promise.resolve([{device:'COM3',label:'COM3'},{device:'COM4',label:'COM4'}]);
|
||||||
|
h.context.pywebview.api.initial_state=()=>++attempts===1?Promise.reject(Error('Temporary startup failure')):Promise.resolve({ports:['COM3','COM4'],battery_config:{},state:{connected:false,armed:null,servos:{}}});
|
||||||
|
h.context.pywebview.api.poll=()=>Promise.resolve({connected:false,armed:null,servos:{}});
|
||||||
|
await h.run('boot()');assert.equal(h.run('booted'),false);
|
||||||
|
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'),'COM discovery survives a configuration startup failure');
|
||||||
|
await h.run('boot()');assert.equal(h.run('booted'),true);
|
||||||
|
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
|
||||||
|
assert.ok(h.el('#port').options.some(p=>p.value==='COM4'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visual status changes cannot resize the header or percentage label',()=>{
|
||||||
|
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||||
|
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||||
|
assert.doesNotMatch(html,/class="water-ring/);
|
||||||
|
assert.match(html,/class="engage-sheen-label"/);
|
||||||
|
assert.match(css,/padding-bottom:\.22em; margin-bottom:-\.22em/);
|
||||||
|
assert.match(css,/grid-template-rows:120px minmax\(0,1fr\) 52px/);
|
||||||
|
assert.match(css,/\.control-status \{ position:absolute/);
|
||||||
|
assert.match(css,/flex:0 0 128px; width:128px/);
|
||||||
|
assert.match(css,/\.vertical-card footer \{ justify-content:center/);
|
||||||
|
assert.match(css,/mask-image:linear-gradient\(to right,#000 calc\(var\(--engage-progress\) - 8%\),transparent var\(--engage-progress\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activity renders oldest at top, latest above its footer; both control states sweep',()=>{
|
||||||
|
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||||
|
assert.ok(html.indexOf('id="activity-oldest"')<html.indexOf('id="activity-previous"'));
|
||||||
|
assert.ok(html.indexOf('id="activity-previous"')<html.indexOf('id="status-text"'));
|
||||||
|
assert.ok(html.indexOf('id="status-text"')<html.indexOf('class="activity-heading"'));
|
||||||
|
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||||
|
assert.match(css,/#engage:is\(\.arm,\.danger\).*animation:engage-sweep/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native drag surface has no interactive descendants; output limit is outside sidebar',()=>{
|
||||||
|
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||||
|
assert.match(html,/<aside class="sidebar-deck"/);
|
||||||
|
assert.match(html,/<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"><\/div>/);
|
||||||
|
assert.ok(html.indexOf('id="gain"')>html.indexOf('</aside>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function harness(){
|
||||||
|
let now=100,id=0;const timers=new Map(),elements=new Map(),listeners=new Map();
|
||||||
|
class Element{
|
||||||
|
constructor(name){this.name=name;this.dataset={};this.style={setProperty(k,v){this[k]=v}};this.classes=new Set();this.classList={add:(...n)=>n.forEach(x=>this.classes.add(x)),remove:(...n)=>n.forEach(x=>this.classes.delete(x)),contains:n=>this.classes.has(n),toggle:(n,b)=>b?this.classes.add(n):this.classes.delete(n)};this.handlers={};this.captured=new Set();this.value='demo';this.options=[];this.tagName='DIV';this.children=[];this.textContent='';}
|
||||||
|
addEventListener(n,fn){(this.handlers[n]??=[]).push(fn)}
|
||||||
|
removeEventListener(){}
|
||||||
|
emit(n,fields={}){const e={pointerId:1,button:0,pointerType:'touch',clientX:160,clientY:160,preventDefault(){},target:this,...fields};for(const fn of this.handlers[n]||[])fn(e);return e}
|
||||||
|
getBoundingClientRect(){return this.name==='#heave'?{left:0,top:0,width:48,height:300,right:48,bottom:300}:this.name==='#engage'?{left:0,top:0,width:180,height:44,right:180,bottom:44}:{left:0,top:0,width:320,height:320,right:320,bottom:320}}
|
||||||
|
setPointerCapture(p){this.captured.add(p)}
|
||||||
|
releasePointerCapture(p){if(this.captured.delete(p))this.emit('lostpointercapture',{pointerId:p})}
|
||||||
|
getContext(){return new Proxy({}, {get:()=>()=>{}})}
|
||||||
|
querySelector(s){return el(this.name+' '+s)}
|
||||||
|
setAttribute(k,v){this[k]=v}
|
||||||
|
append(n){this.children.push(n)}
|
||||||
|
add(n){this.options.push(n)}
|
||||||
|
closest(){return null}
|
||||||
|
click(){if(!this.disabled)this.onclick?.({target:this,currentTarget:this})}
|
||||||
|
}
|
||||||
|
function el(n){if(!elements.has(n))elements.set(n,new Element(n));return elements.get(n)}
|
||||||
|
const document={querySelector:el,querySelectorAll:()=>[],getElementById:n=>el('#'+n),createElement:n=>new Element(n),body:el('body'),readyState:'loading',hidden:false,hasFocus:()=>true,addEventListener(n,f){listeners.set('doc:'+n,f)}};
|
||||||
|
const schedule=(fn,ms,interval=0)=>{timers.set(++id,{fn,at:now+ms,interval});return id};
|
||||||
|
const calls=[];
|
||||||
|
const context=vm.createContext({console,document,devicePixelRatio:1,performance:{now:()=>now},setTimeout:(f,ms=0)=>schedule(f,ms),clearTimeout:i=>timers.delete(i),setInterval:(f,ms)=>schedule(f,ms,ms),requestAnimationFrame:f=>schedule(()=>f(now),16),cancelAnimationFrame:i=>timers.delete(i),addEventListener(n,f){listeners.set(n,f)},Option:function(label,value){this.label=label;this.value=value},fetch:()=>Promise.reject(Error('No network in unit tests'))});
|
||||||
|
context.window=context;
|
||||||
|
context.pywebview={api:{set_axes:(...a)=>{calls.push(['axes',...a]);return Promise.resolve()},stop_controls:(...a)=>{calls.push(['stop',...a]);return Promise.resolve()},command:(name)=>{calls.push(['command',name]);return Promise.resolve(true)}}};
|
||||||
|
for(const f of ['control-state.js','joystick-3d.js','app.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'../web',f),'utf8'),context,{filename:f});
|
||||||
|
function run(s){return vm.runInContext(s,context)}
|
||||||
|
function advance(ms){const end=now+ms;while(true){let next=[...timers].filter(([,t])=>t.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!next)break;const [i,t]=next;now=t.at;timers.delete(i);if(t.interval)timers.set(i,{...t,at:now+t.interval});t.fn()}now=end;}
|
||||||
|
function state(s=armed){context.snapshot=s;run('render(snapshot)')}
|
||||||
|
return {el,run,advance,state,calls,context,fire:(name,e={})=>listeners.get(name)?.(e)};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pending/timeout/unknown presentation never invents armed state',()=>{
|
||||||
|
let p=present({...armed,pending_command:'disarm'});
|
||||||
|
assert.equal(p.label,'DISARMING…');assert.equal(p.drive,false);
|
||||||
|
p=present({...armed,command_error:'Disarm confirmation timed out.'});
|
||||||
|
assert.equal(p.label,'DISARM');assert.match(p.message,/Vehicle armed/);
|
||||||
|
p=present({...armed,armed:false,pending_command:'arm'});
|
||||||
|
assert.equal(p.label,'ENGAGING…');assert.equal(p.drive,false);
|
||||||
|
p=present({...armed,armed:false,command_error:'Engage failed'});
|
||||||
|
assert.equal(p.label,'ENGAGE');assert.equal(p.action,'arm');
|
||||||
|
p=present({...armed,connected:false,armed_known:false});
|
||||||
|
assert.equal(p.label,'STATE UNKNOWN');assert.equal(p.drive,false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activity history keeps three unique recent events, latest first',()=>{
|
||||||
|
const h=harness();h.run("recordActivity('One');recordActivity('Two');recordActivity('Three');recordActivity('Three');recordActivity('Four')");
|
||||||
|
assert.equal(h.el('#status-text').textContent,'Four');assert.equal(h.el('#activity-previous').textContent,'Three');assert.equal(h.el('#activity-oldest').textContent,'Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output limit drag is bounded, owns one pointer and does not recenter',()=>{
|
||||||
|
const h=harness(),slider=h.el('#gain');slider.min='5';slider.max='50';slider.value='20';
|
||||||
|
slider.emit('pointerdown',{pointerId:1,clientX:9});assert.equal(h.el('#gain-value').textContent,'5%');
|
||||||
|
slider.emit('pointermove',{pointerId:2,clientX:311});assert.equal(h.el('#gain-value').textContent,'5%');
|
||||||
|
slider.emit('pointermove',{pointerId:1,clientX:311});assert.equal(h.el('#gain-value').textContent,'50%');
|
||||||
|
slider.emit('pointerup');h.advance(500);assert.equal(h.el('#gain-value').textContent,'50%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maximum travel, diagonal and smooth touch return',()=>{
|
||||||
|
const h=harness();h.state();const c=h.el('#joystick-canvas');
|
||||||
|
c.emit('pointerdown',{clientX:1000,clientY:160});
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,1);
|
||||||
|
c.emit('pointermove',{clientX:1000,clientY:1000});
|
||||||
|
const s=h.context.joystick3D.getState();assert.ok(Math.abs(s.x-Math.SQRT1_2)<1e-6);assert.ok(Math.abs(s.y-Math.SQRT1_2)<1e-6);
|
||||||
|
c.emit('pointerup');h.advance(180);assert.ok(h.context.joystick3D.getState().x>0);h.advance(220);assert.equal(h.context.joystick3D.getState().x,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('independent multi-touch: second joystick finger ignored; heave continues on joystick release',()=>{
|
||||||
|
const h=harness();h.state();const c=h.el('#joystick-canvas'),v=h.el('#heave');
|
||||||
|
c.emit('pointerdown',{pointerId:1,clientX:250});
|
||||||
|
v.emit('pointerdown',{pointerId:2,clientY:0});
|
||||||
|
const x=h.context.joystick3D.getState().x;
|
||||||
|
c.emit('pointerdown',{pointerId:3,clientX:0});c.emit('pointerup',{pointerId:3});
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,x);
|
||||||
|
c.emit('pointerup',{pointerId:1});h.advance(400);
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,1);
|
||||||
|
v.emit('pointercancel',{pointerId:2});h.advance(450);assert.equal(h.context.verticalControl.getState().value,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mouse leaving joystick returns to center',()=>{
|
||||||
|
const h=harness();h.state();const c=h.el('#joystick-canvas');
|
||||||
|
c.emit('pointerdown',{pointerType:'mouse',clientX:300});c.emit('pointerleave',{pointerType:'mouse'});h.advance(400);
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focus loss clears both captured pointers and prevents replay',()=>{
|
||||||
|
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.el('#heave').emit('pointerdown',{pointerId:2,clientY:0});
|
||||||
|
h.fire('blur');h.advance(500);
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,0);
|
||||||
|
assert.equal(h.context.joystick3D.getState().active,false);assert.equal(h.context.verticalControl.getState().active,false);
|
||||||
|
assert.equal(h.run('view.drive'),false);h.fire('focus');assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lost connection during commanded joystick clears command and shows unknown',()=>{
|
||||||
|
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
|
||||||
|
h.state({...armed,connected:false,armed_known:false,app_state:'disconnected'});
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||||
|
h.state();assert.equal(h.run('control.turn'),0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('half hold cancels on touch interruption and focus loss; extra fingers cannot complete it',()=>{
|
||||||
|
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});const b=h.el('#engage');
|
||||||
|
b.emit('pointerdown');h.advance(600);b.emit('pointerdown',{pointerId:2});b.emit('pointerup',{pointerId:2});
|
||||||
|
assert.equal(h.run('engagePointer'),1);b.emit('pointercancel');h.advance(1500);assert.equal(h.calls.length,0);
|
||||||
|
b.emit('pointerdown');h.advance(600);h.fire('blur');h.advance(1500);assert.equal(h.calls.length,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demo full hold waits for simulated confirmation; release cannot immediately disarm',()=>{
|
||||||
|
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
|
||||||
|
b.emit('pointerdown');h.advance(1450);assert.equal(h.el('#engage-label').textContent,'engaging…');assert.equal(h.run('demoState.armed'),false);
|
||||||
|
b.emit('pointerup');b.click();h.advance(700);assert.equal(h.el('#engage-label').textContent,'disengage');
|
||||||
|
b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');assert.equal(h.run('demoState.armed'),true);
|
||||||
|
h.advance(700);assert.equal(h.el('#engage-label').textContent,'engage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real send result and accepted ACK cannot optimistically arm; timeout is visible',async()=>{
|
||||||
|
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});
|
||||||
|
h.run("requestControl('arm')");await Promise.resolve();assert.equal(h.el('#engage-label').textContent,'engaging…');
|
||||||
|
assert.equal(h.run('vehicleSnapshot.armed'),false);h.advance(6100);
|
||||||
|
assert.equal(h.el('#engage-label').textContent,'engage');assert.match(h.el('#control-status').textContent,/timed out/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bridge freeze clears input even if armed snapshot has not changed',()=>{
|
||||||
|
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.run('markBridgeStale()');
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||||
|
assert.match(h.el('#control-status').textContent,/Telemetry unavailable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hung bridge poll has an independent watchdog and cannot trap the controls',async()=>{
|
||||||
|
const h=harness();h.state();h.context.pywebview.api.poll=()=>new Promise(()=>{});
|
||||||
|
h.run('bridgeReady=true;lastBridgeAt=performance.now();poll()');
|
||||||
|
h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
|
||||||
|
h.advance(1700);await Promise.resolve();
|
||||||
|
assert.equal(h.run('view.drive'),false);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holding through confirmation then releasing outside cannot swallow the next disarm tap',()=>{
|
||||||
|
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
|
||||||
|
b.emit('pointerdown');h.advance(2300);assert.equal(h.el('#engage-label').textContent,'disengage');assert.ok(b.captured.has(1));
|
||||||
|
b.emit('pointerup',{clientX:500});h.advance(1);b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""No serial port is opened by these tests."""
|
||||||
|
import queue
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from app import Axes, MavlinkWorker, NEUTRAL_Z
|
||||||
|
from vehicle_state import VehicleState
|
||||||
|
from web_ui import ControlApi
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleStateTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now = 10.0
|
||||||
|
self.state = VehicleState(lambda: self.now)
|
||||||
|
self.state.heartbeat(False)
|
||||||
|
|
||||||
|
def test_ack_is_not_armed_confirmation(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.now += .2
|
||||||
|
self.state.ack("MAV_RESULT_ACCEPTED")
|
||||||
|
self.assertFalse(self.state.snapshot()["armed"])
|
||||||
|
self.assertEqual(self.state.snapshot()["pending_command"], "arm")
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.assertTrue(self.state.snapshot()["controls_allowed"])
|
||||||
|
|
||||||
|
def test_heartbeat_confirms_even_when_ack_packet_is_lost(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.now += .5
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.assertIsNone(self.state.pending)
|
||||||
|
self.assertTrue(self.state.armed)
|
||||||
|
|
||||||
|
def test_old_events_cannot_confirm_new_request(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.state.ack("MAV_RESULT_DENIED", received_at=9)
|
||||||
|
self.state.heartbeat(True, received_at=9)
|
||||||
|
self.assertIsNotNone(self.state.pending)
|
||||||
|
self.assertFalse(self.state.armed)
|
||||||
|
|
||||||
|
def test_rejected_engage_stays_disarmed(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.now += .1
|
||||||
|
self.state.ack("MAV_RESULT_DENIED")
|
||||||
|
self.assertIsNone(self.state.pending)
|
||||||
|
self.assertIn("rejected", self.state.error)
|
||||||
|
self.assertFalse(self.state.armed)
|
||||||
|
|
||||||
|
def test_engage_timeout_then_late_armed_heartbeat_is_truth(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.now += 6
|
||||||
|
self.state.tick()
|
||||||
|
self.assertIn("timed out", self.state.error)
|
||||||
|
self.assertFalse(self.state.snapshot()["armed_known"])
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.assertTrue(self.state.snapshot()["armed"])
|
||||||
|
|
||||||
|
def test_disarm_timeout_never_claims_disarmed(self):
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.state.request("disarm")
|
||||||
|
self.now += 6
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
snap = self.state.snapshot()
|
||||||
|
self.assertTrue(snap["armed"])
|
||||||
|
self.assertIn("timed out", snap["command_error"])
|
||||||
|
|
||||||
|
def test_disarm_ack_waits_for_disarmed_heartbeat(self):
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.state.request("disarm")
|
||||||
|
self.now += .2
|
||||||
|
self.state.ack("MAV_RESULT_ACCEPTED")
|
||||||
|
self.assertTrue(self.state.snapshot()["armed"])
|
||||||
|
self.assertFalse(self.state.snapshot()["controls_allowed"])
|
||||||
|
self.state.heartbeat(False)
|
||||||
|
self.assertIsNone(self.state.pending)
|
||||||
|
self.assertFalse(self.state.armed)
|
||||||
|
|
||||||
|
def test_disconnect_preserves_last_known_armed_but_invalidates_it(self):
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.state.disconnect()
|
||||||
|
snap = self.state.snapshot()
|
||||||
|
self.assertTrue(snap["armed"])
|
||||||
|
self.assertFalse(snap["armed_known"])
|
||||||
|
self.assertFalse(snap["controls_allowed"])
|
||||||
|
|
||||||
|
def test_frozen_telemetry_blocks_new_engage(self):
|
||||||
|
self.now += 3.1
|
||||||
|
self.assertFalse(self.state.snapshot()["armed_known"])
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.state.request("arm")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerWatchdogTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.worker = MavlinkWorker(queue.Queue())
|
||||||
|
self.worker.require_input_lease = True
|
||||||
|
self.worker.connection = Mock()
|
||||||
|
self.worker.target_system = 1
|
||||||
|
self.worker.armed = True
|
||||||
|
self.worker.last_heartbeat = 10
|
||||||
|
|
||||||
|
def assertNeutral(self):
|
||||||
|
self.worker._send_control()
|
||||||
|
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args,
|
||||||
|
(1, 0, 0, NEUTRAL_Z, 0, 0))
|
||||||
|
|
||||||
|
def test_ui_freeze_neutralizes_and_does_not_replay(self):
|
||||||
|
with patch("app.time.monotonic", return_value=10):
|
||||||
|
self.worker.set_axes(Axes(forward=1), 1)
|
||||||
|
self.worker._send_control()
|
||||||
|
self.assertGreater(self.worker.output_axes.forward, 0)
|
||||||
|
with patch("app.time.monotonic", return_value=11.1):
|
||||||
|
self.assertNeutral()
|
||||||
|
self.assertEqual(self.worker.axes, Axes())
|
||||||
|
|
||||||
|
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
|
||||||
|
with patch("app.time.monotonic", return_value=13.1):
|
||||||
|
self.worker.set_axes(Axes(forward=1), 1)
|
||||||
|
self.assertNeutral()
|
||||||
|
|
||||||
|
def test_stale_queued_input_cannot_override_neutral(self):
|
||||||
|
self.worker.set_axes(Axes(), 20)
|
||||||
|
self.worker.set_axes(Axes(forward=1), 19)
|
||||||
|
self.assertEqual(self.worker.axes, Axes())
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeTests(unittest.TestCase):
|
||||||
|
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
|
||||||
|
api = ControlApi()
|
||||||
|
api.worker.connection = Mock()
|
||||||
|
api.worker.target_system = 1
|
||||||
|
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
|
||||||
|
api.worker.set_axes(Axes(), 20)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "newer input reset"):
|
||||||
|
api.command("arm", 19)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "expired"):
|
||||||
|
api.command("arm", 21, expires_at=0)
|
||||||
|
api.worker.connection.mav.command_long_send.assert_not_called()
|
||||||
|
|
||||||
|
def test_real_bridge_ack_pending_disconnect_and_stale_battery(self):
|
||||||
|
api = ControlApi()
|
||||||
|
api.worker.connection = Mock()
|
||||||
|
api.worker.target_system = 1
|
||||||
|
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
|
||||||
|
pending = api.command("arm")
|
||||||
|
self.assertEqual(pending["pending_command"], "arm")
|
||||||
|
self.assertFalse(pending["armed"])
|
||||||
|
api._consume(("ack", 400, "MAV_RESULT_ACCEPTED"))
|
||||||
|
self.assertFalse(api._snapshot()["armed"])
|
||||||
|
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", True))
|
||||||
|
self.assertTrue(api._snapshot()["armed"])
|
||||||
|
api._consume(("disconnected", "Disconnected"))
|
||||||
|
self.assertTrue(api._snapshot()["armed"])
|
||||||
|
self.assertFalse(api._snapshot()["armed_known"])
|
||||||
|
self.assertTrue(api._snapshot()["battery_stale"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Vehicle truth and command transactions; independent of UI/animation clocks."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleState:
|
||||||
|
HEARTBEAT_TIMEOUT = 3.0
|
||||||
|
COMMAND_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
def __init__(self, clock=time.monotonic):
|
||||||
|
self.clock = clock
|
||||||
|
self.connected = False
|
||||||
|
self.armed = None
|
||||||
|
self.heartbeat_at = None
|
||||||
|
self.pending = None
|
||||||
|
self.error = ""
|
||||||
|
|
||||||
|
def fresh(self):
|
||||||
|
return (self.connected and self.heartbeat_at is not None
|
||||||
|
and self.clock() - self.heartbeat_at <= self.HEARTBEAT_TIMEOUT)
|
||||||
|
|
||||||
|
def heartbeat(self, armed, received_at=None):
|
||||||
|
received_at = self.clock() if received_at is None else received_at
|
||||||
|
if self.heartbeat_at is not None and received_at < self.heartbeat_at:
|
||||||
|
return
|
||||||
|
self.connected = True
|
||||||
|
self.heartbeat_at = received_at
|
||||||
|
self.armed = bool(armed)
|
||||||
|
# A fresh heartbeat is the authoritative confirmation of actual state.
|
||||||
|
# ACK ACCEPTED alone only confirms that a command was accepted.
|
||||||
|
if (self.pending and received_at > self.pending["started"]
|
||||||
|
and self.armed == (self.pending["name"] == "arm")):
|
||||||
|
self.pending = None
|
||||||
|
|
||||||
|
def request(self, name):
|
||||||
|
self.tick()
|
||||||
|
if name == "arm" and (not self.fresh() or self.armed is not False):
|
||||||
|
raise RuntimeError("Fresh, disarmed vehicle telemetry is required to engage")
|
||||||
|
if self.pending and (name == "arm" or self.pending["name"] == name):
|
||||||
|
raise RuntimeError("A vehicle confirmation is already pending")
|
||||||
|
now = self.clock()
|
||||||
|
self.pending = {"name": name, "started": now,
|
||||||
|
"deadline": now + self.COMMAND_TIMEOUT, "ack": None}
|
||||||
|
self.error = ""
|
||||||
|
|
||||||
|
def ack(self, result, received_at=None):
|
||||||
|
received_at = self.clock() if received_at is None else received_at
|
||||||
|
if not self.pending or received_at <= self.pending["started"]:
|
||||||
|
return
|
||||||
|
self.pending["ack"] = result
|
||||||
|
if result not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"):
|
||||||
|
self.fail(f"{self.pending['name'].capitalize()} rejected: {result}")
|
||||||
|
|
||||||
|
def fail(self, message):
|
||||||
|
self.error = message
|
||||||
|
self.pending = None
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.connected = False
|
||||||
|
if self.pending:
|
||||||
|
self.fail("Connection lost before vehicle confirmation. State unknown.")
|
||||||
|
|
||||||
|
def tick(self):
|
||||||
|
if self.pending and self.clock() >= self.pending["deadline"]:
|
||||||
|
name = self.pending["name"].capitalize()
|
||||||
|
self.fail(f"{name} confirmation timed out. Check current vehicle state.")
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
self.tick()
|
||||||
|
fresh = self.fresh()
|
||||||
|
return {"armed": self.armed, "armed_known": fresh,
|
||||||
|
"heartbeat_age": None if self.heartbeat_at is None else self.clock() - self.heartbeat_at,
|
||||||
|
"pending_command": self.pending["name"] if self.pending else None,
|
||||||
|
"command_ack": self.pending["ack"] if self.pending else None,
|
||||||
|
"command_error": self.error,
|
||||||
|
"controls_allowed": fresh and self.armed is True and self.pending is None}
|
||||||
+205
-66
@@ -6,11 +6,25 @@ 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}, keys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||||
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,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
|
const activity=[];
|
||||||
|
let lastReportedStatus='';
|
||||||
|
function recordActivity(message){
|
||||||
|
if(!message||activity[0]===message)return;
|
||||||
|
activity.unshift(String(message));activity.length=Math.min(activity.length,3);
|
||||||
|
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
|
||||||
|
const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})}
|
||||||
|
let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true,
|
||||||
|
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
|
||||||
|
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
|
||||||
|
const canDrive=()=>view.drive&&performance.now()-lastSafetyTick<750;
|
||||||
|
window.canDrive=canDrive;
|
||||||
|
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
|
||||||
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
||||||
addEventListener('pointermove',e=>{const button=e.target.closest?.('button:not(.battery-card), .stick');if(!button)return;const rect=button.getBoundingClientRect();button.style.setProperty('--glow-x',`${e.clientX-rect.left}px`);button.style.setProperty('--glow-y',`${e.clientY-rect.top}px`)});
|
|
||||||
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||||
window.showToast=toast;
|
window.showToast=toast;
|
||||||
@@ -51,23 +65,51 @@ function populatePorts(ports){
|
|||||||
p.value=allList[0].device;
|
p.value=allList[0].device;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function sendAxes(){if(appState!=='armed')return;if(demoActive){demoState.status=`Demo controls • surge ${control.forward.toFixed(2)} • yaw ${control.turn.toFixed(2)} • heave ${control.vertical.toFixed(2)}`;$('#status-text').textContent=demoState.status;return}if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
function sendAxes(){
|
||||||
function updateReadouts(){ if(appState!=='armed'){$('#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);}
|
if(!canDrive()||demoActive||!bridgeReady||axisBusy)return;
|
||||||
|
const transmit=()=>{
|
||||||
|
axisTimer=0;
|
||||||
|
if(!canDrive()||demoActive||axisBusy)return;
|
||||||
|
lastAxisSend=performance.now();axisBusy=true;
|
||||||
|
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence))
|
||||||
|
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
|
||||||
|
};
|
||||||
|
const wait=50-(performance.now()-lastAxisSend);
|
||||||
|
if(wait<=0)transmit();else if(!axisTimer)axisTimer=setTimeout(transmit,wait);
|
||||||
|
}
|
||||||
|
// This is a local bridge lease, not extra MAVLink traffic. The worker retains
|
||||||
|
// the configured wire rate and neutralizes if the UI stops renewing its input.
|
||||||
|
setInterval(()=>{if(canDrive())sendAxes()},250);
|
||||||
|
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||||
|
|
||||||
window.setJoyControl=(x,y)=>{if(appState!=='armed')return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||||
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||||
|
|
||||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
|
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
|
||||||
function setHeave(v,send=true){if(appState!=='armed'&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&appState==='armed')sendAxes();}
|
function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();}
|
||||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||||
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
|
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
|
||||||
heave.addEventListener('pointerdown',e=>{if(appState!=='armed'||heavePointer!==null)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
|
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
|
||||||
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||||
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(appState!=='armed'){keys.clear();window.joystick3D?.easeBack();if(heavePointer===null)easeHeave();return;}const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(heavePointer===null)easeHeave();}
|
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else easeHeave()}}
|
||||||
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(appState==='connected_disarmed'&&!e.repeat)startEngageHold();return;}if(e.key==='F3'){e.preventDefault();if(appState==='armed'&&!e.repeat)engageButton?.click();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)){if(appState!=='armed')return;if(!keys.has(k)){keys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys();});
|
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||||
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(heavePointer===null)easeHeave()});
|
function resetInputs(){
|
||||||
|
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||||
|
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
|
||||||
|
window.joystick3D?.reset();
|
||||||
|
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
|
||||||
|
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
|
||||||
|
hknob.classList.remove('pressed','released');
|
||||||
|
control.forward=control.turn=control.vertical=0;setHeave(0,false);
|
||||||
|
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
|
||||||
|
}
|
||||||
|
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
|
||||||
|
addEventListener('blur',()=>focusChanged(false));
|
||||||
|
addEventListener('focus',()=>focusChanged(!document.hidden));
|
||||||
|
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
|
||||||
|
addEventListener('pagehide',()=>focusChanged(false));
|
||||||
|
|
||||||
function openModal(id){
|
function openModal(id){
|
||||||
const el=typeof id==='string'?document.getElementById(id):id;
|
const el=typeof id==='string'?document.getElementById(id):id;
|
||||||
@@ -166,7 +208,14 @@ if(rovStage){
|
|||||||
if(file)handleStlFile(file);
|
if(file)handleStlFile(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)populatePorts(await api('refresh_ports'))};
|
$('#refresh').onclick=async e=>{
|
||||||
|
const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);
|
||||||
|
try{
|
||||||
|
if(typeof window.pywebview?.api?.refresh_ports!=='function')throw new Error('Desktop connection is not ready. Open the app using run.ps1.');
|
||||||
|
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||||
|
if(!booted)boot();
|
||||||
|
}catch(error){recordActivity(`Port refresh failed: ${error.message}`);toast(error.message,true)}
|
||||||
|
};
|
||||||
$('#connect').onclick=async()=>{
|
$('#connect').onclick=async()=>{
|
||||||
const btn=$('#connect');
|
const btn=$('#connect');
|
||||||
const label=btn.querySelector('.btn-label')||btn;
|
const label=btn.querySelector('.btn-label')||btn;
|
||||||
@@ -174,6 +223,7 @@ $('#connect').onclick=async()=>{
|
|||||||
const port=p?p.value:'';
|
const port=p?p.value:'';
|
||||||
|
|
||||||
if(port==='demo'||demoActive){
|
if(port==='demo'||demoActive){
|
||||||
|
resetInputs();clearTimeout(demoCommandTimer);requestSerial++;localRequest=null;commandError='';bridgeStale=false;
|
||||||
if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')}
|
if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')}
|
||||||
else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')}
|
else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')}
|
||||||
return;
|
return;
|
||||||
@@ -186,6 +236,7 @@ $('#connect').onclick=async()=>{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(appState!=='disconnected'){
|
if(appState!=='disconnected'){
|
||||||
|
resetInputs();
|
||||||
label.textContent='Disconnecting…';
|
label.textContent='Disconnecting…';
|
||||||
try{
|
try{
|
||||||
await api('disconnect');
|
await api('disconnect');
|
||||||
@@ -201,17 +252,17 @@ $('#connect').onclick=async()=>{
|
|||||||
btn.classList.add('is-connecting');
|
btn.classList.add('is-connecting');
|
||||||
label.textContent='Connecting…';
|
label.textContent='Connecting…';
|
||||||
const statusEl=$('#status-text');
|
const statusEl=$('#status-text');
|
||||||
if(statusEl)statusEl.textContent=`Opening ${port}…`;
|
recordActivity(`Connecting to ${port}…`);
|
||||||
toast(`Connecting to ${port}…`);
|
toast(`Connecting to ${port}…`);
|
||||||
try{
|
try{
|
||||||
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
|
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
|
||||||
if(confirmedState)render(confirmedState);
|
if(confirmedState)render(confirmedState);
|
||||||
toast('MAVLink connected');
|
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
|
||||||
}catch(e){
|
}catch(e){
|
||||||
btn.classList.remove('is-connecting');
|
btn.classList.remove('is-connecting');
|
||||||
label.textContent='Connect';
|
label.textContent='Connect';
|
||||||
toast(e.message,true);
|
toast(e.message,true);
|
||||||
if(statusEl)statusEl.textContent=`Connection error: ${e.message}`;
|
recordActivity(`Connection error: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -240,27 +291,69 @@ const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
|
|||||||
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
|
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
|
||||||
const ENGAGE_HOLD_MS=1400;
|
const ENGAGE_HOLD_MS=1400;
|
||||||
|
|
||||||
function isManualMode(s){
|
|
||||||
const m=(s&&s.mode)?String(s.mode).toUpperCase():'';
|
|
||||||
return m==='MANUAL';
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyEngageVisual(next){
|
function applyEngageVisual(next){
|
||||||
if(!engageButton||engageVisualState===next)return;
|
if(!engageButton)return;
|
||||||
const previous=engageVisualState;engageVisualState=next;clearTimeout(engageTransitionTimer);
|
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||||
const commit=()=>{engageButton.classList.remove('arm','danger','subtle','charged');engageButton.classList.add(next==='disarm'?'danger':next==='engage'?'arm':'subtle');engageButton.disabled=next==='disabled';const label=next==='disarm'?'DISARM':'ENGAGE';$('#engage-label').textContent=label;if(engageProgressLabel)engageProgressLabel.textContent=label;engageButton.title=next==='disarm'?'Disarm immediately (F3)':next==='engage'?'Hold to Engage (F2)':'Engage';engageButton.setAttribute('aria-label',label)};
|
// Interaction updates immediately; the short fade never gates emergency access.
|
||||||
if((previous==='engage'&&next==='disarm')||(previous==='disarm'&&next==='engage')){engageButton.classList.add('state-transition');engageTransitionTimer=setTimeout(()=>{commit();engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),150)},120)}else commit();
|
engageButton.disabled=!next.action;
|
||||||
|
engageButton.setAttribute('aria-busy',String(next.pending));
|
||||||
|
engageButton.setAttribute('aria-label',label);
|
||||||
|
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message;
|
||||||
|
const key=`${next.label}/${next.tone}`;
|
||||||
|
if(engageVisualState===key)return;
|
||||||
|
const previous=engageVisualState;engageVisualState=key;
|
||||||
|
clearTimeout(engageTransitionTimer);
|
||||||
|
engageButton.classList.remove('arm','danger','subtle','charged','pending');
|
||||||
|
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
|
||||||
|
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%');
|
||||||
|
$('#engage-label').textContent=label;
|
||||||
|
if(engageProgressLabel)engageProgressLabel.textContent=label;
|
||||||
|
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
|
||||||
|
if(previous){
|
||||||
|
engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
|
||||||
|
engageButton.classList.add('state-transition');
|
||||||
|
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function completeEngage(){
|
||||||
|
const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%');
|
||||||
|
requestControl('arm');
|
||||||
|
}
|
||||||
|
async function requestControl(name){
|
||||||
|
if((name==='arm'&&view.action!=='arm')||(name==='disarm'&&view.action!=='disarm'))return;
|
||||||
|
const id=++requestSerial;
|
||||||
|
commandError='';localRequest={name,deadline:performance.now()+6000,sent:false};
|
||||||
|
recordActivity(name==='arm'?'Engage requested':'Disengage requested');
|
||||||
|
resetInputs();render(vehicleSnapshot);
|
||||||
|
try{
|
||||||
|
if(demoActive){
|
||||||
|
// Separate simulated request and simulated heartbeat, just like the real UI.
|
||||||
|
clearTimeout(demoCommandTimer);
|
||||||
|
updateDemo({pending_command:name,command_error:''});
|
||||||
|
demoCommandTimer=setTimeout(()=>{
|
||||||
|
if(!demoActive||id!==requestSerial)return;
|
||||||
|
localRequest=null;
|
||||||
|
updateDemo({pending_command:null,armed:name==='arm',armed_known:true,
|
||||||
|
app_state:name==='arm'?'armed':'connected_disarmed',
|
||||||
|
vehicle:`DEMO • ${name==='arm'?'ARMED':'DISARMED'}`,status:`Demo: ${name} confirmed`});
|
||||||
|
},650);
|
||||||
|
}else{
|
||||||
|
await api('command',name,++inputSequence,Date.now()+2000);
|
||||||
|
if(id===requestSerial&&localRequest)localRequest.sent=true;
|
||||||
|
// Only poll snapshots may confirm state; a send result is not confirmation.
|
||||||
|
}
|
||||||
|
}catch(error){
|
||||||
|
if(id!==requestSerial)return;
|
||||||
|
localRequest=null;commandError=`${name==='arm'?'Engage':'Disarm'} request failed: ${error.message}`;
|
||||||
|
render(vehicleSnapshot);toast(commandError,true);
|
||||||
}
|
}
|
||||||
async function completeEngage(){
|
|
||||||
const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%');
|
|
||||||
try{if(demoActive){updateDemo({armed:true,app_state:'armed',vehicle:'DEMO • ARMED',status:'Demo: Arm confirmed'});toast('Demo vehicle armed')}else{await api('command','arm');toast('Engage command sent');setTimeout(()=>{if(appState!=='armed'){engageButton.classList.remove('charged');engageButton.style.setProperty('--engage-progress','0%')}},2500)}}catch(error){engageButton.classList.remove('charged');engageButton.style.setProperty('--engage-progress','0%');toast(error.message,true)}
|
|
||||||
}
|
}
|
||||||
function updateEngageProgress(now){
|
function updateEngageProgress(now){
|
||||||
if(!engageStart||appState!=='connected_disarmed'||!engageButton?.classList.contains('arm'))return cancelEngageHold(false);
|
if(!engageStart||view.action!=='arm'||!inputFocused)return cancelEngageHold(false);
|
||||||
const progress=Math.min(1,(now-engageStart)/ENGAGE_HOLD_MS);engageButton.style.setProperty('--engage-progress',`${progress*100}%`);if(progress>=1)return void completeEngage();engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
const progress=Math.min(1,(now-engageStart)/ENGAGE_HOLD_MS);engageButton.style.setProperty('--engage-progress',`${progress*100}%`);if(progress>=1)return void completeEngage();engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||||
}
|
}
|
||||||
function startEngageHold(e){
|
function startEngageHold(e){
|
||||||
if(appState!=='connected_disarmed'||!engageButton?.classList.contains('arm')||engageStart)return;
|
if(view.action!=='arm'||!inputFocused||engageStart)return;
|
||||||
if(e&&e.button!==undefined&&e.button!==0)return;
|
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||||
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
|
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
|
||||||
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
|
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
|
||||||
@@ -268,39 +361,51 @@ function startEngageHold(e){
|
|||||||
}
|
}
|
||||||
function cancelEngageHold(showHint=true){
|
function cancelEngageHold(showHint=true){
|
||||||
if(!engageStart)return;
|
if(!engageStart)return;
|
||||||
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.style.setProperty('--engage-progress','0%');if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Keep holding to engage');
|
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
|
||||||
}
|
}
|
||||||
|
|
||||||
if(engageButton){
|
if(engageButton){
|
||||||
engageButton.addEventListener('pointerdown',startEngageHold);
|
engageButton.addEventListener('pointerdown',startEngageHold);
|
||||||
engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;try{engageButton.releasePointerCapture(e.pointerId)}catch(_){}suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
||||||
engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
||||||
engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
|
engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
|
||||||
|
engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null});
|
||||||
|
engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)});
|
||||||
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
|
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
|
||||||
|
|
||||||
engageButton.onclick=async()=>{
|
engageButton.onclick=()=>{
|
||||||
if(suppressEngageClick){suppressEngageClick=false;return}
|
if(suppressEngageClick){suppressEngageClick=false;return}
|
||||||
if(appState==='armed'){
|
if(view.action==='disarm')requestControl('disarm');
|
||||||
try{
|
|
||||||
if(demoActive)updateDemo({armed:false,app_state:'connected_disarmed',vehicle:'DEMO • DISARMED',status:'Demo: Disarm confirmed'});
|
|
||||||
else await api('command','disarm');
|
|
||||||
toast('Disarm command sent');
|
|
||||||
}catch(e){
|
|
||||||
toast(e.message,true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#logs').onclick=()=>api('open_logs');
|
$('#logs').onclick=()=>api('open_logs');
|
||||||
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||||
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||||
|
// Explicit pointer ownership makes the full range rail a reliable touch target
|
||||||
|
// in the embedded window, without depending on native range drag defaults.
|
||||||
|
const gainSlider=$('#gain');let gainPointer=null;
|
||||||
|
function gainFromPointer(e){const r=gainSlider.getBoundingClientRect(),fraction=Math.max(0,Math.min(1,(e.clientX-r.left-9)/Math.max(1,r.width-18)));setGain(Math.round(+gainSlider.min+fraction*(+gainSlider.max-+gainSlider.min)))}
|
||||||
|
function releaseGain(e){if(e.pointerId!==gainPointer)return;gainPointer=null;try{gainSlider.releasePointerCapture(e.pointerId)}catch(_){}}
|
||||||
|
gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)return;gainPointer=e.pointerId;gainSlider.setPointerCapture(e.pointerId);gainFromPointer(e);e.preventDefault()});
|
||||||
|
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
|
||||||
|
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
|
||||||
|
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
|
||||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
|
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
|
||||||
function render(s){
|
function render(s,confirmed=false){
|
||||||
appState=s.app_state||'disconnected';
|
vehicleSnapshot=s;
|
||||||
|
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
|
||||||
|
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
|
||||||
|
const previous=view;
|
||||||
|
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||||
|
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||||
|
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
||||||
document.body.dataset.appState=appState;
|
document.body.dataset.appState=appState;
|
||||||
|
document.body.dataset.controlsEnabled=String(view.drive);
|
||||||
|
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||||
|
$('#port').disabled=!!s.connected;
|
||||||
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||||
const connectBtn=$('#connect');
|
const connectBtn=$('#connect');
|
||||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||||
@@ -324,24 +429,26 @@ function render(s){
|
|||||||
const engageBtn=$('#engage');
|
const engageBtn=$('#engage');
|
||||||
const engageLabel=$('#engage-label');
|
const engageLabel=$('#engage-label');
|
||||||
if(engageBtn&&engageLabel){
|
if(engageBtn&&engageLabel){
|
||||||
applyEngageVisual(appState==='armed'?'disarm':appState==='connected_disarmed'?'engage':'disabled');
|
applyEngageVisual(view);
|
||||||
}
|
}
|
||||||
|
const controlStatus=$('#control-status');
|
||||||
|
controlStatus.textContent=view.message;
|
||||||
|
controlStatus.classList.toggle('is-error',view.error);
|
||||||
|
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
|
||||||
const motorBtn=$('[data-open="motors-modal"]');
|
const motorBtn=$('[data-open="motors-modal"]');
|
||||||
if(motorBtn)motorBtn.disabled=(!isOnline);
|
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||||
|
|
||||||
const statusEl=$('#status-text');
|
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
|
||||||
if(statusEl){
|
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
|
||||||
if(s.status){
|
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
|
||||||
statusEl.textContent=s.status;
|
if(confirmedCommand){
|
||||||
}else{
|
recordActivity(s.armed?'Engage confirmed':'Disengage confirmed');
|
||||||
statusEl.textContent=STATUS_LABELS[appState]||STATUS_LABELS.disconnected;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
updateReadouts();
|
updateReadouts();
|
||||||
const b=s.battery;
|
const b=s.battery;
|
||||||
if(b){
|
if(b){
|
||||||
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
||||||
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s`;
|
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
|
||||||
$('#battery-fill').style.width=`${b.percent}%`;
|
$('#battery-fill').style.width=`${b.percent}%`;
|
||||||
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
|
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
|
||||||
}else{
|
}else{
|
||||||
@@ -349,10 +456,31 @@ function render(s){
|
|||||||
$('#battery-percent').textContent='waiting';
|
$('#battery-percent').textContent='waiting';
|
||||||
$('#battery-fill').style.width='0';
|
$('#battery-fill').style.width='0';
|
||||||
}
|
}
|
||||||
renderMotors(s.servos);
|
renderMotors(s.servos||{});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll(){if(!bridgeReady)return;if(!demoActive)try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
|
function markBridgeStale(){
|
||||||
|
if(demoActive||bridgeStale)return;
|
||||||
|
bridgeStale=true;resetInputs();render(vehicleSnapshot);
|
||||||
|
}
|
||||||
|
setInterval(()=>{
|
||||||
|
const now=performance.now();
|
||||||
|
if(now-lastSafetyTick>750)resetInputs();
|
||||||
|
lastSafetyTick=now;
|
||||||
|
if(!demoActive&&bridgeReady&&vehicleSnapshot.connected&&performance.now()-lastBridgeAt>1500)markBridgeStale();
|
||||||
|
if(localRequest&&performance.now()>localRequest.deadline){
|
||||||
|
commandError=`${localRequest.name==='arm'?'Engage':'Disarm'} confirmation timed out. Check the vehicle state.`;
|
||||||
|
localRequest=null;render(vehicleSnapshot);
|
||||||
|
}
|
||||||
|
},100);
|
||||||
|
async function poll(){
|
||||||
|
if(!bridgeReady)return;
|
||||||
|
if(!demoActive)try{
|
||||||
|
const snapshot=await withTimeout(api('poll'));
|
||||||
|
if(!demoActive){lastBridgeAt=performance.now();bridgeStale=false;render(snapshot,true)}
|
||||||
|
}catch(e){markBridgeStale()}
|
||||||
|
setTimeout(poll,250);
|
||||||
|
}
|
||||||
let modelLoadPromise=null;
|
let modelLoadPromise=null;
|
||||||
async function loadDefaultModel(){
|
async function loadDefaultModel(){
|
||||||
if(modelLoadPromise)return modelLoadPromise;
|
if(modelLoadPromise)return modelLoadPromise;
|
||||||
@@ -426,35 +554,46 @@ async function loadDefaultModel(){
|
|||||||
return modelLoadPromise;
|
return modelLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
let booted=false;
|
let booted=false,booting=false,bootRetry=0,portRefreshTimer=0;
|
||||||
|
function hasDesktopApi(){return ['initial_state','refresh_ports','poll'].every(name=>typeof window.pywebview?.api?.[name]==='function')}
|
||||||
async function boot(){
|
async function boot(){
|
||||||
if(booted||!window.pywebview?.api)return;
|
if(booted||booting||!hasDesktopApi())return;
|
||||||
booted=true;
|
booting=true;clearTimeout(bootRetry);
|
||||||
bridgeReady=true;
|
|
||||||
try{
|
try{
|
||||||
const init=await api('initial_state');
|
// OS port discovery must not depend on model/config initialization succeeding.
|
||||||
batteryConfig=init.battery_config;
|
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||||
|
const init=await withTimeout(api('initial_state'),4000);
|
||||||
|
batteryConfig=init.battery_config||{};
|
||||||
populatePorts(init.ports);
|
populatePorts(init.ports);
|
||||||
fillBatteryForm();
|
fillBatteryForm();
|
||||||
render(init.state);
|
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||||
|
if(!demoActive)render(init.state);
|
||||||
poll();
|
poll();
|
||||||
// Populate the serial selector before starting the optional model load.
|
// Populate the serial selector before starting the optional model load.
|
||||||
// A large cached mesh must never delay an operator seeing USB COM ports.
|
// A large cached mesh must never delay an operator seeing USB COM ports.
|
||||||
loadDefaultModel();
|
loadDefaultModel();
|
||||||
setInterval(async()=>{
|
if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
|
||||||
if(appState==='disconnected'){
|
if(appState==='disconnected'||demoActive){
|
||||||
try{populatePorts(await api('refresh_ports'))}catch(_){}
|
try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
|
||||||
|
catch(error){recordActivity(`Port scan failed: ${error.message}`)}
|
||||||
}
|
}
|
||||||
},2000);
|
},2000);
|
||||||
}catch(e){
|
}catch(e){
|
||||||
|
booted=false;bridgeReady=false;
|
||||||
|
recordActivity(`Desktop startup failed; retrying: ${e.message}`);
|
||||||
toast(`Python bridge: ${e.message}`,true);
|
toast(`Python bridge: ${e.message}`,true);
|
||||||
|
bootRetry=setTimeout(boot,1000);
|
||||||
|
}finally{
|
||||||
|
booting=false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function waitForBridge(){
|
function waitForBridge(){
|
||||||
if(window.pywebview?.api)boot();
|
if(booted)return;
|
||||||
else setTimeout(waitForBridge,50);
|
if(hasDesktopApi())boot();
|
||||||
|
setTimeout(waitForBridge,250);
|
||||||
}
|
}
|
||||||
addEventListener('pywebviewready',boot);
|
addEventListener('pywebviewready',boot);
|
||||||
|
render(vehicleSnapshot);
|
||||||
if(document.readyState==='loading'){
|
if(document.readyState==='loading'){
|
||||||
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
|
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
|
||||||
}else{
|
}else{
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* Presentation derived from telemetry. No animation is allowed to set armed. */
|
||||||
|
((root) => {
|
||||||
|
function present(state, {stale=false, focused=true, pending=null, error=''}={}) {
|
||||||
|
const known = state.armed_known === true && !stale;
|
||||||
|
const online = !!state.connected && !stale;
|
||||||
|
const request = pending || state.pending_command;
|
||||||
|
const uncertain = !known && (state.armed !== null && state.armed !== undefined || !!request || state.heartbeat_age != null);
|
||||||
|
let label='ENGAGE', tone='idle', action=null, message='Connect to receive vehicle state.';
|
||||||
|
if (request === 'disarm') {
|
||||||
|
label='DISARMING…'; tone='danger'; message='Awaiting vehicle confirmation. Not yet confirmed disarmed.';
|
||||||
|
} else if (request === 'arm') {
|
||||||
|
label='ENGAGING…'; tone='pending'; action=online?'disarm':null;
|
||||||
|
message='Awaiting vehicle confirmation. Tap to request disarm.';
|
||||||
|
} else if (known && state.armed) {
|
||||||
|
label='DISARM'; tone='danger'; action=online?'disarm':null;
|
||||||
|
message=focused?'Armed · tap or F3 to disarm.':'Armed · controls neutral while the app is unfocused.';
|
||||||
|
} else if (uncertain) {
|
||||||
|
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
|
||||||
|
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
|
||||||
|
} else if (known && online) {
|
||||||
|
action=focused?'arm':null; message='Disarmed · hold 1.4 seconds to engage.';
|
||||||
|
}
|
||||||
|
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
|
||||||
|
const failure=error || state.command_error;
|
||||||
|
return {label,tone,action,pending:!!request,known,
|
||||||
|
drive:known && online && state.armed === true && !request && focused,
|
||||||
|
message:failure ? `${failure} ${known ? (state.armed?'Vehicle armed.':'Vehicle disarmed.') : 'Vehicle state unknown.'}` : message,
|
||||||
|
error:!!(error || state.command_error || uncertain || stale)};
|
||||||
|
}
|
||||||
|
root.ControlState={present};
|
||||||
|
if(typeof module!=='undefined') module.exports=root.ControlState;
|
||||||
|
})(typeof window!=='undefined'?window:globalThis);
|
||||||
+31
-36
@@ -4,13 +4,14 @@
|
|||||||
<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>Sub Control</title>
|
||||||
<link rel="stylesheet" href="styles.css?v=20260826-controls-v2">
|
<link rel="stylesheet" href="styles.css?v=20260827-bridge-v5">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
|
|
||||||
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
|
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
|
||||||
<aside class="sidebar-deck pywebview-drag-region" aria-label="Vehicle Deck and Status">
|
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
|
||||||
|
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
|
||||||
<!-- Connection Controls -->
|
<!-- Connection Controls -->
|
||||||
<div class="deck-connection">
|
<div class="deck-connection">
|
||||||
<div class="port-row">
|
<div class="port-row">
|
||||||
@@ -33,9 +34,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="eyebrow">live vehicle</div>
|
|
||||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||||
<div class="water-ring r1"></div><div class="water-ring r2"></div>
|
|
||||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
<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">
|
||||||
@@ -43,11 +42,6 @@
|
|||||||
<input id="model-input" type="file" accept=".stl,.STL" hidden>
|
<input id="model-input" type="file" accept=".stl,.STL" hidden>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vehicle-name">
|
|
||||||
<small>vehicle</small>
|
|
||||||
<strong>SimpleROV-3</strong>
|
|
||||||
<span>3 thrusters • differential drive</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Battery Status -->
|
<!-- Battery Status -->
|
||||||
@@ -57,27 +51,15 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="deck-status-bar">
|
<div class="deck-status-bar">
|
||||||
<div class="status-line">
|
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
|
||||||
<span id="status-dot" class="status-dot"></span>
|
<li id="activity-oldest" hidden></li>
|
||||||
<p id="status-text">Disconnected — select a port and connect</p>
|
<li id="activity-previous" hidden></li>
|
||||||
<a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">logs</a>
|
<li id="status-text">Not connected</li>
|
||||||
</div>
|
</ol>
|
||||||
|
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Output Limit & Motor Setup -->
|
|
||||||
<div class="deck-card controls-deck">
|
|
||||||
<label class="output-limit">
|
|
||||||
<span>output limit <b id="gain-value">20%</b></span>
|
|
||||||
<div class="range-pill">
|
|
||||||
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
|
|
||||||
<input id="gain" type="range" min="5" max="50" value="20">
|
|
||||||
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<button class="button subtle config-btn" data-open="motors-modal">Motor Setup</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- ── PRIMARY CONTROL ZONE ── -->
|
<!-- ── PRIMARY CONTROL ZONE ── -->
|
||||||
@@ -95,13 +77,14 @@
|
|||||||
<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">
|
||||||
<span class="zone-label">vehicle control</span>
|
<button id="engage" class="button subtle engage-button" aria-label="Engage" aria-describedby="control-status" title="Engage (F2)">
|
||||||
<button id="engage" class="button subtle engage-button" aria-label="Engage" title="Engage (F2)">
|
|
||||||
<span class="engage-face">
|
<span class="engage-face">
|
||||||
<span id="engage-label" class="engage-label">ENGAGE</span>
|
<span id="engage-label" class="engage-label">engage</span>
|
||||||
<span class="engage-progress-label" aria-hidden="true">ENGAGE</span>
|
<span class="engage-sheen-label" aria-hidden="true">engage</span>
|
||||||
|
<span class="engage-progress-label" aria-hidden="true">engage</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<p id="control-status" class="control-status" role="status" aria-live="polite">Connect to receive vehicle state.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Joysticks & Thrust Control Grid -->
|
<!-- Main Joysticks & Thrust Control Grid -->
|
||||||
@@ -109,12 +92,12 @@
|
|||||||
<!-- Steering / Throttle Joystick -->
|
<!-- Steering / Throttle Joystick -->
|
||||||
<article class="control-card steer-card">
|
<article class="control-card steer-card">
|
||||||
<header>
|
<header>
|
||||||
<div><small>steer + throttle</small><strong>differential drive</strong></div>
|
<div><small>steer + throttle</small></div>
|
||||||
<span class="key-hint">w a s d</span>
|
<span class="key-hint">w a s d</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="joystick-pad">
|
<div class="joystick-pad">
|
||||||
<div id="joystick" class="joystick" touch-action="none">
|
<div id="joystick" class="joystick" touch-action="none">
|
||||||
<canvas id="joystick-canvas" aria-label="3D Interactive Spherical Joystick"></canvas>
|
<canvas id="joystick-canvas" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
@@ -126,7 +109,7 @@
|
|||||||
<!-- Vertical Thruster Slider -->
|
<!-- Vertical Thruster Slider -->
|
||||||
<article class="control-card vertical-card">
|
<article class="control-card vertical-card">
|
||||||
<header>
|
<header>
|
||||||
<div><small>vertical</small><strong>center thruster</strong></div>
|
<div><small>vertical</small></div>
|
||||||
<span class="key-hint">r / f</span>
|
<span class="key-hint">r / f</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="heave-pad">
|
<div class="heave-pad">
|
||||||
@@ -145,6 +128,17 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-toolbar">
|
||||||
|
<label class="output-limit">
|
||||||
|
<span>Output limit <b id="gain-value">20%</b></span>
|
||||||
|
<div class="range-pill">
|
||||||
|
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
|
||||||
|
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
|
||||||
|
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -157,7 +151,8 @@
|
|||||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||||
|
|
||||||
<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=20260826-controls-v2"></script>
|
<script src="joystick-3d.js?v=20260827-polish-v3"></script>
|
||||||
<script src="app.js?v=20260826-controls-v2"></script>
|
<script src="control-state.js?v=20260827-state-v1"></script>
|
||||||
|
<script src="app.js?v=20260827-bridge-v6"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
|
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
|
||||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
|
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
|
||||||
|
|
||||||
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eef1ee';ctx.fill();ctx.strokeStyle='#cfd5d1';ctx.lineWidth=1.5;ctx.stroke();
|
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eee';ctx.fill();ctx.strokeStyle='#d3d3d3';ctx.lineWidth=1.5;ctx.stroke();
|
||||||
ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
|
ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
|
||||||
|
|
||||||
ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
|
ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
|
||||||
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
|
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
|
||||||
|
|
||||||
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
||||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#4d5652':'#303936';ctx.fill();ctx.restore();
|
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore();
|
||||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
|
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
|
||||||
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
|
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
|
||||||
}
|
}
|
||||||
@@ -33,10 +33,10 @@
|
|||||||
function returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
|
function returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
|
||||||
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
|
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
|
||||||
|
|
||||||
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
||||||
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
|
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
|
||||||
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
|
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
|
||||||
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
|
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
|
||||||
addEventListener('resize',resize);resize();
|
addEventListener('resize',resize);resize();
|
||||||
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},getState(){return {x,y,active:activePointer!==null}}};
|
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+126
-19
@@ -118,7 +118,7 @@ body.maximized .app-shell {
|
|||||||
transition: transform 0.15s, background 0.15s, border-color 0.15s;
|
transition: transform 0.15s, background 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
.button:hover, .icon-button:hover {
|
.button:hover, .icon-button:hover {
|
||||||
transform: translateY(-1px);
|
transform: none;
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
background: #293237;
|
background: #293237;
|
||||||
}
|
}
|
||||||
@@ -500,15 +500,15 @@ body[data-app-state="connecting"] .status-line .status-dot {
|
|||||||
}
|
}
|
||||||
body[data-app-state="connected_disarmed"] .status-line .status-dot {
|
body[data-app-state="connected_disarmed"] .status-line .status-dot {
|
||||||
background: var(--cyan);
|
background: var(--cyan);
|
||||||
box-shadow: 0 0 6px rgba(114, 216, 232, 0.6);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
body[data-app-state="armed"] .status-line .status-dot {
|
body[data-app-state="armed"] .status-line .status-dot {
|
||||||
background: var(--acid);
|
background: var(--acid);
|
||||||
box-shadow: 0 0 6px rgba(201, 242, 119, 0.65);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
body[data-app-state="link_degraded"] .status-line .status-dot {
|
body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||||
background: var(--red);
|
background: var(--red);
|
||||||
box-shadow: 0 0 6px rgba(240, 149, 139, 0.65);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.status-line p {
|
.status-line p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -603,10 +603,11 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
|
|
||||||
/* ── Engage / Disarm Slot Above Joysticks ── */
|
/* ── Engage / Disarm Slot Above Joysticks ── */
|
||||||
.engage-header-zone {
|
.engage-header-zone {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-height: 42px;
|
min-height: 76px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.zone-label {
|
.zone-label {
|
||||||
@@ -617,13 +618,13 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
}
|
}
|
||||||
@property --engage-progress {
|
@property --engage-progress {
|
||||||
syntax: "<percentage>";
|
syntax: "<percentage>";
|
||||||
inherits: false;
|
inherits: true;
|
||||||
initial-value: 0%;
|
initial-value: 0%;
|
||||||
}
|
}
|
||||||
.engage-button {
|
.engage-button {
|
||||||
--engage-progress: 0%;
|
--engage-progress: 0%;
|
||||||
min-width: 166px;
|
min-width: 166px;
|
||||||
height: 38px;
|
height: 44px;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -653,15 +654,26 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
}
|
}
|
||||||
.arm:not(.charging):not(.state-transition) .engage-label { animation: engage-shimmer 2.8s linear infinite; }
|
.arm:not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-label { animation: engage-shimmer 4.8s linear infinite; }
|
||||||
.engage-progress-label {
|
.engage-progress-label {
|
||||||
color: #a9ed72;
|
color: #a9ed72;
|
||||||
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
|
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
|
||||||
transition: clip-path .06s linear, opacity .18s ease;
|
transition: opacity .18s ease;
|
||||||
}
|
}
|
||||||
.state-transition .engage-face { opacity: .2; transform: translateY(-2px); }
|
.state-transition .engage-face { animation: state-label .24s ease-out; }
|
||||||
.engage-face { transition: opacity .14s ease, transform .14s ease; }
|
@keyframes state-label { from {opacity:.35} to {opacity:1} }
|
||||||
@keyframes engage-shimmer { from{background-position:160% 0} to{background-position:-90% 0} }
|
@keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} }
|
||||||
|
.engage-button:hover { transform:none; }
|
||||||
|
.engage-button.pending { background:#405346; border-color:#516e56; }
|
||||||
|
.engage-button.pending .engage-label { color:#f4f6f3; background:none; }
|
||||||
|
.engage-button.pending .engage-progress-label { opacity:0; }
|
||||||
|
.control-status { grid-column:1/-1; min-height:28px; margin:6px 0 0; font-size:12px; line-height:1.4; color:#64716a; transition:color .18s ease; }
|
||||||
|
.control-status.is-error { color:#a23931; }
|
||||||
|
.controls-grid { transition:opacity .18s ease; }
|
||||||
|
.heave-track[data-commanded="true"] .heave-center { background:#3d5145; }
|
||||||
|
.heave-track .heave-center, .heave-fill { transition:background-color .12s linear; }
|
||||||
|
body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
|
||||||
|
#battery-fill { transition:width .25s linear, opacity .18s ease; }
|
||||||
.subtle { background: #e2e6e1; color: #28302c; border: 1px solid rgba(0, 0, 0, 0.08); }
|
.subtle { background: #e2e6e1; color: #28302c; border: 1px solid rgba(0, 0, 0, 0.08); }
|
||||||
.subtle:hover { background: #d7dcd5; }
|
.subtle:hover { background: #d7dcd5; }
|
||||||
|
|
||||||
@@ -832,7 +844,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
.heave-knob:active, .heave-knob.pressed {
|
.heave-knob:active, .heave-knob.pressed {
|
||||||
scale: 0.93;
|
filter: brightness(.83);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-card footer {
|
.control-card footer {
|
||||||
@@ -978,7 +990,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
|||||||
/* ══════════════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
APP-STATE CONDITIONAL GATING
|
APP-STATE CONDITIONAL GATING
|
||||||
══════════════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════════════ */
|
||||||
body:not([data-app-state="armed"]) .controls-grid {
|
body:not([data-controls-enabled="true"]) .controls-grid {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -991,10 +1003,6 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
button:disabled::after { display: none !important; }
|
button:disabled::after { display: none !important; }
|
||||||
|
|
||||||
body[data-app-state="disconnected"] #engage,
|
|
||||||
body[data-app-state="connecting"] #engage,
|
|
||||||
body[data-app-state="link_degraded"] #engage,
|
|
||||||
body[data-app-state="connecting"] #connect,
|
|
||||||
body[data-app-state="disconnected"] button[data-open="motors-modal"],
|
body[data-app-state="disconnected"] button[data-open="motors-modal"],
|
||||||
body[data-app-state="connecting"] button[data-open="motors-modal"],
|
body[data-app-state="connecting"] button[data-open="motors-modal"],
|
||||||
body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
|
body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
|
||||||
@@ -1003,8 +1011,107 @@ body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A pending or unknown armed state must stay legible, even without a link. */
|
||||||
|
#engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { animation:none !important; transition:none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 950px) {
|
@media (max-width: 950px) {
|
||||||
.app-shell { grid-template-columns: 210px minmax(0, 1fr); }
|
.app-shell { grid-template-columns: 210px minmax(0, 1fr); }
|
||||||
.controls-grid { grid-template-columns: minmax(230px, 1fr) 150px; }
|
.controls-grid { grid-template-columns: minmax(230px, 1fr) 150px; }
|
||||||
.controls-workspace { padding: 8px 12px; }
|
.controls-workspace { padding: 8px 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact control surface: interactive content is never a native drag region. */
|
||||||
|
.app-shell { grid-template-columns:260px minmax(0,1fr); }
|
||||||
|
.sidebar-deck { position:relative; padding:20px; gap:18px; background:linear-gradient(160deg,#333,#242424); }
|
||||||
|
.sidebar-drag-surface { position:absolute; inset:0; cursor:grab; }
|
||||||
|
.sidebar-deck > :not(.sidebar-drag-surface) { position:relative; pointer-events:none; }
|
||||||
|
.sidebar-deck :is(button,select,input,a,.rov-stage,.activity-history) { pointer-events:auto; }
|
||||||
|
.deck-connection { gap:12px; padding-bottom:16px; }
|
||||||
|
.port-row select { height:36px; font-size:12px; border-radius:999px; background:#3a3a3a; }
|
||||||
|
.icon-button { width:34px; height:34px; background:#3a3a3a; }
|
||||||
|
body #connect, body #connect:hover, body #connect.is-connected, body #connect.is-connecting {
|
||||||
|
width:100%; min-width:0; height:62px; padding:0 14px; align-self:stretch;
|
||||||
|
background:#fff !important; color:#303030 !important; border:0 !important;
|
||||||
|
border-radius:999px; box-shadow:none !important; font-size:30px; font-weight:300; letter-spacing:-.025em;
|
||||||
|
}
|
||||||
|
body #connect .btn-label { animation:none; }
|
||||||
|
.telemetry-zone { gap:20px; }
|
||||||
|
.rov-stage { height:clamp(180px,32vh,250px); min-height:180px; touch-action:none; }
|
||||||
|
.model-card { justify-content:flex-start; }
|
||||||
|
.rov-stage::before { display:none; }
|
||||||
|
.model-tools button { font-size:12px; padding:7px 11px; }
|
||||||
|
.battery-card small { font-size:12px; }
|
||||||
|
.battery-card strong { font-size:23px; }
|
||||||
|
.battery-card i { font-size:12px; }
|
||||||
|
.activity-heading { display:flex; align-items:center; justify-content:space-between; font-size:14px; font-weight:650; color:#eee; margin:8px 0 0; }
|
||||||
|
.activity-heading .logs-link { font-size:14px; font-weight:650; color:#eee; }
|
||||||
|
.activity-history { list-style:none; padding:0; margin:0; display:grid; gap:4px; }
|
||||||
|
.activity-history li { font-size:12px; font-weight:300; line-height:1.25; color:#999; overflow-wrap:anywhere; }
|
||||||
|
.activity-history #status-text { color:#fff; font-size:12px; font-weight:300; }
|
||||||
|
.activity-history li[hidden] { display:none; }
|
||||||
|
.controls-workspace { padding:8px 20px 16px; background:#f6f6f6; }
|
||||||
|
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) 52px; }
|
||||||
|
.engage-header-zone { display:block; position:relative; height:120px; min-height:0; margin:0; padding:0; }
|
||||||
|
body #engage { width:calc(100% + 40px); margin-left:-20px; height:104px; padding:0 20px; border:0; border-radius:0; background:transparent; box-shadow:none; }
|
||||||
|
body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
|
||||||
|
#engage::before, #engage::after { display:none; }
|
||||||
|
body #engage { align-items:flex-end; padding-bottom:12px; }
|
||||||
|
.engage-face > span { font-family:"Segoe UI Variable Display","Segoe UI",Arial,sans-serif; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
|
||||||
|
#engage .engage-label { color:#777; background:none; animation:none; }
|
||||||
|
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; }
|
||||||
|
#engage:is(.arm,.danger):not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-sheen-label { opacity:1; animation:engage-sweep 2.2s linear infinite; }
|
||||||
|
@keyframes engage-sweep { from {mask-position:100% 0;-webkit-mask-position:100% 0} to {mask-position:0% 0;-webkit-mask-position:0% 0} }
|
||||||
|
.engage-progress-label { color:#4cce73; clip-path:none; filter:drop-shadow(0 0 2px rgba(76,206,115,.3)); mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); -webkit-mask-image:linear-gradient(to right,#000 calc(var(--engage-progress) - 8%),transparent var(--engage-progress)); }
|
||||||
|
#engage.charged .engage-progress-label { mask-image:none; -webkit-mask-image:none; }
|
||||||
|
#engage.pending .engage-label { color:#5a8753; background:none; }
|
||||||
|
#engage.danger .engage-label { color:#171717; }
|
||||||
|
#engage:focus-visible { outline:2px solid #8d9991; outline-offset:-6px; }
|
||||||
|
.control-status { position:absolute; z-index:2; top:100%; left:0; right:0; margin:0; min-height:0; text-align:center; font-size:12px; max-height:48px; overflow:auto; background:#f6f6f6; }
|
||||||
|
.control-status[hidden] { display:none; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.engage-sheen-label { display:none; }
|
||||||
|
}
|
||||||
|
.controls-grid {
|
||||||
|
--joy-size:min(54vh,calc(100vw - 480px),460px);
|
||||||
|
--joy-circle-d:calc(var(--joy-size) * .86);
|
||||||
|
grid-template-columns:var(--joy-size) 112px; gap:24px;
|
||||||
|
justify-content:center; align-content:center;
|
||||||
|
}
|
||||||
|
.control-card { border:0; border-radius:0; box-shadow:none; background:transparent; padding:0; }
|
||||||
|
.control-card header { align-items:center; min-height:20px; }
|
||||||
|
.control-card small, .key-hint { font-size:12px; }
|
||||||
|
.key-hint { background:transparent; }
|
||||||
|
.control-card footer { height:22px; font-size:13px; margin:0; }
|
||||||
|
.vertical-card footer { justify-content:center; text-align:center; }
|
||||||
|
.vertical-card footer b { display:inline-block; width:4ch; text-align:right; font-variant-numeric:tabular-nums; }
|
||||||
|
.joystick-pad,.heave-pad { padding:0; flex:none; }
|
||||||
|
.vertical-control { font-size:11px; gap:4px; }
|
||||||
|
.heave-track { width:58px; border-radius:999px; background:#eaeaea; border-color:#d6d6d6; }
|
||||||
|
.heave-knob { width:76px; height:40px; border-radius:999px; background:linear-gradient(145deg,#737373,#454545); }
|
||||||
|
.heave-fill { left:22px; right:22px; background:#999; }
|
||||||
|
.control-toolbar { display:flex; justify-content:center; align-items:center; gap:16px; padding-top:12px; }
|
||||||
|
.control-toolbar .output-limit { width:auto; flex-direction:row; align-items:center; font-size:13px; gap:12px; color:#777; }
|
||||||
|
.control-toolbar .output-limit span { flex:0 0 128px; width:128px; gap:8px; align-items:baseline; justify-content:space-between; }
|
||||||
|
.control-toolbar .output-limit b { display:inline-block; flex:0 0 4ch; width:4ch; text-align:right; color:#333; font-variant-numeric:tabular-nums; }
|
||||||
|
.control-toolbar .range-pill { width:175px; grid-template-columns:28px 1fr 28px; height:40px; background:#e7e7e7; border:0; }
|
||||||
|
.control-toolbar .range-pill button { width:28px; height:40px; color:#666; font-size:22px; }
|
||||||
|
.control-toolbar .range-pill input[type="range"] { background:#c4c4c4; height:20px; background-clip:content-box; padding:8px 0; }
|
||||||
|
.control-toolbar .range-pill input[type="range"]::-webkit-slider-thumb { width:18px; height:18px; background:#858585; }
|
||||||
|
.control-toolbar .config-btn { width:auto; height:40px; padding:0 18px; font-size:13px; background:#e7e7e7; color:#444; border:0; border-radius:999px; }
|
||||||
|
.workspace-topbar { height:40px; min-height:40px; }
|
||||||
|
.window-actions { gap:5px; }
|
||||||
|
.window-actions button { width:42px; height:38px; border-radius:999px; }
|
||||||
|
.window-actions button svg { width:20px; height:20px; }
|
||||||
|
body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
|
||||||
|
@media (max-height:610px) {
|
||||||
|
.primary-control-zone { grid-template-rows:100px minmax(0,1fr) 52px; }
|
||||||
|
.engage-header-zone { height:100px; }
|
||||||
|
body #engage { height:84px; }
|
||||||
|
.controls-grid { --joy-size:min(49vh,calc(100vw - 480px),420px); }
|
||||||
|
.sidebar-deck { gap:12px; padding-top:14px; padding-bottom:14px; }
|
||||||
|
.telemetry-zone { gap:12px; }
|
||||||
|
.activity-history { gap:4px; }
|
||||||
|
}
|
||||||
|
|||||||
+54
-19
@@ -16,6 +16,7 @@ import webview
|
|||||||
from serial.tools import list_ports
|
from serial.tools import list_ports
|
||||||
|
|
||||||
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
||||||
|
from vehicle_state import VehicleState
|
||||||
|
|
||||||
|
|
||||||
class ControlApi:
|
class ControlApi:
|
||||||
@@ -24,8 +25,12 @@ class ControlApi:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.events: queue.Queue = queue.Queue()
|
self.events: queue.Queue = queue.Queue()
|
||||||
self.worker = MavlinkWorker(self.events)
|
self.worker = MavlinkWorker(self.events)
|
||||||
|
self.worker.require_input_lease = True
|
||||||
|
self.vehicle_state = VehicleState()
|
||||||
|
self.battery_received_at = None
|
||||||
self.demo_mode = False
|
self.demo_mode = False
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
self._poll_lock = threading.Lock()
|
||||||
self.state = {
|
self.state = {
|
||||||
"connected": False,
|
"connected": False,
|
||||||
"link": "DISCONNECTED",
|
"link": "DISCONNECTED",
|
||||||
@@ -240,13 +245,13 @@ class ControlApi:
|
|||||||
}
|
}
|
||||||
return self._snapshot()
|
return self._snapshot()
|
||||||
self.worker.rate_hz = int(rate)
|
self.worker.rate_hz = int(rate)
|
||||||
self.worker.connect(str(port), int(baud))
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
self.vehicle_state.disconnect()
|
||||||
self.state.update(
|
self.state.update(
|
||||||
connected=True, link="CONNECTING",
|
connected=True, link="CONNECTING",
|
||||||
status=f"Opening {port} at {baud} baud…",
|
status=f"Opening {port} at {baud} baud…",
|
||||||
armed=False,
|
|
||||||
)
|
)
|
||||||
|
self.worker.connect(str(port), int(baud))
|
||||||
print(f"[web_ui] connect() dispatched to worker thread OK")
|
print(f"[web_ui] connect() dispatched to worker thread OK")
|
||||||
# Return only once the worker has either seen a heartbeat or reported a
|
# Return only once the worker has either seen a heartbeat or reported a
|
||||||
# connection failure. This makes the button independent of the regular
|
# connection failure. This makes the button independent of the regular
|
||||||
@@ -272,7 +277,7 @@ class ControlApi:
|
|||||||
self.worker.disconnect()
|
self.worker.disconnect()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_axes(self, forward, turn, vertical):
|
def set_axes(self, forward, turn, vertical, sequence=None):
|
||||||
if self.demo_mode:
|
if self.demo_mode:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.state["status"] = (
|
self.state["status"] = (
|
||||||
@@ -280,12 +285,20 @@ class ControlApi:
|
|||||||
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
|
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
with self.lock:
|
||||||
|
allowed = self.vehicle_state.snapshot()["controls_allowed"]
|
||||||
self.worker.set_axes(Axes(
|
self.worker.set_axes(Axes(
|
||||||
max(-1.0, min(1.0, float(forward))),
|
max(-1.0, min(1.0, float(forward))), 0.0,
|
||||||
0.0,
|
|
||||||
max(-1.0, min(1.0, float(vertical))),
|
max(-1.0, min(1.0, float(vertical))),
|
||||||
max(-1.0, min(1.0, float(turn))),
|
max(-1.0, min(1.0, float(turn))),
|
||||||
))
|
) if allowed else Axes(), sequence)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop_controls(self, sequence=None):
|
||||||
|
"""Invalidate queued input without claiming that the vehicle disarmed."""
|
||||||
|
with self.lock:
|
||||||
|
self.worker.set_axes(Axes(), sequence)
|
||||||
|
self.worker._send_control(force_neutral=True)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_gain(self, value):
|
def set_gain(self, value):
|
||||||
@@ -293,7 +306,7 @@ class ControlApi:
|
|||||||
self.worker.gain = value / 100.0
|
self.worker.gain = value / 100.0
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def command(self, name):
|
def command(self, name, sequence=None, expires_at=None):
|
||||||
if self.demo_mode:
|
if self.demo_mode:
|
||||||
if name not in ("manual", "arm", "disarm"):
|
if name not in ("manual", "arm", "disarm"):
|
||||||
raise ValueError(f"Unknown command: {name}")
|
raise ValueError(f"Unknown command: {name}")
|
||||||
@@ -311,15 +324,25 @@ class ControlApi:
|
|||||||
elif name == "disarm":
|
elif name == "disarm":
|
||||||
raise RuntimeError("Connect to vehicle before disarming")
|
raise RuntimeError("Connect to vehicle before disarming")
|
||||||
raise RuntimeError("No autopilot heartbeat")
|
raise RuntimeError("No autopilot heartbeat")
|
||||||
|
if name in ("arm", "disarm"):
|
||||||
|
self.poll()
|
||||||
|
with self.lock:
|
||||||
|
if name == "arm":
|
||||||
|
if expires_at is not None and time.time() * 1000 > expires_at:
|
||||||
|
raise RuntimeError("Engage request expired before it could be sent")
|
||||||
|
if sequence is not None and sequence <= self.worker.last_input_sequence:
|
||||||
|
raise RuntimeError("Engage canceled by a newer input reset")
|
||||||
|
self.vehicle_state.request(name)
|
||||||
|
self.worker.set_axes(Axes(), sequence)
|
||||||
|
try:
|
||||||
|
self.worker.command_arm(name == "arm")
|
||||||
|
except Exception as exc:
|
||||||
|
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
|
||||||
|
raise
|
||||||
|
return self._snapshot()
|
||||||
if name == "manual":
|
if name == "manual":
|
||||||
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
||||||
self.worker.set_manual_mode()
|
self.worker.set_manual_mode()
|
||||||
elif name == "arm":
|
|
||||||
self.worker.log("OPERATOR", "clicked ARM")
|
|
||||||
self.worker.command_arm(True)
|
|
||||||
elif name == "disarm":
|
|
||||||
self.worker.log("OPERATOR", "clicked DISARM")
|
|
||||||
self.worker.command_arm(False)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown command: {name}")
|
raise ValueError(f"Unknown command: {name}")
|
||||||
return True
|
return True
|
||||||
@@ -377,13 +400,15 @@ class ControlApi:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def poll(self):
|
def poll(self):
|
||||||
while True:
|
# Bound a poll even if telemetry arrives continuously.
|
||||||
|
with self._poll_lock:
|
||||||
|
for _ in range(500):
|
||||||
try:
|
try:
|
||||||
event = self.events.get_nowait()
|
event = self.events.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
self._consume(event)
|
self._consume(event)
|
||||||
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5:
|
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.state["link"] = (
|
self.state["link"] = (
|
||||||
"DATA OK / HEARTBEAT DELAYED"
|
"DATA OK / HEARTBEAT DELAYED"
|
||||||
@@ -407,14 +432,20 @@ class ControlApi:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
snap = json.loads(json.dumps(self.state))
|
snap = json.loads(json.dumps(self.state))
|
||||||
snap["app_state"] = self._derive_app_state()
|
snap["app_state"] = self._derive_app_state()
|
||||||
snap["armed"] = bool(self.state.get("armed", False))
|
if not self.demo_mode:
|
||||||
|
snap.update(self.vehicle_state.snapshot())
|
||||||
|
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
|
||||||
|
snap["app_state"] = "link_degraded"
|
||||||
|
snap["battery_stale"] = (self.battery_received_at is None
|
||||||
|
or time.monotonic() - self.battery_received_at > 5)
|
||||||
return snap
|
return snap
|
||||||
|
|
||||||
def _consume(self, event):
|
def _consume(self, event):
|
||||||
kind = event[0]
|
kind = event[0]
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
_, system, autopilot, mode, armed = event
|
_, system, autopilot, mode, armed = event[:5]
|
||||||
|
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
|
||||||
self.state.update(
|
self.state.update(
|
||||||
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
|
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
|
||||||
armed=bool(armed),
|
armed=bool(armed),
|
||||||
@@ -423,8 +454,11 @@ class ControlApi:
|
|||||||
elif kind in ("status", "statustext"):
|
elif kind in ("status", "statustext"):
|
||||||
self.state["status"] = event[1]
|
self.state["status"] = event[1]
|
||||||
elif kind == "ack":
|
elif kind == "ack":
|
||||||
|
if event[1] == 400:
|
||||||
|
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
|
||||||
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
||||||
elif kind == "battery":
|
elif kind == "battery":
|
||||||
|
self.battery_received_at = time.monotonic()
|
||||||
voltage = float(event[1])
|
voltage = float(event[1])
|
||||||
cells = self.battery_config["cells"]
|
cells = self.battery_config["cells"]
|
||||||
per_cell = voltage / cells
|
per_cell = voltage / cells
|
||||||
@@ -452,10 +486,11 @@ class ControlApi:
|
|||||||
self.state["status"] = event[1]
|
self.state["status"] = event[1]
|
||||||
self.state["last_error"] = event[1]
|
self.state["last_error"] = event[1]
|
||||||
elif kind == "disconnected":
|
elif kind == "disconnected":
|
||||||
|
self.vehicle_state.disconnect()
|
||||||
last_err = self.state.get("last_error")
|
last_err = self.state.get("last_error")
|
||||||
status_msg = last_err if last_err else "Disconnected"
|
status_msg = last_err if last_err else "Disconnected"
|
||||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||||
armed=False, mode=None, battery=None, status=status_msg)
|
mode=None, status=status_msg)
|
||||||
self.state["last_error"] = None
|
self.state["last_error"] = None
|
||||||
|
|
||||||
def _update_servo(self, servo):
|
def _update_servo(self, servo):
|
||||||
@@ -495,7 +530,7 @@ def _apply_dwm_styling(window):
|
|||||||
|
|
||||||
|
|
||||||
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
|
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
|
||||||
target_w, target_h = 1280, 750
|
target_w, target_h = 1100, 650
|
||||||
min_w, min_h = 960, 580
|
min_w, min_h = 960, 580
|
||||||
try:
|
try:
|
||||||
import ctypes
|
import ctypes
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ class Axes:
|
|||||||
yaw: float = 0.0
|
yaw: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_endpoint(port: str) -> str:
|
||||||
|
p = str(port).strip()
|
||||||
|
if not p:
|
||||||
|
return p
|
||||||
|
lower = p.lower()
|
||||||
|
if "sim" in lower or "5760" in lower or lower in ("tcp", "tcp:", "tcp:127.0.0.1", "127.0.0.1"):
|
||||||
|
return "tcp:127.0.0.1:5760"
|
||||||
|
if "14550" in lower or lower in ("udp", "udp:", "udpin", "udpin:"):
|
||||||
|
return "udpin:127.0.0.1:14550"
|
||||||
|
if p.startswith("127.0.0.1:") or p.startswith("localhost:"):
|
||||||
|
num = p.split(":")[1]
|
||||||
|
if num == "14550":
|
||||||
|
return f"udpin:{p}"
|
||||||
|
return f"tcp:{p}"
|
||||||
|
if ":" in p and not p.startswith(("tcp:", "tcpin:", "udp:", "udpin:", "udpout:")):
|
||||||
|
return f"tcp:{p}"
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
class MavlinkWorker:
|
class MavlinkWorker:
|
||||||
def __init__(self, events: queue.Queue):
|
def __init__(self, events: queue.Queue):
|
||||||
self.events = events
|
self.events = events
|
||||||
@@ -52,6 +71,11 @@ class MavlinkWorker:
|
|||||||
self.axes = Axes()
|
self.axes = Axes()
|
||||||
self.output_axes = Axes()
|
self.output_axes = Axes()
|
||||||
self.last_control_at = 0.0
|
self.last_control_at = 0.0
|
||||||
|
# Enabled by the web bridge only; the legacy Tk frontend is unchanged.
|
||||||
|
self.require_input_lease = False
|
||||||
|
self.last_input_at = 0.0
|
||||||
|
self.last_input_sequence = -1
|
||||||
|
self.input_timeout = 1.0
|
||||||
# Smooth ESC commands without adding a perceptible one-second lag.
|
# Smooth ESC commands without adding a perceptible one-second lag.
|
||||||
# At 20 Hz this produces five small steps from neutral to full command.
|
# At 20 Hz this produces five small steps from neutral to full command.
|
||||||
self.ramp_seconds = 0.25
|
self.ramp_seconds = 0.25
|
||||||
@@ -74,29 +98,52 @@ class MavlinkWorker:
|
|||||||
self.log_path = None
|
self.log_path = None
|
||||||
|
|
||||||
def connect(self, port: str, baud: int):
|
def connect(self, port: str, baud: int):
|
||||||
|
print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}")
|
||||||
if self.thread and self.thread.is_alive():
|
if self.thread and self.thread.is_alive():
|
||||||
|
print(f"[MavlinkWorker] Thread already alive, ignoring connect()")
|
||||||
return
|
return
|
||||||
self.stop_event.clear()
|
self.stop_event.clear()
|
||||||
log_dir = Path(__file__).resolve().parent / "logs"
|
log_dir = Path(__file__).resolve().parent / "logs"
|
||||||
log_dir.mkdir(exist_ok=True)
|
log_dir.mkdir(exist_ok=True)
|
||||||
self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log"
|
self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log"
|
||||||
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
|
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
|
||||||
self.log("SESSION", f"start port={port} baud={baud} control_hz={self.rate_hz}")
|
normalized = normalize_endpoint(port)
|
||||||
|
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
|
||||||
|
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} control_hz={self.rate_hz}")
|
||||||
self.events.put(("log_path", str(self.log_path)))
|
self.events.put(("log_path", str(self.log_path)))
|
||||||
self.thread = threading.Thread(
|
self.thread = threading.Thread(
|
||||||
target=self._run, args=(port, baud), daemon=True
|
target=self._run, args=(normalized, baud), daemon=True
|
||||||
)
|
)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
|
print(f"[MavlinkWorker] worker thread started")
|
||||||
|
|
||||||
def _run(self, port: str, baud: int):
|
def _run(self, port: str, baud: int):
|
||||||
|
print(f"[MavlinkWorker._run] ENTERED: port={port!r}, baud={baud}")
|
||||||
try:
|
try:
|
||||||
|
print(f"[MavlinkWorker._run] Creating mavlink_connection...")
|
||||||
conn = mavutil.mavlink_connection(
|
conn = mavutil.mavlink_connection(
|
||||||
port, baud=baud, source_system=255, source_component=190,
|
port, baud=baud, source_system=255, source_component=190,
|
||||||
autoreconnect=False, force_connected=True,
|
# Fail a missing TCP simulator immediately. pymavlink otherwise
|
||||||
|
# retries the refused socket several times and leaves the UI in
|
||||||
|
# "Opening" long after the actual failure.
|
||||||
|
autoreconnect=False, retries=0, force_connected=True,
|
||||||
)
|
)
|
||||||
|
print(f"[MavlinkWorker._run] mavlink_connection created: {type(conn).__name__}")
|
||||||
self.connection = conn
|
self.connection = conn
|
||||||
self.events.put(("status", f"Port open; waiting for heartbeat at {baud} baud…"))
|
if port.startswith("tcp:"):
|
||||||
next_gcs_hb = 0.0
|
self.events.put(("status", f"Connected to {port}; waiting for vehicle heartbeat…"))
|
||||||
|
else:
|
||||||
|
self.events.put(("status", f"Port {port} open; waiting for heartbeat at {baud} baud…"))
|
||||||
|
|
||||||
|
# Send initial GCS heartbeat immediately
|
||||||
|
self.log("TX_HEARTBEAT", "GCS initial")
|
||||||
|
conn.mav.heartbeat_send(
|
||||||
|
mavutil.mavlink.MAV_TYPE_GCS,
|
||||||
|
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||||
|
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||||
|
)
|
||||||
|
print(f"[MavlinkWorker._run] Initial heartbeat sent, entering recv loop")
|
||||||
|
next_gcs_hb = time.monotonic() + 1.0
|
||||||
next_control = 0.0
|
next_control = 0.0
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
@@ -120,6 +167,7 @@ class MavlinkWorker:
|
|||||||
else:
|
else:
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
|
||||||
self.events.put(("error", f"Connection error: {exc}"))
|
self.events.put(("error", f"Connection error: {exc}"))
|
||||||
finally:
|
finally:
|
||||||
self._safe_stop()
|
self._safe_stop()
|
||||||
@@ -156,7 +204,11 @@ class MavlinkWorker:
|
|||||||
except Exception:
|
except Exception:
|
||||||
payload = str(msg)
|
payload = str(msg)
|
||||||
self.log("RX", payload)
|
self.log("RX", payload)
|
||||||
if msg_type == "HEARTBEAT" and msg.get_srcComponent() == 1:
|
if msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
|
||||||
|
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
|
||||||
|
return
|
||||||
|
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||||
|
return
|
||||||
first = not self.target_system
|
first = not self.target_system
|
||||||
self.target_system = msg.get_srcSystem()
|
self.target_system = msg.get_srcSystem()
|
||||||
self.target_component = msg.get_srcComponent()
|
self.target_component = msg.get_srcComponent()
|
||||||
@@ -170,15 +222,17 @@ class MavlinkWorker:
|
|||||||
mode = str(msg.custom_mode)
|
mode = str(msg.custom_mode)
|
||||||
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
|
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
|
||||||
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
||||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed))
|
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
|
||||||
if first:
|
if first:
|
||||||
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
||||||
self._configure_low_bandwidth_streams()
|
self._configure_low_bandwidth_streams()
|
||||||
self._start_servo_scan()
|
self._start_servo_scan()
|
||||||
elif msg_type == "COMMAND_ACK":
|
elif msg_type == "COMMAND_ACK":
|
||||||
|
if (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
|
||||||
|
return
|
||||||
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
|
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
|
||||||
result_name = result.name if result else str(msg.result)
|
result_name = result.name if result else str(msg.result)
|
||||||
self.events.put(("ack", int(msg.command), result_name))
|
self.events.put(("ack", int(msg.command), result_name, self.last_rx))
|
||||||
elif msg_type == "STATUSTEXT":
|
elif msg_type == "STATUSTEXT":
|
||||||
text = msg.text
|
text = msg.text
|
||||||
if isinstance(text, bytes):
|
if isinstance(text, bytes):
|
||||||
@@ -300,10 +354,17 @@ class MavlinkWorker:
|
|||||||
if not conn or not self.target_system:
|
if not conn or not self.target_system:
|
||||||
return
|
return
|
||||||
with self.lock:
|
with self.lock:
|
||||||
active = not force_neutral
|
now = time.monotonic()
|
||||||
|
expired = self.require_input_lease and (
|
||||||
|
not self.armed or now - self.last_heartbeat > 3.0
|
||||||
|
or now - self.last_input_at > self.input_timeout)
|
||||||
|
if expired or force_neutral:
|
||||||
|
# Never replay a stored command after telemetry/UI recovery.
|
||||||
|
self.axes = Axes()
|
||||||
|
self.output_axes = Axes()
|
||||||
|
active = not (force_neutral or expired)
|
||||||
target = self.axes if active else Axes()
|
target = self.axes if active else Axes()
|
||||||
gain = self.gain
|
gain = self.gain
|
||||||
now = time.monotonic()
|
|
||||||
if force_neutral:
|
if force_neutral:
|
||||||
self.output_axes = Axes()
|
self.output_axes = Axes()
|
||||||
else:
|
else:
|
||||||
@@ -375,7 +436,7 @@ class MavlinkWorker:
|
|||||||
manual,
|
manual,
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_axes(self, axes: Axes):
|
def set_axes(self, axes: Axes, sequence=None):
|
||||||
def conditioned(value):
|
def conditioned(value):
|
||||||
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
|
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
|
||||||
if abs(value) < 0.04:
|
if abs(value) < 0.04:
|
||||||
@@ -383,6 +444,11 @@ class MavlinkWorker:
|
|||||||
return round(max(-1.0, min(1.0, value)), 2)
|
return round(max(-1.0, min(1.0, value)), 2)
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
if sequence is not None:
|
||||||
|
if sequence <= self.last_input_sequence:
|
||||||
|
return
|
||||||
|
self.last_input_sequence = sequence
|
||||||
|
self.last_input_at = time.monotonic()
|
||||||
self.axes = Axes(
|
self.axes = Axes(
|
||||||
conditioned(axes.forward), conditioned(axes.lateral),
|
conditioned(axes.forward), conditioned(axes.lateral),
|
||||||
conditioned(axes.vertical), conditioned(axes.yaw),
|
conditioned(axes.vertical), conditioned(axes.yaw),
|
||||||
@@ -887,6 +953,9 @@ class App(tk.Tk):
|
|||||||
|
|
||||||
def refresh_ports(self):
|
def refresh_ports(self):
|
||||||
ports = [p.device for p in list_ports.comports()]
|
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)
|
self.port.configure(values=ports)
|
||||||
if ports and self.port.get() not in ports:
|
if ports and self.port.get() not in ports:
|
||||||
self.port.set(ports[0])
|
self.port.set(ports[0])
|
||||||
@@ -1139,7 +1208,7 @@ class App(tk.Tk):
|
|||||||
event = self.events.get_nowait()
|
event = self.events.get_nowait()
|
||||||
kind = event[0]
|
kind = event[0]
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
_, system, autopilot, mode, armed = event
|
_, system, autopilot, mode, armed = event[:5]
|
||||||
self.vehicle_label.configure(
|
self.vehicle_label.configure(
|
||||||
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
|
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
|
||||||
)
|
)
|
||||||
|
|||||||
+553
@@ -0,0 +1,553 @@
|
|||||||
|
"""ArduSub Flight Controller (FC) Spoofer & Simulator.
|
||||||
|
|
||||||
|
Emulates an ArduSub SimpleROV-3 vehicle over MAVLink (TCP, UDP, or Serial).
|
||||||
|
Supports full parameter scans, live parameter updates (servo functions / motor reversal),
|
||||||
|
arming/disarming commands, manual control processing, and realistic telemetry streaming
|
||||||
|
(battery voltage/current with load sag, live thruster PWM mixing, simulated attitude & depth).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pymavlink import mavutil
|
||||||
|
|
||||||
|
|
||||||
|
# Default SimpleROV-3 motor mappings and ArduSub parameters
|
||||||
|
DEFAULT_PARAMETERS: dict[str, tuple[float, int]] = {
|
||||||
|
# Servo functions: 33 = Motor 1 (Port), 34 = Motor 2 (Starboard), 35 = Motor 3 (Vertical)
|
||||||
|
"SERVO1_FUNCTION": (33.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
|
||||||
|
"SERVO2_FUNCTION": (34.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
|
||||||
|
"SERVO3_FUNCTION": (35.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
|
||||||
|
# Remaining servos unassigned
|
||||||
|
**{f"SERVO{i}_FUNCTION": (0.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16) for i in range(4, 17)},
|
||||||
|
# Motor directions: 1 = normal, -1 = reversed
|
||||||
|
**{f"MOT_{i}_DIRECTION": (1.0, mavutil.mavlink.MAV_PARAM_TYPE_INT8) for i in range(1, 13)},
|
||||||
|
# SimpleROV-3 thruster PWM limits
|
||||||
|
"SERVO1_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"SERVO1_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"SERVO1_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"SERVO2_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"SERVO2_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"SERVO2_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"SERVO3_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"SERVO3_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"SERVO3_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
# Joystick gain parameters
|
||||||
|
"JS_GAIN_MIN": (0.10, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"JS_GAIN_DEFAULT": (0.20, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
"JS_GAIN_MAX": (1.00, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ControlState:
|
||||||
|
x: float = 0.0 # Surge (forward/back) -1000..1000
|
||||||
|
y: float = 0.0 # Sway (lateral) -1000..1000
|
||||||
|
z: float = 500.0 # Heave (vertical) 0..1000 (500 = neutral)
|
||||||
|
r: float = 0.0 # Yaw (turn) -1000..1000
|
||||||
|
last_rx: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class FlightControllerSpoofer:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
endpoint: str = "tcpin:127.0.0.1:5760",
|
||||||
|
baud: int = 57600,
|
||||||
|
battery_cells: int = 4,
|
||||||
|
nominal_voltage: float = 16.0,
|
||||||
|
show_dashboard: bool = True,
|
||||||
|
):
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.baud = baud
|
||||||
|
self.battery_cells = battery_cells
|
||||||
|
self.battery_base_v = nominal_voltage
|
||||||
|
self.show_dashboard = show_dashboard
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
self.armed = False
|
||||||
|
self.mode = 19 # ArduSub MANUAL mode
|
||||||
|
self.system_status = mavutil.mavlink.MAV_STATE_ACTIVE
|
||||||
|
self.target_system = 255
|
||||||
|
self.target_component = 190
|
||||||
|
|
||||||
|
self.params = dict(DEFAULT_PARAMETERS)
|
||||||
|
self.controls = ControlState()
|
||||||
|
|
||||||
|
# Simulated physical state
|
||||||
|
self.depth_m = 1.5 # Positive depth under water
|
||||||
|
self.heading_deg = 0.0
|
||||||
|
self.pitch_deg = 0.0
|
||||||
|
self.roll_deg = 0.0
|
||||||
|
self.m1_pwm = 1500 # Port
|
||||||
|
self.m2_pwm = 1500 # Starboard
|
||||||
|
self.m3_pwm = 1500 # Vertical
|
||||||
|
self.current_a = 0.35
|
||||||
|
|
||||||
|
self.events: deque[str] = deque(maxlen=6)
|
||||||
|
self.client_connected = False
|
||||||
|
self.total_packets_rx = 0
|
||||||
|
self.total_packets_tx = 0
|
||||||
|
self.start_time = time.monotonic()
|
||||||
|
|
||||||
|
def log_event(self, text: str):
|
||||||
|
now_str = time.strftime("%H:%M:%S")
|
||||||
|
self.events.append(f"[{now_str}] {text}")
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
print(f"\n{'='*72}")
|
||||||
|
print(f" ARDUSUB SIMPLE-ROV FLIGHT CONTROLLER SPOOFER IS RUNNING")
|
||||||
|
print(f" Target Endpoint: {self.endpoint}")
|
||||||
|
print(f" --> In Sub Control app, select or connect to: {self.endpoint.replace('tcpin:', 'tcp:')}")
|
||||||
|
print(f"{'='*72}\n", flush=True)
|
||||||
|
self.log_event(f"Simulator starting on {self.endpoint}...")
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
self._run_server_session()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
except OSError as exc:
|
||||||
|
if getattr(exc, "winerror", None) == 10048 or "already in use" in str(exc).lower():
|
||||||
|
print(f"\n[ERROR] Port {self.endpoint} is already in use by another program or previous spoofer instance.")
|
||||||
|
print("Please close any running python/spoofer processes and try again.\n")
|
||||||
|
break
|
||||||
|
self.log_event(f"Session reset: {exc}")
|
||||||
|
time.sleep(1.0)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log_event(f"Session reset: {exc}")
|
||||||
|
time.sleep(1.0)
|
||||||
|
print("\nSpoofer stopped.")
|
||||||
|
|
||||||
|
def _run_server_session(self):
|
||||||
|
# Bind server / open connection
|
||||||
|
if self.endpoint.startswith("tcpin:"):
|
||||||
|
shown_endpoint = self.endpoint.replace("tcpin:", "tcp:", 1)
|
||||||
|
print(f"[READY] Listening for SUB Control on {shown_endpoint}", flush=True)
|
||||||
|
print(" Leave this window open, then click Connect in the app.\n", flush=True)
|
||||||
|
conn = mavutil.mavlink_connection(
|
||||||
|
self.endpoint,
|
||||||
|
baud=self.baud,
|
||||||
|
source_system=1,
|
||||||
|
source_component=1,
|
||||||
|
autoreconnect=True,
|
||||||
|
force_connected=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log_event(f"Client connected on {self.endpoint}")
|
||||||
|
|
||||||
|
next_heartbeat = 0.0
|
||||||
|
next_sys_status = 0.0
|
||||||
|
next_servos = 0.0
|
||||||
|
next_attitude = 0.0
|
||||||
|
next_dashboard = 0.0
|
||||||
|
|
||||||
|
# Send initial announcement statustext
|
||||||
|
initial_sent = False
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self.start_time
|
||||||
|
time_boot_ms = int(elapsed * 1000) & 0xFFFFFFFF
|
||||||
|
time_boot_us = int(elapsed * 1000000) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
# 1. Heartbeat at 1.0 Hz
|
||||||
|
if now >= next_heartbeat:
|
||||||
|
base_mode = (
|
||||||
|
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
|
||||||
|
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
|
||||||
|
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
|
||||||
|
)
|
||||||
|
if self.armed:
|
||||||
|
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
|
||||||
|
|
||||||
|
conn.mav.heartbeat_send(
|
||||||
|
mavutil.mavlink.MAV_TYPE_SUBMARINE,
|
||||||
|
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
|
||||||
|
base_mode,
|
||||||
|
self.mode,
|
||||||
|
self.system_status,
|
||||||
|
)
|
||||||
|
self.total_packets_tx += 1
|
||||||
|
next_heartbeat = now + 1.0
|
||||||
|
|
||||||
|
if not initial_sent and self.client_connected:
|
||||||
|
conn.mav.statustext_send(
|
||||||
|
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||||
|
b"ArduSub V4.1.0 (SimpleROV-3 Simulator)",
|
||||||
|
)
|
||||||
|
conn.mav.statustext_send(
|
||||||
|
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||||
|
b"EKF3 IMU0 initialised",
|
||||||
|
)
|
||||||
|
initial_sent = True
|
||||||
|
|
||||||
|
# 2. Physics & Motor Mixing step
|
||||||
|
self._update_physics(now)
|
||||||
|
|
||||||
|
# 3. SYS_STATUS at 1.0 Hz (Battery & Sensor Health)
|
||||||
|
if now >= next_sys_status:
|
||||||
|
# Voltage with slight noise + load sag
|
||||||
|
load_factor = (
|
||||||
|
abs(self.m1_pwm - 1500)
|
||||||
|
+ abs(self.m2_pwm - 1500)
|
||||||
|
+ abs(self.m3_pwm - 1500)
|
||||||
|
) / 1200.0 # 0.0 to 1.0
|
||||||
|
voltage_v = self.battery_base_v - (0.45 * load_factor) + (0.02 * math.sin(now * 1.5))
|
||||||
|
voltage_mv = int(max(10.0, voltage_v) * 1000)
|
||||||
|
|
||||||
|
# Current: 0.35A base + up to 12.5A at full throttle
|
||||||
|
self.current_a = 0.35 + (12.5 * load_factor)
|
||||||
|
current_ca = int(self.current_a * 100)
|
||||||
|
|
||||||
|
# Battery remaining percentage (e.g. 88% draining slowly)
|
||||||
|
remaining_pct = max(10, int(88 - (elapsed / 300.0)))
|
||||||
|
|
||||||
|
conn.mav.sys_status_send(
|
||||||
|
322042887, # onboard_control_sensors_present
|
||||||
|
320969735, # onboard_control_sensors_enabled
|
||||||
|
305241095, # onboard_control_sensors_health
|
||||||
|
175, # load (17.5%)
|
||||||
|
voltage_mv,
|
||||||
|
current_ca,
|
||||||
|
remaining_pct,
|
||||||
|
0, 0, 0, 0, 0, 0,
|
||||||
|
)
|
||||||
|
self.total_packets_tx += 1
|
||||||
|
next_sys_status = now + 1.0
|
||||||
|
|
||||||
|
# 4. SERVO_OUTPUT_RAW & RC_CHANNELS at 4.0 Hz
|
||||||
|
if now >= next_servos:
|
||||||
|
conn.mav.servo_output_raw_send(
|
||||||
|
time_boot_us,
|
||||||
|
0,
|
||||||
|
self.m1_pwm,
|
||||||
|
self.m2_pwm,
|
||||||
|
self.m3_pwm,
|
||||||
|
0, 0, 0, 0, 0,
|
||||||
|
)
|
||||||
|
conn.mav.rc_channels_send(
|
||||||
|
time_boot_ms,
|
||||||
|
8,
|
||||||
|
1500 + int(self.controls.r * 0.4), # Chan 1: Yaw
|
||||||
|
1500, # Chan 2: Roll
|
||||||
|
int(self.controls.z), # Chan 3: Throttle/Vertical
|
||||||
|
1500 + int(self.controls.x * 0.4), # Chan 4: Pitch/Forward
|
||||||
|
1500, 1500, 1500, 1500,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
)
|
||||||
|
self.total_packets_tx += 2
|
||||||
|
next_servos = now + 0.25
|
||||||
|
|
||||||
|
# 5. ATTITUDE & VFR_HUD at 4.0 Hz
|
||||||
|
if now >= next_attitude:
|
||||||
|
conn.mav.attitude_send(
|
||||||
|
time_boot_ms,
|
||||||
|
math.radians(self.roll_deg),
|
||||||
|
math.radians(self.pitch_deg),
|
||||||
|
math.radians(self.heading_deg),
|
||||||
|
0.0, 0.0, 0.0,
|
||||||
|
)
|
||||||
|
conn.mav.vfr_hud_send(
|
||||||
|
abs(self.m1_pwm - 1500) / 400.0, # airspeed
|
||||||
|
abs(self.m2_pwm - 1500) / 400.0, # groundspeed
|
||||||
|
int(self.heading_deg) % 360, # heading
|
||||||
|
int((self.controls.z - 500) / 5), # throttle %
|
||||||
|
-self.depth_m, # altitude (negative depth)
|
||||||
|
(self.controls.z - 500) / 500.0, # climb rate
|
||||||
|
)
|
||||||
|
self.total_packets_tx += 2
|
||||||
|
next_attitude = now + 0.25
|
||||||
|
|
||||||
|
# 6. Process incoming MAVLink packets from App
|
||||||
|
self._handle_incoming_messages(conn)
|
||||||
|
|
||||||
|
# 7. Render Console Dashboard
|
||||||
|
if self.show_dashboard and now >= next_dashboard:
|
||||||
|
self._render_dashboard()
|
||||||
|
next_dashboard = now + 0.15
|
||||||
|
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
def _update_physics(self, now: float):
|
||||||
|
# If manual control packet hasn't arrived for > 1.5s, auto-neutralize
|
||||||
|
if now - self.controls.last_rx > 1.5:
|
||||||
|
self.controls.x = 0.0
|
||||||
|
self.controls.y = 0.0
|
||||||
|
self.controls.z = 500.0
|
||||||
|
self.controls.r = 0.0
|
||||||
|
|
||||||
|
if not self.armed:
|
||||||
|
self.m1_pwm = 1500
|
||||||
|
self.m2_pwm = 1500
|
||||||
|
self.m3_pwm = 1500
|
||||||
|
else:
|
||||||
|
# Read motor direction parameters
|
||||||
|
mot1_dir = int(self.params.get("MOT_1_DIRECTION", (1.0,))[0])
|
||||||
|
mot2_dir = int(self.params.get("MOT_2_DIRECTION", (1.0,))[0])
|
||||||
|
mot3_dir = int(self.params.get("MOT_3_DIRECTION", (1.0,))[0])
|
||||||
|
|
||||||
|
# Differential drive mixing for SimpleROV-3:
|
||||||
|
# Motor 1 (Port): Forward + Yaw
|
||||||
|
# Motor 2 (Starboard): Forward - Yaw
|
||||||
|
# Motor 3 (Vertical): Vertical (0..1000 with 500 center)
|
||||||
|
forward_cmd = (self.controls.x / 1000.0) * 400.0 # -400..+400
|
||||||
|
yaw_cmd = (self.controls.r / 1000.0) * 400.0 # -400..+400
|
||||||
|
vert_cmd = ((self.controls.z - 500.0) / 500.0) * 400.0 # -400..+400
|
||||||
|
|
||||||
|
m1_offset = (forward_cmd + yaw_cmd) * mot1_dir
|
||||||
|
m2_offset = (forward_cmd - yaw_cmd) * mot2_dir
|
||||||
|
m3_offset = vert_cmd * mot3_dir
|
||||||
|
|
||||||
|
self.m1_pwm = int(max(1100, min(1900, 1500 + m1_offset)))
|
||||||
|
self.m2_pwm = int(max(1100, min(1900, 1500 + m2_offset)))
|
||||||
|
self.m3_pwm = int(max(1100, min(1900, 1500 + m3_offset)))
|
||||||
|
|
||||||
|
# Simulate subtle underwater motion
|
||||||
|
self.roll_deg = 2.5 * math.sin(now * 0.8)
|
||||||
|
self.pitch_deg = (self.controls.x / 1000.0) * 5.0 + 1.2 * math.cos(now * 0.7)
|
||||||
|
if self.armed and abs(self.controls.r) > 10:
|
||||||
|
self.heading_deg = (self.heading_deg + (self.controls.r / 1000.0) * 2.0) % 360.0
|
||||||
|
|
||||||
|
if self.armed and abs(self.controls.z - 500) > 20:
|
||||||
|
vert_delta = ((self.controls.z - 500.0) / 500.0) * 0.02
|
||||||
|
self.depth_m = max(0.1, min(50.0, self.depth_m - vert_delta))
|
||||||
|
|
||||||
|
def _handle_incoming_messages(self, conn):
|
||||||
|
while True:
|
||||||
|
msg = conn.recv_match(blocking=False)
|
||||||
|
if not msg:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.total_packets_rx += 1
|
||||||
|
if not self.client_connected:
|
||||||
|
self.client_connected = True
|
||||||
|
self.log_event(f"GCS Connected (SysID {msg.get_srcSystem()}, CompID {msg.get_srcComponent()})")
|
||||||
|
# Immediately send heartbeat so GCS locks on with zero delay
|
||||||
|
base_mode = (
|
||||||
|
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
|
||||||
|
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
|
||||||
|
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
|
||||||
|
)
|
||||||
|
if self.armed:
|
||||||
|
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
|
||||||
|
conn.mav.heartbeat_send(
|
||||||
|
mavutil.mavlink.MAV_TYPE_SUBMARINE,
|
||||||
|
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
|
||||||
|
base_mode,
|
||||||
|
self.mode,
|
||||||
|
self.system_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
mtype = msg.get_type()
|
||||||
|
if mtype == "HEARTBEAT":
|
||||||
|
self.target_system = msg.get_srcSystem()
|
||||||
|
self.target_component = msg.get_srcComponent()
|
||||||
|
|
||||||
|
elif mtype == "COMMAND_LONG":
|
||||||
|
cmd = int(msg.command)
|
||||||
|
if cmd == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM:
|
||||||
|
should_arm = bool(msg.param1 == 1.0)
|
||||||
|
self.armed = should_arm
|
||||||
|
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||||
|
status_str = "Motors ARMED" if self.armed else "Motors DISARMED"
|
||||||
|
self.log_event(f"Command: {status_str}")
|
||||||
|
conn.mav.statustext_send(
|
||||||
|
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||||
|
status_str.encode("ascii"),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif cmd == mavutil.mavlink.MAV_CMD_DO_SET_MODE:
|
||||||
|
self.mode = int(msg.param2)
|
||||||
|
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||||
|
self.log_event(f"Command: SET_MODE {self.mode}")
|
||||||
|
conn.mav.statustext_send(
|
||||||
|
mavutil.mavlink.MAV_SEVERITY_INFO,
|
||||||
|
b"Mode: MANUAL",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif cmd == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
|
||||||
|
msg_id = int(msg.param1)
|
||||||
|
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||||
|
self.log_event(f"Stream interval set for MsgID {msg_id}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
||||||
|
|
||||||
|
elif mtype == "SET_MODE":
|
||||||
|
self.mode = int(msg.custom_mode)
|
||||||
|
self.log_event(f"Set custom mode -> {self.mode}")
|
||||||
|
conn.mav.statustext_send(mavutil.mavlink.MAV_SEVERITY_INFO, b"Mode: MANUAL")
|
||||||
|
|
||||||
|
elif mtype == "REQUEST_DATA_STREAM":
|
||||||
|
pass # Acknowledged implicitly by sending streams
|
||||||
|
|
||||||
|
elif mtype == "PARAM_REQUEST_READ":
|
||||||
|
param_id = msg.param_id
|
||||||
|
if isinstance(param_id, bytes):
|
||||||
|
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
|
||||||
|
if param_id in self.params:
|
||||||
|
val, ptype = self.params[param_id]
|
||||||
|
else:
|
||||||
|
val, ptype = (0.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32)
|
||||||
|
conn.mav.param_value_send(
|
||||||
|
param_id.encode("ascii"),
|
||||||
|
float(val),
|
||||||
|
ptype,
|
||||||
|
len(self.params),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
self.log_event(f"Param read: {param_id} = {val}")
|
||||||
|
|
||||||
|
elif mtype == "PARAM_REQUEST_LIST":
|
||||||
|
self.log_event("Param list requested, streaming all...")
|
||||||
|
for idx, (p_name, (p_val, p_type)) in enumerate(self.params.items()):
|
||||||
|
conn.mav.param_value_send(
|
||||||
|
p_name.encode("ascii"),
|
||||||
|
float(p_val),
|
||||||
|
p_type,
|
||||||
|
len(self.params),
|
||||||
|
idx,
|
||||||
|
)
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
elif mtype == "PARAM_SET":
|
||||||
|
param_id = msg.param_id
|
||||||
|
if isinstance(param_id, bytes):
|
||||||
|
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
|
||||||
|
new_val = float(msg.param_value)
|
||||||
|
ptype = int(msg.param_type)
|
||||||
|
self.params[param_id] = (new_val, ptype)
|
||||||
|
conn.mav.param_value_send(
|
||||||
|
param_id.encode("ascii"),
|
||||||
|
new_val,
|
||||||
|
ptype,
|
||||||
|
len(self.params),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
self.log_event(f"Param updated: {param_id} -> {int(new_val) if new_val.is_integer() else new_val}")
|
||||||
|
|
||||||
|
elif mtype == "MANUAL_CONTROL":
|
||||||
|
self.controls.x = float(msg.x)
|
||||||
|
self.controls.y = float(msg.y)
|
||||||
|
self.controls.z = float(msg.z)
|
||||||
|
self.controls.r = float(msg.r)
|
||||||
|
self.controls.last_rx = time.monotonic()
|
||||||
|
|
||||||
|
def _render_dashboard(self):
|
||||||
|
# Clear screen ANSI
|
||||||
|
out = sys.stdout
|
||||||
|
out.write("\033[H")
|
||||||
|
|
||||||
|
# Color codes
|
||||||
|
GREEN = "\033[92m"
|
||||||
|
RED = "\033[91m"
|
||||||
|
YELLOW = "\033[93m"
|
||||||
|
CYAN = "\033[96m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
def bar(val: float, min_v: float, max_v: float, width: int = 14) -> str:
|
||||||
|
norm = max(0.0, min(1.0, (val - min_v) / (max_v - min_v)))
|
||||||
|
pos = int(norm * width)
|
||||||
|
return f"[{'=' * pos}{' ' * (width - pos)}]"
|
||||||
|
|
||||||
|
status_tag = f"{GREEN}{BOLD} ARMED {RESET}" if self.armed else f"{YELLOW}DISARMED{RESET}"
|
||||||
|
link_tag = f"{GREEN}ONLINE{RESET}" if self.client_connected else f"{RED}WAITING{RESET}"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"{BOLD}{CYAN}========================================================================{RESET}",
|
||||||
|
f"{BOLD} ArduSub SimpleROV-3 Flight Controller Spoofer & Telemetry Simulator {RESET}",
|
||||||
|
f"{BOLD}{CYAN}========================================================================{RESET}",
|
||||||
|
f" Endpoint: {BOLD}{self.endpoint}{RESET} Baud: {self.baud} Link: {link_tag} State: {status_tag} Mode: {BOLD}MANUAL{RESET}",
|
||||||
|
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||||
|
f" {BOLD}BATTERY & POWER:{RESET}",
|
||||||
|
f" Voltage: {BOLD}{self.battery_base_v:.2f} V{RESET} ({self.battery_cells}S) Current: {BOLD}{self.current_a:.2f} A{RESET} Power: {BOLD}{self.battery_base_v * self.current_a:.1f} W{RESET}",
|
||||||
|
f" Depth: {BOLD}{self.depth_m:.2f} m{RESET} Heading: {BOLD}{self.heading_deg:.0f}°{RESET} Roll/Pitch: {self.roll_deg:+.1f}° / {self.pitch_deg:+.1f}°",
|
||||||
|
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||||
|
f" {BOLD}INCOMING MANUAL CONTROLS (from Sub Control app):{RESET}",
|
||||||
|
f" Surge (Forward/Back): {self.controls.x:+5.0f} {bar(self.controls.x, -1000, 1000)}",
|
||||||
|
f" Yaw (Steer/Turn): {self.controls.r:+5.0f} {bar(self.controls.r, -1000, 1000)}",
|
||||||
|
f" Heave (Vertical): {self.controls.z:5.0f} {bar(self.controls.z, 0, 1000)}",
|
||||||
|
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||||
|
f" {BOLD}SIMULATED THRUSTER PWM OUTPUTS:{RESET}",
|
||||||
|
f" Motor 1 (Port / Left): {self.m1_pwm:4d} µs {bar(self.m1_pwm, 1100, 1900)}",
|
||||||
|
f" Motor 2 (Starboard / Right): {self.m2_pwm:4d} µs {bar(self.m2_pwm, 1100, 1900)}",
|
||||||
|
f" Motor 3 (Vertical / Center): {self.m3_pwm:4d} µs {bar(self.m3_pwm, 1100, 1900)}",
|
||||||
|
f"{CYAN}------------------------------------------------------------------------{RESET}",
|
||||||
|
f" {BOLD}RECENT EVENT LOG:{RESET}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if not self.events:
|
||||||
|
lines.append(f" {DIM}No events yet. Connect Sub Control app to begin.{RESET}")
|
||||||
|
else:
|
||||||
|
for ev in list(self.events)[-5:]:
|
||||||
|
lines.append(f" {ev}")
|
||||||
|
|
||||||
|
lines.append(f"{CYAN}------------------------------------------------------------------------{RESET}")
|
||||||
|
lines.append(f" Packets RX: {self.total_packets_rx:<8d} Packets TX: {self.total_packets_tx:<8d} (Press Ctrl+C to exit)")
|
||||||
|
|
||||||
|
out.write("\n".join(lines) + "\n")
|
||||||
|
out.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="ArduSub Flight Controller (FC) Spoofer & Telemetry Simulator",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--endpoint",
|
||||||
|
type=str,
|
||||||
|
default="tcpin:127.0.0.1:5760",
|
||||||
|
help="MAVLink connection string (e.g. 'tcpin:127.0.0.1:5760', 'udpin:127.0.0.1:14550', 'COM3')",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--baud",
|
||||||
|
type=int,
|
||||||
|
default=57600,
|
||||||
|
help="Baud rate (for serial COM ports)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cells",
|
||||||
|
type=int,
|
||||||
|
default=4,
|
||||||
|
help="Battery cell count (4S = 16.0V nominal)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--voltage",
|
||||||
|
type=float,
|
||||||
|
default=15.8,
|
||||||
|
help="Nominal starting battery voltage (V)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-dashboard",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable interactive terminal dashboard and use plain output",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Enable ANSI escape sequences on Windows console
|
||||||
|
if os.name == "nt":
|
||||||
|
os.system("")
|
||||||
|
|
||||||
|
spoofer = FlightControllerSpoofer(
|
||||||
|
endpoint=args.endpoint,
|
||||||
|
baud=args.baud,
|
||||||
|
battery_cells=args.cells,
|
||||||
|
nominal_voltage=args.voltage,
|
||||||
|
show_dashboard=not args.no_dashboard,
|
||||||
|
)
|
||||||
|
spoofer.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
title ArduSub FC Spoofer
|
||||||
|
cd /d "%~dp0"
|
||||||
|
if exist ".venv\Scripts\python.exe" (
|
||||||
|
".venv\Scripts\python.exe" -u fc_spoofer.py %*
|
||||||
|
) else (
|
||||||
|
python -u fc_spoofer.py %*
|
||||||
|
)
|
||||||
|
pause
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location -LiteralPath $projectRoot
|
||||||
|
|
||||||
|
$existingListener = Get-NetTCPConnection -LocalPort 5760 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if ($existingListener) {
|
||||||
|
$existingProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$($existingListener.OwningProcess)" -ErrorAction SilentlyContinue
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "The simulator is already LISTENING on tcp:127.0.0.1:5760." -ForegroundColor Green
|
||||||
|
Write-Host "Process: $($existingListener.OwningProcess) $($existingProcess.Name)"
|
||||||
|
Write-Host "Leave that simulator running and select 'Simulator (TCP 5760)' in SUB Control."
|
||||||
|
Write-Host ""
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath ".venv\Scripts\python.exe") {
|
||||||
|
& .venv\Scripts\python.exe -u fc_spoofer.py @args
|
||||||
|
} else {
|
||||||
|
& python -u fc_spoofer.py @args
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Deterministic DOM/pointer harness. No browser, vehicle, or serial port involved.
|
||||||
|
const {test}=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
|
||||||
|
const {present}=require('../web/control-state.js');
|
||||||
|
const armed={connected:true,armed_known:true,armed:true,app_state:'armed',servos:{}};
|
||||||
|
|
||||||
|
test('native pywebview result wrapper can execute alongside app globals',()=>{
|
||||||
|
const h=harness();
|
||||||
|
// Window.evaluate_js declares these variables in the page's global scope.
|
||||||
|
// A global const/let collision prevents every Python reply from executing.
|
||||||
|
assert.doesNotThrow(()=>h.run(`var value; try { value = ['COM3','COM4']; }
|
||||||
|
catch(e) { value = {name:e.name}; var keys = Object.getOwnPropertyNames(e);
|
||||||
|
keys.forEach(function(key) { value[key] = e[key]; }); }
|
||||||
|
populatePorts(value);`));
|
||||||
|
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty pywebview API cannot permanently latch startup as completed',async()=>{
|
||||||
|
const h=harness();h.context.pywebview.api={};await h.run('boot()');
|
||||||
|
assert.equal(h.run('booted'),false);assert.equal(h.run('bridgeReady'),false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop startup retries after failure and populates detected COM ports',async()=>{
|
||||||
|
const h=harness();let attempts=0;
|
||||||
|
h.run('loadDefaultModel=()=>Promise.resolve(false)');
|
||||||
|
h.context.pywebview.api.refresh_ports=()=>Promise.resolve([{device:'COM3',label:'COM3'},{device:'COM4',label:'COM4'}]);
|
||||||
|
h.context.pywebview.api.initial_state=()=>++attempts===1?Promise.reject(Error('Temporary startup failure')):Promise.resolve({ports:['COM3','COM4'],battery_config:{},state:{connected:false,armed:null,servos:{}}});
|
||||||
|
h.context.pywebview.api.poll=()=>Promise.resolve({connected:false,armed:null,servos:{}});
|
||||||
|
await h.run('boot()');assert.equal(h.run('booted'),false);
|
||||||
|
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'),'COM discovery survives a configuration startup failure');
|
||||||
|
await h.run('boot()');assert.equal(h.run('booted'),true);
|
||||||
|
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
|
||||||
|
assert.ok(h.el('#port').options.some(p=>p.value==='COM4'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visual status changes cannot resize the header or percentage label',()=>{
|
||||||
|
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||||
|
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||||
|
assert.doesNotMatch(html,/class="water-ring/);
|
||||||
|
assert.match(html,/class="engage-sheen-label"/);
|
||||||
|
assert.match(css,/padding-bottom:\.22em; margin-bottom:-\.22em/);
|
||||||
|
assert.match(css,/grid-template-rows:120px minmax\(0,1fr\) 52px/);
|
||||||
|
assert.match(css,/\.control-status \{ position:absolute/);
|
||||||
|
assert.match(css,/flex:0 0 128px; width:128px/);
|
||||||
|
assert.match(css,/\.vertical-card footer \{ justify-content:center/);
|
||||||
|
assert.match(css,/mask-image:linear-gradient\(to right,#000 calc\(var\(--engage-progress\) - 8%\),transparent var\(--engage-progress\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activity renders oldest at top, latest above its footer; both control states sweep',()=>{
|
||||||
|
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||||
|
assert.ok(html.indexOf('id="activity-oldest"')<html.indexOf('id="activity-previous"'));
|
||||||
|
assert.ok(html.indexOf('id="activity-previous"')<html.indexOf('id="status-text"'));
|
||||||
|
assert.ok(html.indexOf('id="status-text"')<html.indexOf('class="activity-heading"'));
|
||||||
|
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
|
||||||
|
assert.match(css,/#engage:is\(\.arm,\.danger\).*animation:engage-sweep/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native drag surface has no interactive descendants; output limit is outside sidebar',()=>{
|
||||||
|
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
|
||||||
|
assert.match(html,/<aside class="sidebar-deck"/);
|
||||||
|
assert.match(html,/<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"><\/div>/);
|
||||||
|
assert.ok(html.indexOf('id="gain"')>html.indexOf('</aside>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function harness(){
|
||||||
|
let now=100,id=0;const timers=new Map(),elements=new Map(),listeners=new Map();
|
||||||
|
class Element{
|
||||||
|
constructor(name){this.name=name;this.dataset={};this.style={setProperty(k,v){this[k]=v}};this.classes=new Set();this.classList={add:(...n)=>n.forEach(x=>this.classes.add(x)),remove:(...n)=>n.forEach(x=>this.classes.delete(x)),contains:n=>this.classes.has(n),toggle:(n,b)=>b?this.classes.add(n):this.classes.delete(n)};this.handlers={};this.captured=new Set();this.value='demo';this.options=[];this.tagName='DIV';this.children=[];this.textContent='';}
|
||||||
|
addEventListener(n,fn){(this.handlers[n]??=[]).push(fn)}
|
||||||
|
removeEventListener(){}
|
||||||
|
emit(n,fields={}){const e={pointerId:1,button:0,pointerType:'touch',clientX:160,clientY:160,preventDefault(){},target:this,...fields};for(const fn of this.handlers[n]||[])fn(e);return e}
|
||||||
|
getBoundingClientRect(){return this.name==='#heave'?{left:0,top:0,width:48,height:300,right:48,bottom:300}:this.name==='#engage'?{left:0,top:0,width:180,height:44,right:180,bottom:44}:{left:0,top:0,width:320,height:320,right:320,bottom:320}}
|
||||||
|
setPointerCapture(p){this.captured.add(p)}
|
||||||
|
releasePointerCapture(p){if(this.captured.delete(p))this.emit('lostpointercapture',{pointerId:p})}
|
||||||
|
getContext(){return new Proxy({}, {get:()=>()=>{}})}
|
||||||
|
querySelector(s){return el(this.name+' '+s)}
|
||||||
|
setAttribute(k,v){this[k]=v}
|
||||||
|
append(n){this.children.push(n)}
|
||||||
|
add(n){this.options.push(n)}
|
||||||
|
closest(){return null}
|
||||||
|
click(){if(!this.disabled)this.onclick?.({target:this,currentTarget:this})}
|
||||||
|
}
|
||||||
|
function el(n){if(!elements.has(n))elements.set(n,new Element(n));return elements.get(n)}
|
||||||
|
const document={querySelector:el,querySelectorAll:()=>[],getElementById:n=>el('#'+n),createElement:n=>new Element(n),body:el('body'),readyState:'loading',hidden:false,hasFocus:()=>true,addEventListener(n,f){listeners.set('doc:'+n,f)}};
|
||||||
|
const schedule=(fn,ms,interval=0)=>{timers.set(++id,{fn,at:now+ms,interval});return id};
|
||||||
|
const calls=[];
|
||||||
|
const context=vm.createContext({console,document,devicePixelRatio:1,performance:{now:()=>now},setTimeout:(f,ms=0)=>schedule(f,ms),clearTimeout:i=>timers.delete(i),setInterval:(f,ms)=>schedule(f,ms,ms),requestAnimationFrame:f=>schedule(()=>f(now),16),cancelAnimationFrame:i=>timers.delete(i),addEventListener(n,f){listeners.set(n,f)},Option:function(label,value){this.label=label;this.value=value},fetch:()=>Promise.reject(Error('No network in unit tests'))});
|
||||||
|
context.window=context;
|
||||||
|
context.pywebview={api:{set_axes:(...a)=>{calls.push(['axes',...a]);return Promise.resolve()},stop_controls:(...a)=>{calls.push(['stop',...a]);return Promise.resolve()},command:(name)=>{calls.push(['command',name]);return Promise.resolve(true)}}};
|
||||||
|
for(const f of ['control-state.js','joystick-3d.js','app.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'../web',f),'utf8'),context,{filename:f});
|
||||||
|
function run(s){return vm.runInContext(s,context)}
|
||||||
|
function advance(ms){const end=now+ms;while(true){let next=[...timers].filter(([,t])=>t.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!next)break;const [i,t]=next;now=t.at;timers.delete(i);if(t.interval)timers.set(i,{...t,at:now+t.interval});t.fn()}now=end;}
|
||||||
|
function state(s=armed){context.snapshot=s;run('render(snapshot)')}
|
||||||
|
return {el,run,advance,state,calls,context,fire:(name,e={})=>listeners.get(name)?.(e)};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pending/timeout/unknown presentation never invents armed state',()=>{
|
||||||
|
let p=present({...armed,pending_command:'disarm'});
|
||||||
|
assert.equal(p.label,'DISARMING…');assert.equal(p.drive,false);
|
||||||
|
p=present({...armed,command_error:'Disarm confirmation timed out.'});
|
||||||
|
assert.equal(p.label,'DISARM');assert.match(p.message,/Vehicle armed/);
|
||||||
|
p=present({...armed,armed:false,pending_command:'arm'});
|
||||||
|
assert.equal(p.label,'ENGAGING…');assert.equal(p.drive,false);
|
||||||
|
p=present({...armed,armed:false,command_error:'Engage failed'});
|
||||||
|
assert.equal(p.label,'ENGAGE');assert.equal(p.action,'arm');
|
||||||
|
p=present({...armed,connected:false,armed_known:false});
|
||||||
|
assert.equal(p.label,'STATE UNKNOWN');assert.equal(p.drive,false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activity history keeps three unique recent events, latest first',()=>{
|
||||||
|
const h=harness();h.run("recordActivity('One');recordActivity('Two');recordActivity('Three');recordActivity('Three');recordActivity('Four')");
|
||||||
|
assert.equal(h.el('#status-text').textContent,'Four');assert.equal(h.el('#activity-previous').textContent,'Three');assert.equal(h.el('#activity-oldest').textContent,'Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output limit drag is bounded, owns one pointer and does not recenter',()=>{
|
||||||
|
const h=harness(),slider=h.el('#gain');slider.min='5';slider.max='50';slider.value='20';
|
||||||
|
slider.emit('pointerdown',{pointerId:1,clientX:9});assert.equal(h.el('#gain-value').textContent,'5%');
|
||||||
|
slider.emit('pointermove',{pointerId:2,clientX:311});assert.equal(h.el('#gain-value').textContent,'5%');
|
||||||
|
slider.emit('pointermove',{pointerId:1,clientX:311});assert.equal(h.el('#gain-value').textContent,'50%');
|
||||||
|
slider.emit('pointerup');h.advance(500);assert.equal(h.el('#gain-value').textContent,'50%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maximum travel, diagonal and smooth touch return',()=>{
|
||||||
|
const h=harness();h.state();const c=h.el('#joystick-canvas');
|
||||||
|
c.emit('pointerdown',{clientX:1000,clientY:160});
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,1);
|
||||||
|
c.emit('pointermove',{clientX:1000,clientY:1000});
|
||||||
|
const s=h.context.joystick3D.getState();assert.ok(Math.abs(s.x-Math.SQRT1_2)<1e-6);assert.ok(Math.abs(s.y-Math.SQRT1_2)<1e-6);
|
||||||
|
c.emit('pointerup');h.advance(180);assert.ok(h.context.joystick3D.getState().x>0);h.advance(220);assert.equal(h.context.joystick3D.getState().x,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('independent multi-touch: second joystick finger ignored; heave continues on joystick release',()=>{
|
||||||
|
const h=harness();h.state();const c=h.el('#joystick-canvas'),v=h.el('#heave');
|
||||||
|
c.emit('pointerdown',{pointerId:1,clientX:250});
|
||||||
|
v.emit('pointerdown',{pointerId:2,clientY:0});
|
||||||
|
const x=h.context.joystick3D.getState().x;
|
||||||
|
c.emit('pointerdown',{pointerId:3,clientX:0});c.emit('pointerup',{pointerId:3});
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,x);
|
||||||
|
c.emit('pointerup',{pointerId:1});h.advance(400);
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,1);
|
||||||
|
v.emit('pointercancel',{pointerId:2});h.advance(450);assert.equal(h.context.verticalControl.getState().value,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mouse leaving joystick returns to center',()=>{
|
||||||
|
const h=harness();h.state();const c=h.el('#joystick-canvas');
|
||||||
|
c.emit('pointerdown',{pointerType:'mouse',clientX:300});c.emit('pointerleave',{pointerType:'mouse'});h.advance(400);
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focus loss clears both captured pointers and prevents replay',()=>{
|
||||||
|
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.el('#heave').emit('pointerdown',{pointerId:2,clientY:0});
|
||||||
|
h.fire('blur');h.advance(500);
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,0);
|
||||||
|
assert.equal(h.context.joystick3D.getState().active,false);assert.equal(h.context.verticalControl.getState().active,false);
|
||||||
|
assert.equal(h.run('view.drive'),false);h.fire('focus');assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lost connection during commanded joystick clears command and shows unknown',()=>{
|
||||||
|
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
|
||||||
|
h.state({...armed,connected:false,armed_known:false,app_state:'disconnected'});
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||||
|
h.state();assert.equal(h.run('control.turn'),0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('half hold cancels on touch interruption and focus loss; extra fingers cannot complete it',()=>{
|
||||||
|
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});const b=h.el('#engage');
|
||||||
|
b.emit('pointerdown');h.advance(600);b.emit('pointerdown',{pointerId:2});b.emit('pointerup',{pointerId:2});
|
||||||
|
assert.equal(h.run('engagePointer'),1);b.emit('pointercancel');h.advance(1500);assert.equal(h.calls.length,0);
|
||||||
|
b.emit('pointerdown');h.advance(600);h.fire('blur');h.advance(1500);assert.equal(h.calls.length,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demo full hold waits for simulated confirmation; release cannot immediately disarm',()=>{
|
||||||
|
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
|
||||||
|
b.emit('pointerdown');h.advance(1450);assert.equal(h.el('#engage-label').textContent,'engaging…');assert.equal(h.run('demoState.armed'),false);
|
||||||
|
b.emit('pointerup');b.click();h.advance(700);assert.equal(h.el('#engage-label').textContent,'disengage');
|
||||||
|
b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');assert.equal(h.run('demoState.armed'),true);
|
||||||
|
h.advance(700);assert.equal(h.el('#engage-label').textContent,'engage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real send result and accepted ACK cannot optimistically arm; timeout is visible',async()=>{
|
||||||
|
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});
|
||||||
|
h.run("requestControl('arm')");await Promise.resolve();assert.equal(h.el('#engage-label').textContent,'engaging…');
|
||||||
|
assert.equal(h.run('vehicleSnapshot.armed'),false);h.advance(6100);
|
||||||
|
assert.equal(h.el('#engage-label').textContent,'engage');assert.match(h.el('#control-status').textContent,/timed out/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bridge freeze clears input even if armed snapshot has not changed',()=>{
|
||||||
|
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.run('markBridgeStale()');
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||||
|
assert.match(h.el('#control-status').textContent,/Telemetry unavailable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hung bridge poll has an independent watchdog and cannot trap the controls',async()=>{
|
||||||
|
const h=harness();h.state();h.context.pywebview.api.poll=()=>new Promise(()=>{});
|
||||||
|
h.run('bridgeReady=true;lastBridgeAt=performance.now();poll()');
|
||||||
|
h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
|
||||||
|
h.advance(1700);await Promise.resolve();
|
||||||
|
assert.equal(h.run('view.drive'),false);assert.equal(h.el('#engage-label').textContent,'state unknown');
|
||||||
|
assert.equal(h.context.joystick3D.getState().x,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holding through confirmation then releasing outside cannot swallow the next disarm tap',()=>{
|
||||||
|
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
|
||||||
|
b.emit('pointerdown');h.advance(2300);assert.equal(h.el('#engage-label').textContent,'disengage');assert.ok(b.captured.has(1));
|
||||||
|
b.emit('pointerup',{clientX:500});h.advance(1);b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""No serial port is opened by these tests."""
|
||||||
|
import queue
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from app import Axes, MavlinkWorker, NEUTRAL_Z
|
||||||
|
from vehicle_state import VehicleState
|
||||||
|
from web_ui import ControlApi
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleStateTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now = 10.0
|
||||||
|
self.state = VehicleState(lambda: self.now)
|
||||||
|
self.state.heartbeat(False)
|
||||||
|
|
||||||
|
def test_ack_is_not_armed_confirmation(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.now += .2
|
||||||
|
self.state.ack("MAV_RESULT_ACCEPTED")
|
||||||
|
self.assertFalse(self.state.snapshot()["armed"])
|
||||||
|
self.assertEqual(self.state.snapshot()["pending_command"], "arm")
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.assertTrue(self.state.snapshot()["controls_allowed"])
|
||||||
|
|
||||||
|
def test_heartbeat_confirms_even_when_ack_packet_is_lost(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.now += .5
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.assertIsNone(self.state.pending)
|
||||||
|
self.assertTrue(self.state.armed)
|
||||||
|
|
||||||
|
def test_old_events_cannot_confirm_new_request(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.state.ack("MAV_RESULT_DENIED", received_at=9)
|
||||||
|
self.state.heartbeat(True, received_at=9)
|
||||||
|
self.assertIsNotNone(self.state.pending)
|
||||||
|
self.assertFalse(self.state.armed)
|
||||||
|
|
||||||
|
def test_rejected_engage_stays_disarmed(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.now += .1
|
||||||
|
self.state.ack("MAV_RESULT_DENIED")
|
||||||
|
self.assertIsNone(self.state.pending)
|
||||||
|
self.assertIn("rejected", self.state.error)
|
||||||
|
self.assertFalse(self.state.armed)
|
||||||
|
|
||||||
|
def test_engage_timeout_then_late_armed_heartbeat_is_truth(self):
|
||||||
|
self.state.request("arm")
|
||||||
|
self.now += 6
|
||||||
|
self.state.tick()
|
||||||
|
self.assertIn("timed out", self.state.error)
|
||||||
|
self.assertFalse(self.state.snapshot()["armed_known"])
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.assertTrue(self.state.snapshot()["armed"])
|
||||||
|
|
||||||
|
def test_disarm_timeout_never_claims_disarmed(self):
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.state.request("disarm")
|
||||||
|
self.now += 6
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
snap = self.state.snapshot()
|
||||||
|
self.assertTrue(snap["armed"])
|
||||||
|
self.assertIn("timed out", snap["command_error"])
|
||||||
|
|
||||||
|
def test_disarm_ack_waits_for_disarmed_heartbeat(self):
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.state.request("disarm")
|
||||||
|
self.now += .2
|
||||||
|
self.state.ack("MAV_RESULT_ACCEPTED")
|
||||||
|
self.assertTrue(self.state.snapshot()["armed"])
|
||||||
|
self.assertFalse(self.state.snapshot()["controls_allowed"])
|
||||||
|
self.state.heartbeat(False)
|
||||||
|
self.assertIsNone(self.state.pending)
|
||||||
|
self.assertFalse(self.state.armed)
|
||||||
|
|
||||||
|
def test_disconnect_preserves_last_known_armed_but_invalidates_it(self):
|
||||||
|
self.state.heartbeat(True)
|
||||||
|
self.state.disconnect()
|
||||||
|
snap = self.state.snapshot()
|
||||||
|
self.assertTrue(snap["armed"])
|
||||||
|
self.assertFalse(snap["armed_known"])
|
||||||
|
self.assertFalse(snap["controls_allowed"])
|
||||||
|
|
||||||
|
def test_frozen_telemetry_blocks_new_engage(self):
|
||||||
|
self.now += 3.1
|
||||||
|
self.assertFalse(self.state.snapshot()["armed_known"])
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.state.request("arm")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerWatchdogTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.worker = MavlinkWorker(queue.Queue())
|
||||||
|
self.worker.require_input_lease = True
|
||||||
|
self.worker.connection = Mock()
|
||||||
|
self.worker.target_system = 1
|
||||||
|
self.worker.armed = True
|
||||||
|
self.worker.last_heartbeat = 10
|
||||||
|
|
||||||
|
def assertNeutral(self):
|
||||||
|
self.worker._send_control()
|
||||||
|
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args,
|
||||||
|
(1, 0, 0, NEUTRAL_Z, 0, 0))
|
||||||
|
|
||||||
|
def test_ui_freeze_neutralizes_and_does_not_replay(self):
|
||||||
|
with patch("app.time.monotonic", return_value=10):
|
||||||
|
self.worker.set_axes(Axes(forward=1), 1)
|
||||||
|
self.worker._send_control()
|
||||||
|
self.assertGreater(self.worker.output_axes.forward, 0)
|
||||||
|
with patch("app.time.monotonic", return_value=11.1):
|
||||||
|
self.assertNeutral()
|
||||||
|
self.assertEqual(self.worker.axes, Axes())
|
||||||
|
|
||||||
|
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
|
||||||
|
with patch("app.time.monotonic", return_value=13.1):
|
||||||
|
self.worker.set_axes(Axes(forward=1), 1)
|
||||||
|
self.assertNeutral()
|
||||||
|
|
||||||
|
def test_stale_queued_input_cannot_override_neutral(self):
|
||||||
|
self.worker.set_axes(Axes(), 20)
|
||||||
|
self.worker.set_axes(Axes(forward=1), 19)
|
||||||
|
self.assertEqual(self.worker.axes, Axes())
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeTests(unittest.TestCase):
|
||||||
|
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
|
||||||
|
api = ControlApi()
|
||||||
|
api.worker.connection = Mock()
|
||||||
|
api.worker.target_system = 1
|
||||||
|
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
|
||||||
|
api.worker.set_axes(Axes(), 20)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "newer input reset"):
|
||||||
|
api.command("arm", 19)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "expired"):
|
||||||
|
api.command("arm", 21, expires_at=0)
|
||||||
|
api.worker.connection.mav.command_long_send.assert_not_called()
|
||||||
|
|
||||||
|
def test_real_bridge_ack_pending_disconnect_and_stale_battery(self):
|
||||||
|
api = ControlApi()
|
||||||
|
api.worker.connection = Mock()
|
||||||
|
api.worker.target_system = 1
|
||||||
|
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
|
||||||
|
pending = api.command("arm")
|
||||||
|
self.assertEqual(pending["pending_command"], "arm")
|
||||||
|
self.assertFalse(pending["armed"])
|
||||||
|
api._consume(("ack", 400, "MAV_RESULT_ACCEPTED"))
|
||||||
|
self.assertFalse(api._snapshot()["armed"])
|
||||||
|
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", True))
|
||||||
|
self.assertTrue(api._snapshot()["armed"])
|
||||||
|
api._consume(("disconnected", "Disconnected"))
|
||||||
|
self.assertTrue(api._snapshot()["armed"])
|
||||||
|
self.assertFalse(api._snapshot()["armed_known"])
|
||||||
|
self.assertTrue(api._snapshot()["battery_stale"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Vehicle truth and command transactions; independent of UI/animation clocks."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleState:
|
||||||
|
HEARTBEAT_TIMEOUT = 3.0
|
||||||
|
COMMAND_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
def __init__(self, clock=time.monotonic):
|
||||||
|
self.clock = clock
|
||||||
|
self.connected = False
|
||||||
|
self.armed = None
|
||||||
|
self.heartbeat_at = None
|
||||||
|
self.pending = None
|
||||||
|
self.error = ""
|
||||||
|
|
||||||
|
def fresh(self):
|
||||||
|
return (self.connected and self.heartbeat_at is not None
|
||||||
|
and self.clock() - self.heartbeat_at <= self.HEARTBEAT_TIMEOUT)
|
||||||
|
|
||||||
|
def heartbeat(self, armed, received_at=None):
|
||||||
|
received_at = self.clock() if received_at is None else received_at
|
||||||
|
if self.heartbeat_at is not None and received_at < self.heartbeat_at:
|
||||||
|
return
|
||||||
|
self.connected = True
|
||||||
|
self.heartbeat_at = received_at
|
||||||
|
self.armed = bool(armed)
|
||||||
|
# A fresh heartbeat is the authoritative confirmation of actual state.
|
||||||
|
# ACK ACCEPTED alone only confirms that a command was accepted.
|
||||||
|
if (self.pending and received_at > self.pending["started"]
|
||||||
|
and self.armed == (self.pending["name"] == "arm")):
|
||||||
|
self.pending = None
|
||||||
|
|
||||||
|
def request(self, name):
|
||||||
|
self.tick()
|
||||||
|
if name == "arm" and (not self.fresh() or self.armed is not False):
|
||||||
|
raise RuntimeError("Fresh, disarmed vehicle telemetry is required to engage")
|
||||||
|
if self.pending and (name == "arm" or self.pending["name"] == name):
|
||||||
|
raise RuntimeError("A vehicle confirmation is already pending")
|
||||||
|
now = self.clock()
|
||||||
|
self.pending = {"name": name, "started": now,
|
||||||
|
"deadline": now + self.COMMAND_TIMEOUT, "ack": None}
|
||||||
|
self.error = ""
|
||||||
|
|
||||||
|
def ack(self, result, received_at=None):
|
||||||
|
received_at = self.clock() if received_at is None else received_at
|
||||||
|
if not self.pending or received_at <= self.pending["started"]:
|
||||||
|
return
|
||||||
|
self.pending["ack"] = result
|
||||||
|
if result not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"):
|
||||||
|
self.fail(f"{self.pending['name'].capitalize()} rejected: {result}")
|
||||||
|
|
||||||
|
def fail(self, message):
|
||||||
|
self.error = message
|
||||||
|
self.pending = None
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.connected = False
|
||||||
|
if self.pending:
|
||||||
|
self.fail("Connection lost before vehicle confirmation. State unknown.")
|
||||||
|
|
||||||
|
def tick(self):
|
||||||
|
if self.pending and self.clock() >= self.pending["deadline"]:
|
||||||
|
name = self.pending["name"].capitalize()
|
||||||
|
self.fail(f"{name} confirmation timed out. Check current vehicle state.")
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
self.tick()
|
||||||
|
fresh = self.fresh()
|
||||||
|
return {"armed": self.armed, "armed_known": fresh,
|
||||||
|
"heartbeat_age": None if self.heartbeat_at is None else self.clock() - self.heartbeat_at,
|
||||||
|
"pending_command": self.pending["name"] if self.pending else None,
|
||||||
|
"command_ack": self.pending["ack"] if self.pending else None,
|
||||||
|
"command_error": self.error,
|
||||||
|
"controls_allowed": fresh and self.armed is True and self.pending is None}
|
||||||
+590
-34
@@ -1,46 +1,602 @@
|
|||||||
|
const STATUS_LABELS = {
|
||||||
|
disconnected: 'Disconnected — select a port and connect',
|
||||||
|
connecting: 'Connecting…',
|
||||||
|
connected_disarmed: 'Connected · Disarmed',
|
||||||
|
armed: 'Connected · Armed',
|
||||||
|
link_degraded: 'Signal lost — check tether',
|
||||||
|
};
|
||||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||||
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0;
|
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
||||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||||
|
const activity=[];
|
||||||
|
let lastReportedStatus='';
|
||||||
|
function recordActivity(message){
|
||||||
|
if(!message||activity[0]===message)return;
|
||||||
|
activity.unshift(String(message));activity.length=Math.min(activity.length,3);
|
||||||
|
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
|
||||||
|
const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})}
|
||||||
|
let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true,
|
||||||
|
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
|
||||||
|
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
|
||||||
|
const canDrive=()=>view.drive&&performance.now()-lastSafetyTick<750;
|
||||||
|
window.canDrive=canDrive;
|
||||||
|
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
|
||||||
|
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
|
||||||
|
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||||
window.showToast=toast;
|
window.showToast=toast;
|
||||||
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
|
function populatePorts(ports){
|
||||||
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
const p=$('#port');
|
||||||
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
|
if(!p)return;
|
||||||
|
const old=p.value||'';
|
||||||
|
p.innerHTML='';
|
||||||
|
|
||||||
const joy=$('#joystick'),stick=$('#stick');let joyDrag=false,returnAnim=0;
|
const allList=[];
|
||||||
function setJoy(x,y,send=true){const r=joy.clientWidth*.34,n=Math.hypot(x,y);if(n>1){x/=n;y/=n}control.turn=x;control.forward=-y;stick.style.transform=`translate(calc(-50% + ${x*r}px),calc(-50% + ${y*r}px))`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)}
|
const seen=new Set();
|
||||||
function joyEvent(e){const b=joy.getBoundingClientRect();setJoy((e.clientX-b.left-b.width/2)/(b.width*.34),(e.clientY-b.top-b.height/2)/(b.height*.34))}
|
|
||||||
joy.addEventListener('pointerdown',e=>{joyDrag=true;cancelAnimationFrame(returnAnim);joy.setPointerCapture(e.pointerId);joyEvent(e)});joy.addEventListener('pointermove',e=>{if(joyDrag)joyEvent(e)});joy.addEventListener('pointerup',()=>{joyDrag=false;easeJoy()});
|
|
||||||
function easeJoy(){const sx=control.turn,sy=-control.forward,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setJoy(sx*(1-smooth),sy*(1-smooth));if(p<1)returnAnim=requestAnimationFrame(frame)}returnAnim=requestAnimationFrame(frame)}
|
|
||||||
|
|
||||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
|
const addPort=item=>{
|
||||||
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)}
|
let dev=item, lbl=item;
|
||||||
|
if(item&&typeof item==='object'){
|
||||||
|
dev=item.device||'';
|
||||||
|
lbl=item.label||dev;
|
||||||
|
}
|
||||||
|
if(dev&&!seen.has(dev)){
|
||||||
|
seen.add(dev);
|
||||||
|
allList.push({device:dev, label:lbl});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if(Array.isArray(ports)) ports.forEach(addPort);
|
||||||
|
addPort({device:'demo', label:'Demo Mode (no hardware)'});
|
||||||
|
|
||||||
|
let matched=false;
|
||||||
|
allList.forEach(item=>{
|
||||||
|
const opt=new Option(item.label, item.device);
|
||||||
|
p.add(opt);
|
||||||
|
if(item.device===old){
|
||||||
|
p.value=item.device;
|
||||||
|
matched=true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if(!matched&&allList.length>0){
|
||||||
|
p.value=allList[0].device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sendAxes(){
|
||||||
|
if(!canDrive()||demoActive||!bridgeReady||axisBusy)return;
|
||||||
|
const transmit=()=>{
|
||||||
|
axisTimer=0;
|
||||||
|
if(!canDrive()||demoActive||axisBusy)return;
|
||||||
|
lastAxisSend=performance.now();axisBusy=true;
|
||||||
|
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence))
|
||||||
|
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
|
||||||
|
};
|
||||||
|
const wait=50-(performance.now()-lastAxisSend);
|
||||||
|
if(wait<=0)transmit();else if(!axisTimer)axisTimer=setTimeout(transmit,wait);
|
||||||
|
}
|
||||||
|
// This is a local bridge lease, not extra MAVLink traffic. The worker retains
|
||||||
|
// the configured wire rate and neutralizes if the UI stops renewing its input.
|
||||||
|
setInterval(()=>{if(canDrive())sendAxes()},250);
|
||||||
|
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||||
|
|
||||||
|
window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||||
|
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||||
|
|
||||||
|
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
|
||||||
|
function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();}
|
||||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||||
heave.addEventListener('pointerdown',e=>{heaveDrag=true;cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;easeHeave()});
|
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
|
||||||
|
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
|
||||||
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||||
|
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
|
||||||
|
|
||||||
function applyKeys(){const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){cancelAnimationFrame(returnAnim);setJoy(turn,-forward)}else if(!joyDrag)easeJoy();if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()}
|
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else easeHeave()}}
|
||||||
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
|
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
|
||||||
addEventListener('blur',()=>{keys.clear();if(!joyDrag)easeJoy();if(!heaveDrag)easeHeave()});
|
function resetInputs(){
|
||||||
|
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||||
|
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
|
||||||
|
window.joystick3D?.reset();
|
||||||
|
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
|
||||||
|
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
|
||||||
|
hknob.classList.remove('pressed','released');
|
||||||
|
control.forward=control.turn=control.vertical=0;setHeave(0,false);
|
||||||
|
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
|
||||||
|
}
|
||||||
|
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
|
||||||
|
addEventListener('blur',()=>focusChanged(false));
|
||||||
|
addEventListener('focus',()=>focusChanged(!document.hidden));
|
||||||
|
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
|
||||||
|
addEventListener('pagehide',()=>focusChanged(false));
|
||||||
|
|
||||||
function openModal(id){document.getElementById(id)?.showModal()}$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));$$('[data-close]').forEach(b=>b.onclick=()=>b.closest('dialog').close());
|
function openModal(id){
|
||||||
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=()=>api('toggle_maximize_window');$('#window-close').onclick=()=>api('close_window');$('.window-drag').ondblclick=()=>api('toggle_maximize_window');
|
const el=typeof id==='string'?document.getElementById(id):id;
|
||||||
const modelInput=$('#model-input'),modelButton=$('#load-model');
|
if(!el)return;
|
||||||
modelButton.onclick=()=>{modelInput.value='';modelInput.click()};
|
el.classList.remove('closing');
|
||||||
function bytesToBase64(bytes){let value='';for(let i=0;i<bytes.length;i+=0x8000)value+=String.fromCharCode(...bytes.subarray(i,i+0x8000));return btoa(value)}
|
if(!el.open)el.showModal();
|
||||||
async function cacheModel(file){const token=await api('begin_model_save',file.name,file.size),chunk=1048576;for(let offset=0;offset<file.size;offset+=chunk){const bytes=new Uint8Array(await file.slice(offset,offset+chunk).arrayBuffer());await api('append_model_chunk',token,bytesToBase64(bytes));await new Promise(r=>setTimeout(r,0))}await api('finish_model_save',token)}
|
}
|
||||||
async function loadCachedModel(info){const bytes=new Uint8Array(info.size),chunk=1048576;for(let offset=0;offset<info.size;offset+=chunk){const response=await api('read_saved_model_chunk',offset,chunk),part=atob(response.data);for(let i=0;i<part.length;i++)bytes[offset+i]=part.charCodeAt(i);await new Promise(r=>setTimeout(r,0))}return window.rovModel.loadBuffer(bytes.buffer,info.name)}
|
function closeModal(dialog){
|
||||||
modelInput.onchange=async()=>{const file=modelInput.files?.[0],old=modelButton.textContent;if(!file)return;try{if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh');if(file.size>30*1024*1024)throw new Error('The STL is larger than 30 MB; export a lighter mesh');modelButton.disabled=true;modelButton.textContent='Loading…';await new Promise(requestAnimationFrame);const buffer=await file.arrayBuffer(),result=window.rovModel.loadBuffer(buffer,file.name);modelButton.textContent='Replace STL';toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`);if(bridgeReady)cacheModel(file).then(()=>toast('STL saved for the next launch')).catch(error=>toast(`Model loaded, but could not be saved: ${error.message}`,true))}catch(error){toast(`STL import failed: ${error.message}`,true)}finally{modelButton.disabled=false;if(modelButton.textContent==='Loading…')modelButton.textContent=old}};
|
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
|
||||||
$('#refresh').onclick=async()=>populatePorts(await api('refresh_ports'));$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}};
|
dialog.classList.add('closing');
|
||||||
$('#manual').onclick=()=>api('command','manual').catch(e=>toast(e.message,true));$('#arm').onclick=()=>openModal('arm-dialog');$('#confirm-arm').onclick=()=>{api('command','arm').catch(e=>toast(e.message,true));$('#arm-dialog').close()};$('#disarm').onclick=()=>api('command','disarm').catch(e=>toast(e.message,true));$('#logs').onclick=()=>api('open_logs');
|
let done=false;
|
||||||
$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)};
|
const finish=()=>{
|
||||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);$('#battery-modal').close();toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
if(done)return;
|
||||||
|
done=true;
|
||||||
|
dialog.removeEventListener('animationend',finish);
|
||||||
|
dialog.classList.remove('closing');
|
||||||
|
dialog.close();
|
||||||
|
};
|
||||||
|
dialog.addEventListener('animationend',finish,{once:true});
|
||||||
|
setTimeout(finish,300);
|
||||||
|
}
|
||||||
|
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
|
||||||
|
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
|
||||||
|
$$('dialog').forEach(d=>{
|
||||||
|
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
|
||||||
|
d.addEventListener('click',e=>{
|
||||||
|
const r=d.getBoundingClientRect();
|
||||||
|
if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};const windowBar=$('.workspace-topbar');if(windowBar)windowBar.ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}};
|
||||||
|
const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage');
|
||||||
|
|
||||||
|
async function handleStlFile(file){
|
||||||
|
if(!file)return;
|
||||||
|
try{
|
||||||
|
if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh (*.stl)');
|
||||||
|
if(file.size>30*1024*1024)throw new Error('The STL is larger than 30 MB; export a lighter mesh');
|
||||||
|
modelButton.disabled=true;
|
||||||
|
modelButton.textContent='Loading…';
|
||||||
|
const buffer=await file.arrayBuffer();
|
||||||
|
const result=window.rovModel.loadBuffer(buffer,file.name);
|
||||||
|
modelButton.textContent='Replace STL';
|
||||||
|
toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`);
|
||||||
|
if(bridgeReady&&window.pywebview?.api?.save_model){
|
||||||
|
const dataUrl=await new Promise((resolve,reject)=>{
|
||||||
|
const reader=new FileReader();
|
||||||
|
reader.onload=()=>resolve(reader.result);
|
||||||
|
reader.onerror=()=>reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
api('save_model',file.name,String(dataUrl).split(',')[1]).catch(e=>console.warn(e));
|
||||||
|
}
|
||||||
|
}catch(error){
|
||||||
|
toast(`STL import failed: ${error.message}`,true);
|
||||||
|
}finally{
|
||||||
|
modelButton.disabled=false;
|
||||||
|
if(modelButton.textContent==='Loading…')modelButton.textContent='Load STL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modelButton.onclick=async()=>{
|
||||||
|
let loaded=false;
|
||||||
|
if(window.pywebview?.api?.choose_model){
|
||||||
|
try{
|
||||||
|
const model=await api('choose_model');
|
||||||
|
if(model&&model.data){
|
||||||
|
const res=window.rovModel.load(model);
|
||||||
|
modelButton.textContent='Replace STL';
|
||||||
|
toast(`Loaded ${res.name} • ${res.triangles.toLocaleString()} triangles`);
|
||||||
|
loaded=true;
|
||||||
|
}
|
||||||
|
}catch(err){
|
||||||
|
console.warn('choose_model error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!loaded&&modelInput){
|
||||||
|
modelInput.value='';
|
||||||
|
modelInput.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if(modelInput){
|
||||||
|
modelInput.onchange=()=>{
|
||||||
|
const file=modelInput.files?.[0];
|
||||||
|
if(file)handleStlFile(file);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if(rovStage){
|
||||||
|
rovStage.addEventListener('dragover',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.add('drag-over')});
|
||||||
|
rovStage.addEventListener('dragleave',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over')});
|
||||||
|
rovStage.addEventListener('drop',e=>{
|
||||||
|
e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over');
|
||||||
|
const file=e.dataTransfer?.files?.[0];
|
||||||
|
if(file)handleStlFile(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('#refresh').onclick=async e=>{
|
||||||
|
const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);
|
||||||
|
try{
|
||||||
|
if(typeof window.pywebview?.api?.refresh_ports!=='function')throw new Error('Desktop connection is not ready. Open the app using run.ps1.');
|
||||||
|
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||||
|
if(!booted)boot();
|
||||||
|
}catch(error){recordActivity(`Port refresh failed: ${error.message}`);toast(error.message,true)}
|
||||||
|
};
|
||||||
|
$('#connect').onclick=async()=>{
|
||||||
|
const btn=$('#connect');
|
||||||
|
const label=btn.querySelector('.btn-label')||btn;
|
||||||
|
const p=$('#port');
|
||||||
|
const port=p?p.value:'';
|
||||||
|
|
||||||
|
if(port==='demo'||demoActive){
|
||||||
|
resetInputs();clearTimeout(demoCommandTimer);requestSerial++;localRequest=null;commandError='';bridgeStale=false;
|
||||||
|
if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')}
|
||||||
|
else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(appState==='connecting'){
|
||||||
|
label.textContent='Canceling…';
|
||||||
|
try{ await api('disconnect'); }catch(_){}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(appState!=='disconnected'){
|
||||||
|
resetInputs();
|
||||||
|
label.textContent='Disconnecting…';
|
||||||
|
try{
|
||||||
|
await api('disconnect');
|
||||||
|
toast('Disconnected');
|
||||||
|
}catch(e){
|
||||||
|
toast(e.message,true);
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
if(!port){
|
||||||
|
toast('Select or enter a port to connect',true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.classList.add('is-connecting');
|
||||||
|
label.textContent='Connecting…';
|
||||||
|
const statusEl=$('#status-text');
|
||||||
|
recordActivity(`Connecting to ${port}…`);
|
||||||
|
toast(`Connecting to ${port}…`);
|
||||||
|
try{
|
||||||
|
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
|
||||||
|
if(confirmedState)render(confirmedState);
|
||||||
|
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
|
||||||
|
}catch(e){
|
||||||
|
btn.classList.remove('is-connecting');
|
||||||
|
label.textContent='Connect';
|
||||||
|
toast(e.message,true);
|
||||||
|
recordActivity(`Connection error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input');
|
||||||
|
if(customPortBtn&&customPortInput){
|
||||||
|
const applyCustomPort=()=>{
|
||||||
|
const val=customPortInput.value.trim();
|
||||||
|
if(!val)return;
|
||||||
|
const p=$('#port');
|
||||||
|
let exists=false;
|
||||||
|
for(const opt of p.options){
|
||||||
|
if(opt.value===val){opt.selected=true;exists=true;break;}
|
||||||
|
}
|
||||||
|
if(!exists){
|
||||||
|
p.add(new Option(`Custom (${val})`, val), 0);
|
||||||
|
p.value=val;
|
||||||
|
}
|
||||||
|
closeModal($('#link-modal'));
|
||||||
|
toast(`Selected port: ${val}`);
|
||||||
|
};
|
||||||
|
customPortBtn.onclick=applyCustomPort;
|
||||||
|
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
||||||
|
}
|
||||||
|
const engageButton=$('#engage');
|
||||||
|
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
|
||||||
|
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
|
||||||
|
const ENGAGE_HOLD_MS=1400;
|
||||||
|
|
||||||
|
function applyEngageVisual(next){
|
||||||
|
if(!engageButton)return;
|
||||||
|
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
|
||||||
|
// Interaction updates immediately; the short fade never gates emergency access.
|
||||||
|
engageButton.disabled=!next.action;
|
||||||
|
engageButton.setAttribute('aria-busy',String(next.pending));
|
||||||
|
engageButton.setAttribute('aria-label',label);
|
||||||
|
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message;
|
||||||
|
const key=`${next.label}/${next.tone}`;
|
||||||
|
if(engageVisualState===key)return;
|
||||||
|
const previous=engageVisualState;engageVisualState=key;
|
||||||
|
clearTimeout(engageTransitionTimer);
|
||||||
|
engageButton.classList.remove('arm','danger','subtle','charged','pending');
|
||||||
|
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
|
||||||
|
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%');
|
||||||
|
$('#engage-label').textContent=label;
|
||||||
|
if(engageProgressLabel)engageProgressLabel.textContent=label;
|
||||||
|
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
|
||||||
|
if(previous){
|
||||||
|
engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
|
||||||
|
engageButton.classList.add('state-transition');
|
||||||
|
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function completeEngage(){
|
||||||
|
const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%');
|
||||||
|
requestControl('arm');
|
||||||
|
}
|
||||||
|
async function requestControl(name){
|
||||||
|
if((name==='arm'&&view.action!=='arm')||(name==='disarm'&&view.action!=='disarm'))return;
|
||||||
|
const id=++requestSerial;
|
||||||
|
commandError='';localRequest={name,deadline:performance.now()+6000,sent:false};
|
||||||
|
recordActivity(name==='arm'?'Engage requested':'Disengage requested');
|
||||||
|
resetInputs();render(vehicleSnapshot);
|
||||||
|
try{
|
||||||
|
if(demoActive){
|
||||||
|
// Separate simulated request and simulated heartbeat, just like the real UI.
|
||||||
|
clearTimeout(demoCommandTimer);
|
||||||
|
updateDemo({pending_command:name,command_error:''});
|
||||||
|
demoCommandTimer=setTimeout(()=>{
|
||||||
|
if(!demoActive||id!==requestSerial)return;
|
||||||
|
localRequest=null;
|
||||||
|
updateDemo({pending_command:null,armed:name==='arm',armed_known:true,
|
||||||
|
app_state:name==='arm'?'armed':'connected_disarmed',
|
||||||
|
vehicle:`DEMO • ${name==='arm'?'ARMED':'DISARMED'}`,status:`Demo: ${name} confirmed`});
|
||||||
|
},650);
|
||||||
|
}else{
|
||||||
|
await api('command',name,++inputSequence,Date.now()+2000);
|
||||||
|
if(id===requestSerial&&localRequest)localRequest.sent=true;
|
||||||
|
// Only poll snapshots may confirm state; a send result is not confirmation.
|
||||||
|
}
|
||||||
|
}catch(error){
|
||||||
|
if(id!==requestSerial)return;
|
||||||
|
localRequest=null;commandError=`${name==='arm'?'Engage':'Disarm'} request failed: ${error.message}`;
|
||||||
|
render(vehicleSnapshot);toast(commandError,true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateEngageProgress(now){
|
||||||
|
if(!engageStart||view.action!=='arm'||!inputFocused)return cancelEngageHold(false);
|
||||||
|
const progress=Math.min(1,(now-engageStart)/ENGAGE_HOLD_MS);engageButton.style.setProperty('--engage-progress',`${progress*100}%`);if(progress>=1)return void completeEngage();engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||||
|
}
|
||||||
|
function startEngageHold(e){
|
||||||
|
if(view.action!=='arm'||!inputFocused||engageStart)return;
|
||||||
|
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||||
|
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
|
||||||
|
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
|
||||||
|
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||||
|
}
|
||||||
|
function cancelEngageHold(showHint=true){
|
||||||
|
if(!engageStart)return;
|
||||||
|
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if(engageButton){
|
||||||
|
engageButton.addEventListener('pointerdown',startEngageHold);
|
||||||
|
engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;try{engageButton.releasePointerCapture(e.pointerId)}catch(_){}suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
||||||
|
engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
|
||||||
|
engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
|
||||||
|
engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null});
|
||||||
|
engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)});
|
||||||
|
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
|
||||||
|
|
||||||
|
engageButton.onclick=()=>{
|
||||||
|
if(suppressEngageClick){suppressEngageClick=false;return}
|
||||||
|
if(view.action==='disarm')requestControl('disarm');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#logs').onclick=()=>api('open_logs');
|
||||||
|
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||||
|
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||||
|
// Explicit pointer ownership makes the full range rail a reliable touch target
|
||||||
|
// in the embedded window, without depending on native range drag defaults.
|
||||||
|
const gainSlider=$('#gain');let gainPointer=null;
|
||||||
|
function gainFromPointer(e){const r=gainSlider.getBoundingClientRect(),fraction=Math.max(0,Math.min(1,(e.clientX-r.left-9)/Math.max(1,r.width-18)));setGain(Math.round(+gainSlider.min+fraction*(+gainSlider.max-+gainSlider.min)))}
|
||||||
|
function releaseGain(e){if(e.pointerId!==gainPointer)return;gainPointer=null;try{gainSlider.releasePointerCapture(e.pointerId)}catch(_){}}
|
||||||
|
gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)return;gainPointer=e.pointerId;gainSlider.setPointerCapture(e.pointerId);gainFromPointer(e);e.preventDefault()});
|
||||||
|
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
|
||||||
|
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
|
||||||
|
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
|
||||||
|
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}}
|
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
|
||||||
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';$('#link').textContent=s.link;$('.link-state').classList.toggle('online',s.link==='LINK OK');$('#vehicle').textContent=s.vehicle;$('#status').textContent=s.status;const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}S`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='Waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)}
|
function render(s,confirmed=false){
|
||||||
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
|
vehicleSnapshot=s;
|
||||||
let booted=false;
|
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
|
||||||
async function boot(){if(booted||!window.pywebview?.api)return;booted=true;bridgeReady=true;try{const init=await api('initial_state');batteryConfig=init.battery_config;populatePorts(init.ports);fillBatteryForm();render(init.state);const saved=init.saved_model||await api('get_saved_model_info');if(saved){try{$('#load-model').textContent='Loading saved STL…';await loadCachedModel(saved);$('#load-model').textContent='Replace STL'}catch(error){$('#load-model').textContent='Load STL';toast(`Saved STL failed: ${error.message}`,true)}}poll();setInterval(async()=>{if(!connected)try{populatePorts(await api('refresh_ports'))}catch(_){}},2000)}catch(e){booted=false;bridgeReady=false;toast(`Python bridge: ${e.message}`,true);setTimeout(waitForBridge,500)}}
|
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
|
||||||
function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)}
|
const previous=view;
|
||||||
addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge();
|
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
|
||||||
|
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||||
|
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
|
||||||
|
document.body.dataset.appState=appState;
|
||||||
|
document.body.dataset.controlsEnabled=String(view.drive);
|
||||||
|
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||||
|
$('#port').disabled=!!s.connected;
|
||||||
|
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||||
|
const connectBtn=$('#connect');
|
||||||
|
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||||
|
if(connectBtn){
|
||||||
|
if(appState==='connecting'){
|
||||||
|
if(connectLabel)connectLabel.textContent='Connecting…';
|
||||||
|
else connectBtn.textContent='Connecting…';
|
||||||
|
connectBtn.classList.add('is-connecting');
|
||||||
|
connectBtn.classList.remove('is-connected');
|
||||||
|
}else if(appState!=='disconnected'){
|
||||||
|
if(connectLabel)connectLabel.textContent='Disconnect';
|
||||||
|
else connectBtn.textContent='Disconnect';
|
||||||
|
connectBtn.classList.remove('is-connecting');
|
||||||
|
connectBtn.classList.add('is-connected');
|
||||||
|
}else{
|
||||||
|
if(connectLabel)connectLabel.textContent='Connect';
|
||||||
|
else connectBtn.textContent='Connect';
|
||||||
|
connectBtn.classList.remove('is-connecting', 'is-connected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const engageBtn=$('#engage');
|
||||||
|
const engageLabel=$('#engage-label');
|
||||||
|
if(engageBtn&&engageLabel){
|
||||||
|
applyEngageVisual(view);
|
||||||
|
}
|
||||||
|
const controlStatus=$('#control-status');
|
||||||
|
controlStatus.textContent=view.message;
|
||||||
|
controlStatus.classList.toggle('is-error',view.error);
|
||||||
|
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
|
||||||
|
const motorBtn=$('[data-open="motors-modal"]');
|
||||||
|
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||||
|
|
||||||
|
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
|
||||||
|
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
|
||||||
|
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
|
||||||
|
if(confirmedCommand){
|
||||||
|
recordActivity(s.armed?'Engage confirmed':'Disengage confirmed');
|
||||||
|
}
|
||||||
|
updateReadouts();
|
||||||
|
const b=s.battery;
|
||||||
|
if(b){
|
||||||
|
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
||||||
|
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
|
||||||
|
$('#battery-fill').style.width=`${b.percent}%`;
|
||||||
|
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
|
||||||
|
}else{
|
||||||
|
$('#battery-title').textContent='—';
|
||||||
|
$('#battery-percent').textContent='waiting';
|
||||||
|
$('#battery-fill').style.width='0';
|
||||||
|
}
|
||||||
|
renderMotors(s.servos||{});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markBridgeStale(){
|
||||||
|
if(demoActive||bridgeStale)return;
|
||||||
|
bridgeStale=true;resetInputs();render(vehicleSnapshot);
|
||||||
|
}
|
||||||
|
setInterval(()=>{
|
||||||
|
const now=performance.now();
|
||||||
|
if(now-lastSafetyTick>750)resetInputs();
|
||||||
|
lastSafetyTick=now;
|
||||||
|
if(!demoActive&&bridgeReady&&vehicleSnapshot.connected&&performance.now()-lastBridgeAt>1500)markBridgeStale();
|
||||||
|
if(localRequest&&performance.now()>localRequest.deadline){
|
||||||
|
commandError=`${localRequest.name==='arm'?'Engage':'Disarm'} confirmation timed out. Check the vehicle state.`;
|
||||||
|
localRequest=null;render(vehicleSnapshot);
|
||||||
|
}
|
||||||
|
},100);
|
||||||
|
async function poll(){
|
||||||
|
if(!bridgeReady)return;
|
||||||
|
if(!demoActive)try{
|
||||||
|
const snapshot=await withTimeout(api('poll'));
|
||||||
|
if(!demoActive){lastBridgeAt=performance.now();bridgeStale=false;render(snapshot,true)}
|
||||||
|
}catch(e){markBridgeStale()}
|
||||||
|
setTimeout(poll,250);
|
||||||
|
}
|
||||||
|
let modelLoadPromise=null;
|
||||||
|
async function loadDefaultModel(){
|
||||||
|
if(modelLoadPromise)return modelLoadPromise;
|
||||||
|
modelLoadPromise=(async()=>{
|
||||||
|
const statusEl=$('#model-status');
|
||||||
|
|
||||||
|
// 1. Direct high-speed HTTP fetch (instant, ~20ms)
|
||||||
|
for(const url of [
|
||||||
|
'/model.stl',
|
||||||
|
'/model',
|
||||||
|
'/runtime/vehicle_model.stl',
|
||||||
|
'/assets/Main%20Assembly%20V1%20(2).stl',
|
||||||
|
'../runtime/vehicle_model.stl',
|
||||||
|
'../assets/Main%20Assembly%20V1%20(2).stl'
|
||||||
|
]){
|
||||||
|
try{
|
||||||
|
const res=await fetch(url);
|
||||||
|
if(res.ok){
|
||||||
|
if(statusEl)statusEl.textContent='Loading mesh…';
|
||||||
|
const buf=await res.arrayBuffer();
|
||||||
|
if(window.rovModel){
|
||||||
|
const result=window.rovModel.loadBuffer(buf,'Main Assembly V1 (2).stl');
|
||||||
|
$('#load-model').textContent='Replace STL';
|
||||||
|
console.log('[Direct Fetch] Model loaded:', result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(err){
|
||||||
|
console.warn(`[Direct Fetch] ${url} failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Python Bridge Chunked Stream (fallback)
|
||||||
|
if(window.pywebview?.api?.get_saved_model_info){
|
||||||
|
try{
|
||||||
|
const info=await api('get_saved_model_info');
|
||||||
|
if(info&&info.size){
|
||||||
|
const total=info.size, chunk=262144;
|
||||||
|
const bytes=new Uint8Array(total);
|
||||||
|
let loaded=0;
|
||||||
|
if(statusEl){
|
||||||
|
statusEl.className='model-status';
|
||||||
|
statusEl.textContent=`Streaming ${info.name} (0%)…`;
|
||||||
|
}
|
||||||
|
while(loaded<total){
|
||||||
|
const reqSize=Math.min(chunk,total-loaded);
|
||||||
|
const resp=await api('read_saved_model_chunk',loaded,reqSize);
|
||||||
|
const part=atob(resp.data);
|
||||||
|
for(let i=0;i<part.length;i++)bytes[loaded+i]=part.charCodeAt(i);
|
||||||
|
loaded+=part.length;
|
||||||
|
const pct=Math.round((loaded/total)*100);
|
||||||
|
if(statusEl)statusEl.textContent=`Streaming ${pct}% (${(loaded/(1024*1024)).toFixed(1)}MB)…`;
|
||||||
|
if(loaded%(chunk*4)===0)await new Promise(r=>setTimeout(r,0));
|
||||||
|
}
|
||||||
|
if(window.rovModel){
|
||||||
|
const res=window.rovModel.loadBuffer(bytes.buffer,info.name);
|
||||||
|
$('#load-model').textContent='Replace STL';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
console.warn('[Bridge Stream] Failed:', e);
|
||||||
|
if(statusEl){
|
||||||
|
statusEl.className='model-status error';
|
||||||
|
statusEl.textContent=`Bridge Error: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
return modelLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
let booted=false,booting=false,bootRetry=0,portRefreshTimer=0;
|
||||||
|
function hasDesktopApi(){return ['initial_state','refresh_ports','poll'].every(name=>typeof window.pywebview?.api?.[name]==='function')}
|
||||||
|
async function boot(){
|
||||||
|
if(booted||booting||!hasDesktopApi())return;
|
||||||
|
booting=true;clearTimeout(bootRetry);
|
||||||
|
try{
|
||||||
|
// OS port discovery must not depend on model/config initialization succeeding.
|
||||||
|
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||||
|
const init=await withTimeout(api('initial_state'),4000);
|
||||||
|
batteryConfig=init.battery_config||{};
|
||||||
|
populatePorts(init.ports);
|
||||||
|
fillBatteryForm();
|
||||||
|
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||||
|
if(!demoActive)render(init.state);
|
||||||
|
poll();
|
||||||
|
// Populate the serial selector before starting the optional model load.
|
||||||
|
// A large cached mesh must never delay an operator seeing USB COM ports.
|
||||||
|
loadDefaultModel();
|
||||||
|
if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
|
||||||
|
if(appState==='disconnected'||demoActive){
|
||||||
|
try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
|
||||||
|
catch(error){recordActivity(`Port scan failed: ${error.message}`)}
|
||||||
|
}
|
||||||
|
},2000);
|
||||||
|
}catch(e){
|
||||||
|
booted=false;bridgeReady=false;
|
||||||
|
recordActivity(`Desktop startup failed; retrying: ${e.message}`);
|
||||||
|
toast(`Python bridge: ${e.message}`,true);
|
||||||
|
bootRetry=setTimeout(boot,1000);
|
||||||
|
}finally{
|
||||||
|
booting=false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function waitForBridge(){
|
||||||
|
if(booted)return;
|
||||||
|
if(hasDesktopApi())boot();
|
||||||
|
setTimeout(waitForBridge,250);
|
||||||
|
}
|
||||||
|
addEventListener('pywebviewready',boot);
|
||||||
|
render(vehicleSnapshot);
|
||||||
|
if(document.readyState==='loading'){
|
||||||
|
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
|
||||||
|
}else{
|
||||||
|
loadDefaultModel();
|
||||||
|
waitForBridge();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* Presentation derived from telemetry. No animation is allowed to set armed. */
|
||||||
|
((root) => {
|
||||||
|
function present(state, {stale=false, focused=true, pending=null, error=''}={}) {
|
||||||
|
const known = state.armed_known === true && !stale;
|
||||||
|
const online = !!state.connected && !stale;
|
||||||
|
const request = pending || state.pending_command;
|
||||||
|
const uncertain = !known && (state.armed !== null && state.armed !== undefined || !!request || state.heartbeat_age != null);
|
||||||
|
let label='ENGAGE', tone='idle', action=null, message='Connect to receive vehicle state.';
|
||||||
|
if (request === 'disarm') {
|
||||||
|
label='DISARMING…'; tone='danger'; message='Awaiting vehicle confirmation. Not yet confirmed disarmed.';
|
||||||
|
} else if (request === 'arm') {
|
||||||
|
label='ENGAGING…'; tone='pending'; action=online?'disarm':null;
|
||||||
|
message='Awaiting vehicle confirmation. Tap to request disarm.';
|
||||||
|
} else if (known && state.armed) {
|
||||||
|
label='DISARM'; tone='danger'; action=online?'disarm':null;
|
||||||
|
message=focused?'Armed · tap or F3 to disarm.':'Armed · controls neutral while the app is unfocused.';
|
||||||
|
} else if (uncertain) {
|
||||||
|
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
|
||||||
|
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
|
||||||
|
} else if (known && online) {
|
||||||
|
action=focused?'arm':null; message='Disarmed · hold 1.4 seconds to engage.';
|
||||||
|
}
|
||||||
|
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
|
||||||
|
const failure=error || state.command_error;
|
||||||
|
return {label,tone,action,pending:!!request,known,
|
||||||
|
drive:known && online && state.armed === true && !request && focused,
|
||||||
|
message:failure ? `${failure} ${known ? (state.armed?'Vehicle armed.':'Vehicle disarmed.') : 'Vehicle state unknown.'}` : message,
|
||||||
|
error:!!(error || state.command_error || uncertain || stale)};
|
||||||
|
}
|
||||||
|
root.ControlState={present};
|
||||||
|
if(typeof module!=='undefined') module.exports=root.ControlState;
|
||||||
|
})(typeof window!=='undefined'?window:globalThis);
|
||||||
+117
-78
@@ -3,117 +3,156 @@
|
|||||||
<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>Sub Control</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css?v=20260827-bridge-v5">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="window-bar">
|
|
||||||
<div class="window-drag pywebview-drag-region"><span class="mini-mark">S</span><strong>SUB Control</strong><i id="model-file-name">Built-in vehicle</i></div>
|
|
||||||
<div class="window-actions">
|
|
||||||
<button id="window-min" aria-label="Minimize">—</button>
|
|
||||||
<button id="window-max" aria-label="Maximize">□</button>
|
|
||||||
<button id="window-close" class="window-close" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<aside class="vehicle-rail">
|
|
||||||
<header class="brand"><span class="brand-mark">S</span><span>SUB CONTROL</span><small>03</small></header>
|
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
|
||||||
<div class="eyebrow">LIVE VEHICLE</div>
|
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
|
||||||
|
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
|
||||||
|
<!-- Connection Controls -->
|
||||||
|
<div class="deck-connection">
|
||||||
|
<div class="port-row">
|
||||||
|
<select id="port" aria-label="Serial port">
|
||||||
|
<option value="demo" selected>Demo Mode (no hardware)</option>
|
||||||
|
</select>
|
||||||
|
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings">
|
||||||
|
<svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button id="connect" class="button connect-button" aria-label="Toggle connection">
|
||||||
|
<span class="btn-spinner" aria-hidden="true"></span>
|
||||||
|
<span class="btn-label">Connect</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Telemetry / Status Zone -->
|
||||||
|
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
|
||||||
|
<div class="deck-card model-card">
|
||||||
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
|
||||||
<div class="water-ring r1"></div><div class="water-ring r2"></div>
|
|
||||||
<svg id="default-model" viewBox="0 0 260 310" role="img">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#566168"/><stop offset="1" stop-color="#22292d"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="13" flood-opacity=".45"/></filter>
|
|
||||||
</defs>
|
|
||||||
<g filter="url(#shadow)">
|
|
||||||
<rect x="73" y="34" width="114" height="226" rx="55" fill="url(#body)" stroke="#707b81" stroke-width="2"/>
|
|
||||||
<rect x="93" y="60" width="74" height="168" rx="35" fill="#30383c" stroke="#49545a"/>
|
|
||||||
<circle cx="130" cy="145" r="28" fill="#7d898f" stroke="#a3adb1" stroke-width="3"/>
|
|
||||||
<circle cx="130" cy="145" r="12" fill="#d9e0df"/>
|
|
||||||
<path d="M78 98 48 80M182 98l30-18" stroke="#7e8a90" stroke-width="9" stroke-linecap="round"/>
|
|
||||||
<g fill="#191f22" stroke="#7b878c" stroke-width="3">
|
|
||||||
<ellipse cx="38" cy="74" rx="27" ry="34"/><ellipse cx="222" cy="74" rx="27" ry="34"/>
|
|
||||||
</g>
|
|
||||||
<g stroke="#59656a" stroke-width="2">
|
|
||||||
<path d="M22 54l32 40M54 54 22 94M206 54l32 40M238 54l-32 40"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
|
||||||
|
<div id="model-status" class="model-status">Initializing…</div>
|
||||||
<div class="model-tools">
|
<div class="model-tools">
|
||||||
<button id="load-model">Load STL</button>
|
<button id="load-model">Load STL</button>
|
||||||
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden>
|
<input id="model-input" type="file" accept=".stl,.STL" hidden>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vehicle-name"><small>VEHICLE</small><strong id="vehicle">No heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
|
</div>
|
||||||
<div class="rail-bottom">
|
|
||||||
|
<!-- Battery Status -->
|
||||||
<button class="battery-card ghost" data-open="battery-modal">
|
<button class="battery-card ghost" data-open="battery-modal">
|
||||||
<span><small>BATTERY</small><strong id="battery-title">—</strong></span><i id="battery-percent">Waiting</i>
|
<span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i>
|
||||||
<b><em id="battery-fill"></em></b>
|
<b><em id="battery-fill"></em></b>
|
||||||
</button>
|
</button>
|
||||||
<div class="link-state"><span id="link-dot"></span><strong id="link">DISCONNECTED</strong></div>
|
|
||||||
|
<div class="deck-status-bar">
|
||||||
|
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
|
||||||
|
<li id="activity-oldest" hidden></li>
|
||||||
|
<li id="activity-previous" hidden></li>
|
||||||
|
<li id="status-text">Not connected</li>
|
||||||
|
</ol>
|
||||||
|
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="workspace">
|
<!-- ── PRIMARY CONTROL ZONE ── -->
|
||||||
<header class="topbar">
|
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
|
||||||
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div>
|
<!-- Frameless Drag & Window Controls Header -->
|
||||||
<div class="connection">
|
<div class="workspace-topbar">
|
||||||
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select>
|
<div class="workspace-drag-area pywebview-drag-region"></div>
|
||||||
<button id="refresh" class="icon-button" title="Refresh ports">↻</button>
|
<div class="window-actions">
|
||||||
<button class="icon-button" data-open="link-modal" title="Link settings">•••</button>
|
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
||||||
<button id="connect" class="button connect-button">Connect</button>
|
<button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button>
|
||||||
|
<button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="drive-grid">
|
<section class="primary-control-zone" aria-label="Primary Control Zone">
|
||||||
|
<!-- Reserved Engage / Disarm row -->
|
||||||
|
<div class="engage-header-zone">
|
||||||
|
<button id="engage" class="button subtle engage-button" aria-label="Engage" aria-describedby="control-status" title="Engage (F2)">
|
||||||
|
<span class="engage-face">
|
||||||
|
<span id="engage-label" class="engage-label">engage</span>
|
||||||
|
<span class="engage-sheen-label" aria-hidden="true">engage</span>
|
||||||
|
<span class="engage-progress-label" aria-hidden="true">engage</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<p id="control-status" class="control-status" role="status" aria-live="polite">Connect to receive vehicle state.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Joysticks & Thrust Control Grid -->
|
||||||
|
<div class="controls-grid">
|
||||||
|
<!-- Steering / Throttle Joystick -->
|
||||||
<article class="control-card steer-card">
|
<article class="control-card steer-card">
|
||||||
<header><div><small>STEER + THROTTLE</small><strong>Differential drive</strong></div><span class="key-hint">W A S D</span></header>
|
<header>
|
||||||
|
<div><small>steer + throttle</small></div>
|
||||||
|
<span class="key-hint">w a s d</span>
|
||||||
|
</header>
|
||||||
|
<div class="joystick-pad">
|
||||||
<div id="joystick" class="joystick" touch-action="none">
|
<div id="joystick" class="joystick" touch-action="none">
|
||||||
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div>
|
<canvas id="joystick-canvas" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
|
||||||
<span class="axis-label forward">FORWARD</span><span class="axis-label reverse">REVERSE</span>
|
|
||||||
<span class="axis-label left">TURN LEFT</span><span class="axis-label right">TURN RIGHT</span>
|
|
||||||
<div class="stick-shadow"></div><div id="stick" class="stick"><span></span></div>
|
|
||||||
</div>
|
</div>
|
||||||
<footer><span><i class="dot surge"></i><b id="surge-readout">0</b>% surge</span><span><i class="dot yaw"></i><b id="yaw-readout">0</b>% turn</span></footer>
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span class="steer-readout-left">surge <b id="surge-readout">—</b></span>
|
||||||
|
<span class="steer-readout-right"><b id="yaw-readout">—</b> turn</span>
|
||||||
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- Vertical Thruster Slider -->
|
||||||
<article class="control-card vertical-card">
|
<article class="control-card vertical-card">
|
||||||
<header><div><small>VERTICAL</small><strong>Center thruster</strong></div><span class="key-hint">R / F</span></header>
|
<header>
|
||||||
|
<div><small>vertical</small></div>
|
||||||
|
<span class="key-hint">r / f</span>
|
||||||
|
</header>
|
||||||
|
<div class="heave-pad">
|
||||||
<div class="vertical-control">
|
<div class="vertical-control">
|
||||||
<span>RISE</span>
|
<span>rise</span>
|
||||||
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div>
|
<div id="heave" class="heave-track" touch-action="none">
|
||||||
<span>DIVE</span>
|
<div class="heave-center"></div>
|
||||||
|
<div id="heave-fill" class="heave-fill"></div>
|
||||||
|
<div id="heave-knob" class="heave-knob"></div>
|
||||||
</div>
|
</div>
|
||||||
<footer><span><i class="dot depth"></i><b id="heave-readout">0</b>% vertical</span></footer>
|
<span>dive</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span><b id="heave-readout">—</b> vertical</span>
|
||||||
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="command-bar">
|
|
||||||
<div class="commands">
|
|
||||||
<button id="manual" class="button subtle">Manual</button>
|
|
||||||
<button id="arm" class="button arm">Arm</button>
|
|
||||||
<button id="disarm" class="button danger">Disarm</button>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="output-limit"><span>Output limit <b id="gain-value">20%</b></span><input id="gain" type="range" min="5" max="50" value="20"></label>
|
<div class="control-toolbar">
|
||||||
<button class="button subtle" data-open="motors-modal">Motor setup</button>
|
<label class="output-limit">
|
||||||
<button id="logs" class="icon-button" title="Open session logs">⌁</button>
|
<span>Output limit <b id="gain-value">20%</b></span>
|
||||||
|
<div class="range-pill">
|
||||||
|
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
|
||||||
|
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
|
||||||
|
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
<dialog id="arm-dialog" class="modal confirm-modal"><button class="modal-close" data-close>×</button><small>SAFETY CHECK</small><h2>Arm the vehicle?</h2><p>Remove propellers or securely restrain the ROV before enabling motor output.</p><div class="modal-actions"><button class="button subtle" data-close>Cancel</button><button id="confirm-arm" class="button arm">Confirm arm</button></div></dialog>
|
<!-- Modals -->
|
||||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>CONNECTION</small><h2>Link settings</h2><div class="form-grid two"><label>Baud rate<select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label>Control rate<select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><div style="margin-top:14px;"><label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:11px;font-weight:500;color:var(--muted)">Custom Port / Endpoint</span><div style="display:flex;gap:8px;"><input id="custom-port-input" placeholder="e.g. COM3 or tcp:127.0.0.1:5760" style="flex:1;height:32px;padding:0 12px;font-size:11px;"><button type="button" id="custom-port-btn" class="button subtle" style="white-space:nowrap;">Use Port</button></div></label></div><p class="note" style="margin-top:14px;">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>POWER</small><h2>Battery settings</h2><form id="battery-form" class="form-grid three"><label>Cells (S)<input name="cells" type="number" min="1"></label><label>Capacity (mAh)<input name="capacity_mah" type="number" min="1"></label><label>C rating<input name="c_rating" type="number" min="1" step=".1"></label><label>Full V / cell<input name="full_v_per_cell" type="number" step=".01"></label><label>Empty V / cell<input name="empty_v_per_cell" type="number" step=".01"></label><label>Warning %<input name="warning_percent" type="number" min="0" max="100"></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save settings</button></div></form></dialog>
|
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
|
||||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>CONFIGURATION</small><h2>Motor outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||||
|
|
||||||
<script src="model-viewer.js"></script>
|
<script src="model-viewer.js?v=20260826-controls-v2"></script>
|
||||||
<script src="app.js"></script>
|
<script src="joystick-3d.js?v=20260827-polish-v3"></script>
|
||||||
|
<script src="control-state.js?v=20260827-state-v1"></script>
|
||||||
|
<script src="app.js?v=20260827-bridge-v6"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/* Visual joystick. Control transport remains in app.js through setJoyControl. */
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const host=document.getElementById('joystick'),canvas=document.getElementById('joystick-canvas');
|
||||||
|
if(!host||!canvas)return;
|
||||||
|
const ctx=canvas.getContext('2d');
|
||||||
|
let width=0,height=0,dpr=1,x=0,y=0,activePointer=null,returnFrame=0;
|
||||||
|
|
||||||
|
function geometry(){
|
||||||
|
const radius=Math.min(width,height)*.43;
|
||||||
|
return {cx:width/2,cy:height/2,radius,travel:radius*.67,knob:Math.max(25,Math.min(34,radius*.22))};
|
||||||
|
}
|
||||||
|
function resize(){const rect=host.getBoundingClientRect();width=rect.width;height=rect.height;dpr=Math.min(devicePixelRatio||1,2);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;draw()}
|
||||||
|
function draw(){
|
||||||
|
if(!width||!height)return;
|
||||||
|
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
|
||||||
|
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
|
||||||
|
|
||||||
|
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eee';ctx.fill();ctx.strokeStyle='#d3d3d3';ctx.lineWidth=1.5;ctx.stroke();
|
||||||
|
ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
|
||||||
|
|
||||||
|
ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
|
||||||
|
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
|
||||||
|
|
||||||
|
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
||||||
|
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore();
|
||||||
|
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
|
||||||
|
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
|
||||||
|
}
|
||||||
|
function publish(){window.setJoyControl?.(x,y)}
|
||||||
|
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
|
||||||
|
function positionFromEvent(event){const rect=canvas.getBoundingClientRect(),g=geometry();setPosition((event.clientX-rect.left-g.cx)/g.travel,(event.clientY-rect.top-g.cy)/g.travel)}
|
||||||
|
function returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
|
||||||
|
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
||||||
|
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
|
||||||
|
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
|
||||||
|
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
|
||||||
|
addEventListener('resize',resize);resize();
|
||||||
|
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
|
||||||
|
})();
|
||||||
+491
-436
File diff suppressed because it is too large
Load Diff
+1117
-12
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,16 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import base64
|
import base64
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import http.server
|
||||||
|
import socketserver
|
||||||
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import webview
|
import webview
|
||||||
from serial.tools import list_ports
|
from serial.tools import list_ports
|
||||||
|
|
||||||
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
|
||||||
|
from vehicle_state import VehicleState
|
||||||
|
|
||||||
|
|
||||||
class ControlApi:
|
class ControlApi:
|
||||||
@@ -22,12 +25,19 @@ class ControlApi:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.events: queue.Queue = queue.Queue()
|
self.events: queue.Queue = queue.Queue()
|
||||||
self.worker = MavlinkWorker(self.events)
|
self.worker = MavlinkWorker(self.events)
|
||||||
|
self.worker.require_input_lease = True
|
||||||
|
self.vehicle_state = VehicleState()
|
||||||
|
self.battery_received_at = None
|
||||||
|
self.demo_mode = False
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
self._poll_lock = threading.Lock()
|
||||||
self.state = {
|
self.state = {
|
||||||
"connected": False,
|
"connected": False,
|
||||||
"link": "DISCONNECTED",
|
"link": "DISCONNECTED",
|
||||||
"vehicle": "No heartbeat",
|
"vehicle": "No heartbeat",
|
||||||
"armed": False,
|
"armed": False,
|
||||||
|
"mode": None,
|
||||||
|
"app_state": "disconnected",
|
||||||
"status": "Ready. Select a USB port to begin.",
|
"status": "Ready. Select a USB port to begin.",
|
||||||
"battery": None,
|
"battery": None,
|
||||||
"log_path": None,
|
"log_path": None,
|
||||||
@@ -39,8 +49,6 @@ class ControlApi:
|
|||||||
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
|
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
|
||||||
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
|
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
|
||||||
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
|
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
|
||||||
self.model_uploads: dict[str, tuple[object, Path, str, int, int]] = {}
|
|
||||||
self.model_lock = threading.Lock()
|
|
||||||
# Leading underscore keeps the native WebView object out of js_api
|
# Leading underscore keeps the native WebView object out of js_api
|
||||||
# serialization. Only the small public methods below are exposed.
|
# serialization. Only the small public methods below are exposed.
|
||||||
self._window = None
|
self._window = None
|
||||||
@@ -57,14 +65,26 @@ class ControlApi:
|
|||||||
|
|
||||||
def initial_state(self):
|
def initial_state(self):
|
||||||
return {
|
return {
|
||||||
"ports": [p.device for p in list_ports.comports()],
|
"ports": self.refresh_ports(),
|
||||||
"battery_config": self.battery_config,
|
"battery_config": self.battery_config,
|
||||||
"saved_model": self.get_saved_model_info(),
|
|
||||||
"state": self._snapshot(),
|
"state": self._snapshot(),
|
||||||
|
"model_info": self.get_saved_model_info(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def refresh_ports(self):
|
def refresh_ports(self):
|
||||||
return [p.device for p in list_ports.comports()]
|
ports = []
|
||||||
|
# Add physical and virtual serial COM ports
|
||||||
|
for p in list_ports.comports():
|
||||||
|
desc = p.description or ""
|
||||||
|
if desc and desc != "n/a" and desc != p.device:
|
||||||
|
label = f"{p.device} ({desc})"
|
||||||
|
else:
|
||||||
|
label = p.device
|
||||||
|
ports.append({"device": p.device, "label": label, "type": "serial"})
|
||||||
|
|
||||||
|
# Built-in UI testing: no external process, port, or MAVLink socket.
|
||||||
|
ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"})
|
||||||
|
return ports
|
||||||
|
|
||||||
def minimize_window(self):
|
def minimize_window(self):
|
||||||
if self._window:
|
if self._window:
|
||||||
@@ -86,62 +106,83 @@ class ControlApi:
|
|||||||
self._window.destroy()
|
self._window.destroy()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def begin_model_save(self, name, size):
|
def choose_model(self):
|
||||||
|
selected = None
|
||||||
|
# 1. Native Windows topmost open file dialog (instant, reliable)
|
||||||
|
try:
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
root.attributes("-topmost", True)
|
||||||
|
file_path = filedialog.askopenfilename(
|
||||||
|
title="Select 3D STL Vehicle Model",
|
||||||
|
initialdir=str(Path.home() / "Downloads"),
|
||||||
|
filetypes=[("STL 3D Meshes (*.stl)", "*.stl"), ("All files (*.*)", "*.*")],
|
||||||
|
)
|
||||||
|
root.destroy()
|
||||||
|
if file_path:
|
||||||
|
selected = (file_path,)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Fallback to pywebview create_file_dialog
|
||||||
|
if not selected and self._window:
|
||||||
|
try:
|
||||||
|
selected = self._window.create_file_dialog(
|
||||||
|
webview.FileDialog.OPEN,
|
||||||
|
directory=str(Path.home()),
|
||||||
|
allow_multiple=False,
|
||||||
|
file_types=("STL mesh (*.stl)", "All files (*.*)"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
selected = None
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = Path(selected[0])
|
||||||
|
if not path.is_file() or not path.suffix.lower().endswith(".stl"):
|
||||||
|
raise ValueError("Choose an STL mesh")
|
||||||
|
if path.stat().st_size > 30 * 1024 * 1024:
|
||||||
|
raise ValueError("The STL is larger than 30 MB; export a lighter mesh")
|
||||||
|
content = path.read_bytes()
|
||||||
|
self.model_cache.parent.mkdir(exist_ok=True)
|
||||||
|
self.model_cache.write_bytes(content)
|
||||||
|
self.model_cache_name.write_text(path.name, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"name": path.name, "data": base64.b64encode(content).decode("ascii")}
|
||||||
|
|
||||||
|
def save_model(self, name, data):
|
||||||
safe_name = Path(str(name)).name
|
safe_name = Path(str(name)).name
|
||||||
if not safe_name.lower().endswith(".stl"):
|
if not safe_name.lower().endswith(".stl"):
|
||||||
raise ValueError("Choose an STL mesh")
|
raise ValueError("Choose an STL mesh")
|
||||||
size = int(size)
|
try:
|
||||||
if not 0 < size <= 30 * 1024 * 1024:
|
content = base64.b64decode(data, validate=True)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise ValueError("Invalid STL data") from exc
|
||||||
|
if not content or len(content) > 30 * 1024 * 1024:
|
||||||
raise ValueError("STL must be between 1 byte and 30 MB")
|
raise ValueError("STL must be between 1 byte and 30 MB")
|
||||||
self.model_cache.parent.mkdir(exist_ok=True)
|
self.model_cache.parent.mkdir(exist_ok=True)
|
||||||
token = uuid.uuid4().hex
|
self.model_cache.write_bytes(content)
|
||||||
temp = self.model_cache.parent / f".{token}.upload"
|
self.model_cache_name.write_text(safe_name, encoding="utf-8")
|
||||||
with self.model_lock:
|
|
||||||
self.model_uploads[token] = (temp.open("wb"), temp, safe_name, size, 0)
|
|
||||||
return token
|
|
||||||
|
|
||||||
def append_model_chunk(self, token, data):
|
|
||||||
try:
|
|
||||||
chunk = base64.b64decode(data, validate=True)
|
|
||||||
except (ValueError, TypeError) as exc:
|
|
||||||
raise ValueError("Invalid STL data chunk") from exc
|
|
||||||
with self.model_lock:
|
|
||||||
upload = self.model_uploads.get(str(token))
|
|
||||||
if not upload:
|
|
||||||
raise ValueError("Model upload expired")
|
|
||||||
handle, temp, name, expected, received = upload
|
|
||||||
if received + len(chunk) > expected:
|
|
||||||
raise ValueError("Model upload is larger than expected")
|
|
||||||
handle.write(chunk)
|
|
||||||
self.model_uploads[str(token)] = (handle, temp, name, expected, received + len(chunk))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def finish_model_save(self, token):
|
|
||||||
with self.model_lock:
|
|
||||||
upload = self.model_uploads.pop(str(token), None)
|
|
||||||
if not upload:
|
|
||||||
raise ValueError("Model upload expired")
|
|
||||||
handle, temp, name, expected, received = upload
|
|
||||||
handle.close()
|
|
||||||
if received != expected:
|
|
||||||
temp.unlink(missing_ok=True)
|
|
||||||
raise ValueError("Model upload was incomplete")
|
|
||||||
os.replace(temp, self.model_cache)
|
|
||||||
self.model_cache_name.write_text(name, encoding="utf-8")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_saved_model_info(self):
|
def get_saved_model_info(self):
|
||||||
self._migrate_legacy_model()
|
self._migrate_legacy_model()
|
||||||
try:
|
try:
|
||||||
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
|
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
|
||||||
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "vehicle_model.stl"
|
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
|
||||||
return {"name": name, "size": self.model_cache.stat().st_size}
|
return {"name": name, "size": self.model_cache.stat().st_size}
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def read_saved_model_chunk(self, offset, size=1048576):
|
def read_saved_model_chunk(self, offset, size=262144):
|
||||||
offset, size = int(offset), min(int(size), 1048576)
|
offset, size = int(offset), min(int(size), 524288)
|
||||||
if offset < 0 or size < 1 or not self.model_cache.is_file():
|
if offset < 0 or size < 1 or not self.model_cache.is_file():
|
||||||
raise ValueError("Saved model is unavailable")
|
raise ValueError("Saved model is unavailable")
|
||||||
with self.model_cache.open("rb") as stream:
|
with self.model_cache.open("rb") as stream:
|
||||||
@@ -150,8 +191,7 @@ class ControlApi:
|
|||||||
return {"offset": offset, "data": base64.b64encode(content).decode("ascii"), "eof": offset + len(content) >= self.model_cache.stat().st_size}
|
return {"offset": offset, "data": base64.b64encode(content).decode("ascii"), "eof": offset + len(content) >= self.model_cache.stat().st_size}
|
||||||
|
|
||||||
def _migrate_legacy_model(self):
|
def _migrate_legacy_model(self):
|
||||||
if self.model_cache.is_file():
|
if not self.model_cache.is_file():
|
||||||
return
|
|
||||||
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
|
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
|
||||||
if default_asset.is_file():
|
if default_asset.is_file():
|
||||||
try:
|
try:
|
||||||
@@ -162,37 +202,103 @@ class ControlApi:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
|
if self.model_path.is_file():
|
||||||
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
|
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
|
||||||
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
|
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
|
||||||
self.model_cache.parent.mkdir(exist_ok=True)
|
self.model_cache.parent.mkdir(exist_ok=True)
|
||||||
shutil.copyfile(path, self.model_cache)
|
shutil.copyfile(path, self.model_cache)
|
||||||
self.model_cache_name.write_text(path.name, encoding="utf-8")
|
self.model_cache_name.write_text(path.name, encoding="utf-8")
|
||||||
except (OSError, ValueError, TypeError, KeyError):
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def saved_model(self):
|
||||||
|
self._migrate_legacy_model()
|
||||||
|
try:
|
||||||
|
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
|
||||||
|
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
|
||||||
|
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
def connect(self, port, baud=57600, rate=20):
|
def connect(self, port, baud=57600, rate=20):
|
||||||
|
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
|
||||||
if not port:
|
if not port:
|
||||||
raise ValueError("Select a USB serial port")
|
raise ValueError("Select a USB serial port")
|
||||||
self.worker.rate_hz = int(rate)
|
if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
|
||||||
self.worker.connect(str(port), int(baud))
|
self.demo_mode = True
|
||||||
|
self.servo_functions = {1: 33, 2: 34, 3: 35}
|
||||||
|
self.motor_directions = {1: 1, 2: 1, 3: 1}
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
self.state.update(
|
||||||
|
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
|
||||||
|
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
|
||||||
|
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
|
||||||
|
"cells": 4, "low": False},
|
||||||
|
)
|
||||||
|
for servo in range(1, 17):
|
||||||
|
function = self.servo_functions.get(servo, 0)
|
||||||
|
motor = motor_number_for_function(function)
|
||||||
|
self.state["servos"][str(servo)] = {
|
||||||
|
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
|
||||||
|
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
|
||||||
|
}
|
||||||
|
return self._snapshot()
|
||||||
|
self.worker.rate_hz = int(rate)
|
||||||
|
with self.lock:
|
||||||
|
self.vehicle_state.disconnect()
|
||||||
self.state.update(
|
self.state.update(
|
||||||
connected=True, link="CONNECTING",
|
connected=True, link="CONNECTING",
|
||||||
status=f"Opening {port} at {baud} baud…",
|
status=f"Opening {port} at {baud} baud…",
|
||||||
)
|
)
|
||||||
return True
|
self.worker.connect(str(port), int(baud))
|
||||||
|
print(f"[web_ui] connect() dispatched to worker thread OK")
|
||||||
|
# Return only once the worker has either seen a heartbeat or reported a
|
||||||
|
# connection failure. This makes the button independent of the regular
|
||||||
|
# UI polling loop and prevents an indefinite visual "Connecting" state.
|
||||||
|
deadline = time.monotonic() + 8.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
snapshot = self.poll()
|
||||||
|
if snapshot["app_state"] != "connecting":
|
||||||
|
return snapshot
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.worker.disconnect()
|
||||||
|
time.sleep(0.1)
|
||||||
|
self.poll()
|
||||||
|
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
|
if self.demo_mode:
|
||||||
|
self.demo_mode = False
|
||||||
|
with self.lock:
|
||||||
|
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||||
|
armed=False, mode=None, battery=None, status="Demo mode disconnected")
|
||||||
|
return True
|
||||||
self.worker.disconnect()
|
self.worker.disconnect()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_axes(self, forward, turn, vertical):
|
def set_axes(self, forward, turn, vertical, sequence=None):
|
||||||
|
if self.demo_mode:
|
||||||
|
with self.lock:
|
||||||
|
self.state["status"] = (
|
||||||
|
f"Demo controls • surge {float(forward):+.2f} • "
|
||||||
|
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
with self.lock:
|
||||||
|
allowed = self.vehicle_state.snapshot()["controls_allowed"]
|
||||||
self.worker.set_axes(Axes(
|
self.worker.set_axes(Axes(
|
||||||
max(-1.0, min(1.0, float(forward))),
|
max(-1.0, min(1.0, float(forward))), 0.0,
|
||||||
0.0,
|
|
||||||
max(-1.0, min(1.0, float(vertical))),
|
max(-1.0, min(1.0, float(vertical))),
|
||||||
max(-1.0, min(1.0, float(turn))),
|
max(-1.0, min(1.0, float(turn))),
|
||||||
))
|
) if allowed else Axes(), sequence)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop_controls(self, sequence=None):
|
||||||
|
"""Invalidate queued input without claiming that the vehicle disarmed."""
|
||||||
|
with self.lock:
|
||||||
|
self.worker.set_axes(Axes(), sequence)
|
||||||
|
self.worker._send_control(force_neutral=True)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_gain(self, value):
|
def set_gain(self, value):
|
||||||
@@ -200,18 +306,45 @@ class ControlApi:
|
|||||||
self.worker.gain = value / 100.0
|
self.worker.gain = value / 100.0
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def command(self, name):
|
def command(self, name, sequence=None, expires_at=None):
|
||||||
|
if self.demo_mode:
|
||||||
|
if name not in ("manual", "arm", "disarm"):
|
||||||
|
raise ValueError(f"Unknown command: {name}")
|
||||||
|
armed = name == "arm" if name != "manual" else self.state.get("armed", False)
|
||||||
|
with self.lock:
|
||||||
|
self.state.update(armed=armed, mode="MANUAL",
|
||||||
|
vehicle=f"DEMO • {'ARMED' if armed else 'DISARMED'}",
|
||||||
|
status=f"Demo: {'Manual mode' if name == 'manual' else name.capitalize()} confirmed")
|
||||||
|
return True
|
||||||
|
if not self.worker.connection or not self.worker.target_system:
|
||||||
|
if name == "manual":
|
||||||
|
raise RuntimeError("Connect to vehicle to enable Manual mode")
|
||||||
|
elif name == "arm":
|
||||||
|
raise RuntimeError("Connect to vehicle before arming")
|
||||||
|
elif name == "disarm":
|
||||||
|
raise RuntimeError("Connect to vehicle before disarming")
|
||||||
|
raise RuntimeError("No autopilot heartbeat")
|
||||||
|
if name in ("arm", "disarm"):
|
||||||
|
self.poll()
|
||||||
|
with self.lock:
|
||||||
|
if name == "arm":
|
||||||
|
if expires_at is not None and time.time() * 1000 > expires_at:
|
||||||
|
raise RuntimeError("Engage request expired before it could be sent")
|
||||||
|
if sequence is not None and sequence <= self.worker.last_input_sequence:
|
||||||
|
raise RuntimeError("Engage canceled by a newer input reset")
|
||||||
|
self.vehicle_state.request(name)
|
||||||
|
self.worker.set_axes(Axes(), sequence)
|
||||||
|
try:
|
||||||
|
self.worker.command_arm(name == "arm")
|
||||||
|
except Exception as exc:
|
||||||
|
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
|
||||||
|
raise
|
||||||
|
return self._snapshot()
|
||||||
if name == "manual":
|
if name == "manual":
|
||||||
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
||||||
self.worker.set_manual_mode()
|
self.worker.set_manual_mode()
|
||||||
elif name == "arm":
|
|
||||||
self.worker.log("OPERATOR", "clicked ARM")
|
|
||||||
self.worker.command_arm(True)
|
|
||||||
elif name == "disarm":
|
|
||||||
self.worker.log("OPERATOR", "clicked DISARM")
|
|
||||||
self.worker.command_arm(False)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unknown command")
|
raise ValueError(f"Unknown command: {name}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_servo_function(self, servo, motor):
|
def set_servo_function(self, servo, motor):
|
||||||
@@ -219,6 +352,10 @@ class ControlApi:
|
|||||||
if not 1 <= servo <= 16 or not 0 <= motor <= 12:
|
if not 1 <= servo <= 16 or not 0 <= motor <= 12:
|
||||||
raise ValueError("Invalid servo or motor")
|
raise ValueError("Invalid servo or motor")
|
||||||
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
|
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
|
||||||
|
if self.demo_mode:
|
||||||
|
self.servo_functions[servo] = function
|
||||||
|
self._update_servo(servo)
|
||||||
|
return True
|
||||||
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
|
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -230,6 +367,10 @@ class ControlApi:
|
|||||||
current = self.motor_directions.get(motor)
|
current = self.motor_directions.get(motor)
|
||||||
if current is None:
|
if current is None:
|
||||||
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
|
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
|
||||||
|
if self.demo_mode:
|
||||||
|
self.motor_directions[motor] = -1 if current == 1 else 1
|
||||||
|
self._update_servo(servo)
|
||||||
|
return True
|
||||||
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
|
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -259,13 +400,15 @@ class ControlApi:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def poll(self):
|
def poll(self):
|
||||||
while True:
|
# Bound a poll even if telemetry arrives continuously.
|
||||||
|
with self._poll_lock:
|
||||||
|
for _ in range(500):
|
||||||
try:
|
try:
|
||||||
event = self.events.get_nowait()
|
event = self.events.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
self._consume(event)
|
self._consume(event)
|
||||||
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5:
|
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.state["link"] = (
|
self.state["link"] = (
|
||||||
"DATA OK / HEARTBEAT DELAYED"
|
"DATA OK / HEARTBEAT DELAYED"
|
||||||
@@ -273,24 +416,49 @@ class ControlApi:
|
|||||||
)
|
)
|
||||||
return self._snapshot()
|
return self._snapshot()
|
||||||
|
|
||||||
|
def _derive_app_state(self) -> str:
|
||||||
|
if not self.state.get("connected"):
|
||||||
|
return "disconnected"
|
||||||
|
link = self.state.get("link", "")
|
||||||
|
if link == "CONNECTING":
|
||||||
|
return "connecting"
|
||||||
|
if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"):
|
||||||
|
return "link_degraded"
|
||||||
|
if self.state.get("armed"):
|
||||||
|
return "armed"
|
||||||
|
return "connected_disarmed"
|
||||||
|
|
||||||
def _snapshot(self):
|
def _snapshot(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return json.loads(json.dumps(self.state))
|
snap = json.loads(json.dumps(self.state))
|
||||||
|
snap["app_state"] = self._derive_app_state()
|
||||||
|
if not self.demo_mode:
|
||||||
|
snap.update(self.vehicle_state.snapshot())
|
||||||
|
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
|
||||||
|
snap["app_state"] = "link_degraded"
|
||||||
|
snap["battery_stale"] = (self.battery_received_at is None
|
||||||
|
or time.monotonic() - self.battery_received_at > 5)
|
||||||
|
return snap
|
||||||
|
|
||||||
def _consume(self, event):
|
def _consume(self, event):
|
||||||
kind = event[0]
|
kind = event[0]
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
_, system, autopilot, mode, armed = event
|
_, system, autopilot, mode, armed = event[:5]
|
||||||
|
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
|
||||||
self.state.update(
|
self.state.update(
|
||||||
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
|
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
|
||||||
armed=bool(armed),
|
armed=bool(armed),
|
||||||
|
mode=str(mode).upper(),
|
||||||
)
|
)
|
||||||
elif kind in ("status", "statustext"):
|
elif kind in ("status", "statustext"):
|
||||||
self.state["status"] = event[1]
|
self.state["status"] = event[1]
|
||||||
elif kind == "ack":
|
elif kind == "ack":
|
||||||
|
if event[1] == 400:
|
||||||
|
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
|
||||||
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
|
||||||
elif kind == "battery":
|
elif kind == "battery":
|
||||||
|
self.battery_received_at = time.monotonic()
|
||||||
voltage = float(event[1])
|
voltage = float(event[1])
|
||||||
cells = self.battery_config["cells"]
|
cells = self.battery_config["cells"]
|
||||||
per_cell = voltage / cells
|
per_cell = voltage / cells
|
||||||
@@ -316,9 +484,14 @@ class ControlApi:
|
|||||||
self.state["log_path"] = event[1]
|
self.state["log_path"] = event[1]
|
||||||
elif kind == "error":
|
elif kind == "error":
|
||||||
self.state["status"] = event[1]
|
self.state["status"] = event[1]
|
||||||
|
self.state["last_error"] = event[1]
|
||||||
elif kind == "disconnected":
|
elif kind == "disconnected":
|
||||||
|
self.vehicle_state.disconnect()
|
||||||
|
last_err = self.state.get("last_error")
|
||||||
|
status_msg = last_err if last_err else "Disconnected"
|
||||||
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
|
||||||
armed=False, battery=None, status="Disconnected")
|
mode=None, status=status_msg)
|
||||||
|
self.state["last_error"] = None
|
||||||
|
|
||||||
def _update_servo(self, servo):
|
def _update_servo(self, servo):
|
||||||
function = self.servo_functions.get(servo)
|
function = self.servo_functions.get(servo)
|
||||||
@@ -334,23 +507,106 @@ class ControlApi:
|
|||||||
self.worker.disconnect()
|
self.worker.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_dwm_styling(window):
|
||||||
|
try:
|
||||||
|
hwnd = None
|
||||||
|
if hasattr(window, "native") and window.native:
|
||||||
|
hwnd = int(window.native.Handle.ToInt64())
|
||||||
|
if hwnd:
|
||||||
|
import ctypes
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
dwmapi = ctypes.windll.dwmapi
|
||||||
|
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
|
||||||
|
dwmapi.DwmSetWindowAttribute(
|
||||||
|
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
|
||||||
|
)
|
||||||
|
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||||
|
dwmapi.DwmSetWindowAttribute(
|
||||||
|
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
|
||||||
|
)
|
||||||
|
user32.SetForegroundWindow(ctypes.c_void_p(hwnd))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
|
||||||
|
target_w, target_h = 1100, 650
|
||||||
|
min_w, min_h = 960, 580
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
sw = user32.GetSystemMetrics(0)
|
||||||
|
sh = user32.GetSystemMetrics(1)
|
||||||
|
if sw > 0 and sh > 0:
|
||||||
|
target_w = min(target_w, max(min_w, int(sw * 0.92)))
|
||||||
|
target_h = min(target_h, max(min_h, int(sh * 0.90)))
|
||||||
|
min_w = min(min_w, target_w)
|
||||||
|
min_h = min(min_h, target_h)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return target_w, target_h, (min_w, min_h)
|
||||||
|
|
||||||
|
|
||||||
|
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass # Suppress HTTP request logs in console
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
self.send_header("Pragma", "no-cache")
|
||||||
|
self.send_header("Expires", "0")
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
unquoted = urllib.parse.unquote(self.path)
|
||||||
|
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
|
||||||
|
project_root = Path(__file__).resolve().parent
|
||||||
|
model_file = project_root / "assets" / "Main Assembly V1 (2).stl"
|
||||||
|
if not model_file.is_file():
|
||||||
|
model_file = project_root / "runtime" / "vehicle_model.stl"
|
||||||
|
if model_file.is_file():
|
||||||
|
data = model_file.read_bytes()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "model/stl")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
return
|
||||||
|
return super().do_GET()
|
||||||
|
|
||||||
|
|
||||||
|
def start_local_server(directory: Path) -> int:
|
||||||
|
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
|
||||||
|
server = socketserver.TCPServer(("127.0.0.1", 0), handler)
|
||||||
|
port = server.server_address[1]
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
api = ControlApi()
|
api = ControlApi()
|
||||||
ui_path = Path(__file__).resolve().parent / "web" / "index.html"
|
api._migrate_legacy_model()
|
||||||
|
project_root = Path(__file__).resolve().parent
|
||||||
|
port = start_local_server(project_root)
|
||||||
|
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
|
||||||
|
win_w, win_h, min_dims = _get_window_size()
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
"SUB Control", url=str(ui_path), js_api=api,
|
"SUB Control", url=app_url, js_api=api,
|
||||||
width=1120, height=760, min_size=(900, 650),
|
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,
|
||||||
)
|
)
|
||||||
api._window = window
|
api._window = window
|
||||||
|
window.events.shown += lambda: _apply_dwm_styling(window)
|
||||||
window.events.maximized += lambda: setattr(api, "maximized", True)
|
window.events.maximized += lambda: setattr(api, "maximized", True)
|
||||||
window.events.restored += lambda: setattr(api, "maximized", False)
|
window.events.restored += lambda: setattr(api, "maximized", False)
|
||||||
try:
|
try:
|
||||||
webview.start(debug=False)
|
webview.start(debug=True)
|
||||||
finally:
|
finally:
|
||||||
api.close()
|
api.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user