diff --git a/README.md b/README.md index 3c5c185..676fa07 100644 --- a/README.md +++ b/README.md @@ -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 slider ease back to center over 400 ms after mouse/key release, so neither can be left commanding motion. -Heartbeat loss, disconnect, and disarm remain immediate safety stops and are -intentionally not eased. +Focus loss, stale telemetry, disconnect, and disarm clear commanded inputs +without easing. This is a neutral-command request, **not proof that motors have +stopped** when the communication link is unavailable. In addition, transmitted pilot demands have a small 4% input deadband and a -0.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. 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 @@ -122,3 +123,51 @@ selected horizontal limit. ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this application's system ID (default 255), or when `MAV_OPTIONS` does not allow MAVLink input. Those parameters must already be correctly configured. + +## Engage, confirmation, and interruption handling + +The compact window opens at 1100 × 650. Empty sidebar areas drag the window; +the STL viewer, buttons, and sliders are separate interactive regions. Output +limit and Motor setup sit below the main controls. Recent activity retains the +last three events, oldest at the top and newest emphasized at the bottom. +Routine hold instructions are kept in tooltips; +pending confirmations and safety warnings still appear above the controls. +The full-width text control displays `engage` / `disengage`; disengage sends the +same immediate disarm request described below. + +- Hold **ENGAGE** for 1.4 seconds (or hold F2). Early release, leaving the + button, interrupted touch, and focus loss cancel the hold without sending arm. +- **ENGAGING…** means a request is pending. A send result or accepted command + ACK alone does not prove that the vehicle armed: a fresh autopilot heartbeat + reporting the requested state is the final confirmation. A heartbeat can + confirm state even if the ACK packet was lost. +- **DISARM** is one tap or F3, with no long hold. While confirmation is pending, + **DISARMING…** stays red and driving is disabled. Disarm can also interrupt a + pending Engage request. No forced-arm/disarm bypass is used. +- Confirmation timeout: 5 seconds in the backend (6-second UI fallback). + Rejection and timeout remain visible. Late heartbeat updates still determine + the displayed state; the UI never substitutes an animation state for telemetry. +- A heartbeat older than 3 seconds is stale. A missing bridge reply after + 1.5 seconds shows **STATE UNKNOWN**, preserving the last-known state without + claiming disarm. Battery readings older than 5 seconds are marked stale. +- The web frontend renews an input lease locally every 250 ms. If it stops for + 1 second, the worker clears stored demands and sends neutral at its configured + control rate. This adds no MAVLink stream or parameter download. Focus loss + clears both captured touch pointers and queued input; reconnect never replays + the old joystick position. Normal release still eases back to center. +- Link loss cannot deliver a stop over a broken tether. Verify the vehicle's + own failsafe and use independent physical power isolation for bench tests. + +### Regression checks (no hardware) + +Run from this project folder after setting up the Python environment: + +```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. diff --git a/SUB Control/README.md b/SUB Control/README.md index 3c5c185..676fa07 100644 --- a/SUB Control/README.md +++ b/SUB Control/README.md @@ -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 slider ease back to center over 400 ms after mouse/key release, so neither can be left commanding motion. -Heartbeat loss, disconnect, and disarm remain immediate safety stops and are -intentionally not eased. +Focus loss, stale telemetry, disconnect, and disarm clear commanded inputs +without easing. This is a neutral-command request, **not proof that motors have +stopped** when the communication link is unavailable. In addition, transmitted pilot demands have a small 4% input deadband and a -0.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. 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 @@ -122,3 +123,51 @@ selected horizontal limit. ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this application's system ID (default 255), or when `MAV_OPTIONS` does not allow MAVLink input. Those parameters must already be correctly configured. + +## Engage, confirmation, and interruption handling + +The compact window opens at 1100 × 650. Empty sidebar areas drag the window; +the STL viewer, buttons, and sliders are separate interactive regions. Output +limit and Motor setup sit below the main controls. Recent activity retains the +last three events, oldest at the top and newest emphasized at the bottom. +Routine hold instructions are kept in tooltips; +pending confirmations and safety warnings still appear above the controls. +The full-width text control displays `engage` / `disengage`; disengage sends the +same immediate disarm request described below. + +- Hold **ENGAGE** for 1.4 seconds (or hold F2). Early release, leaving the + button, interrupted touch, and focus loss cancel the hold without sending arm. +- **ENGAGING…** means a request is pending. A send result or accepted command + ACK alone does not prove that the vehicle armed: a fresh autopilot heartbeat + reporting the requested state is the final confirmation. A heartbeat can + confirm state even if the ACK packet was lost. +- **DISARM** is one tap or F3, with no long hold. While confirmation is pending, + **DISARMING…** stays red and driving is disabled. Disarm can also interrupt a + pending Engage request. No forced-arm/disarm bypass is used. +- Confirmation timeout: 5 seconds in the backend (6-second UI fallback). + Rejection and timeout remain visible. Late heartbeat updates still determine + the displayed state; the UI never substitutes an animation state for telemetry. +- A heartbeat older than 3 seconds is stale. A missing bridge reply after + 1.5 seconds shows **STATE UNKNOWN**, preserving the last-known state without + claiming disarm. Battery readings older than 5 seconds are marked stale. +- The web frontend renews an input lease locally every 250 ms. If it stops for + 1 second, the worker clears stored demands and sends neutral at its configured + control rate. This adds no MAVLink stream or parameter download. Focus loss + clears both captured touch pointers and queued input; reconnect never replays + the old joystick position. Normal release still eases back to center. +- Link loss cannot deliver a stop over a broken tether. Verify the vehicle's + own failsafe and use independent physical power isolation for bench tests. + +### Regression checks (no hardware) + +Run from this project folder after setting up the Python environment: + +```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. diff --git a/SUB Control/app.py b/SUB Control/app.py index fe6b5de..3c38f65 100644 --- a/SUB Control/app.py +++ b/SUB Control/app.py @@ -71,6 +71,11 @@ class MavlinkWorker: self.axes = Axes() self.output_axes = Axes() self.last_control_at = 0.0 + # Enabled by the web bridge only; the legacy Tk frontend is unchanged. + self.require_input_lease = False + self.last_input_at = 0.0 + self.last_input_sequence = -1 + self.input_timeout = 1.0 # Smooth ESC commands without adding a perceptible one-second lag. # At 20 Hz this produces five small steps from neutral to full command. self.ramp_seconds = 0.25 @@ -200,6 +205,10 @@ class MavlinkWorker: payload = str(msg) self.log("RX", payload) 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 self.target_system = msg.get_srcSystem() self.target_component = msg.get_srcComponent() @@ -213,15 +222,17 @@ class MavlinkWorker: mode = str(msg.custom_mode) autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot) ap_name = autopilot.name if autopilot else str(msg.autopilot) - self.events.put(("heartbeat", self.target_system, ap_name, mode, armed)) + self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat)) if first: self.events.put(("status", "Heartbeat received; controls are neutral.")) self._configure_low_bandwidth_streams() self._start_servo_scan() elif msg_type == "COMMAND_ACK": + if (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component): + return result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result) result_name = result.name if result else str(msg.result) - self.events.put(("ack", int(msg.command), result_name)) + self.events.put(("ack", int(msg.command), result_name, self.last_rx)) elif msg_type == "STATUSTEXT": text = msg.text if isinstance(text, bytes): @@ -343,10 +354,17 @@ class MavlinkWorker: if not conn or not self.target_system: return with self.lock: - active = not force_neutral + now = time.monotonic() + expired = self.require_input_lease and ( + not self.armed or now - self.last_heartbeat > 3.0 + or now - self.last_input_at > self.input_timeout) + if expired or force_neutral: + # Never replay a stored command after telemetry/UI recovery. + self.axes = Axes() + self.output_axes = Axes() + active = not (force_neutral or expired) target = self.axes if active else Axes() gain = self.gain - now = time.monotonic() if force_neutral: self.output_axes = Axes() else: @@ -418,7 +436,7 @@ class MavlinkWorker: manual, ) - def set_axes(self, axes: Axes): + def set_axes(self, axes: Axes, sequence=None): def conditioned(value): # Ignore tiny pointer/key return noise, then quantize to stable 1% steps. if abs(value) < 0.04: @@ -426,6 +444,11 @@ class MavlinkWorker: return round(max(-1.0, min(1.0, value)), 2) with self.lock: + if sequence is not None: + if sequence <= self.last_input_sequence: + return + self.last_input_sequence = sequence + self.last_input_at = time.monotonic() self.axes = Axes( conditioned(axes.forward), conditioned(axes.lateral), conditioned(axes.vertical), conditioned(axes.yaw), @@ -1185,7 +1208,7 @@ class App(tk.Tk): event = self.events.get_nowait() kind = event[0] if kind == "heartbeat": - _, system, autopilot, mode, armed = event + _, system, autopilot, mode, armed = event[:5] self.vehicle_label.configure( text=f"{mode} • {'ARMED' if armed else 'DISARMED'}" ) diff --git a/SUB Control/tests/control_ui.test.cjs b/SUB Control/tests/control_ui.test.cjs new file mode 100644 index 0000000..d2c2a77 --- /dev/null +++ b/SUB Control/tests/control_ui.test.cjs @@ -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"'){ + const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8'); + assert.match(html,/')); +}); + +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…'); +}); diff --git a/SUB Control/tests/test_vehicle_state.py b/SUB Control/tests/test_vehicle_state.py new file mode 100644 index 0000000..2c63620 --- /dev/null +++ b/SUB Control/tests/test_vehicle_state.py @@ -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() diff --git a/SUB Control/vehicle_state.py b/SUB Control/vehicle_state.py new file mode 100644 index 0000000..dcf6f86 --- /dev/null +++ b/SUB Control/vehicle_state.py @@ -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} diff --git a/SUB Control/web/app.js b/SUB Control/web/app.js index d45086f..7029b9e 100644 --- a/SUB Control/web/app.js +++ b/SUB Control/web/app.js @@ -6,11 +6,25 @@ const STATUS_LABELS = { link_degraded: 'Signal lost — check tether', }; const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; -const control={forward:0,turn:0,vertical:0}, 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); -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)} -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}))}); 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; @@ -51,23 +65,51 @@ function populatePorts(ports){ 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 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);} +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(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)} 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 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)} 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();} -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('blur',()=>{keys.clear();window.joystick3D?.easeBack();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(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); +function 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){ const el=typeof id==='string'?document.getElementById(id):id; @@ -166,7 +208,14 @@ if(rovStage){ if(file)handleStlFile(file); }); } -$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)populatePorts(await api('refresh_ports'))}; +$('#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; @@ -174,6 +223,7 @@ $('#connect').onclick=async()=>{ 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; @@ -186,6 +236,7 @@ $('#connect').onclick=async()=>{ } if(appState!=='disconnected'){ + resetInputs(); label.textContent='Disconnecting…'; try{ await api('disconnect'); @@ -201,17 +252,17 @@ $('#connect').onclick=async()=>{ btn.classList.add('is-connecting'); label.textContent='Connecting…'; const statusEl=$('#status-text'); - if(statusEl)statusEl.textContent=`Opening ${port}…`; + recordActivity(`Connecting to ${port}…`); toast(`Connecting to ${port}…`); try{ const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value); if(confirmedState)render(confirmedState); - toast('MAVLink connected'); + 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); - 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; const ENGAGE_HOLD_MS=1400; -function isManualMode(s){ - const m=(s&&s.mode)?String(s.mode).toUpperCase():''; - return m==='MANUAL'; -} - function applyEngageVisual(next){ - if(!engageButton||engageVisualState===next)return; - const previous=engageVisualState;engageVisualState=next;clearTimeout(engageTransitionTimer); - 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)}; - 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(); + 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); + } } -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 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||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); } 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; 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); @@ -268,39 +361,51 @@ function startEngageHold(e){ } 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.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){ 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('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.clientXr.right||e.clientYr.bottom)cancelEngageHold(false)}); engageButton.addEventListener('contextmenu',e=>e.preventDefault()); - engageButton.onclick=async()=>{ + engageButton.onclick=()=>{ if(suppressEngageClick){suppressEngageClick=false;return} - if(appState==='armed'){ - 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); - } - } + 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 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=['',...Array.from({length:12},(_,n)=>``)].join('');row.innerHTML=`SERVO${i}${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}`;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){ - appState=s.app_state||'disconnected'; +function render(s,confirmed=false){ + vehicleSnapshot=s; + if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error || + (s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null; + const previous=view; + view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError}); + 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; @@ -324,24 +429,26 @@ function render(s){ const engageBtn=$('#engage'); const engageLabel=$('#engage-label'); 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"]'); if(motorBtn)motorBtn.disabled=(!isOnline); - const statusEl=$('#status-text'); - if(statusEl){ - if(s.status){ - statusEl.textContent=s.status; - }else{ - statusEl.textContent=STATUS_LABELS[appState]||STATUS_LABELS.disconnected; - } + 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`; + $('#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{ @@ -349,10 +456,31 @@ function render(s){ $('#battery-percent').textContent='waiting'; $('#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; async function loadDefaultModel(){ if(modelLoadPromise)return modelLoadPromise; @@ -426,35 +554,46 @@ async function loadDefaultModel(){ return modelLoadPromise; } -let booted=false; +let booted=false,booting=false,bootRetry=0,portRefreshTimer=0; +function hasDesktopApi(){return ['initial_state','refresh_ports','poll'].every(name=>typeof window.pywebview?.api?.[name]==='function')} async function boot(){ - if(booted||!window.pywebview?.api)return; - booted=true; - bridgeReady=true; + if(booted||booting||!hasDesktopApi())return; + booting=true;clearTimeout(bootRetry); try{ - const init=await api('initial_state'); - batteryConfig=init.battery_config; + // 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(); - render(init.state); + 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(); - setInterval(async()=>{ - if(appState==='disconnected'){ - try{populatePorts(await api('refresh_ports'))}catch(_){} + if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{ + if(appState==='disconnected'||demoActive){ + try{populatePorts(await withTimeout(api('refresh_ports'),1500))} + catch(error){recordActivity(`Port scan failed: ${error.message}`)} } },2000); }catch(e){ + booted=false;bridgeReady=false; + recordActivity(`Desktop startup failed; retrying: ${e.message}`); toast(`Python bridge: ${e.message}`,true); + bootRetry=setTimeout(boot,1000); + }finally{ + booting=false; } } function waitForBridge(){ - if(window.pywebview?.api)boot(); - else setTimeout(waitForBridge,50); + if(booted)return; + if(hasDesktopApi())boot(); + setTimeout(waitForBridge,250); } addEventListener('pywebviewready',boot); +render(vehicleSnapshot); if(document.readyState==='loading'){ addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()}); }else{ diff --git a/SUB Control/web/control-state.js b/SUB Control/web/control-state.js new file mode 100644 index 0000000..9c72c4b --- /dev/null +++ b/SUB Control/web/control-state.js @@ -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); diff --git a/SUB Control/web/index.html b/SUB Control/web/index.html index b73ceca..9c06abb 100644 --- a/SUB Control/web/index.html +++ b/SUB Control/web/index.html @@ -4,13 +4,14 @@ Sub Control - +
-
@@ -157,7 +151,8 @@ configuration

Motor Outputs

Function and direction are read one parameter at a time to protect the low-bandwidth link.

OutputFunctionDirection
- - + + + diff --git a/SUB Control/web/joystick-3d.js b/SUB Control/web/joystick-3d.js index f481c46..4a277e7 100644 --- a/SUB Control/web/joystick-3d.js +++ b/SUB Control/web/joystick-3d.js @@ -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)); 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.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?'#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,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 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('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()},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}}}; })(); diff --git a/SUB Control/web/styles.css b/SUB Control/web/styles.css index 6a7e7b3..8dc091a 100644 --- a/SUB Control/web/styles.css +++ b/SUB Control/web/styles.css @@ -118,7 +118,7 @@ body.maximized .app-shell { transition: transform 0.15s, background 0.15s, border-color 0.15s; } .button:hover, .icon-button:hover { - transform: translateY(-1px); + transform: none; border-color: rgba(255, 255, 255, 0.18); 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 { 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 { 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 { background: var(--red); - box-shadow: 0 0 6px rgba(240, 149, 139, 0.65); + box-shadow: none; } .status-line p { margin: 0; @@ -603,10 +603,11 @@ body[data-app-state="link_degraded"] .status-line .status-dot { /* ── Engage / Disarm Slot Above Joysticks ── */ .engage-header-zone { - display: flex; + display: grid; + grid-template-columns: 1fr auto; align-items: center; justify-content: space-between; - min-height: 42px; + min-height: 76px; margin-bottom: 10px; } .zone-label { @@ -617,13 +618,13 @@ body[data-app-state="link_degraded"] .status-line .status-dot { } @property --engage-progress { syntax: ""; - inherits: false; + inherits: true; initial-value: 0%; } .engage-button { --engage-progress: 0%; min-width: 166px; - height: 38px; + height: 44px; padding: 0 24px; border-radius: 10px; position: relative; @@ -653,15 +654,26 @@ body[data-app-state="link_degraded"] .status-line .status-dot { 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 { color: #a9ed72; 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); } -.engage-face { transition: opacity .14s ease, transform .14s ease; } -@keyframes engage-shimmer { from{background-position:160% 0} to{background-position:-90% 0} } +.state-transition .engage-face { animation: state-label .24s ease-out; } +@keyframes state-label { from {opacity:.35} to {opacity:1} } +@keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} } +.engage-button:hover { transform:none; } +.engage-button.pending { background:#405346; border-color:#516e56; } +.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:hover { background: #d7dcd5; } @@ -832,7 +844,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot { transform: translate(-50%, -50%); } .heave-knob:active, .heave-knob.pressed { - scale: 0.93; + filter: brightness(.83); } .control-card footer { @@ -978,7 +990,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot { /* ══════════════════════════════════════════════════════════════════ APP-STATE CONDITIONAL GATING ══════════════════════════════════════════════════════════════════ */ -body:not([data-app-state="armed"]) .controls-grid { +body:not([data-controls-enabled="true"]) .controls-grid { opacity: 0.45; pointer-events: none; user-select: none; @@ -991,10 +1003,6 @@ button:disabled { } 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="connecting"] 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; } +/* 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) { .app-shell { grid-template-columns: 210px minmax(0, 1fr); } .controls-grid { grid-template-columns: minmax(230px, 1fr) 150px; } .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; } +} diff --git a/SUB Control/web_ui.py b/SUB Control/web_ui.py index d57e3ff..825347d 100644 --- a/SUB Control/web_ui.py +++ b/SUB Control/web_ui.py @@ -16,6 +16,7 @@ import webview from serial.tools import list_ports from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function +from vehicle_state import VehicleState class ControlApi: @@ -24,8 +25,12 @@ class ControlApi: def __init__(self): self.events: queue.Queue = queue.Queue() self.worker = MavlinkWorker(self.events) + self.worker.require_input_lease = True + self.vehicle_state = VehicleState() + self.battery_received_at = None self.demo_mode = False self.lock = threading.Lock() + self._poll_lock = threading.Lock() self.state = { "connected": False, "link": "DISCONNECTED", @@ -240,13 +245,13 @@ class ControlApi: } return self._snapshot() self.worker.rate_hz = int(rate) - self.worker.connect(str(port), int(baud)) with self.lock: + self.vehicle_state.disconnect() self.state.update( connected=True, link="CONNECTING", status=f"Opening {port} at {baud} baud…", - armed=False, ) + 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 @@ -272,7 +277,7 @@ class ControlApi: self.worker.disconnect() 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"] = ( @@ -280,12 +285,20 @@ class ControlApi: f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}" ) return True - self.worker.set_axes(Axes( - max(-1.0, min(1.0, float(forward))), - 0.0, - max(-1.0, min(1.0, float(vertical))), - max(-1.0, min(1.0, float(turn))), - )) + with self.lock: + allowed = self.vehicle_state.snapshot()["controls_allowed"] + self.worker.set_axes(Axes( + max(-1.0, min(1.0, float(forward))), 0.0, + max(-1.0, min(1.0, float(vertical))), + max(-1.0, min(1.0, float(turn))), + ) if allowed else Axes(), sequence) + return True + + def stop_controls(self, sequence=None): + """Invalidate queued input without claiming that the vehicle disarmed.""" + with self.lock: + self.worker.set_axes(Axes(), sequence) + self.worker._send_control(force_neutral=True) return True def set_gain(self, value): @@ -293,7 +306,7 @@ class ControlApi: self.worker.gain = value / 100.0 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}") @@ -311,15 +324,25 @@ class ControlApi: elif name == "disarm": raise RuntimeError("Connect to vehicle before disarming") raise RuntimeError("No autopilot heartbeat") + if name in ("arm", "disarm"): + self.poll() + with self.lock: + if name == "arm": + if expires_at is not None and time.time() * 1000 > expires_at: + raise RuntimeError("Engage request expired before it could be sent") + if sequence is not None and sequence <= self.worker.last_input_sequence: + raise RuntimeError("Engage canceled by a newer input reset") + self.vehicle_state.request(name) + self.worker.set_axes(Axes(), sequence) + try: + self.worker.command_arm(name == "arm") + except Exception as exc: + self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}") + raise + return self._snapshot() if name == "manual": self.worker.log("OPERATOR", "clicked SET MANUAL") self.worker.set_manual_mode() - elif name == "arm": - self.worker.log("OPERATOR", "clicked ARM") - self.worker.command_arm(True) - elif name == "disarm": - self.worker.log("OPERATOR", "clicked DISARM") - self.worker.command_arm(False) else: raise ValueError(f"Unknown command: {name}") return True @@ -377,13 +400,15 @@ class ControlApi: return True def poll(self): - while True: - try: - event = self.events.get_nowait() - except queue.Empty: - break - self._consume(event) - if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5: + # Bound a poll even if telemetry arrives continuously. + with self._poll_lock: + for _ in range(500): + try: + event = self.events.get_nowait() + except queue.Empty: + break + self._consume(event) + if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3: with self.lock: self.state["link"] = ( "DATA OK / HEARTBEAT DELAYED" @@ -407,14 +432,20 @@ class ControlApi: with self.lock: snap = json.loads(json.dumps(self.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 def _consume(self, event): kind = event[0] with self.lock: if kind == "heartbeat": - _, system, autopilot, mode, armed = event + _, system, autopilot, mode, armed = event[:5] + self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None) self.state.update( connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}", armed=bool(armed), @@ -423,8 +454,11 @@ class ControlApi: elif kind in ("status", "statustext"): self.state["status"] = event[1] elif kind == "ack": + if event[1] == 400: + self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None) self.state["status"] = f"Command {event[1]} ACK: {event[2]}" elif kind == "battery": + self.battery_received_at = time.monotonic() voltage = float(event[1]) cells = self.battery_config["cells"] per_cell = voltage / cells @@ -452,10 +486,11 @@ class ControlApi: self.state["status"] = event[1] self.state["last_error"] = event[1] elif kind == "disconnected": + self.vehicle_state.disconnect() last_err = self.state.get("last_error") status_msg = last_err if last_err else "Disconnected" self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", - armed=False, mode=None, battery=None, status=status_msg) + mode=None, status=status_msg) self.state["last_error"] = None def _update_servo(self, servo): @@ -495,7 +530,7 @@ def _apply_dwm_styling(window): 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 try: import ctypes diff --git a/app.py b/app.py index e4664ac..3c38f65 100644 --- a/app.py +++ b/app.py @@ -42,6 +42,25 @@ class Axes: yaw: float = 0.0 +def normalize_endpoint(port: str) -> str: + p = str(port).strip() + if not p: + return p + lower = p.lower() + if "sim" in lower or "5760" in lower or lower in ("tcp", "tcp:", "tcp:127.0.0.1", "127.0.0.1"): + return "tcp:127.0.0.1:5760" + if "14550" in lower or lower in ("udp", "udp:", "udpin", "udpin:"): + return "udpin:127.0.0.1:14550" + if p.startswith("127.0.0.1:") or p.startswith("localhost:"): + num = p.split(":")[1] + if num == "14550": + return f"udpin:{p}" + return f"tcp:{p}" + if ":" in p and not p.startswith(("tcp:", "tcpin:", "udp:", "udpin:", "udpout:")): + return f"tcp:{p}" + return p + + class MavlinkWorker: def __init__(self, events: queue.Queue): self.events = events @@ -52,6 +71,11 @@ class MavlinkWorker: self.axes = Axes() self.output_axes = Axes() self.last_control_at = 0.0 + # Enabled by the web bridge only; the legacy Tk frontend is unchanged. + self.require_input_lease = False + self.last_input_at = 0.0 + self.last_input_sequence = -1 + self.input_timeout = 1.0 # Smooth ESC commands without adding a perceptible one-second lag. # At 20 Hz this produces five small steps from neutral to full command. self.ramp_seconds = 0.25 @@ -74,29 +98,52 @@ class MavlinkWorker: self.log_path = None def connect(self, port: str, baud: int): + print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}") if self.thread and self.thread.is_alive(): + print(f"[MavlinkWorker] Thread already alive, ignoring connect()") return self.stop_event.clear() log_dir = Path(__file__).resolve().parent / "logs" log_dir.mkdir(exist_ok=True) self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log" self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1) - self.log("SESSION", f"start port={port} baud={baud} control_hz={self.rate_hz}") + normalized = normalize_endpoint(port) + print(f"[MavlinkWorker] normalized endpoint: {normalized!r}") + self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} control_hz={self.rate_hz}") self.events.put(("log_path", str(self.log_path))) self.thread = threading.Thread( - target=self._run, args=(port, baud), daemon=True + target=self._run, args=(normalized, baud), daemon=True ) self.thread.start() + print(f"[MavlinkWorker] worker thread started") def _run(self, port: str, baud: int): + print(f"[MavlinkWorker._run] ENTERED: port={port!r}, baud={baud}") try: + print(f"[MavlinkWorker._run] Creating mavlink_connection...") conn = mavutil.mavlink_connection( port, baud=baud, source_system=255, source_component=190, - autoreconnect=False, force_connected=True, + # Fail a missing TCP simulator immediately. pymavlink otherwise + # retries the refused socket several times and leaves the UI in + # "Opening" long after the actual failure. + autoreconnect=False, retries=0, force_connected=True, ) + print(f"[MavlinkWorker._run] mavlink_connection created: {type(conn).__name__}") self.connection = conn - self.events.put(("status", f"Port open; waiting for heartbeat at {baud} baud…")) - next_gcs_hb = 0.0 + if port.startswith("tcp:"): + self.events.put(("status", f"Connected to {port}; waiting for vehicle heartbeat…")) + else: + self.events.put(("status", f"Port {port} open; waiting for heartbeat at {baud} baud…")) + + # Send initial GCS heartbeat immediately + self.log("TX_HEARTBEAT", "GCS initial") + 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 while not self.stop_event.is_set(): now = time.monotonic() @@ -120,6 +167,7 @@ class MavlinkWorker: else: time.sleep(0.01) except Exception as exc: + print(f"[MavlinkWorker._run] EXCEPTION: {exc}") self.events.put(("error", f"Connection error: {exc}")) finally: self._safe_stop() @@ -156,7 +204,11 @@ class MavlinkWorker: except Exception: payload = str(msg) 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 self.target_system = msg.get_srcSystem() self.target_component = msg.get_srcComponent() @@ -170,15 +222,17 @@ class MavlinkWorker: mode = str(msg.custom_mode) autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot) ap_name = autopilot.name if autopilot else str(msg.autopilot) - self.events.put(("heartbeat", self.target_system, ap_name, mode, armed)) + self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat)) if first: self.events.put(("status", "Heartbeat received; controls are neutral.")) self._configure_low_bandwidth_streams() self._start_servo_scan() elif msg_type == "COMMAND_ACK": + if (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component): + return result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result) result_name = result.name if result else str(msg.result) - self.events.put(("ack", int(msg.command), result_name)) + self.events.put(("ack", int(msg.command), result_name, self.last_rx)) elif msg_type == "STATUSTEXT": text = msg.text if isinstance(text, bytes): @@ -300,10 +354,17 @@ class MavlinkWorker: if not conn or not self.target_system: return with self.lock: - active = not force_neutral + now = time.monotonic() + expired = self.require_input_lease and ( + not self.armed or now - self.last_heartbeat > 3.0 + or now - self.last_input_at > self.input_timeout) + if expired or force_neutral: + # Never replay a stored command after telemetry/UI recovery. + self.axes = Axes() + self.output_axes = Axes() + active = not (force_neutral or expired) target = self.axes if active else Axes() gain = self.gain - now = time.monotonic() if force_neutral: self.output_axes = Axes() else: @@ -375,7 +436,7 @@ class MavlinkWorker: manual, ) - def set_axes(self, axes: Axes): + def set_axes(self, axes: Axes, sequence=None): def conditioned(value): # Ignore tiny pointer/key return noise, then quantize to stable 1% steps. if abs(value) < 0.04: @@ -383,6 +444,11 @@ class MavlinkWorker: return round(max(-1.0, min(1.0, value)), 2) with self.lock: + if sequence is not None: + if sequence <= self.last_input_sequence: + return + self.last_input_sequence = sequence + self.last_input_at = time.monotonic() self.axes = Axes( conditioned(axes.forward), conditioned(axes.lateral), conditioned(axes.vertical), conditioned(axes.yaw), @@ -887,6 +953,9 @@ class App(tk.Tk): def refresh_ports(self): ports = [p.device for p in list_ports.comports()] + for sim in ("tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"): + if sim not in ports: + ports.append(sim) self.port.configure(values=ports) if ports and self.port.get() not in ports: self.port.set(ports[0]) @@ -1139,7 +1208,7 @@ class App(tk.Tk): event = self.events.get_nowait() kind = event[0] if kind == "heartbeat": - _, system, autopilot, mode, armed = event + _, system, autopilot, mode, armed = event[:5] self.vehicle_label.configure( text=f"{mode} • {'ARMED' if armed else 'DISARMED'}" ) diff --git a/fc_spoofer.py b/fc_spoofer.py new file mode 100644 index 0000000..250d6ec --- /dev/null +++ b/fc_spoofer.py @@ -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() diff --git a/run_spoofer.bat b/run_spoofer.bat new file mode 100644 index 0000000..c07c487 --- /dev/null +++ b/run_spoofer.bat @@ -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 diff --git a/run_spoofer.ps1 b/run_spoofer.ps1 new file mode 100644 index 0000000..0d261c9 --- /dev/null +++ b/run_spoofer.ps1 @@ -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 +} diff --git a/tests/control_ui.test.cjs b/tests/control_ui.test.cjs new file mode 100644 index 0000000..d2c2a77 --- /dev/null +++ b/tests/control_ui.test.cjs @@ -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"'){ + const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8'); + assert.match(html,/')); +}); + +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…'); +}); diff --git a/tests/test_vehicle_state.py b/tests/test_vehicle_state.py new file mode 100644 index 0000000..2c63620 --- /dev/null +++ b/tests/test_vehicle_state.py @@ -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() diff --git a/vehicle_state.py b/vehicle_state.py new file mode 100644 index 0000000..dcf6f86 --- /dev/null +++ b/vehicle_state.py @@ -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} diff --git a/web/app.js b/web/app.js index 27c14c2..7029b9e 100644 --- a/web/app.js +++ b/web/app.js @@ -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 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 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)} window.showToast=toast; -function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old} -function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}} -function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)} +function populatePorts(ports){ + const p=$('#port'); + if(!p)return; + const old=p.value||''; + p.innerHTML=''; + + const allList=[]; + const seen=new Set(); -const joy=$('#joystick'),stick=$('#stick');let joyDrag=false,returnAnim=0; -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)} -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 addPort=item=>{ + let dev=item, lbl=item; + if(item&&typeof item==='object'){ + dev=item.device||''; + lbl=item.label||dev; + } + if(dev&&!seen.has(dev)){ + seen.add(dev); + allList.push({device:dev, label:lbl}); + } + }; -const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0; -function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)} + 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))} -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)} +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()} -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('blur',()=>{keys.clear();if(!joyDrag)easeJoy();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(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); +function 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()); -$('#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 modelInput=$('#model-input'),modelButton=$('#load-model'); -modelButton.onclick=()=>{modelInput.value='';modelInput.click()}; -function bytesToBase64(bytes){let value='';for(let i=0;isetTimeout(r,0))}await api('finish_model_save',token)} -async function loadCachedModel(info){const bytes=new Uint8Array(info.size),chunk=1048576;for(let offset=0;offsetsetTimeout(r,0))}return window.rovModel.loadBuffer(bytes.buffer,info.name)} -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}}; -$('#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)}}; -$('#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'); -$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)}; -$('#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)}}; +function openModal(id){ + const el=typeof id==='string'?document.getElementById(id):id; + if(!el)return; + el.classList.remove('closing'); + if(!el.open)el.showModal(); +} +function closeModal(dialog){ + if(!dialog||!dialog.open||dialog.classList.contains('closing'))return; + dialog.classList.add('closing'); + let done=false; + const finish=()=>{ + 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.clientXr.right||e.clientYr.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.clientXr.right||e.clientYr.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 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=['',...Array.from({length:12},(_,n)=>``)].join('');row.innerHTML=`SERVO${i}${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}} -function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';$('#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)} -async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)} -let booted=false; -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)}} -function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)} -addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge(); +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=['',...Array.from({length:12},(_,n)=>``)].join('');row.innerHTML=`SERVO${i}${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}`;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,confirmed=false){ + vehicleSnapshot=s; + if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error || + (s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null; + const previous=view; + view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError}); + 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(loadedsetTimeout(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(); +} diff --git a/web/control-state.js b/web/control-state.js new file mode 100644 index 0000000..9c72c4b --- /dev/null +++ b/web/control-state.js @@ -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); diff --git a/web/index.html b/web/index.html index c7a86fa..9c06abb 100644 --- a/web/index.html +++ b/web/index.html @@ -3,117 +3,156 @@ - SUB Control - + Sub Control + -
-
SSUB ControlBuilt-in vehicle
-
- - - -
-
-
- SAFETY CHECK

Arm the vehicle?

Remove propellers or securely restrain the ROV before enabling motor output.

- CONNECTION

Link settings

20 Hz is recommended for smooth intermediate commands on this link.

- POWER

Battery settings

- CONFIGURATION

Motor outputs

Function and direction are read one parameter at a time to protect the low-bandwidth link.

OutputFunctionDirection
+ + connection

Link Settings

20 Hz is recommended for smooth intermediate commands on this link.

+ Power

Battery Settings

+ configuration

Motor Outputs

Function and direction are read one parameter at a time to protect the low-bandwidth link.

OutputFunctionDirection
- - + + + + diff --git a/web/joystick-3d.js b/web/joystick-3d.js new file mode 100644 index 0000000..4a277e7 --- /dev/null +++ b/web/joystick-3d.js @@ -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}}}; +})(); diff --git a/web/model-viewer.js b/web/model-viewer.js index bf003e0..60353eb 100644 --- a/web/model-viewer.js +++ b/web/model-viewer.js @@ -1,528 +1,583 @@ +/** + * ARGO 3D Vehicle Model Viewer + * Clean, lightweight, hardware-accelerated WebGL STL renderer with: + * - Dynamic ARGO subsea caustic light shader + * - Curvature & Depth Ambient Occlusion (AO) + * - Titanium sub hull metallic materials + Cyan Fresnel rim glow + * - 60 FPS Orbit Controls (drag, wheel zoom, double-click reset) + * - High-speed Binary & ASCII STL parser with auto-centering + */ + (() => { + 'use strict'; + const canvas = document.querySelector('#model-canvas'); const stage = document.querySelector('.rov-stage'); - const renderScale = Math.max(2.0, window.devicePixelRatio || 2.0); - const gl = canvas.getContext('webgl', { alpha: true, antialias: true, preserveDrawingBuffer: false }); - if (!gl) { window.rovModel = { loadBuffer() { throw new Error('WebGL is unavailable in this WebView'); } }; return; } + if (!canvas || !stage) return; - // Pass 1: Geometry shader - renders view-space normals and depth to FBO (Hero-sized 0.88 dynamic projection) - const vertexSceneSource = ` - attribute vec3 aPosition; - attribute vec3 aNormal; - uniform float uYaw, uPitch, uZoom, uAspect; - varying vec3 vNormal; - varying vec3 vViewPos; - void main() { - float cy = cos(uYaw), sy = sin(uYaw); - vec3 p = vec3(aPosition.x * cy + aPosition.z * sy, aPosition.y, -aPosition.x * sy + aPosition.z * cy); - vec3 n = vec3(aNormal.x * cy + aNormal.z * sy, aNormal.y, -aNormal.x * sy + aNormal.z * cy); - float cp = cos(uPitch), sp = sin(uPitch); - p = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp); - n = vec3(n.x, n.y * cp - n.z * sp, n.y * sp + n.z * cp); + let gl = null; + let shaderProgram = null; + let posBuffer = null; + let normBuffer = null; + let vertexCount = 0; + let modelLoaded = false; + let modelName = 'vehicle_model.stl'; - // Base scale (1.15 fills 95-100% of canvas frame without over-zooming out) - float baseScale = 1.15 * uZoom; + // Uniform & Attribute Locations + let locPos = -1; + let locNorm = -1; + let locMVP = null; + let locNormalMat = null; + let locModelMat = null; + let locTime = null; + let locLight1 = null; + let locLight2 = null; - // Dynamic pixel-perfect aspect projection: fills window canvas without distortion or shrinking - vec2 clipPos; - if (uAspect >= 1.0) { - clipPos = vec2(p.x * baseScale / uAspect, p.y * baseScale); - } else { - clipPos = vec2(p.x * baseScale, p.y * baseScale * uAspect); + // Camera & Orbit State + let yaw = -0.45; + let pitch = 0.35; + let targetYaw = -0.45; + let targetPitch = 0.35; + let zoom = 1.0; + let targetZoom = 1.0; + let dragging = false; + let lastX = 0; + let lastY = 0; + let startTime = performance.now(); + let isRendering = false; + + // Initialize WebGL + function initGL() { + if (gl) return true; + try { + gl = canvas.getContext('webgl', { + antialias: true, + alpha: true, + depth: true, + preserveDrawingBuffer: false, + powerPreference: 'high-performance' + }) || canvas.getContext('experimental-webgl', { antialias: true, alpha: true, depth: true }); + } catch (e) { + console.warn('WebGL initialization error:', e); + return false; + } + + if (!gl) return false; + + const vsSource = ` + attribute vec3 a_position; + attribute vec3 a_normal; + uniform mat4 u_mvp; + uniform mat3 u_normalMat; + uniform mat4 u_modelMat; + varying vec3 v_normal; + varying vec3 v_modelPos; + + void main() { + v_normal = normalize(u_normalMat * a_normal); + v_modelPos = a_position; + gl_Position = u_mvp * vec4(a_position, 1.0); + } + `; + + const fsSource = ` + precision mediump float; + varying vec3 v_normal; + varying vec3 v_modelPos; + uniform float u_time; + uniform vec3 u_light1; + uniform vec3 u_light2; + + // ARGO subsea caustic wave generator + float getCaustics(vec2 p, float t) { + vec2 p1 = p * 3.8 + vec2(t * 0.35, t * 0.25); + vec2 p2 = p * 6.2 - vec2(t * 0.28, -t * 0.38); + vec2 p3 = p * 8.5 + vec2(-t * 0.18, t * 0.42); + + float c = sin(p1.x + sin(p1.y * 1.2 + t * 0.6)) * 0.5 + 0.5; + c += sin(p2.y + cos(p2.x * 1.1 - t * 0.5)) * 0.5 + 0.5; + c += sin(p3.x * 0.8 + p3.y * 0.9 + t * 0.9) * 0.5 + 0.5; + return pow(c / 3.0, 3.0) * 2.2; } - gl_Position = vec4(clipPos.x, clipPos.y, p.z * baseScale * .4, 1.); - vViewPos = vec3(clipPos.x, clipPos.y, p.z * baseScale); - vNormal = normalize(n); - } - `; + void main() { + vec3 n = normalize(v_normal); + if (n.z < 0.0) n = -n; // Two-sided CAD shading - const fragmentSceneSource = ` - precision mediump float; - varying vec3 vNormal; - varying vec3 vViewPos; - void main() { - vec3 encNormal = normalize(vNormal) * .5 + .5; - float encDepth = clamp(vViewPos.z * .4 + .5, .01, .99); - gl_FragColor = vec4(encNormal, encDepth); - } - `; + vec3 viewDir = vec3(0.0, 0.0, 1.0); + vec3 l1 = normalize(u_light1); + vec3 l2 = normalize(u_light2); - // Pass 2: Quad shader - Vibrant Soft Cyan-Slate Material + Water Caustics + Argo Glare (Non-grey) - const vertexQuadSource = ` - attribute vec2 aQuadPos; - varying vec2 vTexCoord; - void main() { - vTexCoord = aQuadPos * .5 + .5; - gl_Position = vec4(aQuadPos, 0., 1.); - } - `; + // Diffuse & Ambient Lighting (Soft, lifted shadows) + float diff1 = max(dot(n, l1), 0.0); + float diff2 = max(dot(n, l2), 0.0) * 0.30; + float ambient = 0.46; - const fragmentQuadSource = ` - precision mediump float; - uniform sampler2D uTex; - uniform vec2 uTexSize; - uniform float uTime; - varying vec2 vTexCoord; + // Matte Specular (Soft diffuse sheen, no harsh shine) + vec3 h1 = normalize(l1 + viewDir); + float spec = pow(max(dot(n, h1), 0.0), 10.0) * 0.07; - // Animated water caustics shader - float waterCaustics(vec2 uv, float t) { - vec2 p = uv * 8.0; - vec2 i = vec2(p); - float c = 1.0; - float intent = 0.14; - for (int n = 0; n < 4; n++) { - float t_ang = t * (0.8 - (2.2 / float(n + 1))); - vec2 coord = i + vec2(cos(t_ang - i.x) + sin(t_ang + i.y), sin(t_ang - i.y) + cos(t_ang + i.x)); - c += 1.0 / length(vec2(i.x / (sin(coord.x + t_ang) / intent), i.y / (cos(coord.y + t_ang) / intent))); - i = coord; + // Subtle Cyan Fresnel Accent + float fresnel = pow(1.0 - max(dot(n, viewDir), 0.0), 2.8); + + // Soft Ambient Occlusion (Gentle curvature without heavy dark shadows) + float dist = length(v_modelPos); + float ao = clamp(0.76 + 0.24 * (dist * 0.9 + abs(n.y) * 0.15), 0.68, 1.0); + + // Caustic ripple projection + float caust = getCaustics(v_modelPos.xz + v_modelPos.yy * 0.25, u_time) * max(n.y * 0.65 + 0.35, 0.0); + + // Clean Matte White Palette + vec3 hullDark = vec3(0.66, 0.72, 0.76); + vec3 hullLit = vec3(0.96, 0.98, 1.00); + vec3 cyanGlow = vec3(0.35, 0.88, 0.98); + vec3 caustColor = vec3(0.55, 0.92, 1.00); + + // Final Composite + vec3 color = mix(hullDark, hullLit, clamp((diff1 * 0.50 + diff2 + ambient) * ao, 0.0, 1.0)); + color += caustColor * caust * 0.18; + color += vec3(1.0) * spec; + color += cyanGlow * fresnel * 0.18; + + gl_FragColor = vec4(color, 1.0); } - c /= 4.0; - c = 1.15 - sqrt(c); - return clamp(pow(max(0.0, c), 2.2) * 1.4, 0.0, 1.4); + `; + + function createShader(type, src) { + const s = gl.createShader(type); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + console.error('Shader compile error:', gl.getShaderInfoLog(s)); + gl.deleteShader(s); + return null; + } + return s; } - void main() { - vec2 texel = 1. / uTexSize; + const vs = createShader(gl.VERTEX_SHADER, vsSource); + const fs = createShader(gl.FRAGMENT_SHADER, fsSource); + if (!vs || !fs) return false; - // Subpixel 4-sample Anti-Aliasing for smooth geometry boundaries - vec4 s0 = texture2D(uTex, vTexCoord + vec2(-0.25, -0.25) * texel); - vec4 s1 = texture2D(uTex, vTexCoord + vec2( 0.25, -0.25) * texel); - vec4 s2 = texture2D(uTex, vTexCoord + vec2(-0.25, 0.25) * texel); - vec4 s3 = texture2D(uTex, vTexCoord + vec2( 0.25, 0.25) * texel); - vec4 center = (s0 + s1 + s2 + s3) * 0.25; + shaderProgram = gl.createProgram(); + gl.attachShader(shaderProgram, vs); + gl.attachShader(shaderProgram, fs); + gl.linkProgram(shaderProgram); - float alphaCoverage = clamp((s0.a > .001 ? .25 : 0.) + (s1.a > .001 ? .25 : 0.) + (s2.a > .001 ? .25 : 0.) + (s3.a > .001 ? .25 : 0.), 0., 1.); - if (alphaCoverage <= .001) { - gl_FragColor = vec4(0., 0., 0., 0.); - return; - } + if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { + console.error('Program link error:', gl.getProgramInfoLog(shaderProgram)); + return false; + } - vec3 N = normalize(center.rgb * 2. - 1.); - float centerDepth = center.a; + locPos = gl.getAttribLocation(shaderProgram, 'a_position'); + locNorm = gl.getAttribLocation(shaderProgram, 'a_normal'); + locMVP = gl.getUniformLocation(shaderProgram, 'u_mvp'); + locNormalMat = gl.getUniformLocation(shaderProgram, 'u_normalMat'); + locModelMat = gl.getUniformLocation(shaderProgram, 'u_modelMat'); + locTime = gl.getUniformLocation(shaderProgram, 'u_time'); + locLight1 = gl.getUniformLocation(shaderProgram, 'u_light1'); + locLight2 = gl.getUniformLocation(shaderProgram, 'u_light2'); - // 1. Light Ambient Occlusion - float occlusion = 0.; - vec2 offsets[8]; - offsets[0] = vec2(1.5, 0.); offsets[1] = vec2(-1.5, 0.); - offsets[2] = vec2(0., 1.5); offsets[3] = vec2(0., -1.5); - offsets[4] = vec2(1.1, 1.1); offsets[5] = vec2(-1.1, 1.1); - offsets[6] = vec2(1.1, -1.1); offsets[7] = vec2(-1.1, -1.1); + posBuffer = gl.createBuffer(); + normBuffer = gl.createBuffer(); - for (int i = 0; i < 8; i++) { - vec4 sampleVal = texture2D(uTex, vTexCoord + offsets[i] * texel); - if (sampleVal.a > .001) { - float dDiff = sampleVal.a - centerDepth; - if (dDiff < -.0005 && dDiff > -.12) { - occlusion += clamp(-dDiff * 14.0, 0., 1.); - } + gl.enable(gl.DEPTH_TEST); + gl.depthFunc(gl.LEQUAL); + gl.clearDepth(1.0); + gl.disable(gl.CULL_FACE); + + return true; + } + + // Matrix math + function mat4Perspective(out, fovy, aspect, near, far) { + const f = 1.0 / Math.tan(fovy / 2); + const nf = 1 / (near - far); + out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0; + out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0; + out[8] = 0; out[9] = 0; out[10] = (far + near) * nf; out[11] = -1; + out[12] = 0; out[13] = 0; out[14] = 2 * far * near * nf; out[15] = 0; + } + + function mat4Multiply(out, a, b) { + for (let c = 0; c < 4; c++) { + const c4 = c * 4; + const b0 = b[c4], b1 = b[c4 + 1], b2 = b[c4 + 2], b3 = b[c4 + 3]; + out[c4] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3; + out[c4 + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3; + out[c4 + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3; + out[c4 + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3; + } + return out; + } + + // Fast Binary & ASCII STL Parser + function parseSTL(buffer) { + const isBinary = buffer.byteLength >= 84 && + (84 + new DataView(buffer).getUint32(80, true) * 50 <= buffer.byteLength); + + let positions, normals; + + if (isBinary) { + const view = new DataView(buffer); + const count = view.getUint32(80, true); + if (count === 0) throw new Error('Empty binary STL'); + + positions = new Float32Array(count * 9); + normals = new Float32Array(count * 9); + + let pIdx = 0, nIdx = 0; + for (let i = 0; i < count; i++) { + const off = 84 + i * 50; + let nx = view.getFloat32(off, true); + let ny = view.getFloat32(off + 4, true); + let nz = view.getFloat32(off + 8, true); + + const v0x = view.getFloat32(off + 12, true); + const v0y = view.getFloat32(off + 16, true); + const v0z = view.getFloat32(off + 20, true); + + const v1x = view.getFloat32(off + 24, true); + const v1y = view.getFloat32(off + 28, true); + const v1z = view.getFloat32(off + 32, true); + + const v2x = view.getFloat32(off + 36, true); + const v2y = view.getFloat32(off + 40, true); + const v2z = view.getFloat32(off + 44, true); + + if (nx * nx + ny * ny + nz * nz < 1e-6) { + const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z; + const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z; + nx = ay * bz - az * by; + ny = az * bx - ax * bz; + nz = ax * by - ay * bx; + const len = Math.hypot(nx, ny, nz) || 1; + nx /= len; ny /= len; nz /= len; + } + + positions[pIdx++] = v0x; positions[pIdx++] = v0y; positions[pIdx++] = v0z; + positions[pIdx++] = v1x; positions[pIdx++] = v1y; positions[pIdx++] = v1z; + positions[pIdx++] = v2x; positions[pIdx++] = v2y; positions[pIdx++] = v2z; + + for (let k = 0; k < 3; k++) { + normals[nIdx++] = nx; + normals[nIdx++] = ny; + normals[nIdx++] = nz; } } - float ao = 1. - clamp(occlusion / 8.0 * 0.36, 0., .20); - - // 2. Soft Luminous Cyan-Slate Material Shading (Vibrant, non-grey) - vec3 lightDir = normalize(vec3(.35, .85, .45)); - float diff = max(dot(N, lightDir), 0.0); - vec3 viewDir = vec3(0., 0., 1.); - vec3 halfDir = normalize(lightDir + viewDir); - float spec = pow(max(dot(N, halfDir), 0.0), 28.0); - - // Argo crisp luminous glare - vec3 argoGlare = vec3(0.98, 0.96, 0.92) * spec * 0.95; - - // Soft rim light - float fresnel = pow(1. - max(dot(N, viewDir), 0.), 2.0); - vec3 rimLight = vec3(0.65, 0.82, 0.95) * fresnel * 0.35; - - // Soft vibrant cyan-slate gradient (non-grey, soft luminous depth) - vec3 baseShadow = vec3(.22, .44, .58); - vec3 baseBody = vec3(.38, .62, .78); - vec3 baseTop = vec3(.88, .92, .96); - - float lFactor = clamp(diff * 0.7 + (N.y * 0.3 + 0.3), 0.0, 1.0); - vec3 bodyColor = mix(baseShadow, baseBody, smoothstep(0.0, 0.5, lFactor)); - bodyColor = mix(bodyColor, baseTop, smoothstep(0.5, 1.0, lFactor)); - - // 3. Luminous Water Caustics on top/front facing surfaces - float topFacing = clamp(N.y * 0.7 + 0.45, 0.0, 1.0); - float causticsVal = waterCaustics(vTexCoord + vec2(sin(uTime * 0.2) * 0.04, uTime * 0.025), uTime * 0.75) * topFacing; - vec3 causticColor = vec3(0.30, 0.85, 1.0) * causticsVal * 1.1; - - // Combine soft diffuse, vibrant cyan-slate body, rim light, glare, caustics, and AO - vec3 rawColor = (bodyColor + rimLight + argoGlare + causticColor) * ao; - - // Soft filmic highlight compression (preserves vibrant colors without white crushing) - vec3 finalColor = rawColor / (rawColor + vec3(0.18)) * 1.08; - - gl_FragColor = vec4(finalColor, alphaCoverage); - } - `; - - function compileShader(type, source) { - const s = gl.createShader(type); - gl.shaderSource(s, source); - gl.compileShader(s); - if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); - return s; - } - - function createProgram(vSrc, fSrc) { - const p = gl.createProgram(); - gl.attachShader(p, compileShader(gl.VERTEX_SHADER, vSrc)); - gl.attachShader(p, compileShader(gl.FRAGMENT_SHADER, fSrc)); - gl.linkProgram(p); - if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p)); - return p; - } - - const programScene = createProgram(vertexSceneSource, fragmentSceneSource); - const programQuad = createProgram(vertexQuadSource, fragmentQuadSource); - - const locScene = { - aPosition: gl.getAttribLocation(programScene, 'aPosition'), - aNormal: gl.getAttribLocation(programScene, 'aNormal'), - uYaw: gl.getUniformLocation(programScene, 'uYaw'), - uPitch: gl.getUniformLocation(programScene, 'uPitch'), - uZoom: gl.getUniformLocation(programScene, 'uZoom'), - uAspect: gl.getUniformLocation(programScene, 'uAspect') - }; - - const locQuad = { - aQuadPos: gl.getAttribLocation(programQuad, 'aQuadPos'), - uTex: gl.getUniformLocation(programQuad, 'uTex'), - uTexSize: gl.getUniformLocation(programQuad, 'uTexSize'), - uTime: gl.getUniformLocation(programQuad, 'uTime') - }; - - const bufferScene = gl.createBuffer(); - const bufferQuad = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW); - - let fbo = null, fboTexture = null, fboDepth = null, fboWidth = 0, fboHeight = 0; - function updateFbo(w, h) { - if (fboWidth === w && fboHeight === h) return; - fboWidth = w; fboHeight = h; - if (fbo) { gl.deleteFramebuffer(fbo); gl.deleteTexture(fboTexture); gl.deleteRenderbuffer(fboDepth); } - fboTexture = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, fboTexture); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - - fboDepth = gl.createRenderbuffer(); - gl.bindRenderbuffer(gl.RENDERBUFFER, fboDepth); - gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, w, h); - - fbo = gl.createFramebuffer(); - gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTexture, 0); - gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, fboDepth); - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - } - - let vertexCount = 0, frame = 0; - let yaw = 0, pitch = 0, zoom = 1; - let targetYaw = 0.68, targetPitch = 0.42, targetZoom = 1.0; - let yawVelocity = 0, pitchVelocity = 0; - let dragging = false, lastX = 0, lastY = 0; - let introAnimating = false, introStartTime = 0, startYaw = 0, startPitch = 0, startZoom = 1; - - // Live joystick & vertical knob control inputs - let liveTurn = 0, liveForward = 0, liveVertical = 0; - let targetLiveTurn = 0, targetLiveForward = 0, targetLiveVertical = 0; - - const timeStart = performance.now(); - - function normalize(data) { - let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; - for (let i = 0; i < data.length; i += 6) { - const x = data[i], y = data[i + 1], z = data[i + 2]; - minX = Math.min(minX, x); maxX = Math.max(maxX, x); - minY = Math.min(minY, y); maxY = Math.max(maxY, y); - minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z); - } - const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2; - const scale = 2 / Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-9); - for (let i = 0; i < data.length; i += 6) { - data[i] = (data[i] - cx) * scale; - data[i + 1] = (data[i + 1] - cy) * scale; - data[i + 2] = (data[i + 2] - cz) * scale; - } - return data; - } - - // Smooth vertex normal computation (welds shared vertices to eliminate sharp polygon facet lines) - function parseStl(binary) { - const view = new DataView(binary), count = binary.byteLength >= 84 ? view.getUint32(80, true) : 0; - let rawTriangles = []; - - if (count > 0 && 84 + count * 50 <= binary.byteLength) { - for (let t = 0; t < count; t++) { - const base = 84 + t * 50; - const v0x = view.getFloat32(base + 12, true), v0y = view.getFloat32(base + 16, true), v0z = view.getFloat32(base + 20, true); - const v1x = view.getFloat32(base + 24, true), v1y = view.getFloat32(base + 28, true), v1z = view.getFloat32(base + 32, true); - const v2x = view.getFloat32(base + 36, true), v2y = view.getFloat32(base + 40, true), v2z = view.getFloat32(base + 44, true); - rawTriangles.push(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z); - } } else { - const text = new TextDecoder().decode(binary), matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)]; - const triCount = Math.floor(matches.length / 3); - if (triCount < 1) throw new Error('This file does not contain a readable STL mesh'); - for (let i = 0; i + 2 < matches.length; i += 3) { - rawTriangles.push( - +matches[i][1], +matches[i][2], +matches[i][3], - +matches[i+1][1], +matches[i+1][2], +matches[i+1][3], - +matches[i+2][1], +matches[i+2][2], +matches[i+2][3] - ); + const text = new TextDecoder().decode(buffer); + const matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)]; + if (matches.length < 3) throw new Error('Invalid ASCII STL'); + + const count = Math.floor(matches.length / 3); + positions = new Float32Array(count * 9); + normals = new Float32Array(count * 9); + + let pIdx = 0, nIdx = 0; + for (let i = 0; i < count; i++) { + const i3 = i * 3; + const v0x = +matches[i3][1], v0y = +matches[i3][2], v0z = +matches[i3][3]; + const v1x = +matches[i3 + 1][1], v1y = +matches[i3 + 1][2], v1z = +matches[i3 + 1][3]; + const v2x = +matches[i3 + 2][1], v2y = +matches[i3 + 2][2], v2z = +matches[i3 + 2][3]; + + const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z; + const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z; + let nx = ay * bz - az * by; + let ny = az * bx - ax * bz; + let nz = ax * by - ay * bx; + const len = Math.hypot(nx, ny, nz) || 1; + nx /= len; ny /= len; nz /= len; + + positions[pIdx++] = v0x; positions[pIdx++] = v0y; positions[pIdx++] = v0z; + positions[pIdx++] = v1x; positions[pIdx++] = v1y; positions[pIdx++] = v1z; + positions[pIdx++] = v2x; positions[pIdx++] = v2y; positions[pIdx++] = v2z; + + for (let k = 0; k < 3; k++) { + normals[nIdx++] = nx; + normals[nIdx++] = ny; + normals[nIdx++] = nz; + } } } - const numTriangles = Math.floor(rawTriangles.length / 9); - const posMap = new Map(); - const getVertexIndex = (x, y, z) => { - const ix = (x * 2000) | 0, iy = (y * 2000) | 0, iz = (z * 2000) | 0; - const key = (ix * 73856093 ^ iy * 19349663 ^ iz * 83492791) >>> 0; - let idx = posMap.get(key); - if (idx === undefined) { - idx = posMap.size; - posMap.set(key, idx); - } - return idx; - }; + // Center to origin and scale to fit unit sphere + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; - // First pass: Index vertices and accumulate smooth face normals - const triangleIndices = new Int32Array(numTriangles * 3); - for (let t = 0; t < numTriangles; t++) { - const b = t * 9; - triangleIndices[t * 3] = getVertexIndex(rawTriangles[b], rawTriangles[b+1], rawTriangles[b+2]); - triangleIndices[t * 3 + 1] = getVertexIndex(rawTriangles[b+3], rawTriangles[b+4], rawTriangles[b+5]); - triangleIndices[t * 3 + 2] = getVertexIndex(rawTriangles[b+6], rawTriangles[b+7], rawTriangles[b+8]); + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i], y = positions[i + 1], z = positions[i + 2]; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; } - const numUnique = posMap.size; - const accumNormals = new Float32Array(numUnique * 3); - for (let t = 0; t < numTriangles; t++) { - const b = t * 9; - const v0x = rawTriangles[b], v0y = rawTriangles[b+1], v0z = rawTriangles[b+2]; - const v1x = rawTriangles[b+3], v1y = rawTriangles[b+4], v1z = rawTriangles[b+5]; - const v2x = rawTriangles[b+6], v2y = rawTriangles[b+7], v2z = rawTriangles[b+8]; + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const cz = (minZ + maxZ) / 2; + const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-6); + const scale = 2.0 / maxDim; - const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z; - const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z; - const nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx; - - const i0 = triangleIndices[t * 3], i1 = triangleIndices[t * 3 + 1], i2 = triangleIndices[t * 3 + 2]; - accumNormals[i0 * 3] += nx; accumNormals[i0 * 3 + 1] += ny; accumNormals[i0 * 3 + 2] += nz; - accumNormals[i1 * 3] += nx; accumNormals[i1 * 3 + 1] += ny; accumNormals[i1 * 3 + 2] += nz; - accumNormals[i2 * 3] += nx; accumNormals[i2 * 3 + 1] += ny; accumNormals[i2 * 3 + 2] += nz; + for (let i = 0; i < positions.length; i += 3) { + positions[i] = (positions[i] - cx) * scale; + positions[i + 1] = (positions[i + 1] - cy) * scale; + positions[i + 2] = (positions[i + 2] - cz) * scale; } - // Second pass: Normalize smooth vertex normals - for (let i = 0; i < numUnique; i++) { - const b = i * 3; - const len = Math.hypot(accumNormals[b], accumNormals[b+1], accumNormals[b+2]) || 1; - accumNormals[b] /= len; accumNormals[b+1] /= len; accumNormals[b+2] /= len; - } - - // Third pass: Assemble final vertex array (6 floats per vertex) - const data = new Float32Array(numTriangles * 18); - let out = 0; - for (let t = 0; t < numTriangles; t++) { - const b = t * 9; - for (let v = 0; v < 3; v++) { - const vx = rawTriangles[b + v * 3], vy = rawTriangles[b + v * 3 + 1], vz = rawTriangles[b + v * 3 + 2]; - const idx = triangleIndices[t * 3 + v]; - data[out++] = vx; data[out++] = vy; data[out++] = vz; - data[out++] = accumNormals[idx * 3]; - data[out++] = accumNormals[idx * 3 + 1]; - data[out++] = accumNormals[idx * 3 + 2]; - } - } - - return normalize(data); + return { positions, normals, count: positions.length / 3 }; } - function draw() { - frame = 0; - if (!vertexCount) return; - const rect = canvas.getBoundingClientRect(); - if (rect.width < 10 || rect.height < 10) { - // Container layout is not ready yet; retry on next frame - requestDraw(); + // Render Loop + function render() { + if (!modelLoaded || vertexCount === 0) { + isRendering = false; return; } - const width = Math.max(1, Math.round(rect.width * renderScale)); - const height = Math.max(1, Math.round(rect.height * renderScale)); + + if (!gl) { + if (!initGL()) { + isRendering = false; + return; + } + } + + // Inertia damping + yaw += (targetYaw - yaw) * 0.16; + pitch += (targetPitch - pitch) * 0.16; + zoom += (targetZoom - zoom) * 0.16; + + // Viewport resize + const rect = canvas.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const width = Math.max(10, Math.round(rect.width * dpr)); + const height = Math.max(10, Math.round(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } - updateFbo(width, height); - const now = performance.now(); - const currentTime = (now - timeStart) * 0.001; - - // Interpolate live control inputs (turn, forward, vertical) - liveTurn += (targetLiveTurn - liveTurn) * 0.15; - liveForward += (targetLiveForward - liveForward) * 0.15; - liveVertical += (targetLiveVertical - liveVertical) * 0.15; - - // Handle auto-center 3/4 hero camera transition & floaty physics - if (introAnimating && !dragging) { - const elapsed = (now - introStartTime) / 1200; - if (elapsed >= 1.0) { - introAnimating = false; - yaw = targetYaw; pitch = targetPitch; zoom = targetZoom; - } else { - const t = 1 - Math.pow(1 - elapsed, 3); - yaw = startYaw + (targetYaw - startYaw) * t; - pitch = startPitch + (targetPitch - startPitch) * t; - zoom = startZoom + (targetZoom - startZoom) * t; - } - } else { - yaw += yawVelocity; - pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity)); - zoom += (targetZoom - zoom) * 0.1; - yawVelocity *= 0.92; - pitchVelocity *= 0.92; - } - - // Effective yaw & pitch including live joystick/heave control offsets - const effectiveYaw = yaw + liveTurn * 0.7; - const effectivePitch = Math.max(-1.45, Math.min(1.45, pitch + (liveVertical * 0.65 + liveForward * 0.4))); - - // Pass 1: Render Normals & Linear Depth to FBO - gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.viewport(0, 0, width, height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); - gl.enable(gl.DEPTH_TEST); - gl.depthFunc(gl.LEQUAL); - gl.useProgram(programScene); - gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene); - gl.enableVertexAttribArray(locScene.aPosition); - gl.vertexAttribPointer(locScene.aPosition, 3, gl.FLOAT, false, 24, 0); - gl.enableVertexAttribArray(locScene.aNormal); - gl.vertexAttribPointer(locScene.aNormal, 3, gl.FLOAT, false, 24, 12); + gl.useProgram(shaderProgram); - gl.uniform1f(locScene.uYaw, effectiveYaw); - gl.uniform1f(locScene.uPitch, effectivePitch); - gl.uniform1f(locScene.uZoom, zoom); - gl.uniform1f(locScene.uAspect, width / height); + // Matrix calculations + const aspect = width / height; + const proj = new Float32Array(16); + mat4Perspective(proj, (42 * Math.PI) / 180, aspect, 0.1, 50.0); + + const cosY = Math.cos(yaw), sinY = Math.sin(yaw); + const cosP = Math.cos(pitch), sinP = Math.sin(pitch); + const dist = 3.2 / zoom; + + const model = new Float32Array(16); + model[0] = cosY; + model[1] = sinP * sinY; + model[2] = -cosP * sinY; + model[3] = 0; + + model[4] = 0; + model[5] = cosP; + model[6] = sinP; + model[7] = 0; + + model[8] = sinY; + model[9] = -sinP * cosY; + model[10] = cosP * cosY; + model[11] = 0; + + model[12] = 0; + model[13] = 0; + model[14] = -dist; + model[15] = 1; + + const mvp = new Float32Array(16); + mat4Multiply(mvp, proj, model); + + const normalMat = new Float32Array([ + model[0], model[1], model[2], + model[4], model[5], model[6], + model[8], model[9], model[10], + ]); + + const elapsedTime = (performance.now() - startTime) * 0.001; + + gl.uniformMatrix4fv(locMVP, false, mvp); + gl.uniformMatrix3fv(locNormalMat, false, normalMat); + gl.uniformMatrix4fv(locModelMat, false, model); + gl.uniform1f(locTime, elapsedTime); + gl.uniform3f(locLight1, 0.65, 0.85, 0.60); + gl.uniform3f(locLight2, -0.60, -0.40, 0.30); + + gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer); + gl.enableVertexAttribArray(locPos); + gl.vertexAttribPointer(locPos, 3, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer); + gl.enableVertexAttribArray(locNorm); + gl.vertexAttribPointer(locNorm, 3, gl.FLOAT, false, 0, 0); gl.drawArrays(gl.TRIANGLES, 0, vertexCount); - // Pass 2: Soft "Eve"-style Material Pass to Canvas - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.viewport(0, 0, width, height); - gl.clearColor(0, 0, 0, 0); - gl.clear(gl.COLOR_BUFFER_BIT); - gl.disable(gl.DEPTH_TEST); + // Continue animation loop for dynamic caustic wave shimmer + requestAnimationFrame(render); + } - gl.useProgram(programQuad); - gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad); - gl.enableVertexAttribArray(locQuad.aQuadPos); - gl.vertexAttribPointer(locQuad.aQuadPos, 2, gl.FLOAT, false, 0, 0); - - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, fboTexture); - gl.uniform1i(locQuad.uTex, 0); - gl.uniform2f(locQuad.uTexSize, width, height); - gl.uniform1f(locQuad.uTime, currentTime); - - gl.drawArrays(gl.TRIANGLES, 0, 6); - - // Keep animation active if floaty velocity or control interpolation is active - if (Math.abs(yawVelocity) > 1e-4 || Math.abs(pitchVelocity) > 1e-4 || - Math.abs(targetLiveTurn - liveTurn) > 1e-3 || Math.abs(targetLiveVertical - liveVertical) > 1e-3 || - introAnimating) { - requestDraw(); + function startRenderLoop() { + if (!isRendering && modelLoaded) { + isRendering = true; + requestAnimationFrame(render); } } - function requestDraw() { - if (!frame) frame = requestAnimationFrame(draw); - } - - canvas.addEventListener('pointerdown', event => { + // Orbit Pointer Interaction + canvas.addEventListener('pointerdown', e => { dragging = true; - introAnimating = false; - lastX = event.clientX; - lastY = event.clientY; - yawVelocity = 0; - pitchVelocity = 0; - canvas.setPointerCapture(event.pointerId); + lastX = e.clientX; + lastY = e.clientY; + canvas.setPointerCapture(e.pointerId); + canvas.style.cursor = 'grabbing'; }); - canvas.addEventListener('pointermove', event => { + canvas.addEventListener('pointermove', e => { if (!dragging) return; - const dx = event.clientX - lastX; - const dy = event.clientY - lastY; - yawVelocity = dx * 0.0012; - pitchVelocity = dy * 0.0012; - yaw += yawVelocity; - pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity)); - lastX = event.clientX; - lastY = event.clientY; - requestDraw(); + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + targetYaw += dx * 0.012; + targetPitch = Math.max(-1.45, Math.min(1.45, targetPitch + dy * 0.012)); + lastX = e.clientX; + lastY = e.clientY; }); - canvas.addEventListener('pointerup', () => dragging = false); - canvas.addEventListener('pointercancel', () => dragging = false); + const stopDrag = () => { + dragging = false; + canvas.style.cursor = 'grab'; + }; - canvas.addEventListener('wheel', event => { - event.preventDefault(); - introAnimating = false; - targetZoom = Math.max(.55, Math.min(2.2, targetZoom * (event.deltaY > 0 ? .9 : 1.1))); - requestDraw(); + canvas.addEventListener('pointerup', stopDrag); + canvas.addEventListener('pointercancel', stopDrag); + + canvas.addEventListener('wheel', e => { + e.preventDefault(); + const factor = e.deltaY > 0 ? 0.90 : 1.11; + targetZoom = Math.max(0.45, Math.min(3.5, targetZoom * factor)); }, { passive: false }); - addEventListener('resize', requestDraw); + canvas.addEventListener('dblclick', () => { + targetYaw = -0.45; + targetPitch = 0.35; + targetZoom = 1.0; + }); + // Early context init + initGL(); + + // Public Interface window.rovModel = { - setLiveControl(turn, forward, vertical) { - targetLiveTurn = turn || 0; - targetLiveForward = forward || 0; - targetLiveVertical = vertical || 0; - requestDraw(); + loadBuffer(buffer, name = 'Main Assembly V1 (2).stl') { + console.log(`[ROV 3D] Loading STL buffer "${name}" (${(buffer.byteLength / (1024 * 1024)).toFixed(2)} MB)...`); + const statusEl = document.querySelector('#model-status'); + if (statusEl) { + statusEl.className = 'model-status'; + statusEl.textContent = `Parsing ${name}…`; + } + try { + const mesh = parseSTL(buffer); + vertexCount = mesh.count; + modelLoaded = true; + modelName = name; + + initGL(); + + if (gl) { + gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer); + gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer); + gl.bufferData(gl.ARRAY_BUFFER, mesh.normals, gl.STATIC_DRAW); + } else { + throw new Error('WebGL context unavailable'); + } + + stage.classList.add('model-loaded'); + targetYaw = -0.45; + targetPitch = 0.35; + targetZoom = 1.0; + yaw = targetYaw; + pitch = targetPitch; + zoom = targetZoom; + + startRenderLoop(); + const tris = vertexCount / 3; + console.log(`[ROV 3D] Successfully parsed & uploaded ${tris.toLocaleString()} triangles to GPU.`); + if (statusEl) { + statusEl.textContent = `${name} • ${tris.toLocaleString()} \u0394`; + statusEl.classList.remove('error'); + setTimeout(() => statusEl.classList.add('fade-out'), 3500); + } + return { name, triangles: tris }; + } catch (err) { + console.error('[ROV 3D Error]', err); + if (statusEl) { + statusEl.className = 'model-status error'; + statusEl.textContent = `STL Error: ${err.message}`; + } + throw err; + } }, - loadBuffer(data, name) { - const vertices = parseStl(data); - vertexCount = vertices.length / 6; - gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene); - gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); - stage.classList.add('model-loaded'); - document.querySelector('#model-file-name').textContent = name; - - // Auto-center camera transition to 3/4 corner hero shot upon load - yaw = 0; - pitch = 0; - zoom = 0.75; - targetYaw = 0.68; - targetPitch = 0.42; + + load(payload) { + if (!payload) throw new Error('No model payload provided'); + if (payload instanceof ArrayBuffer) { + return this.loadBuffer(payload); + } + if (payload.buffer instanceof ArrayBuffer) { + return this.loadBuffer(payload.buffer, payload.name); + } + let rawBase64 = ''; + let name = 'vehicle_model.stl'; + if (typeof payload === 'string') { + rawBase64 = payload.includes(',') ? payload.split(',')[1] : payload; + } else if (payload.data) { + rawBase64 = payload.data.includes(',') ? payload.data.split(',')[1] : payload.data; + name = payload.name || name; + } + const binary = atob(rawBase64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); + return this.loadBuffer(bytes.buffer, name); + }, + + resetView() { + targetYaw = -0.45; + targetPitch = 0.35; targetZoom = 1.0; - yawVelocity = 0; - pitchVelocity = 0; - - // Defer rendering to post-reflow rAF so canvas dimensions are ready - requestAnimationFrame(() => { - requestDraw(); - setTimeout(() => { - introAnimating = true; - introStartTime = performance.now(); - startYaw = yaw; - startPitch = pitch; - startZoom = zoom; - requestDraw(); - }, 150); - }); - - requestDraw(); - return { name, triangles: vertexCount / 3 }; } }; + + // Immediate auto-fetch of runtime default model + 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 { + fetch(url) + .then(r => r.ok ? r.arrayBuffer() : null) + .then(buf => { + if (buf && !modelLoaded) { + window.rovModel.loadBuffer(buf, 'Main Assembly V1 (2).stl'); + const btn = document.querySelector('#load-model'); + if (btn) btn.textContent = 'Replace STL'; + } + }) + .catch(() => {}); + } catch (_) {} + } })(); diff --git a/web/styles.css b/web/styles.css index 9304651..8dc091a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,12 +1,1117 @@ -:root{color-scheme:dark;--bg:#0d1113;--rail:#161c1f;--panel:#f1f3f0;--card:#fff;--ink:#141719;--muted:#747a7d;--line:#dfe3de;--acid:#c9f277;--cyan:#72d8e8;--amber:#f2c675;--red:#f0958b;--shadow:0 25px 70px #0007;font-family:Inter,"SF Pro Display","Helvetica Neue",Arial,sans-serif} -.window-bar{height:38px;display:flex;align-items:center;justify-content:space-between;background:#111719;color:#aeb6b8;border-bottom:1px solid #ffffff10;user-select:none}.window-drag{display:flex;align-items:center;align-self:stretch;flex:1;gap:9px;padding-left:13px}.window-drag strong{font-size:10px;letter-spacing:.04em;color:#d8dddc}.window-drag i{font-size:9px;font-style:normal;color:#667175}.mini-mark{display:grid;place-items:center;width:19px;height:19px;border-radius:5px;background:var(--acid);color:#1d2818;font-size:9px;font-weight:900}.window-actions{display:flex;height:100%}.window-actions button{width:46px;height:100%;border:0;border-radius:0;background:transparent;color:#9ca5a7;cursor:pointer;font-size:15px}.window-actions button:hover{background:#ffffff0c;color:#fff}.window-actions .window-close:hover{background:#c84c43;color:#fff} -*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:#111719;font-size:14px}button,select,input{font:inherit}.app-shell{width:100%;height:calc(100% - 38px);margin:0;display:grid;grid-template-columns:270px 1fr;border:0;border-radius:0;overflow:hidden;background:var(--panel)} -.vehicle-rail{position:relative;display:flex;flex-direction:column;padding:25px 24px 22px;background:linear-gradient(155deg,#1c2428,#111618);color:#f4f6f3;overflow:hidden}.brand{display:flex;align-items:center;gap:10px;letter-spacing:.08em;font-size:12px;font-weight:700}.brand small{margin-left:auto;color:#758086}.brand-mark{display:grid;place-items:center;width:27px;height:27px;border-radius:9px;background:var(--acid);color:#1d2818;font-weight:900}.eyebrow{font-size:9px;letter-spacing:.16em;color:#899295;font-weight:750}.vehicle-rail>.eyebrow{margin-top:30px}.rov-stage{position:relative;height:min(41vh,330px);min-height:240px;display:grid;place-items:center}.rov-stage svg{position:relative;width:210px;height:270px;z-index:2}.water-ring{position:absolute;border:1px solid #78939d24;border-radius:50%;transform:scaleY(.5)}.water-ring.r1{width:230px;height:230px}.water-ring.r2{width:165px;height:165px;border-color:#9ec7d235}.vehicle-name{display:flex;flex-direction:column;gap:5px}.vehicle-name small,.battery-card small{font-size:9px;letter-spacing:.14em;color:#778186;font-style:normal}.vehicle-name strong{font-size:17px}.vehicle-name span{font-size:10px;color:#7d878b}.rail-bottom{margin-top:auto}.battery-card{display:grid;grid-template-columns:1fr auto;width:100%;padding:0 0 19px;text-align:left;color:inherit;border:0;border-bottom:1px solid #ffffff17;cursor:pointer}.battery-card>span{display:flex;flex-direction:column;gap:3px}.battery-card strong{font-size:19px}.battery-card i{align-self:end;color:#8c979b;font-size:11px;font-style:normal}.battery-card b{grid-column:1/-1;height:5px;margin-top:10px;background:#353d40;border-radius:20px;overflow:hidden}.battery-card em{display:block;width:0;height:100%;background:var(--acid);border-radius:inherit;transition:width .35s}.ghost{background:none}.link-state{display:flex;align-items:center;gap:9px;margin-top:17px;font-size:10px;letter-spacing:.08em;color:#a9b0b2}.link-state span{width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 0 4px #f0958b18}.link-state.online span{background:var(--acid);box-shadow:0 0 0 4px #c9f27718} -.workspace{min-width:0;display:flex;flex-direction:column;padding:26px 30px 18px;color:var(--ink);background:linear-gradient(145deg,#f7f8f6,#ecefeb)}.topbar{display:flex;align-items:center;justify-content:space-between;padding-bottom:20px;border-bottom:1px solid var(--line)}h1{margin:3px 0 0;font-size:30px;letter-spacing:-.04em}.connection{display:flex;align-items:center;gap:7px}select,input{border:1px solid #d7dbd6;background:#fff;color:#25292b;border-radius:11px;outline:0}.connection select{width:155px;height:38px;padding:0 12px}.button,.icon-button{height:38px;border:1px solid transparent;border-radius:12px;padding:0 16px;color:#28302a;background:#e9ece8;cursor:pointer;transition:transform .12s,background .12s,box-shadow .12s}.button:hover,.icon-button:hover{transform:translateY(-1px);box-shadow:0 5px 14px #28322c18}.icon-button{width:39px;padding:0;font-weight:700}.connect-button{background:#1d2421;color:white}.drive-grid{display:grid;grid-template-columns:minmax(380px,1fr) 230px;gap:16px;min-height:0;flex:1;padding:18px 0}.control-card{display:flex;flex-direction:column;min-height:0;padding:18px 20px 14px;border:1px solid #e0e3df;border-radius:22px;background:#fff;box-shadow:0 12px 34px #3039340b}.control-card header{display:flex;justify-content:space-between;align-items:start}.control-card header div{display:flex;flex-direction:column;gap:3px}.control-card small,.modal>small{font-size:9px;letter-spacing:.14em;color:#8b9190;font-weight:750}.control-card header strong{font-size:15px}.key-hint{padding:6px 9px;border-radius:8px;background:#f0f2ef;color:#838887;font-size:9px;font-weight:700;letter-spacing:.08em}.control-card footer{display:flex;justify-content:center;gap:25px;color:#7e8482;font-size:10px}.control-card footer span{display:flex;align-items:center;gap:4px}.control-card footer b{color:#353a38}.dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)}.dot.yaw{background:var(--amber)}.dot.depth{background:#bc9feb} -.joystick{--size:min(36vh,300px);position:relative;width:var(--size);height:var(--size);min-width:220px;min-height:220px;margin:auto;border:1px solid #d5d9d5;border-radius:50%;background:radial-gradient(circle at 45% 38%,#f8f9f7 0 20%,#ecefeb 70%);box-shadow:inset 0 3px 8px #fff,inset 0 -8px 18px #2630280c;user-select:none;touch-action:none}.joystick:after{content:"";position:absolute;inset:23%;border:1px solid #dce0dc;border-radius:50%}.axis{position:absolute;background:#ccd1cc}.vertical-axis{top:8%;bottom:8%;left:50%;width:1px}.horizontal-axis{left:8%;right:8%;top:50%;height:1px}.axis-label{position:absolute;color:#979c99;font-size:8px;font-weight:750;letter-spacing:.12em}.forward{top:7px;left:50%;transform:translateX(-50%)}.reverse{bottom:7px;left:50%;transform:translateX(-50%)}.left{left:6px;top:50%;transform:translateY(-50%) rotate(-90deg)}.right{right:3px;top:50%;transform:translateY(-50%) rotate(90deg)}.stick-shadow{position:absolute;left:50%;top:50%;width:68px;height:68px;border-radius:50%;background:#21282422;filter:blur(9px);transform:translate(-45%,-35%)}.stick{position:absolute;z-index:3;left:50%;top:50%;width:62px;height:62px;border-radius:50%;transform:translate(-50%,-50%);background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #111719;box-shadow:inset 0 2px 2px #ffffff38,0 10px 18px #18201d40;will-change:transform}.stick span{position:absolute;inset:18px;border-radius:50%;border:1px solid #a9b3b544} -.vertical-control{display:flex;flex:1;min-height:240px;flex-direction:column;align-items:center;gap:8px;padding:16px 0 12px;color:#989d9a;font-size:8px;font-weight:750;letter-spacing:.12em}.heave-track{position:relative;width:38px;flex:1;min-height:180px;border-radius:22px;background:linear-gradient(90deg,#e3e6e2,#f3f4f2 50%,#dfe3de);border:1px solid #d8dcd7;box-shadow:inset 0 3px 7px #9ca59f25;touch-action:none}.heave-center{position:absolute;left:7px;right:7px;top:50%;height:1px;background:#adb4af}.heave-fill{position:absolute;left:12px;right:12px;top:50%;height:0;background:#bc9feb88}.heave-knob{position:absolute;left:50%;top:50%;width:56px;height:35px;border-radius:18px;background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #151a1d;box-shadow:0 8px 14px #1d252238,inset 0 1px #ffffff36;transform:translate(-50%,-50%);will-change:top} -.command-bar{display:flex;align-items:center;gap:10px;min-height:57px;padding:10px 12px;border:1px solid #dde1dc;border-radius:17px;background:#fff}.commands{display:flex;gap:7px}.subtle{background:#f0f2ef;border-color:#e4e7e2}.arm{background:#ddf1bd;color:#29371f}.danger{background:#f7dfdc;color:#7b3029}.output-limit{display:flex;flex:1;align-items:center;gap:12px;margin:0 10px;color:#747a77;font-size:10px}.output-limit span{white-space:nowrap}.output-limit b{color:#222724}.output-limit input{width:100%;accent-color:#27302b}.status-line{display:flex;align-items:center;gap:9px;min-height:27px;color:#747b78;font-size:10px}.status-line span{width:6px;height:6px;border-radius:50%;background:#96a09a}.status-line p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.modal{width:min(620px,calc(100vw - 40px));padding:27px;border:1px solid #ffffff24;border-radius:24px;background:#f7f8f5;color:#1c2021;box-shadow:0 35px 100px #000b}.modal::backdrop{background:#071012b5;backdrop-filter:blur(8px)}.modal h2{margin:5px 0 17px;font-size:25px}.modal-close{position:absolute;top:18px;right:18px;width:34px;height:34px;border:0;border-radius:50%;background:#e8ebe6;color:#555;cursor:pointer;font-size:20px}.form-grid{display:grid;gap:13px}.form-grid.two{grid-template-columns:1fr 1fr}.form-grid.three{grid-template-columns:repeat(3,1fr)}.form-grid label{display:flex;flex-direction:column;gap:6px;color:#737976;font-size:10px}.form-grid input,.form-grid select{width:100%;height:40px;padding:0 11px}.note{color:#7a807d;font-size:11px;line-height:1.5}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.modal-actions.wide{grid-column:1/-1}.motor-modal{width:min(760px,calc(100vw - 40px))}.motor-head,.motor-row{display:grid;grid-template-columns:70px 1fr 110px 94px;align-items:center;gap:9px}.motor-head{padding:8px 10px;color:#969b99;font-size:9px;letter-spacing:.1em}.motor-list{max-height:390px;overflow:auto}.motor-row{min-height:47px;padding:5px 10px;border-top:1px solid #e3e6e1}.motor-row select{height:34px;padding:0 9px}.motor-row .button{height:32px;padding:0 10px}.toast{position:fixed;z-index:20;left:50%;bottom:28px;padding:11px 16px;border-radius:12px;background:#202725;color:#fff;box-shadow:0 10px 35px #0006;transform:translate(-50%,25px);opacity:0;pointer-events:none;transition:.2s}.toast.show{transform:translate(-50%,0);opacity:1}.toast.error{background:#77352f} -@media(max-width:950px){.app-shell{grid-template-columns:225px minmax(0,1fr)}.vehicle-rail{padding-left:18px;padding-right:18px}.drive-grid{grid-template-columns:minmax(330px,1fr) 190px}.workspace{padding-left:22px;padding-right:22px}.connection select{width:120px}}@media(max-height:700px){.rov-stage{height:240px}.drive-grid{padding:12px 0}.joystick{--size:min(32vh,250px)}.workspace{padding-top:18px}} -.control-card{border-radius:14px}.command-bar{border-radius:12px}.button,.icon-button,.connection select,select,input{border-radius:9px}.modal{border-radius:16px}.brand-mark{border-radius:7px} -.rov-stage #model-canvas{display:none;position:absolute;inset:0;width:100%;height:100%;z-index:3;image-rendering:auto;cursor:grab;touch-action:none}.rov-stage #model-canvas:active{cursor:grabbing}.rov-stage.model-loaded #default-model{display:none}.rov-stage.model-loaded #model-canvas{display:block;filter:drop-shadow(0 0 1px rgba(8,15,17,.9)) drop-shadow(0 4px 5px rgba(0,0,0,.3))}.model-tools{position:absolute;z-index:5;left:50%;bottom:5px;display:flex;gap:5px;transform:translateX(-50%);opacity:.72;transition:opacity .18s}.rov-stage:hover .model-tools{opacity:1}.model-tools button{height:27px;padding:0 9px;border:1px solid #ffffff18;border-radius:7px;background:#20282bcc;color:#aab3b5;font-size:8px;letter-spacing:.06em;cursor:pointer;backdrop-filter:blur(7px)}.model-tools button:hover{color:#fff;background:#303a3e}.model-tools button:disabled{opacity:.35;cursor:default} +/* SUB Control UI – Desktop Layout */ + +:root { + color-scheme: dark; + --bg: #0c1012; + --rail: #151a1d; + --panel: #f3f4f1; + --card: #ffffff; + --ink: #16191b; + --muted: #727a7c; + --line: #dfe2dd; + --acid: #c9f277; + --cyan: #72d8e8; + --amber: #f2c675; + --red: #f0958b; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.05); + font-family: Inter, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; + font-weight: 300; +} + +* { box-sizing: border-box; } +html { + height: 100%; + margin: 0; + overflow: hidden; + background: var(--bg); +} +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: var(--bg); + font-size: 13px; + color: var(--ink); + -webkit-font-smoothing: antialiased; +} +button, select, input { font: inherit; } + +/* ── App Shell (Root Desktop Split Grid) ── */ +.app-shell { + width: 100vw; + height: 100vh; + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + overflow: hidden; + background: var(--panel); + border-radius: 8px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05); +} +body.maximized .app-shell { + border-radius: 0; +} + +/* ══════════════════════════════════════════════════════════════════ + SIDEBAR DECK: Connection, 3D Model, Battery, Deck Controls, Status + ══════════════════════════════════════════════════════════════════ */ +.sidebar-deck { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 14px 10px; + background: + radial-gradient(ellipse 110% 45% at 50% 28%, rgba(30, 144, 255, 0.12) 0%, rgba(0, 200, 255, 0.04) 45%, transparent 75%), + linear-gradient(175deg, #1b2328 0%, #12171a 50%, #0c1012 100%); + border-right: 1px solid rgba(255, 255, 255, 0.08); + color: #f1f4f2; + overflow-y: auto; + overflow-x: hidden; +} + +/* ── Connection Controls in Deck ── */ +.deck-connection { + display: flex; + flex-direction: column; + gap: 6px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.port-row { + display: flex; + align-items: center; + gap: 4px; +} +.port-row select { + flex: 1; + min-width: 0; + height: 28px; + padding: 0 8px; + font-size: 10.5px; + font-weight: 500; + cursor: pointer; + background: #20272b; + color: #f1f4f2; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + outline: none; + text-overflow: ellipsis; +} +.port-row select option { + background: #171b19; + color: #f1f4f2; +} + +.button, .icon-button { + height: 28px; + border: 1px solid transparent; + border-radius: 999px; + padding: 0 12px; + color: #f1f4f2; + background: #20272b; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10.5px; + font-weight: 400; + transition: transform 0.15s, background 0.15s, border-color 0.15s; +} +.button:hover, .icon-button:hover { + transform: none; + border-color: rgba(255, 255, 255, 0.18); + background: #293237; +} +.icon-button { + width: 28px; + padding: 0; + flex-shrink: 0; + border: 1px solid rgba(255, 255, 255, 0.1); +} +.icon-button svg { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.icon-button .dots-icon { + fill: currentColor; + stroke: none; +} + +.connect-button { + position: relative; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + height: 29px; + padding: 0 14px; + background: #253036; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + color: #ffffff; + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + user-select: none; +} +.connect-button:hover { + background: #313e46; + border-color: rgba(255, 255, 255, 0.25); +} +.connect-button:active { + transform: scale(0.97); +} + +.connect-button .btn-spinner { + display: none; + width: 11px; + height: 11px; + border: 2px solid rgba(114, 216, 232, 0.25); + border-top-color: #72d8e8; + border-radius: 50%; + animation: connect-spin 0.65s linear infinite; + flex-shrink: 0; +} +.connect-button .btn-label { + display: inline-block; + white-space: nowrap; +} + +@keyframes connect-spin { to { transform: rotate(360deg); } } +@keyframes connect-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.65; transform: scale(0.98); } +} + +body[data-app-state="connecting"] .connect-button, +.connect-button.is-connecting { + background: rgba(18, 36, 42, 0.95) !important; + border-color: rgba(114, 216, 232, 0.6) !important; + color: #72d8e8 !important; + box-shadow: 0 0 12px rgba(114, 216, 232, 0.25) !important; + cursor: progress !important; + pointer-events: auto !important; + opacity: 1 !important; +} +body[data-app-state="connecting"] .connect-button .btn-spinner, +.connect-button.is-connecting .btn-spinner { + display: inline-block !important; +} +body[data-app-state="connecting"] .connect-button .btn-label, +.connect-button.is-connecting .btn-label { + animation: connect-pulse 1.3s ease-in-out infinite; +} + +body[data-app-state="connected_disarmed"] .connect-button, +body[data-app-state="armed"] .connect-button, +body[data-app-state="link_degraded"] .connect-button, +.connect-button.is-connected { + background: rgba(50, 18, 18, 0.85) !important; + border-color: rgba(255, 107, 107, 0.4) !important; + color: #ffb4b4 !important; +} +body[data-app-state="connected_disarmed"] .connect-button:hover, +body[data-app-state="armed"] .connect-button:hover, +body[data-app-state="link_degraded"] .connect-button:hover, +.connect-button.is-connected:hover { + background: rgba(70, 22, 22, 0.95) !important; + border-color: rgba(255, 107, 107, 0.75) !important; + color: #ffffff !important; +} + +/* ── 3D Model Stage in Deck ── */ +.deck-card { + display: flex; + flex-direction: column; +} +.sidebar-deck button, +.sidebar-deck select, +.sidebar-deck input, +.sidebar-deck a, +.sidebar-deck canvas { + -webkit-app-region: no-drag; +} +.telemetry-zone { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 10px; +} +.telemetry-zone .model-card { flex: 1; min-height: 0; } +.eyebrow { + font-size: 8.5px; + font-weight: 400; + color: #727c80; + letter-spacing: 0.03em; +} +.rov-stage { + position: relative; + height: min(24vh, 160px); + min-height: 120px; + display: grid; + place-items: center; + border-radius: 10px; + transition: background 0.2s ease, border-color 0.2s ease; +} +.rov-stage::before { + content: ""; + position: absolute; + width: 110px; + height: 110px; + border-radius: 50%; + background: radial-gradient(circle, rgba(0, 180, 255, 0.2) 0%, rgba(0, 120, 220, 0.06) 50%, transparent 75%); + filter: blur(14px); + pointer-events: none; + z-index: 1; +} +.water-ring { + position: absolute; + border: 1px solid rgba(0, 200, 255, 0.2); + border-radius: 50%; + transform: scaleY(0.55); +} +.water-ring.r1 { width: 140px; height: 140px; } +.water-ring.r2 { width: 95px; height: 95px; border-color: rgba(114, 216, 232, 0.28); } + +.rov-stage #model-canvas { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 3; + cursor: grab; + touch-action: none; +} +.model-status { + position: absolute; + top: 6px; + left: 50%; + transform: translateX(-50%); + padding: 2px 7px; + background: rgba(18, 24, 27, 0.85); + border: 1px solid rgba(79, 195, 247, 0.25); + border-radius: 5px; + font-size: 8px; + letter-spacing: 0.04em; + color: #8edcf8; + z-index: 5; + pointer-events: none; + white-space: nowrap; + backdrop-filter: blur(6px); + transition: opacity 0.4s ease; +} +.model-status.error { + border-color: rgba(255, 82, 82, 0.5); + color: #ff8a80; + background: rgba(40, 15, 15, 0.9); +} +.model-status.fade-out { opacity: 0; } +.model-tools { display: none !important; } + +.vehicle-name { + display: flex; + flex-direction: column; + gap: 1px; + margin-top: 2px; +} +.vehicle-name small, .battery-card small { + font-size: 8px; + color: #687276; + font-weight: 400; +} +.vehicle-name strong { + font-size: 13.5px; + font-weight: 400; + letter-spacing: -0.01em; +} +.vehicle-name span { + font-size: 9px; + color: #727c80; +} + +/* ── Battery Card ── */ +.battery-card { + display: grid; + grid-template-columns: 1fr auto; + width: 100%; + padding: 6px 0 8px; + text-align: left; + color: inherit; + border: 0; + border-top: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: transparent; + cursor: pointer; +} +.battery-card::after { display: none !important; } +.battery-card:hover { transform: none !important; box-shadow: none !important; } +.battery-card > span { + display: flex; + flex-direction: column; + gap: 1px; +} +.battery-card strong { + font-size: 14.5px; + font-weight: 400; +} +.battery-card i { + align-self: end; + color: #859094; + font-size: 9.5px; + font-style: normal; +} +.battery-card b { + grid-column: 1 / -1; + height: 3px; + margin-top: 5px; + background: #31383c; + border-radius: 999px; + overflow: hidden; +} +.battery-card em { + display: block; + width: 0; + height: 100%; + background: var(--acid); + border-radius: inherit; + transition: width 0.35s; +} + +/* ── Output Limit & Motor Setup ── */ +.controls-deck { + display: flex; + flex-direction: column; + gap: 8px; + padding: 2px 0; +} +.output-limit { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 9px; + color: #8a9497; + font-weight: 300; + width: 100%; +} +.output-limit span { + display: flex; + justify-content: space-between; + white-space: nowrap; +} +.output-limit b { + font-weight: 500; + color: #f1f4f2; +} +.range-pill { + display: grid; + grid-template-columns: 20px 1fr 20px; + align-items: center; + width: 100%; + height: 22px; + border-radius: 999px; + background: #20272b; + border: 1px solid rgba(255, 255, 255, 0.08); + overflow: hidden; +} +.range-pill button { + width: 20px; + height: 22px; + border: 0; + background: transparent; + color: #8a9497; + cursor: pointer; + display: grid; + place-items: center; + padding: 0; +} +.range-pill button:hover { color: #f1f4f2; } +.range-pill button::after { display: none !important; } +.range-pill input[type="range"] { + width: 100%; + height: 4px; + margin: 0; + padding: 0; + border: 0; + background: #394247; + border-radius: 999px; + outline: none; + cursor: pointer; + -webkit-appearance: none; +} +.range-pill input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 11px; + height: 11px; + border-radius: 50%; + background: #c9f277; + cursor: pointer; +} + +.config-btn { + width: 100%; + height: 26px; + font-size: 10px; + background: #20272b; + color: #d1d8d6; + border: 1px solid rgba(255, 255, 255, 0.08); +} +.config-btn:hover { + background: #2b353b; + color: #ffffff; +} + +/* ── Status Bar in Deck ── */ +.deck-status-bar { + margin-top: auto; + padding-top: 6px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} +.status-line { + display: flex; + align-items: center; + gap: 6px; + font-size: 9px; + color: #8a9497; +} +.status-line .status-dot { + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; + background: var(--muted); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08); + transition: background 0.25s ease, box-shadow 0.25s ease; +} +body[data-app-state="disconnected"] .status-line .status-dot, +body[data-app-state="connecting"] .status-line .status-dot { + background: var(--muted); +} +body[data-app-state="connected_disarmed"] .status-line .status-dot { + background: var(--cyan); + box-shadow: none; +} +body[data-app-state="armed"] .status-line .status-dot { + background: var(--acid); + box-shadow: none; +} +body[data-app-state="link_degraded"] .status-line .status-dot { + background: var(--red); + box-shadow: none; +} +.status-line p { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.logs-link { + all: unset; + cursor: pointer; + color: #9aa29f; + font-size: 9px; + letter-spacing: 0.02em; + margin-left: auto; +} +.logs-link:hover { color: #f1f4f2; } + +/* ══════════════════════════════════════════════════════════════════ + PRIMARY CONTROL ZONE (Workspace) + Engage/Disarm banner directly above Joysticks + Controls Grid + ══════════════════════════════════════════════════════════════════ */ +.controls-workspace { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 10px 16px 14px; + background: linear-gradient(155deg, #f7f8f6, #ebefec); + min-width: 0; + position: relative; +} +.primary-control-zone { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + flex: 1; + min-height: 0; +} + +/* ── Minimal Window Bar ── */ +.workspace-topbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 24px; + min-height: 24px; + margin-bottom: 6px; + user-select: none; +} +.workspace-drag-area { + flex: 1; + height: 100%; +} +.window-actions { + display: flex; + align-items: center; + gap: 4px; + -webkit-app-region: no-drag; +} +.window-actions button { + width: 22px; + height: 22px; + border-radius: 4px; + border: 0; + background: transparent; + color: #7a8588; + cursor: pointer; + display: grid; + place-items: center; + padding: 0; + box-shadow: none; + transition: color 0.2s ease, opacity 0.2s ease; +} +.window-actions button::after { display: none !important; } +.window-actions button svg { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.window-actions button:hover { + background: transparent; + color: #1a1e1d; +} +.window-actions button:active { opacity: 0.65; } +.window-actions .window-close:hover { + background: transparent; + color: #e04438; +} + +/* ── Engage / Disarm Slot Above Joysticks ── */ +.engage-header-zone { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + justify-content: space-between; + min-height: 76px; + margin-bottom: 10px; +} +.zone-label { + font-size: 9px; + color: #727a7c; + text-transform: uppercase; + letter-spacing: .08em; +} +@property --engage-progress { + syntax: ""; + inherits: true; + initial-value: 0%; +} +.engage-button { + --engage-progress: 0%; + min-width: 166px; + height: 44px; + padding: 0 24px; + border-radius: 10px; + position: relative; + overflow: hidden; + user-select: none; + touch-action: none; + background: #343a39; + border: 1px solid #242927; + color: #f7f8f6; + transition: --engage-progress .28s ease-out, background-color .24s ease, border-color .24s ease, color .2s ease, transform .16s ease; +} +.engage-button::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: var(--engage-progress); + background: rgba(139, 218, 83, .18); + pointer-events: none; +} +.engage-button.charging { transition: background-color .24s ease, border-color .24s ease, color .2s ease; } +.engage-face { position: relative; display: grid; place-items: center; min-width: 74px; } +.engage-face > span { grid-area: 1/1; font-size: 11px; font-weight: 650; letter-spacing: .12em; } +.engage-label { + color: transparent; + background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%); + background-size: 250% 100%; + background-clip: text; + -webkit-background-clip: text; +} +.arm:not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-label { animation: engage-shimmer 4.8s linear infinite; } +.engage-progress-label { + color: #a9ed72; + clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0); + transition: opacity .18s ease; +} +.state-transition .engage-face { animation: state-label .24s ease-out; } +@keyframes state-label { from {opacity:.35} to {opacity:1} } +@keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} } +.engage-button:hover { transform:none; } +.engage-button.pending { background:#405346; border-color:#516e56; } +.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:hover { background: #d7dcd5; } + +.arm { + background: #343a39; + color: #f7f8f6; + border-color: #242927; + cursor: pointer; +} +.arm.charged { + --engage-progress: 100%; + background: #4e7140; + border-color: #78ad5a; +} +.danger { + --engage-progress: 0%; + background: #ad3f38; + color: #ffffff; + border: 1px solid #8e302b; +} +.danger .engage-label { color:#fff; background:none; } +.danger .engage-progress-label { opacity:0; } +.danger:hover { + background: #bd4740; + border-color: #8e302b; +} + +/* ── Main Controls Grid ── */ +.controls-grid { + --joy-size: min(46vh, 320px); + --joy-circle-d: calc(var(--joy-size) * 0.90); + display: grid; + grid-template-columns: minmax(300px, 1fr) 175px; + gap: 12px; + flex: 1; + min-height: 0; +} + +.control-card { + display: flex; + flex-direction: column; + padding: 12px 14px 10px; + border: 1px solid #e1e4e0; + border-radius: 18px; + background: var(--card); + box-shadow: var(--shadow); + min-height: 0; +} +.control-card header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} +.control-card header div { + display: flex; + flex-direction: column; + gap: 1px; +} +.control-card small { + font-size: 8.5px; + color: #828a88; + font-weight: 400; +} +.control-card header strong { + font-size: 13px; + font-weight: 400; +} +.key-hint { + padding: 2px 5px; + border-radius: 5px; + background: #f0f2ef; + color: #7e8583; + font-size: 8px; + font-weight: 400; +} + +.joystick-pad, .heave-pad { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 0; + padding: 6px; +} + +.joystick { + --size: var(--joy-size); + position: relative; + width: var(--size); + height: var(--size); + min-width: 220px; + min-height: 220px; + margin: auto; + border: 0; + background: transparent; + user-select: none; + touch-action: none; +} +.joystick:before, .joystick:after { display: none !important; } +.axis, .axis-label, .axis-copy, .stick, .stick-shadow { display: none !important; } + +.vertical-control { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + min-height: var(--joy-size); + gap: 3px; + font-size: 8.5px; + font-weight: 400; + color: #828a87; +} +.heave-track { + position: relative; + width: 48px; + height: var(--joy-circle-d); + border-radius: 18px; + background: #eaeee8; + border: 1px solid #d6dbd4; + box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.05); + touch-action: none; +} +.heave-track::before { display: none !important; } +.heave-center { + position: absolute; + left: 7px; + right: 7px; + top: 50%; + height: 3px; + border-radius: 2px; + background: #75807b; +} +.heave-fill { + position: absolute; + left: 18px; + right: 18px; + top: 50%; + height: 0; + background: #87958e; + border-radius: 3px; +} +.heave-knob { + position: absolute; + left: 50%; + top: 50%; + translate: -50% -50%; + width: 64px; + height: 34px; + border-radius: 14px; + background: linear-gradient(145deg, #687271, #3c4443); + border: 1px solid #d7dedc; + box-shadow: 0 3px 10px rgba(20, 30, 25, 0.18), inset 0 1px rgba(255, 255, 255, 0.55); + cursor: grab; + touch-action: none; + transition: scale 0.12s, filter 0.12s; + will-change: top; +} +.heave-knob::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 12px; + height: 1.5px; + background: #8e9694; + border-radius: 1px; + transform: translate(-50%, -50%); +} +.heave-knob:active, .heave-knob.pressed { + filter: brightness(.83); +} + +.control-card footer { + display: flex; + align-items: center; + height: 14px; + margin-top: auto; + color: #7e8583; + font-size: 9px; + font-weight: 300; + line-height: 1; +} +.control-card footer b { font-weight: 400; color: #28302c; } +.steer-card footer { + justify-content: space-between; + width: 100%; + padding: 0 2px; +} +.steer-readout-left, .steer-readout-right { + display: inline-flex; + align-items: center; + gap: 3px; +} + +/* ══════════════════════════════════════════════════════════════════ + MODALS + ══════════════════════════════════════════════════════════════════ */ +.modal { + width: min(540px, calc(100vw - 40px)); + padding: 22px 24px; + border: 1px solid rgba(255, 255, 255, 0.45); + border-radius: 24px; + background: rgba(247, 248, 245, 0.96); + color: #1a1e1f; + box-shadow: + 0 30px 80px -12px rgba(0, 0, 0, 0.42), + 0 0 0 1px rgba(0, 0, 0, 0.06), + 0 10px 24px -6px rgba(0, 0, 0, 0.16); + backdrop-filter: blur(28px) saturate(160%); + -webkit-backdrop-filter: blur(28px) saturate(160%); +} +.modal::backdrop { + background: rgba(10, 16, 18, 0.45); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.modal-close { + position: absolute; + right: 14px; + top: 14px; + width: 24px; + height: 24px; + border: 0; + background: transparent; + font-size: 16px; + cursor: pointer; +} +.form-grid { + display: grid; + gap: 12px; + margin-top: 12px; +} +.form-grid.two { grid-template-columns: 1fr 1fr; } +.form-grid.three { grid-template-columns: 1fr 1fr 1fr; } +.form-grid label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 10px; +} +.number-pill { + display: grid; + grid-template-columns: 24px 1fr 24px; + align-items: center; + height: 30px; + border-radius: 999px; + background: #eaede8; + overflow: hidden; +} +.number-pill button { + width: 24px; + height: 30px; + border: 0; + background: transparent; + cursor: pointer; +} +.number-pill input { + border: 0; + text-align: center; + background: transparent; +} +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} +.note { + font-size: 9.5px; + color: #7a8280; + margin-top: 8px; +} +.motor-modal { width: min(600px, calc(100vw - 40px)); } +.motor-head, .motor-row { + display: grid; + grid-template-columns: 70px 1fr 80px 75px; + align-items: center; + gap: 8px; + padding: 6px 0; + border-bottom: 1px solid #e0e4df; + font-size: 10.5px; +} +.motor-head { + font-weight: 500; + color: #727a7c; + border-bottom: 1.5px solid #c8cec6; +} + +/* ── Toast ── */ +.toast { + position: fixed; + bottom: 16px; + right: 16px; + padding: 8px 14px; + background: #171b19; + color: #fff; + border-radius: 8px; + font-size: 11px; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.25s, transform 0.25s; + pointer-events: none; + z-index: 100; +} +.toast.show { + opacity: 1; + transform: translateY(0); +} +.toast.error { + background: #7a2822; +} + +/* ══════════════════════════════════════════════════════════════════ + APP-STATE CONDITIONAL GATING + ══════════════════════════════════════════════════════════════════ */ +body:not([data-controls-enabled="true"]) .controls-grid { + opacity: 0.45; + pointer-events: none; + user-select: none; +} + +button:disabled { + opacity: 0.38 !important; + pointer-events: none !important; + cursor: not-allowed !important; +} +button:disabled::after { display: none !important; } + +body[data-app-state="disconnected"] 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"] { + opacity: 0.38; + pointer-events: none; + 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) { + .app-shell { grid-template-columns: 210px minmax(0, 1fr); } + .controls-grid { grid-template-columns: minmax(230px, 1fr) 150px; } + .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; } +} diff --git a/web_ui.py b/web_ui.py index 0410b62..825347d 100644 --- a/web_ui.py +++ b/web_ui.py @@ -7,13 +7,16 @@ import threading import time import base64 import shutil -import uuid +import http.server +import socketserver +import urllib.parse from pathlib import Path import webview from serial.tools import list_ports from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function +from vehicle_state import VehicleState class ControlApi: @@ -22,12 +25,19 @@ class ControlApi: def __init__(self): self.events: queue.Queue = queue.Queue() self.worker = MavlinkWorker(self.events) + self.worker.require_input_lease = True + self.vehicle_state = VehicleState() + self.battery_received_at = None + self.demo_mode = False self.lock = threading.Lock() + self._poll_lock = threading.Lock() self.state = { "connected": False, "link": "DISCONNECTED", "vehicle": "No heartbeat", "armed": False, + "mode": None, + "app_state": "disconnected", "status": "Ready. Select a USB port to begin.", "battery": None, "log_path": None, @@ -39,8 +49,6 @@ class ControlApi: self.model_path = Path(__file__).resolve().parent / "model_settings.json" self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl" self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt" - self.model_uploads: dict[str, tuple[object, Path, str, int, int]] = {} - self.model_lock = threading.Lock() # Leading underscore keeps the native WebView object out of js_api # serialization. Only the small public methods below are exposed. self._window = None @@ -57,14 +65,26 @@ class ControlApi: def initial_state(self): return { - "ports": [p.device for p in list_ports.comports()], + "ports": self.refresh_ports(), "battery_config": self.battery_config, - "saved_model": self.get_saved_model_info(), "state": self._snapshot(), + "model_info": self.get_saved_model_info(), } def refresh_ports(self): - return [p.device for p in list_ports.comports()] + ports = [] + # Add physical and virtual serial COM ports + for p in list_ports.comports(): + desc = p.description or "" + if desc and desc != "n/a" and desc != p.device: + label = f"{p.device} ({desc})" + else: + label = p.device + ports.append({"device": p.device, "label": label, "type": "serial"}) + + # Built-in UI testing: no external process, port, or MAVLink socket. + ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"}) + return ports def minimize_window(self): if self._window: @@ -86,62 +106,83 @@ class ControlApi: self._window.destroy() return True - def begin_model_save(self, name, size): + def choose_model(self): + selected = None + # 1. Native Windows topmost open file dialog (instant, reliable) + try: + import tkinter as tk + from tkinter import filedialog + root = tk.Tk() + root.withdraw() + root.attributes("-topmost", True) + file_path = filedialog.askopenfilename( + title="Select 3D STL Vehicle Model", + initialdir=str(Path.home() / "Downloads"), + filetypes=[("STL 3D Meshes (*.stl)", "*.stl"), ("All files (*.*)", "*.*")], + ) + root.destroy() + if file_path: + selected = (file_path,) + except Exception: + pass + + # 2. Fallback to pywebview create_file_dialog + if not selected and self._window: + try: + selected = self._window.create_file_dialog( + webview.FileDialog.OPEN, + directory=str(Path.home()), + allow_multiple=False, + file_types=("STL mesh (*.stl)", "All files (*.*)"), + ) + except Exception: + selected = None + + if not selected: + return None + + path = Path(selected[0]) + if not path.is_file() or not path.suffix.lower().endswith(".stl"): + raise ValueError("Choose an STL mesh") + if path.stat().st_size > 30 * 1024 * 1024: + raise ValueError("The STL is larger than 30 MB; export a lighter mesh") + content = path.read_bytes() + self.model_cache.parent.mkdir(exist_ok=True) + self.model_cache.write_bytes(content) + self.model_cache_name.write_text(path.name, encoding="utf-8") + try: + self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8") + except Exception: + pass + return {"name": path.name, "data": base64.b64encode(content).decode("ascii")} + + def save_model(self, name, data): safe_name = Path(str(name)).name if not safe_name.lower().endswith(".stl"): raise ValueError("Choose an STL mesh") - size = int(size) - if not 0 < size <= 30 * 1024 * 1024: + try: + content = base64.b64decode(data, validate=True) + except (ValueError, TypeError) as exc: + raise ValueError("Invalid STL data") from exc + if not content or len(content) > 30 * 1024 * 1024: raise ValueError("STL must be between 1 byte and 30 MB") self.model_cache.parent.mkdir(exist_ok=True) - token = uuid.uuid4().hex - temp = self.model_cache.parent / f".{token}.upload" - with self.model_lock: - self.model_uploads[token] = (temp.open("wb"), temp, safe_name, size, 0) - return token - - def append_model_chunk(self, token, data): - try: - chunk = base64.b64decode(data, validate=True) - except (ValueError, TypeError) as exc: - raise ValueError("Invalid STL data chunk") from exc - with self.model_lock: - upload = self.model_uploads.get(str(token)) - if not upload: - raise ValueError("Model upload expired") - handle, temp, name, expected, received = upload - if received + len(chunk) > expected: - raise ValueError("Model upload is larger than expected") - handle.write(chunk) - self.model_uploads[str(token)] = (handle, temp, name, expected, received + len(chunk)) - return True - - def finish_model_save(self, token): - with self.model_lock: - upload = self.model_uploads.pop(str(token), None) - if not upload: - raise ValueError("Model upload expired") - handle, temp, name, expected, received = upload - handle.close() - if received != expected: - temp.unlink(missing_ok=True) - raise ValueError("Model upload was incomplete") - os.replace(temp, self.model_cache) - self.model_cache_name.write_text(name, encoding="utf-8") + self.model_cache.write_bytes(content) + self.model_cache_name.write_text(safe_name, encoding="utf-8") return True def get_saved_model_info(self): self._migrate_legacy_model() try: if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024: - name = self.model_cache_name.read_text(encoding="utf-8").strip() or "vehicle_model.stl" + name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl" return {"name": name, "size": self.model_cache.stat().st_size} except (OSError, ValueError): pass return None - def read_saved_model_chunk(self, offset, size=1048576): - offset, size = int(offset), min(int(size), 1048576) + def read_saved_model_chunk(self, offset, size=262144): + offset, size = int(offset), min(int(size), 524288) if offset < 0 or size < 1 or not self.model_cache.is_file(): raise ValueError("Saved model is unavailable") with self.model_cache.open("rb") as stream: @@ -150,49 +191,114 @@ class ControlApi: return {"offset": offset, "data": base64.b64encode(content).decode("ascii"), "eof": offset + len(content) >= self.model_cache.stat().st_size} def _migrate_legacy_model(self): - if self.model_cache.is_file(): - return - default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl" - if default_asset.is_file(): + if not self.model_cache.is_file(): + default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl" + if default_asset.is_file(): + try: + self.model_cache.parent.mkdir(exist_ok=True) + shutil.copyfile(default_asset, self.model_cache) + self.model_cache_name.write_text(default_asset.name, encoding="utf-8") + return + except Exception: + pass try: - self.model_cache.parent.mkdir(exist_ok=True) - shutil.copyfile(default_asset, self.model_cache) - self.model_cache_name.write_text(default_asset.name, encoding="utf-8") - return + if self.model_path.is_file(): + path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"]) + if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024: + self.model_cache.parent.mkdir(exist_ok=True) + shutil.copyfile(path, self.model_cache) + self.model_cache_name.write_text(path.name, encoding="utf-8") except Exception: pass + + def saved_model(self): + self._migrate_legacy_model() try: - path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"]) - if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024: - self.model_cache.parent.mkdir(exist_ok=True) - shutil.copyfile(path, self.model_cache) - self.model_cache_name.write_text(path.name, encoding="utf-8") - except (OSError, ValueError, TypeError, KeyError): + if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024: + name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl" + return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")} + except (OSError, ValueError): pass + return None def connect(self, port, baud=57600, rate=20): + print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}") if not port: raise ValueError("Select a USB serial port") + if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"): + self.demo_mode = True + self.servo_functions = {1: 33, 2: 34, 3: 35} + self.motor_directions = {1: 1, 2: 1, 3: 1} + with self.lock: + self.state.update( + connected=True, link="LINK OK", vehicle="DEMO • DISARMED", + armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.", + 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) - self.worker.connect(str(port), int(baud)) with self.lock: + self.vehicle_state.disconnect() self.state.update( connected=True, link="CONNECTING", status=f"Opening {port} at {baud} baud…", ) - return True + self.worker.connect(str(port), int(baud)) + print(f"[web_ui] connect() dispatched to worker thread OK") + # Return only once the worker has either seen a heartbeat or reported a + # connection failure. This makes the button independent of the regular + # UI polling loop and prevents an indefinite visual "Connecting" state. + deadline = time.monotonic() + 8.0 + while time.monotonic() < deadline: + snapshot = self.poll() + if snapshot["app_state"] != "connecting": + return snapshot + time.sleep(0.05) + self.worker.disconnect() + time.sleep(0.1) + self.poll() + raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds") def disconnect(self): + 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() return True - def set_axes(self, forward, turn, vertical): - self.worker.set_axes(Axes( - max(-1.0, min(1.0, float(forward))), - 0.0, - max(-1.0, min(1.0, float(vertical))), - max(-1.0, min(1.0, float(turn))), - )) + def set_axes(self, forward, turn, vertical, sequence=None): + if self.demo_mode: + with self.lock: + self.state["status"] = ( + f"Demo controls • surge {float(forward):+.2f} • " + f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}" + ) + return True + with self.lock: + allowed = self.vehicle_state.snapshot()["controls_allowed"] + self.worker.set_axes(Axes( + max(-1.0, min(1.0, float(forward))), 0.0, + max(-1.0, min(1.0, float(vertical))), + max(-1.0, min(1.0, float(turn))), + ) if allowed else Axes(), sequence) + return True + + def stop_controls(self, sequence=None): + """Invalidate queued input without claiming that the vehicle disarmed.""" + with self.lock: + self.worker.set_axes(Axes(), sequence) + self.worker._send_control(force_neutral=True) return True def set_gain(self, value): @@ -200,18 +306,45 @@ class ControlApi: self.worker.gain = value / 100.0 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": self.worker.log("OPERATOR", "clicked SET MANUAL") self.worker.set_manual_mode() - elif name == "arm": - self.worker.log("OPERATOR", "clicked ARM") - self.worker.command_arm(True) - elif name == "disarm": - self.worker.log("OPERATOR", "clicked DISARM") - self.worker.command_arm(False) else: - raise ValueError("Unknown command") + raise ValueError(f"Unknown command: {name}") return True def set_servo_function(self, servo, motor): @@ -219,6 +352,10 @@ class ControlApi: if not 1 <= servo <= 16 or not 0 <= motor <= 12: raise ValueError("Invalid servo or motor") function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor) + if self.demo_mode: + self.servo_functions[servo] = function + self._update_servo(servo) + return True self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function) return True @@ -230,6 +367,10 @@ class ControlApi: current = self.motor_directions.get(motor) if current is None: raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet") + if self.demo_mode: + self.motor_directions[motor] = -1 if current == 1 else 1 + self._update_servo(servo) + return True self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1) return True @@ -259,13 +400,15 @@ class ControlApi: return True def poll(self): - while True: - try: - event = self.events.get_nowait() - except queue.Empty: - break - self._consume(event) - if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5: + # Bound a poll even if telemetry arrives continuously. + with self._poll_lock: + for _ in range(500): + try: + event = self.events.get_nowait() + except queue.Empty: + break + self._consume(event) + if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3: with self.lock: self.state["link"] = ( "DATA OK / HEARTBEAT DELAYED" @@ -273,24 +416,49 @@ class ControlApi: ) return self._snapshot() + def _derive_app_state(self) -> str: + if not self.state.get("connected"): + return "disconnected" + link = self.state.get("link", "") + if link == "CONNECTING": + return "connecting" + if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"): + return "link_degraded" + if self.state.get("armed"): + return "armed" + return "connected_disarmed" + def _snapshot(self): with self.lock: - return json.loads(json.dumps(self.state)) + snap = json.loads(json.dumps(self.state)) + snap["app_state"] = self._derive_app_state() + if not self.demo_mode: + snap.update(self.vehicle_state.snapshot()) + if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]: + snap["app_state"] = "link_degraded" + snap["battery_stale"] = (self.battery_received_at is None + or time.monotonic() - self.battery_received_at > 5) + return snap def _consume(self, event): kind = event[0] with self.lock: if kind == "heartbeat": - _, system, autopilot, mode, armed = event + _, system, autopilot, mode, armed = event[:5] + self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None) self.state.update( connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}", armed=bool(armed), + mode=str(mode).upper(), ) elif kind in ("status", "statustext"): self.state["status"] = event[1] elif kind == "ack": + if event[1] == 400: + self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None) self.state["status"] = f"Command {event[1]} ACK: {event[2]}" elif kind == "battery": + self.battery_received_at = time.monotonic() voltage = float(event[1]) cells = self.battery_config["cells"] per_cell = voltage / cells @@ -316,9 +484,14 @@ class ControlApi: self.state["log_path"] = event[1] elif kind == "error": self.state["status"] = event[1] + self.state["last_error"] = event[1] elif kind == "disconnected": + self.vehicle_state.disconnect() + last_err = self.state.get("last_error") + status_msg = last_err if last_err else "Disconnected" self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", - armed=False, battery=None, status="Disconnected") + mode=None, status=status_msg) + self.state["last_error"] = None def _update_servo(self, servo): function = self.servo_functions.get(servo) @@ -334,23 +507,106 @@ class ControlApi: 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(): 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( - "SUB Control", url=str(ui_path), js_api=api, - width=1120, height=760, min_size=(900, 650), + "SUB Control", url=app_url, js_api=api, + width=win_w, height=win_h, min_size=min_dims, background_color="#111719", frameless=True, easy_drag=False, shadow=True, ) api._window = window + window.events.shown += lambda: _apply_dwm_styling(window) window.events.maximized += lambda: setattr(api, "maximized", True) window.events.restored += lambda: setattr(api, "maximized", False) try: - webview.start(debug=False) + webview.start(debug=True) finally: api.close() if __name__ == "__main__": main() +