From 31f720a316212ada3d4f8c5c3080ea6e1c4afdb5 Mon Sep 17 00:00:00 2001 From: PitresAli Date: Fri, 2 Oct 2026 08:02:41 +0200 Subject: [PATCH] Improve controller input, console, and UI consistency --- README.md | 4 +- SUB Control/README.md | 4 +- SUB Control/tests/controller_input.cjs | 31 ++++ SUB Control/tests/ui_windows.cjs | 137 +++++++++++++- SUB Control/web/app.js | 203 ++++++++++++++++++--- SUB Control/web/console-view.js | 70 ++++++++ SUB Control/web/controller-input.js | 53 ++++++ SUB Control/web/index.html | 85 +++++---- SUB Control/web/joystick-3d.js | 10 +- SUB Control/web/styles.css | 239 +++++++++++++++++++++---- tests/controller_input.cjs | 31 ++++ tests/ui_windows.cjs | 137 +++++++++++++- web/app.js | 203 ++++++++++++++++++--- web/console-view.js | 70 ++++++++ web/controller-input.js | 53 ++++++ web/index.html | 85 +++++---- web/joystick-3d.js | 10 +- web/styles.css | 177 +++++++++++++++++- 18 files changed, 1427 insertions(+), 175 deletions(-) create mode 100644 SUB Control/tests/controller_input.cjs create mode 100644 SUB Control/web/console-view.js create mode 100644 SUB Control/web/controller-input.js create mode 100644 tests/controller_input.cjs create mode 100644 web/console-view.js create mode 100644 web/controller-input.js diff --git a/README.md b/README.md index 3ee6e41..20091f1 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,16 @@ The script creates a virtual environment and installs dependencies automatically On first launch, it also adds a **SUB Control** desktop shortcut. Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware. Connection settings are under **•••**. -Click **Data link** for an extended connection test, or **Console** to inspect RX/TX messages and pause the view. +Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details. ## Controls - Joystick: forward/back and turning. Vertical slider: rise/dive. - Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive. +- Physical controller: select **Joystick** while disarmed. Left stick drives/turns; right stick controls rise/dive. **Setup** changes axes, inversion, and dead zone while disarmed. - Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation. - Movement controls ease back to neutral when released. +- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle. Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`. diff --git a/SUB Control/README.md b/SUB Control/README.md index 3ee6e41..20091f1 100644 --- a/SUB Control/README.md +++ b/SUB Control/README.md @@ -16,14 +16,16 @@ The script creates a virtual environment and installs dependencies automatically On first launch, it also adds a **SUB Control** desktop shortcut. Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware. Connection settings are under **•••**. -Click **Data link** for an extended connection test, or **Console** to inspect RX/TX messages and pause the view. +Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details. ## Controls - Joystick: forward/back and turning. Vertical slider: rise/dive. - Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive. +- Physical controller: select **Joystick** while disarmed. Left stick drives/turns; right stick controls rise/dive. **Setup** changes axes, inversion, and dead zone while disarmed. - Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation. - Movement controls ease back to neutral when released. +- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle. Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`. diff --git a/SUB Control/tests/controller_input.cjs b/SUB Control/tests/controller_input.cjs new file mode 100644 index 0000000..04641de --- /dev/null +++ b/SUB Control/tests/controller_input.cjs @@ -0,0 +1,31 @@ +/* Pure conditioning and log parsing tests: no hardware or browser needed. */ +const assert=require('node:assert/strict'); +const {deadZone,defaults,read,Motion}=require('../web/controller-input.js'); +const {parse}=require('../web/console-view.js'); +const pad={connected:true,axes:[0,0,0,0]}; +assert.equal(deadZone(.06),0);assert.equal(deadZone(-1),-1);assert.equal(deadZone(1),1); +assert.equal(defaults({axes:[0,0]}).vertical,-1); +assert.equal(read({...pad,connected:false},defaults(pad)),null); +assert.equal(read(pad,{...defaults(pad),vertical:10}),null); +const diagonal=read({...pad,axes:[1,-1,0,-1]},defaults(pad)); +assert(Math.abs(Math.hypot(diagonal.turn,diagonal.forward)-1)<1e-10); +assert.equal(diagonal.vertical,1); +assert.equal(read({...pad,axes:[-.5,0,0,0]},{...defaults(pad),invertTurn:true}).turn,deadZone(.5)); +const motion=new Motion(),target={turn:.6,forward:.8,vertical:1}; +assert.deepEqual(motion.step(target,0),{turn:0,forward:0,vertical:0}); +const partial=motion.step(target,16);assert(partial.vertical>0&&partial.vertical<.3); +let current;for(let time=32;time<=1000;time+=16){current=motion.step(target,time);assert(Math.hypot(current.turn,current.forward)<=1+1e-10)} +assert(current.vertical>.99); +const neutral={turn:0,forward:0,vertical:0}; +assert(motion.step(neutral,1024).vertical>.99); +assert(motion.step(neutral,1200).vertical>0); +assert.deepEqual(motion.step(neutral,1400),neutral); +motion.reset();assert.deepEqual(motion.step(target,1500),neutral); +assert.equal(parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"HEARTBEAT","base_mode":128}').quiet,true); +const ack=parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0}'); +assert.equal(ack.time,'10:11:12.123');assert.equal(ack.type,'COMMAND_ACK');assert.equal(ack.tone,'command'); +assert.equal(parse('2026-10-01T10:11:12 [TX_LINK_PROBE_TIMEOUT] retry').quiet,false); +assert.equal(parse('2026-10-01T10:11:12 [RX] {"mavpackettype":"STATUSTEXT","severity":3,"text":"warning"}').tone,'warning'); +assert.equal(parse('2026-10-01T10:11:12 [RX] null').type,'RX'); +assert.equal(parse('unstructured text').payload,'unstructured text'); +console.log('PASS: dead zone, mapping/inversion, diagonal cap, ramp, finite center return, resets, console parsing and warning visibility.'); diff --git a/SUB Control/tests/ui_windows.cjs b/SUB Control/tests/ui_windows.cjs index b28af0c..1051102 100644 --- a/SUB Control/tests/ui_windows.cjs +++ b/SUB Control/tests/ui_windows.cjs @@ -24,19 +24,23 @@ async function main(){ try{ const page=await browser.newPage({viewport:{width:1080,height:720}}); const errors=[];page.on('pageerror',error=>errors.push(error.message)); + const setArmed=value=>page.evaluate(value=>{ + Object.assign(testBridge.state,{connected:true,armed:value,armed_known:true,app_state:value?'armed':'connected_disarmed'});render(testBridge.state); + },value); await page.addInitScript(()=>{ const clone=value=>JSON.parse(JSON.stringify(value)); const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null}; const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null}; const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20}; const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}; - const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[]}; + const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[],axes:[],axisVisual:[],stops:0}; const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false}; + window.testPads=[];Object.defineProperty(navigator,'getGamepads',{value:()=>window.testPads}); window.pywebview={api:{ initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}), refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state), - get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>true,set_axes:async()=>true, + get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>{calls.stops++;return true},set_axes:async(...axes)=>{calls.axes.push(axes);calls.axisVisual.push({joy:joystick3D.getState(),vertical:verticalControl.getState().value});return true}, save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)}, save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)}, connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)}, @@ -45,7 +49,7 @@ async function main(){ set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true}, start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)}, cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)}, - read_console:async()=>{calls.console++;return {rx:['RX sample '+calls.console],tx:['TX sample '+calls.console]}}, + read_console:async()=>{calls.console++;return {rx:['2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0,"sample":1}', '2026-10-01T10:11:12.124 [RX_BAD_DATA] corrupt_frame='+calls.console, '2026-10-01T10:11:12.125 [RX] {"mavpackettype":"HEARTBEAT"}'],tx:['2026-10-01T10:11:12.124 [TX_COMMAND] ARM '+calls.console]}}, minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true }}; }); @@ -107,27 +111,146 @@ async function main(){ await page.waitForFunction(()=>!testBridge.state.heavy_quality.running); await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal'); await page.locator('#console').click(); - await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('RX sample')); + await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('COMMAND_ACK')); await page.locator('#console-pause').click(); const paused=await page.locator('#console-rx-output').textContent(); await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused); await page.locator('#console-pause').click(); await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused); - await page.screenshot({path:path.join(output,'console.png')});await close('console-modal'); + await page.screenshot({path:path.join(output,'console.png')});await page.locator('#console-close').click(); for(const viewport of [{width:900,height:560},{width:480,height:720}]){ await page.setViewportSize(viewport); - for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','console-modal']){ + for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','controller-modal']){ await page.evaluate(id=>openModal(id),id); const box=await page.locator('#'+id).boundingBox(); assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport'); assert.equal(await page.evaluate(()=>canDrive()),false); + const closeGeometry=await page.locator('#'+id+' .modal-close').evaluate(button=>{ + const b=button.getBoundingClientRect(),s=button.querySelector('svg').getBoundingClientRect(); + return {dx:Math.abs(b.x+b.width/2-s.x-s.width/2),dy:Math.abs(b.y+b.height/2-s.y-s.height/2),bg:getComputedStyle(button).backgroundColor}; + }); + assert(closeGeometry.dx<.5&&closeGeometry.dy<.5,id+' close icon must be centered'); + assert.equal(closeGeometry.bg,'rgba(0, 0, 0, 0)'); await close(id); } } await page.setViewportSize({width:1080,height:720});await openLink(); await page.screenshot({path:path.join(output,'link.png')});await close('link-modal'); + + await page.setViewportSize({width:1080,height:720}); + await page.evaluate(()=>{Object.assign(testBridge.state,{connected:true,armed:false,armed_known:true,app_state:'connected_disarmed',battery:{voltage:15.8,percent:72,cells:4,low:false}});focusChanged(true);render(testBridge.state)}); + await page.locator('#engage').focus(); + assert.equal(await page.locator('#engage').evaluate(el=>getComputedStyle(el).outlineStyle),'none'); + await page.evaluate(()=>startEngageHold()); + assert.equal(await page.locator('#input-joystick').isDisabled(),true); + await page.evaluate(()=>cancelEngageHold(false)); + assert.equal(await page.locator('#input-joystick').isDisabled(),false); + await page.locator('#input-joystick').click(); + for(const pending of ['arm','disarm']){ + await page.evaluate(pending=>{testBridge.state.pending_command=pending;render(testBridge.state)},pending); + assert.equal(await page.locator('#input-keyboard').isDisabled(),true); + assert.equal(await page.evaluate(()=>{setInputMode('keyboard');return inputMode}),'joystick'); + } + await page.evaluate(()=>{delete testBridge.state.pending_command;testBridge.state.armed_known=false;render(testBridge.state)}); + assert.equal(await page.locator('#input-keyboard').isDisabled(),true); + await setArmed(true); + assert.equal(await page.locator('#input-keyboard').isDisabled(),true); + await page.waitForFunction(()=>document.getElementById('controller-status').textContent.includes('Waiting')); + await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[1,-1,0,-1]}]); + await page.waitForFunction(()=>document.getElementById('controller-status').textContent==='Center sticks to resume'); + assert.deepEqual(await page.evaluate(()=>({...control})),{turn:0,forward:0,vertical:0}); + await page.evaluate(()=>testPads[0].axes=[0,0,0,0]); + await page.waitForFunction(()=>!controllerNeedCenter); + await page.keyboard.down('w');await page.waitForTimeout(420);await page.keyboard.up('w'); + assert.equal(await page.evaluate(()=>control.forward),0); + await page.evaluate(()=>testPads[0].axes=[1,-1,0,-1]); + await page.waitForFunction(()=>control.vertical>0&&control.vertical<.8); + await page.waitForFunction(()=>control.vertical>.99); + const matching=await page.evaluate(()=>{ + const joy=joystick3D.getState(),sent=testBridge.calls.axes.at(-1); + return {length:Math.hypot(control.turn,control.forward),joy,vertical:control.vertical,sent,visual:testBridge.calls.axisVisual.at(-1),control:{...control}}; + }); + assert(matching.length<=1.00001); + assert.equal(matching.joy.x,matching.control.turn);assert.equal(matching.joy.y,-matching.control.forward); + assert.equal(matching.sent[0],-matching.visual.joy.y);assert.equal(matching.sent[1],matching.visual.joy.x);assert.equal(matching.sent[2],matching.visual.vertical); + // Dispatch even though CSS excludes pointers, to exercise the handler gate. + await page.locator('#joystick-canvas').dispatchEvent('pointerdown',{pointerId:91,clientX:20,clientY:20,button:0}); + await page.locator('#heave').dispatchEvent('pointerdown',{pointerId:92,clientX:20,clientY:20,button:0}); + assert.equal(await page.evaluate(()=>joystick3D.getState().active||verticalControl.getState().active),false); + await page.locator('#console').click(); + await page.waitForFunction(()=>!document.getElementById('console-dock').hidden); + await page.waitForFunction(()=>document.querySelector('.rx-dot').classList.contains('lit')&&document.querySelector('.tx-dot').classList.contains('lit')); + await page.locator('#console-pause').click(); + await page.waitForTimeout(500); + assert.equal(await page.locator('.rx-dot').evaluate(el=>el.classList.contains('lit')),false); + assert.equal(await page.locator('.tx-dot').evaluate(el=>el.classList.contains('lit')),false); + await page.locator('#console-pause').click(); + assert.equal(await page.evaluate(()=>canDrive()),true); + assert.equal(await page.locator('#console-rx-output .console-row').count(),2); + await page.locator('#console-filter').selectOption('all'); + assert.equal(await page.locator('#console-rx-output .console-row').count(),3); + await page.locator('#console-rx-output details').first().locator('summary').click(); + await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output details').first().evaluate(el=>el.open),true); + await page.screenshot({path:path.join(output,'controller-console.png')});await page.locator('#console-close').click(); + await page.evaluate(()=>testPads[0].axes=[0,0,0,0]); + await page.waitForFunction(()=>control.vertical>0&&control.vertical<.95); + await page.waitForFunction(()=>control.vertical===0&&control.turn===0&&control.forward===0); + await setArmed(false); + assert.equal(await page.locator('#input-keyboard').isDisabled(),false); + await page.locator('#controller-settings').click(); + await page.screenshot({path:path.join(output,'controller-setup.png')}); + await page.locator('#controller-form [name="invertTurn"]').check(); + await page.locator('#controller-form').getByRole('button',{name:'Cancel',exact:true}).click(); + await page.waitForFunction(()=>!document.getElementById('controller-modal').open); + await page.locator('#controller-settings').click(); + assert.equal(await page.locator('#controller-form [name="invertTurn"]').isChecked(),false); + await page.locator('#controller-form [name="invertTurn"]').check(); + await page.locator('#controller-form').getByRole('button',{name:'Save mapping',exact:true}).click(); + await page.waitForFunction(()=>!document.getElementById('controller-modal').open); + await setArmed(true); + await page.waitForFunction(()=>!controllerNeedCenter); + await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]); + await page.waitForFunction(()=>control.turn<-.2); + await page.evaluate(()=>testPads=[]); + await page.waitForFunction(()=>control.turn===0); + await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[.5,0,0,0]}]); + await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0); + await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter); + await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2); + await page.evaluate(()=>focusChanged(false));assert.equal(await page.evaluate(()=>control.turn),0); + await page.evaluate(()=>focusChanged(true));await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0); + await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter); + await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2); + await page.evaluate(()=>testPads[0].id='Different controller'); + await page.waitForFunction(()=>control.turn===0); + assert.equal(await page.locator('#input-keyboard').isDisabled(),true); + await setArmed(false); + await page.locator('#input-keyboard').click(); + await setArmed(true); + await page.keyboard.down('w');await page.waitForFunction(()=>control.forward>.9);await page.keyboard.up('w'); + await page.waitForFunction(()=>control.forward===0); + await page.evaluate(()=>{Object.assign(testBridge.state,{connected:false,armed_known:false});render(testBridge.state)}); + assert.equal(await page.evaluate(()=>canDrive()),false); + + await setArmed(false); + const deckLayout=await page.evaluate(()=>{ + const battery=document.querySelector('.battery-card'),sidebar=document.querySelector('.sidebar-deck'),actions=document.querySelector('.activity-actions'),heading=document.querySelector('.activity-heading'); + return {last:battery===document.querySelector('.telemetry-zone').lastElementChild,title:heading.textContent, + actionHeight:actions.querySelector('button').getBoundingClientRect().height, + batteryHeight:battery.getBoundingClientRect().height,connectHeight:document.querySelector('#connect').getBoundingClientRect().height, + bottomGap:sidebar.getBoundingClientRect().bottom-battery.getBoundingClientRect().bottom, + overlay:getComputedStyle(document.querySelector('.battery-copy')).zIndex, + actionRightGap:actions.getBoundingClientRect().right-actions.lastElementChild.getBoundingClientRect().right, + sidebarPadding:getComputedStyle(sidebar).paddingTop, + workspacePadding:getComputedStyle(document.querySelector('.controls-workspace')).paddingTop}; + }); + assert.equal(deckLayout.last,true);assert.equal(deckLayout.title,'Activity:'); + assert.equal(deckLayout.actionHeight,32);assert.equal(deckLayout.batteryHeight,62);assert.equal(deckLayout.connectHeight,62); + assert(deckLayout.bottomGap>=10&&deckLayout.bottomGap<=24);assert.equal(deckLayout.overlay,'1'); + assert.equal(deckLayout.actionRightGap,0);assert.equal(deckLayout.sidebarPadding,deckLayout.workspacePadding); + await page.screenshot({path:path.join(output,'ui-cleanup.png')}); assert.deepEqual(errors,[]); - console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, and five dialogs at two window sizes.'); + console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, five dialogs at two window sizes; controller conditioning/read-only graphics, actual bridge values, mapping, focus/unplug/identity safety and dock filtering; mode locking during hold/armed/pending/unknown states, centered SVG close icons and bottom battery layout.'); }catch(error){ const page=browser.contexts()[0]?.pages()[0]; if(page){ diff --git a/SUB Control/web/app.js b/SUB Control/web/app.js index 9772c33..5e704b1 100644 --- a/SUB Control/web/app.js +++ b/SUB Control/web/app.js @@ -14,7 +14,7 @@ function recordActivity(message){ if(!message||activity[0]===message)return; activity.unshift(String(message));activity.length=Math.min(activity.length,3); ['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{ - const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]} + const row=$(selector);if(row){const changed=row.textContent!==(activity[i]||'');row.textContent=activity[i]||'';row.hidden=!activity[i];if(changed&&!row.hidden&&!matchMedia('(prefers-reduced-motion:reduce)').matches)row.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'})} }); } function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})} @@ -22,7 +22,127 @@ let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehi bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(), localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now(); const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750; + window.canDrive=canDrive; +let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false; +const controllerMotion=new ControllerInput.Motion(); +const controllerId=pad=>pad.index+':'+pad.id; +function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}} +function loadControllerConfig(pad){ + let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){} + const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})}; + return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults; +} +function selectController(pad){ + resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad); +} +function setInputMode(mode){ + if(mode===inputMode||inputConfigurationLocked())return false; + resetInputs();inputMode=mode;document.body.dataset.inputMode=mode; + $('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard')); + $('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick')); + $('#controller-settings').hidden=mode!=='joystick'; + $('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys'); + $('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control'); + return true; +} +function inputConfigurationLocked(){ + return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known)); +} +function syncInputModeControls(){ + const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey); + $('.input-mode-switch').dataset.locked=String(locked); + for(const button of [$('#input-keyboard'),$('#input-joystick')]){ + button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode'; + } + $('#controller-settings').disabled=locked||!pads.length; + $('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup'; + if($('#controller-modal').open){ + const form=$('#controller-form'); + for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad; + $('#controller-device').disabled=locked||!pads.length; + const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.'; + if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback); + } +} +window.canTouchControl=()=>inputMode==='keyboard'&&canDrive(); +$('#input-keyboard').onclick=()=>setInputMode('keyboard'); +$('#input-joystick').onclick=()=>setInputMode('joystick'); +function fillControllerForm(){ + const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form'); + device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad)))); + if(!pads.length)device.add(new Option('No controller detected','')); + device.value=controllerKey||''; + const pad=pads.find(pad=>controllerId(pad)===controllerKey); + const config=controllerConfig||ControllerInput.defaults(pad); + for(const name of ['turn','forward','vertical']){ + const field=form.elements[name];field.replaceChildren(new Option('Off','-1')); + for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis))); + field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad; + const invert='invert'+name[0].toUpperCase()+name.slice(1); + form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad; + } + form.elements.deadZone.value=Math.round(config.deadZone*100); + device.disabled=inputConfigurationLocked()||!pads.length; + form.elements.deadZone.disabled=inputConfigurationLocked()||!pad; + form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad; +} +$('#controller-device').onchange=e=>{ + if(inputConfigurationLocked())return; + const pad=getControllers().find(pad=>controllerId(pad)===e.target.value); + if(pad){selectController(pad);fillControllerForm()} +}; +$('#controller-form').onsubmit=e=>{ + e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey); + if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return} + if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return} + const config={deadZone:Number(form.elements.deadZone.value)/100}; + for(const name of ['turn','forward','vertical']){ + config[name]=Number(form.elements[name].value); + const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked; + } + if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){ + setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return; + } + controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){} + resetInputs();closeModal($('#controller-modal')); +}; +function pollController(now){ + try{ + if(inputMode==='joystick'){ + const pads=getControllers(); + if(controllerKey===null&&pads.length)selectController(pads[0]); + const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig); + let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id; + $('#controller-settings').disabled=inputConfigurationLocked()||!pads.length; + if(!target||!canDrive()){ + if(controllerWasDriving)resetInputs(); + controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false; + }else{ + if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id} + if(!controllerNeedCenter){ + const demand=controllerMotion.step(target,now);controllerWasDriving=true; + control.turn=demand.turn;control.forward=demand.forward; + window.joystick3D?.setVisual(demand.turn,-demand.forward); + setHeave(demand.vertical,false);updateReadouts();sendAxes(); + } + } + if($('#controller-status').textContent!==status)$('#controller-status').textContent=status; + $('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready')); + if($('#controller-modal').open){ + const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected'; + if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes; + if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm(); + } + }else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'} + }catch(error){ + if(controllerWasDriving)resetInputs(); + $('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard'; + } + requestAnimationFrame(pollController); +} +document.body.dataset.inputMode=inputMode; + function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}} function updateDemo(patch){Object.assign(demoState,patch);render(demoState)} document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))}); @@ -34,7 +154,7 @@ function populatePorts(ports){ if(!p)return; const old=p.value||''; p.innerHTML=''; - + const allList=[]; const seen=new Set(); @@ -84,20 +204,21 @@ function sendAxes(){ setInterval(()=>{if(canDrive())sendAxes()},250); function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);} -window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();}; +window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();}; function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)} const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0; function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();} function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()} -heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)}); +heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)}); function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)} window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})}; -function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} -addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); +function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} +addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); function resetInputs(){ + controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false; if(gainPointer!==null)releaseGain({pointerId:gainPointer}); clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false); window.joystick3D?.reset(); @@ -120,9 +241,11 @@ function setFeedback(el,message=''){ function openModal(id){ const el=typeof id==='string'?document.getElementById(id):id; if(!el||el.open)return; + if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return} resetInputs(); el._returnFocus=document.activeElement; if(el.id==='battery-modal')fillBatteryForm(); + if(el.id==='controller-modal')fillControllerForm(); if(el.id==='link-modal'){ fillLinkForm(); $('#custom-port-status').textContent=''; @@ -393,12 +516,14 @@ function startEngageHold(e){ if(view.action!=='arm'||!inputFocused||engageStart)return; if(e&&e.button!==undefined&&e.button!==0)return; engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%'); + syncInputModeControls(); if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId); engageHoldTimer=requestAnimationFrame(updateEngageProgress); } function cancelEngageHold(showHint=true){ if(!engageStart)return; const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled'); + syncInputModeControls(); } if(engageButton){ @@ -409,7 +534,7 @@ if(engageButton){ engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null}); engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.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'); @@ -417,33 +542,55 @@ if(engageButton){ } $('#logs').onclick=()=>api('open_logs'); -const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output'); -let consoleRefreshBusy=false,consolePaused=false; +const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output'); +let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0; +const consolePulseTimers={}; +function pulseConsole(side){ + const dot=$('.'+side+'-dot'); + clearTimeout(consolePulseTimers[side]);dot.classList.add('lit'); + consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260); +} +function renderConsole(){ + const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache); + if(signature===consoleSignature)return; + consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter); +} +function setConsoleOpen(open){ + consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open); + if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')} + else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180); + $('#console').setAttribute('aria-expanded',String(open)); + if(open)refreshConsole();else $('#console').focus({preventScroll:true}); +} +$('#console').setAttribute('aria-controls','console-dock'); +$('#console').setAttribute('aria-expanded','false'); +$('#console').onclick=()=>setConsoleOpen(!consoleOpen); +$('#console-close').onclick=()=>setConsoleOpen(false); +$('#console-filter').onchange=renderConsole; $('#console-pause').onclick=()=>{ consolePaused=!consolePaused; $('#console-pause').textContent=consolePaused?'Resume':'Pause'; $('#console-pause').setAttribute('aria-pressed',String(consolePaused)); - $('#console-state').textContent=consolePaused?'Paused · session logging continues':'Live · follows new messages'; + $('#console-state').textContent=consolePaused?'Paused':''; + $('#console-state').hidden=!consolePaused; if(!consolePaused)refreshConsole(); }; async function refreshConsole(){ - if(!consoleDialog?.open||!bridgeReady||consoleRefreshBusy||consolePaused)return; + if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return; consoleRefreshBusy=true; try{ - const rxAtBottom=consoleRx.scrollHeight-consoleRx.scrollTop-consoleRx.clientHeight<36; - const txAtBottom=consoleTx.scrollHeight-consoleTx.scrollTop-consoleTx.clientHeight<36; - const sides=await api('read_console',150); - if(consolePaused||!consoleDialog.open)return; - consoleRx.textContent=sides.rx?.length?sides.rx.join('\n'):'Waiting for vehicle data…'; - consoleTx.textContent=sides.tx?.length?sides.tx.join('\n'):'Waiting for transmitted data…'; - if(rxAtBottom)consoleRx.scrollTop=consoleRx.scrollHeight; - if(txAtBottom)consoleTx.scrollTop=consoleTx.scrollHeight; - }catch(error){ - $('#console-state').textContent=`Console unavailable: ${error.message}`; - } + const sides=await withTimeout(api('read_console',150),1500); + if(consolePaused||!consoleOpen)return; + const filter=$('#console-filter').value; + for(const side of ['rx','tx']){ + const previous=new Set(consoleCache[side]||[]); + if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side); + } + consoleCache=sides;renderConsole(); + $('#console-state').textContent='';$('#console-state').hidden=true; + }catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}} finally{consoleRefreshBusy=false} } -$('#console').onclick=()=>{openModal(consoleDialog);refreshConsole()}; setInterval(refreshConsole,750); 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); @@ -564,6 +711,7 @@ function render(s,confirmed=false){ (s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null; const previous=view; view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError}); + syncInputModeControls(); if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs(); appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected'; renderHeavyQuality(s.heavy_quality,s.connected,s.armed); @@ -604,7 +752,7 @@ function render(s,confirmed=false){ 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)} @@ -617,11 +765,13 @@ function render(s,confirmed=false){ $('#battery-title').textContent=`${b.voltage.toFixed(2)} V`; $('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`; $('#battery-fill').style.width=`${b.percent}%`; - $('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'; + $('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)'; + $('.battery-card').dataset.low=String(!!b.low); }else{ $('#battery-title').textContent='—'; $('#battery-percent').textContent='waiting'; $('#battery-fill').style.width='0'; + $('.battery-card').dataset.low='false'; } const quality=s.link_quality,qualityCard=$('#link-quality'); if(qualityCard){ @@ -800,3 +950,6 @@ if(document.readyState==='loading'){ loadDefaultModel(); waitForBridge(); } + + +requestAnimationFrame(pollController); diff --git a/SUB Control/web/console-view.js b/SUB Control/web/console-view.js new file mode 100644 index 0000000..2bd12c0 --- /dev/null +++ b/SUB Control/web/console-view.js @@ -0,0 +1,70 @@ +/* Read-only presentation of the existing local RX/TX logs. */ +((root)=>{ + const quiet=/^(HEARTBEAT|TIMESYNC|LINK_PROBE|HEAVY_PROBE|GCS)/; + function parse(line){ + const match=String(line).match(/^(\S+) \[([^\]]+)\] ([\s\S]*)$/); + if(!match)return {time:'',type:'Message',payload:String(line),raw:String(line),tone:'normal',quiet:false}; + const [,timestamp,category,message]=match; + let payload=message,type=category.replace(/^(RX|TX)_?/,'')||category; + try{payload=JSON.parse(message);type=payload.mavpackettype||type}catch(_){} + const tone=/BAD_DATA|ERROR|TIMEOUT|FAIL/.test(category)||type==='STATUSTEXT'&&Number(payload.severity)<=4?'warning':/COMMAND|ACK|PARAM_SET|OPERATOR/.test(category+' '+type)?'command':'normal'; + const time=timestamp.match(/T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)?.[1]||timestamp; + const pairs=typeof payload==='string'?[...payload.matchAll(/(?:^|\s)([a-zA-Z_]\w*)=([^\s]+)/g)]:[]; + const fields=pairs.length?Object.fromEntries(pairs.map(([,key,value])=>[key,value])):payload; + return {time,type,payload,fields,raw:String(line),tone,quiet:tone!=='warning'&&(quiet.test(type)||/LINK_PROBE|HEAVY_PROBE|HEARTBEAT/.test(category))}; + } + function valueText(value){ + if(typeof value==='number')return Number.isInteger(value)?String(value):Number(value.toFixed(3)).toString(); + if(Array.isArray(value))return value.slice(0,6).map(valueText).join(', ')+(value.length>6?'…':''); + return typeof value==='object'?JSON.stringify(value):String(value); + } + function makeRow(row,previous){ + if(previous&&previous.dataset.key===row.raw&&previous.dataset.tone===row.tone)return previous; + const details=previous||document.createElement('details'); + details.className='console-row';details.dataset.key=row.raw;details.dataset.tone=row.tone; + let summary=details.querySelector('summary'),time,body,type,fields,raw; + if(!summary){ + details.replaceChildren();summary=document.createElement('summary');time=document.createElement('time'); + body=document.createElement('span');body.className='console-message';type=document.createElement('strong'); + fields=document.createElement('span');fields.className='console-fields';body.append(type,fields);summary.append(time,body); + raw=document.createElement('pre');details.append(summary,raw); + }else{ + time=summary.querySelector('time');body=summary.querySelector('.console-message'); + type=body.querySelector('strong');fields=body.querySelector('.console-fields');raw=details.querySelector('pre'); + } + time.textContent=row.time;type.textContent=row.type;fields.replaceChildren(); + const values=row.fields??row.payload; + if(typeof values==='object'&&values!==null){ + for(const [key,value] of Object.entries(values).filter(([key])=>key!=='mavpackettype').slice(0,6)){ + const item=document.createElement('span'),label=document.createElement('i'),text=document.createElement('b'); + label.textContent=key+' ';text.textContent=valueText(value);item.append(label,text);fields.append(item); + } + }else fields.textContent=row.payload; + raw.textContent=typeof row.payload==='object'?JSON.stringify(row.payload,null,2):row.raw; + return details; + } + function render(host,lines,filter){ + const atBottom=host.scrollHeight-host.scrollTop-host.clientHeight<36,scroll=host.scrollTop; + const rows=lines.map(parse).filter(row=>filter==='all'||!row.quiet); + const existing=new Map(); + for(const node of host.querySelectorAll('details.console-row')){ + const key=node.dataset.key,list=existing.get(key)||[];list.push(node);existing.set(key,list); + } + if(!rows.length){ + if(!host.querySelector('.console-empty')){ + const empty=document.createElement('p');empty.className='console-empty'; + empty.textContent='No messages'; + empty.title=filter==='all'?'Waiting for session messages.':'Routine heartbeat and probe traffic is hidden. Choose All traffic to see it.'; + host.replaceChildren(empty); + } + return; + } + const wanted=rows.map(row=>makeRow(row,existing.get(row.raw)?.shift()||null)); + let cursor=host.firstElementChild; + for(const node of wanted){if(node!==cursor)host.insertBefore(node,cursor);cursor=node.nextElementSibling} + while(cursor){const next=cursor.nextElementSibling;cursor.remove();cursor=next} + if(atBottom&&!host.querySelector('details[open]'))host.scrollTop=host.scrollHeight;else host.scrollTop=scroll; + } + root.ConsoleView={parse,render}; + if(typeof module!=='undefined')module.exports=root.ConsoleView; +})(typeof window!=='undefined'?window:globalThis); diff --git a/SUB Control/web/controller-input.js b/SUB Control/web/controller-input.js new file mode 100644 index 0000000..4948712 --- /dev/null +++ b/SUB Control/web/controller-input.js @@ -0,0 +1,53 @@ +/* Controller conditioning is independent of drawing and MAVLink transport. */ +((root)=>{ + const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0)); + function deadZone(value,zone=.08){ + value=clamp(value);zone=Math.max(0,Math.min(.4,zone)); + return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone); + } + function defaults(pad){ + return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1, + invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08}; + } + function read(pad,config){ + if(!pad?.connected||!pad.axes)return null; + const axis=(index,invert)=>{ + if(index===-1)return 0; + if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null; + return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1); + }; + let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical); + if(turn===null||forward===null||vertical===null)return null; + const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length} + return {turn,forward,vertical}; + } + class Smoother{ + constructor(){this.reset()} + reset(){this.value=0;this.last=null;this.release=null} + step(target,now){ + target=clamp(target); + const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now; + if(target===0){ + if(!this.release)this.release={at:now,start:this.value}; + const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t); + this.value=this.release.start*(1-ease); + }else{ + this.release=null; + this.value+=(target-this.value)*(1-Math.exp(-dt/.1)); + if(Math.abs(target-this.value)<.001)this.value=target; + } + return this.value; + } + } + class Motion{ + constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}} + reset(){Object.values(this.axes).forEach(axis=>axis.reset())} + step(target,now){ + const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)])); + const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length} + return demand; + } + } + root.ControllerInput={deadZone,defaults,read,Motion}; + if(typeof module!=='undefined')module.exports=root.ControllerInput; +})(typeof window!=='undefined'?window:globalThis); diff --git a/SUB Control/web/index.html b/SUB Control/web/index.html index 77b6747..1c0f964 100644 --- a/SUB Control/web/index.html +++ b/SUB Control/web/index.html @@ -4,11 +4,11 @@ Sub Control - +
- + @@ -72,6 +71,7 @@
+
@@ -80,6 +80,7 @@
+

Keyboard + touch

@@ -98,12 +99,12 @@
-
steer + throttle
- w a s d +
+
- +
@@ -115,13 +116,13 @@
-
vertical
- r / f +
+
rise -
+
@@ -147,21 +148,27 @@
+
- Connection - - Connection

Link quality

-

The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.

-
Round trip—
Probe loss—
FC → PC loss—
-

Extended test

Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.

+

Link quality

+ +
Round trip—
Probe loss—
FC → PC loss—
+

Extended test

Connect to run an extended test.
@@ -187,8 +194,8 @@
- Power

Battery settings

Charge is estimated from voltage. Capacity and C rating describe your battery.

-
+

Battery settings

+

Voltage per cell

@@ -196,20 +203,32 @@
- Configuration

Motor outputs

Waiting for motor assignments.

+

Motor outputs

Waiting for motor assignments.

OutputAssignmentDirection
- - Local session

Live console

-

Live · follows new messages

-
RXVehicle → PC
Waiting for vehicle data…
TXPC → Vehicle
Waiting for transmitted data…
+ +

Joystick setup

+
+ +

Move a stick or press a button to detect your controller.

+
MovementAxisInvert
+
Turn
+
Forward / back
+
Rise / dive
+ + + + +
- + - + + + diff --git a/SUB Control/web/joystick-3d.js b/SUB Control/web/joystick-3d.js index 703df4c..2a4f176 100644 --- a/SUB Control/web/joystick-3d.js +++ b/SUB Control/web/joystick-3d.js @@ -23,7 +23,9 @@ if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()} ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3; - ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore(); + const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819); + handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939'); + ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;ctx.fill();ctx.restore(); ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke(); ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill(); } @@ -40,10 +42,10 @@ function returnToCenter(){easeTo(0,0)} function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()} - canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()}); + canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canTouchControl?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()}); canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)}); canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)}); canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)}); - addEventListener('resize',resize);resize(); - window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}}; + addEventListener('resize',resize);new ResizeObserver(resize).observe(host);resize(); + window.joystick3D={setVisual(nextX,nextY){setPosition(nextX,nextY,false)},setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}}; })(); diff --git a/SUB Control/web/styles.css b/SUB Control/web/styles.css index 217748c..0fe96a9 100644 --- a/SUB Control/web/styles.css +++ b/SUB Control/web/styles.css @@ -756,7 +756,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; } background: #4e7140; border-color: #78ad5a; } -.danger { +.danger { --engage-progress: 0%; background: #ad3f38; color: #ffffff; @@ -947,7 +947,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; } border-radius: 24px; background: rgba(247, 248, 245, 0.96); color: #1a1e1f; - box-shadow: + box-shadow: 0 30px 80px -12px rgba(0, 0, 0, 0.42), 0 0 0 1px rgba(0, 0, 0, 0.06), 0 10px 24px -6px rgba(0, 0, 0, 0.16); @@ -1031,37 +1031,6 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; } border-bottom: 1.5px solid #c8cec6; } -.console-modal { - width: min(1040px, calc(100vw - 64px)); - max-width: 1040px; -} -.console-split { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; min-width:0; } -.console-split section { min-width:0; } -.console-split header { display:flex; align-items:center; gap:7px; margin:0 2px 7px; } -.console-split header i { width:7px; height:7px; border-radius:50%; flex:0 0 auto; } -.console-split header .rx-dot { background:#79c8eb; } -.console-split header .tx-dot { background:#bce96c; } -.console-split header span { color:#303836; font-size:12px; font-weight:600; } -.console-split header small { margin-left:auto; color:#7d8683; font-size:10px; } -.console-split pre { - height: min(55vh, 480px); - margin: 0; - padding: 14px; - overflow: auto; - border: 1px solid #d9ddda; - border-radius: 12px; - background: #171b1d; - color: #dce4e1; - font: 11px/1.45 Consolas, "SFMono-Regular", monospace; - white-space: pre-wrap; - overflow-wrap: anywhere; - user-select: text; -} -@media (max-width: 760px) { - .console-split { grid-template-columns:1fr; } - .console-split pre { height:220px; } -} - /* ── Toast ── */ .toast { position: fixed; @@ -1110,6 +1079,37 @@ body[data-app-state="link_degraded"] button[data-open="motors-modal"] { cursor: not-allowed; } +.console-modal { + width: min(1040px, calc(100vw - 64px)); + max-width: 1040px; +} +.console-split { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; min-width:0; } +.console-split section { min-width:0; } +.console-split header { display:flex; align-items:center; gap:7px; margin:0 2px 7px; } +.console-split header i { width:7px; height:7px; border-radius:50%; flex:0 0 auto; } +.console-split header .rx-dot { background:#79c8eb; } +.console-split header .tx-dot { background:#bce96c; } +.console-split header span { color:#303836; font-size:12px; font-weight:600; } +.console-split header small { margin-left:auto; color:#7d8683; font-size:10px; } +.console-split pre { + height: min(55vh, 480px); + margin: 0; + padding: 14px; + overflow: auto; + border: 1px solid #d9ddda; + border-radius: 12px; + background: #171b1d; + color: #dce4e1; + font: 11px/1.45 Consolas, "SFMono-Regular", monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} +@media (max-width: 760px) { + .console-split { grid-template-columns:1fr; } + .console-split pre { height:220px; } +} + /* A pending or unknown armed state must stay legible, even without a link. */ #engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; } @media (prefers-reduced-motion: reduce) { @@ -1298,3 +1298,176 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; } @media (prefers-reduced-motion:reduce) { .modal[open],.modal.closing { animation:none; } } + +/* Input selection stays separate from vehicle arming and command ownership. */ +#engage:focus,#engage:focus-visible { outline:none!important; box-shadow:none!important; } +.controls-workspace { container-type:inline-size; } +.input-mode-controls { display:flex; align-items:center; gap:6px; flex-shrink:0; -webkit-app-region:no-drag; } +.input-mode-switch { display:flex; padding:3px; gap:2px; background:#dedede; border-radius:999px; } +.input-mode-switch button,.controller-settings { border:0; border-radius:999px; background:transparent; color:#686868; padding:7px 11px; font-size:12px; cursor:pointer; } +.input-mode-switch button[aria-pressed="true"] { background:#fafafa; color:#252525; box-shadow:0 1px 3px #0000000c; } +.controller-settings { background:#dedede; } +.controller-status { margin:0; height:24px; flex-shrink:0; font-size:12px; line-height:20px; color:#6d7478; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +body[data-input-mode="joystick"] .key-hint { visibility:hidden; } +body[data-input-mode="joystick"] :is(#joystick-canvas,#heave) { pointer-events:none; cursor:default; } +.controls-grid { --joy-size:min(52vh,calc(100cqw - 136px),460px); } +.control-toolbar { flex-wrap:wrap; gap:8px 14px; } +.primary-control-zone { grid-template-rows:120px minmax(0,1fr) auto; } +.control-toolbar { min-height:52px; } +.engage-face > span { font-size:clamp(52px,11cqw,90px); } +.controller-mapping-head,.controller-mapping-row { display:grid; grid-template-columns:1fr 1.25fr 50px; gap:14px; align-items:center; } +.controller-mapping-head { color:#777; font-size:12px; margin-bottom:8px; } +.controller-mapping-row { margin:8px 0; } +.modal .controller-mapping-row input[type="checkbox"] { width:22px; height:22px; min-height:0; margin:auto; padding:0; accent-color:#527f9b; } +.controller-mapping-row label { display:flex; align-items:center; } +#controller-axes { min-height:22px; margin:12px 0; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; color:#65737d; font-size:12px; } +#controller-form > label { margin-top:18px; } +.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } + +/* Read-only local console, not a modal: driving stays available while it is open. */ +.app-shell.console-open { grid-template-columns:260px minmax(0,1fr) 340px; } +.console-dock[hidden] { display:none!important; } +.console-dock { display:flex; flex-direction:column; min-width:0; min-height:0; padding:18px 16px; background:#f5f5f5; border-left:1px solid #d8dcde; color:#29343b; color-scheme:light; } +.console-dock-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:14px; } +.console-dock-header small { font-size:11px; color:#81888d; } +.console-dock-header h2 { font-size:22px; font-weight:400; margin:4px 0 0; letter-spacing:-.02em; } +.console-close { width:38px; height:38px; flex-shrink:0; border:0; border-radius:999px; background:#e7e8e9; color:#48525a; font-size:25px; cursor:pointer; } +.console-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; } +.console-toolbar label { flex:1; min-width:0; } +.console-toolbar select,.console-toolbar button { height:34px; border:0; border-radius:999px; background:#e7e9ea; color:#42525e; font-size:12px; padding:0 12px; } +.console-toolbar select { width:100%; } +.console-toolbar button { cursor:pointer; } +.console-state { min-height:28px; margin:10px 0 8px; font-size:11px; color:#7e868b; line-height:1.4; } +.console-streams { display:grid; grid-template-rows:1fr 1fr; gap:14px; min-height:0; flex:1; } +.console-side { display:flex; flex-direction:column; min-height:0; } +.console-side > header { display:flex; align-items:center; gap:6px; padding:0 4px 8px; font-size:12px; font-weight:500; color:#435966; } +.console-side > header small { margin-left:auto; font-size:11px; font-weight:300; color:#899198; } +.console-side > header i { width:6px; height:6px; border-radius:50%; background:#5888a3; } +.console-side > header .tx-dot { background:#659287; } +.console-output { min-height:0; flex:1; overflow:auto; overscroll-behavior:contain; padding:0 8px; border:1px solid #e0e3e5; border-radius:12px; background:#fafbfc; color:#343d43; font:12px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif; } +.console-row { border-bottom:1px solid #e9edef; } +.console-row:last-child { border-bottom:0; } +.console-row summary { display:flex; gap:8px; cursor:pointer; padding:10px 0; list-style:none; } +.console-row summary::-webkit-details-marker { display:none; } +.console-row time { flex-shrink:0; color:#929ba1; font:10px/1.8 ui-monospace,Consolas,monospace; font-variant-numeric:tabular-nums; } +.console-message { min-width:0; display:block; overflow-wrap:anywhere; } +.console-message strong { display:block; font-size:11px; font-weight:500; color:#517c97; letter-spacing:.025em; } +.console-row[data-tone="command"] strong { color:#467a69; } +.console-row[data-tone="warning"] strong { color:#aa664b; } +.console-fields { display:flex; flex-wrap:wrap; gap:2px 8px; font-size:11px; } +.console-fields i { color:#929a9f; font-style:normal; } +.console-fields b { font-weight:400; color:#45535d; } +.console-row pre { margin:0 0 10px; padding:10px; border-radius:8px; background:#eef1f3; color:#52616c; font:11px/1.5 ui-monospace,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; } +.console-empty { color:#959da3; font-size:12px; line-height:1.6; margin:14px 0; } +@media (max-height:610px) { .primary-control-zone { grid-template-rows:100px minmax(0,1fr) auto; } .controls-grid { --joy-size:min(46vh,calc(100cqw - 136px),420px); } } +@media (max-width:1000px) { + .app-shell.console-open { grid-template-columns:260px minmax(0,1fr); } + .console-dock { position:absolute; top:0; right:0; bottom:0; width:min(340px,calc(100vw - 32px)); z-index:12; box-shadow:-8px 0 24px #00000012; border-radius:12px 0 0 12px; } +} + +/* Three shared sizes: compact 32px, standard 40px, primary 62px. */ +:root { + --pill-sm:32px; --pill-md:40px; --pill-lg:62px; --pill-radius:999px; + --battery-charge:#63cb85; --battery-low:#e98a7b; +} +.port-row select,.icon-button { height:var(--pill-sm); } +.icon-button { width:var(--pill-sm); } +body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); } +.input-mode-switch { height:var(--pill-md); padding:4px; } +.input-mode-switch button { height:var(--pill-sm); padding:0 12px; } +.controller-settings { height:var(--pill-md); padding:0 14px; } +.input-mode-switch button:disabled { opacity:.65!important; } +.input-mode-switch button[aria-pressed="true"]:disabled { opacity:1!important; } +.control-toolbar .range-pill,.control-toolbar .config-btn { height:var(--pill-md); border-radius:var(--pill-radius); } +.control-toolbar .range-pill button { height:var(--pill-md); } +.window-actions button { width:var(--pill-md); height:var(--pill-md); } +.console-toolbar select,.console-toolbar button { height:var(--pill-sm); border-radius:var(--pill-radius); } +.modal input,.modal select,.modal .number-pill { height:var(--pill-md); border-radius:var(--pill-radius); } +.modal .number-pill input,.modal .number-pill button { height:calc(var(--pill-md) - 2px); } +.modal .button { min-height:var(--pill-md); padding:8px 18px; border-radius:var(--pill-radius); } +.modal .motor-row .button { min-height:var(--pill-sm); padding:6px 12px; } +.modal .controller-mapping-row input[type="checkbox"] { height:22px; border-radius:4px; } + +/* Fixed SVG geometry avoids font baselines shifting the close icon. */ +.modal-close,.modal-close:hover,.console-close,.console-close:hover,.window-actions .window-close,.window-actions .window-close:hover { + display:grid; place-items:center; width:var(--pill-md); height:var(--pill-md); + padding:0; border:0; border-radius:0; background:transparent; box-shadow:none; +} +.modal-close svg,.console-close svg { display:block; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; } +.modal-close:hover,.console-close:hover { color:#222; } +.heave-knob { height:var(--pill-md); background:linear-gradient(145deg,#737373,#454545); } + +/* Activity has a heading, compact event history, then explicit actions. */ +.telemetry-zone { gap:16px; } +.deck-status-bar { flex-shrink:0; margin-top:0; padding-top:12px; } +.activity-heading { display:block; margin:0 0 8px; color:#eee; font-size:14px; font-weight:650; } +.activity-history { gap:4px; margin:0 0 10px; } +.activity-actions { display:flex; gap:8px; } +.activity-actions .logs-link { + display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box; + height:var(--pill-sm); min-width:76px; padding:0 14px; margin:0; + border:0; border-radius:var(--pill-radius); background:#414141; color:#eee; + font-size:12px; font-weight:400; letter-spacing:0; line-height:1; cursor:pointer; +} +.activity-actions .logs-link:hover { background:#4b4b4b; } +.activity-actions .logs-link:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; } +.activity-actions .logs-link[aria-expanded="true"] { background:#555; } + +/* Battery is the final deck item: text floats above the charge fill. */ +.battery-card { + position:relative; display:block; flex-shrink:0; width:100%; height:var(--pill-lg); + min-height:var(--pill-lg); margin:0; padding:0; overflow:hidden; + border:1px solid #565956; border-radius:var(--pill-radius); background:#383c39; + color:#f2f5f2; isolation:isolate; +} +.battery-meter { position:absolute; inset:0; z-index:0; overflow:hidden; border-radius:inherit; pointer-events:none; } +.battery-meter #battery-fill { display:block; height:100%; width:0; border-radius:inherit; background:var(--battery-charge); opacity:.25; transition:width .35s ease,background .2s ease; } +.battery-card .battery-copy { position:absolute; inset:0; z-index:1; display:flex; flex-direction:row; align-items:center; justify-content:space-between; gap:8px; padding:0 18px; pointer-events:none; } +.battery-summary { display:flex; flex-direction:column; gap:3px; flex-shrink:0; } +.battery-card small { color:#c3cec5; font-size:11px; line-height:1; } +.battery-card strong { font-size:20px; font-weight:400; line-height:1.15; font-variant-numeric:tabular-nums; } +.battery-card i { align-self:auto; color:#d9e0da; text-align:right; font-size:12px; line-height:1.35; font-style:normal; } +.battery-card[data-low="true"] { border-color:#a87468; } +.battery-card:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; } + +/* The same slender, rounded scrollbar is used by every scrollable panel. */ +*::-webkit-scrollbar { width:8px; height:8px; } +*::-webkit-scrollbar-track,*::-webkit-scrollbar-corner { background:transparent; } +*::-webkit-scrollbar-thumb { min-height:28px; border:2px solid transparent; border-radius:var(--pill-radius); background:#aeb6ba; background-clip:padding-box; } +*::-webkit-scrollbar-thumb:hover { background:#87959e; background-clip:padding-box; } +*::-webkit-scrollbar-button { display:none; width:0; height:0; } +.sidebar-deck::-webkit-scrollbar-thumb { background:#666; background-clip:padding-box; } +.modal { scrollbar-gutter:stable; } +@supports not selector(::-webkit-scrollbar) { * { scrollbar-width:thin; scrollbar-color:#aeb6ba transparent; } } +@media (max-height:610px) { .telemetry-zone { gap:12px; } } + +/* Shared panel geometry and quiet, presentational motion. Never ease command data. */ +:root { --panel-inset:20px; --ui-gap:12px; --ui-duration:180ms; --ui-ease:cubic-bezier(.2,0,.2,1); } +.sidebar-deck,.controls-workspace,.console-dock { padding:var(--panel-inset); } +.sidebar-deck,.telemetry-zone,.deck-connection,.port-row,.activity-actions,.input-mode-controls,.console-toolbar,.console-streams { gap:var(--ui-gap); } +.deck-connection { padding-bottom:var(--ui-gap); } +.workspace-topbar,.console-dock-header { min-height:var(--pill-md); height:var(--pill-md); margin-bottom:var(--ui-gap); } +.port-row select,.port-row .icon-button { height:var(--pill-md); } +.port-row .icon-button { width:var(--pill-md); } +.activity-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); } +.activity-actions .logs-link { min-width:0; width:100%; } +.console-dock-header h2 { margin:0; } +.console-toolbar { margin-bottom:var(--ui-gap); } +.console-state { min-height:0; margin:0 0 var(--ui-gap); font-size:12px; } +.console-side > header { padding-left:0; padding-right:0; } +.console-side > header i { background:#b4bdc2; transition:background-color var(--ui-duration) ease-out,box-shadow var(--ui-duration) ease-out; } +.console-side > header .rx-dot.lit { background:#4fa4cf; box-shadow:0 0 5px #4fa4cf66; } +.console-side > header .tx-dot.lit { background:#56ae8a; box-shadow:0 0 5px #56ae8a66; } +:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::before, +:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::after, +:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card) { + transition-property:background-color,border-color,color,opacity,box-shadow,filter; + transition-duration:var(--ui-duration); transition-timing-function:var(--ui-ease); +} +/* Keep pointer positions immediate; the existing conditioning owns motor easing. */ +.modal[open],.console-dock:not([hidden]),.form-feedback:not([hidden]),.console-state:not([hidden]),.console-row pre { animation:ui-fade-in var(--ui-duration) var(--ui-ease); } +.modal.closing,.console-dock.closing { animation:ui-fade-out var(--ui-duration) ease-out forwards; pointer-events:none; } +@keyframes ui-fade-in { from { opacity:0; } to { opacity:1; } } +@keyframes ui-fade-out { from { opacity:1; } to { opacity:0; } } +.controller-status { transition:opacity var(--ui-duration) ease-out; } +.controller-status[data-quiet="true"] { opacity:0; pointer-events:none; } diff --git a/tests/controller_input.cjs b/tests/controller_input.cjs new file mode 100644 index 0000000..04641de --- /dev/null +++ b/tests/controller_input.cjs @@ -0,0 +1,31 @@ +/* Pure conditioning and log parsing tests: no hardware or browser needed. */ +const assert=require('node:assert/strict'); +const {deadZone,defaults,read,Motion}=require('../web/controller-input.js'); +const {parse}=require('../web/console-view.js'); +const pad={connected:true,axes:[0,0,0,0]}; +assert.equal(deadZone(.06),0);assert.equal(deadZone(-1),-1);assert.equal(deadZone(1),1); +assert.equal(defaults({axes:[0,0]}).vertical,-1); +assert.equal(read({...pad,connected:false},defaults(pad)),null); +assert.equal(read(pad,{...defaults(pad),vertical:10}),null); +const diagonal=read({...pad,axes:[1,-1,0,-1]},defaults(pad)); +assert(Math.abs(Math.hypot(diagonal.turn,diagonal.forward)-1)<1e-10); +assert.equal(diagonal.vertical,1); +assert.equal(read({...pad,axes:[-.5,0,0,0]},{...defaults(pad),invertTurn:true}).turn,deadZone(.5)); +const motion=new Motion(),target={turn:.6,forward:.8,vertical:1}; +assert.deepEqual(motion.step(target,0),{turn:0,forward:0,vertical:0}); +const partial=motion.step(target,16);assert(partial.vertical>0&&partial.vertical<.3); +let current;for(let time=32;time<=1000;time+=16){current=motion.step(target,time);assert(Math.hypot(current.turn,current.forward)<=1+1e-10)} +assert(current.vertical>.99); +const neutral={turn:0,forward:0,vertical:0}; +assert(motion.step(neutral,1024).vertical>.99); +assert(motion.step(neutral,1200).vertical>0); +assert.deepEqual(motion.step(neutral,1400),neutral); +motion.reset();assert.deepEqual(motion.step(target,1500),neutral); +assert.equal(parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"HEARTBEAT","base_mode":128}').quiet,true); +const ack=parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0}'); +assert.equal(ack.time,'10:11:12.123');assert.equal(ack.type,'COMMAND_ACK');assert.equal(ack.tone,'command'); +assert.equal(parse('2026-10-01T10:11:12 [TX_LINK_PROBE_TIMEOUT] retry').quiet,false); +assert.equal(parse('2026-10-01T10:11:12 [RX] {"mavpackettype":"STATUSTEXT","severity":3,"text":"warning"}').tone,'warning'); +assert.equal(parse('2026-10-01T10:11:12 [RX] null').type,'RX'); +assert.equal(parse('unstructured text').payload,'unstructured text'); +console.log('PASS: dead zone, mapping/inversion, diagonal cap, ramp, finite center return, resets, console parsing and warning visibility.'); diff --git a/tests/ui_windows.cjs b/tests/ui_windows.cjs index b28af0c..1051102 100644 --- a/tests/ui_windows.cjs +++ b/tests/ui_windows.cjs @@ -24,19 +24,23 @@ async function main(){ try{ const page=await browser.newPage({viewport:{width:1080,height:720}}); const errors=[];page.on('pageerror',error=>errors.push(error.message)); + const setArmed=value=>page.evaluate(value=>{ + Object.assign(testBridge.state,{connected:true,armed:value,armed_known:true,app_state:value?'armed':'connected_disarmed'});render(testBridge.state); + },value); await page.addInitScript(()=>{ const clone=value=>JSON.parse(JSON.stringify(value)); const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null}; const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null}; const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20}; const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}; - const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[]}; + const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[],axes:[],axisVisual:[],stops:0}; const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false}; + window.testPads=[];Object.defineProperty(navigator,'getGamepads',{value:()=>window.testPads}); window.pywebview={api:{ initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}), refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state), - get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>true,set_axes:async()=>true, + get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>{calls.stops++;return true},set_axes:async(...axes)=>{calls.axes.push(axes);calls.axisVisual.push({joy:joystick3D.getState(),vertical:verticalControl.getState().value});return true}, save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)}, save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)}, connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)}, @@ -45,7 +49,7 @@ async function main(){ set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true}, start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)}, cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)}, - read_console:async()=>{calls.console++;return {rx:['RX sample '+calls.console],tx:['TX sample '+calls.console]}}, + read_console:async()=>{calls.console++;return {rx:['2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0,"sample":1}', '2026-10-01T10:11:12.124 [RX_BAD_DATA] corrupt_frame='+calls.console, '2026-10-01T10:11:12.125 [RX] {"mavpackettype":"HEARTBEAT"}'],tx:['2026-10-01T10:11:12.124 [TX_COMMAND] ARM '+calls.console]}}, minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true }}; }); @@ -107,27 +111,146 @@ async function main(){ await page.waitForFunction(()=>!testBridge.state.heavy_quality.running); await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal'); await page.locator('#console').click(); - await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('RX sample')); + await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('COMMAND_ACK')); await page.locator('#console-pause').click(); const paused=await page.locator('#console-rx-output').textContent(); await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused); await page.locator('#console-pause').click(); await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused); - await page.screenshot({path:path.join(output,'console.png')});await close('console-modal'); + await page.screenshot({path:path.join(output,'console.png')});await page.locator('#console-close').click(); for(const viewport of [{width:900,height:560},{width:480,height:720}]){ await page.setViewportSize(viewport); - for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','console-modal']){ + for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','controller-modal']){ await page.evaluate(id=>openModal(id),id); const box=await page.locator('#'+id).boundingBox(); assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport'); assert.equal(await page.evaluate(()=>canDrive()),false); + const closeGeometry=await page.locator('#'+id+' .modal-close').evaluate(button=>{ + const b=button.getBoundingClientRect(),s=button.querySelector('svg').getBoundingClientRect(); + return {dx:Math.abs(b.x+b.width/2-s.x-s.width/2),dy:Math.abs(b.y+b.height/2-s.y-s.height/2),bg:getComputedStyle(button).backgroundColor}; + }); + assert(closeGeometry.dx<.5&&closeGeometry.dy<.5,id+' close icon must be centered'); + assert.equal(closeGeometry.bg,'rgba(0, 0, 0, 0)'); await close(id); } } await page.setViewportSize({width:1080,height:720});await openLink(); await page.screenshot({path:path.join(output,'link.png')});await close('link-modal'); + + await page.setViewportSize({width:1080,height:720}); + await page.evaluate(()=>{Object.assign(testBridge.state,{connected:true,armed:false,armed_known:true,app_state:'connected_disarmed',battery:{voltage:15.8,percent:72,cells:4,low:false}});focusChanged(true);render(testBridge.state)}); + await page.locator('#engage').focus(); + assert.equal(await page.locator('#engage').evaluate(el=>getComputedStyle(el).outlineStyle),'none'); + await page.evaluate(()=>startEngageHold()); + assert.equal(await page.locator('#input-joystick').isDisabled(),true); + await page.evaluate(()=>cancelEngageHold(false)); + assert.equal(await page.locator('#input-joystick').isDisabled(),false); + await page.locator('#input-joystick').click(); + for(const pending of ['arm','disarm']){ + await page.evaluate(pending=>{testBridge.state.pending_command=pending;render(testBridge.state)},pending); + assert.equal(await page.locator('#input-keyboard').isDisabled(),true); + assert.equal(await page.evaluate(()=>{setInputMode('keyboard');return inputMode}),'joystick'); + } + await page.evaluate(()=>{delete testBridge.state.pending_command;testBridge.state.armed_known=false;render(testBridge.state)}); + assert.equal(await page.locator('#input-keyboard').isDisabled(),true); + await setArmed(true); + assert.equal(await page.locator('#input-keyboard').isDisabled(),true); + await page.waitForFunction(()=>document.getElementById('controller-status').textContent.includes('Waiting')); + await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[1,-1,0,-1]}]); + await page.waitForFunction(()=>document.getElementById('controller-status').textContent==='Center sticks to resume'); + assert.deepEqual(await page.evaluate(()=>({...control})),{turn:0,forward:0,vertical:0}); + await page.evaluate(()=>testPads[0].axes=[0,0,0,0]); + await page.waitForFunction(()=>!controllerNeedCenter); + await page.keyboard.down('w');await page.waitForTimeout(420);await page.keyboard.up('w'); + assert.equal(await page.evaluate(()=>control.forward),0); + await page.evaluate(()=>testPads[0].axes=[1,-1,0,-1]); + await page.waitForFunction(()=>control.vertical>0&&control.vertical<.8); + await page.waitForFunction(()=>control.vertical>.99); + const matching=await page.evaluate(()=>{ + const joy=joystick3D.getState(),sent=testBridge.calls.axes.at(-1); + return {length:Math.hypot(control.turn,control.forward),joy,vertical:control.vertical,sent,visual:testBridge.calls.axisVisual.at(-1),control:{...control}}; + }); + assert(matching.length<=1.00001); + assert.equal(matching.joy.x,matching.control.turn);assert.equal(matching.joy.y,-matching.control.forward); + assert.equal(matching.sent[0],-matching.visual.joy.y);assert.equal(matching.sent[1],matching.visual.joy.x);assert.equal(matching.sent[2],matching.visual.vertical); + // Dispatch even though CSS excludes pointers, to exercise the handler gate. + await page.locator('#joystick-canvas').dispatchEvent('pointerdown',{pointerId:91,clientX:20,clientY:20,button:0}); + await page.locator('#heave').dispatchEvent('pointerdown',{pointerId:92,clientX:20,clientY:20,button:0}); + assert.equal(await page.evaluate(()=>joystick3D.getState().active||verticalControl.getState().active),false); + await page.locator('#console').click(); + await page.waitForFunction(()=>!document.getElementById('console-dock').hidden); + await page.waitForFunction(()=>document.querySelector('.rx-dot').classList.contains('lit')&&document.querySelector('.tx-dot').classList.contains('lit')); + await page.locator('#console-pause').click(); + await page.waitForTimeout(500); + assert.equal(await page.locator('.rx-dot').evaluate(el=>el.classList.contains('lit')),false); + assert.equal(await page.locator('.tx-dot').evaluate(el=>el.classList.contains('lit')),false); + await page.locator('#console-pause').click(); + assert.equal(await page.evaluate(()=>canDrive()),true); + assert.equal(await page.locator('#console-rx-output .console-row').count(),2); + await page.locator('#console-filter').selectOption('all'); + assert.equal(await page.locator('#console-rx-output .console-row').count(),3); + await page.locator('#console-rx-output details').first().locator('summary').click(); + await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output details').first().evaluate(el=>el.open),true); + await page.screenshot({path:path.join(output,'controller-console.png')});await page.locator('#console-close').click(); + await page.evaluate(()=>testPads[0].axes=[0,0,0,0]); + await page.waitForFunction(()=>control.vertical>0&&control.vertical<.95); + await page.waitForFunction(()=>control.vertical===0&&control.turn===0&&control.forward===0); + await setArmed(false); + assert.equal(await page.locator('#input-keyboard').isDisabled(),false); + await page.locator('#controller-settings').click(); + await page.screenshot({path:path.join(output,'controller-setup.png')}); + await page.locator('#controller-form [name="invertTurn"]').check(); + await page.locator('#controller-form').getByRole('button',{name:'Cancel',exact:true}).click(); + await page.waitForFunction(()=>!document.getElementById('controller-modal').open); + await page.locator('#controller-settings').click(); + assert.equal(await page.locator('#controller-form [name="invertTurn"]').isChecked(),false); + await page.locator('#controller-form [name="invertTurn"]').check(); + await page.locator('#controller-form').getByRole('button',{name:'Save mapping',exact:true}).click(); + await page.waitForFunction(()=>!document.getElementById('controller-modal').open); + await setArmed(true); + await page.waitForFunction(()=>!controllerNeedCenter); + await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]); + await page.waitForFunction(()=>control.turn<-.2); + await page.evaluate(()=>testPads=[]); + await page.waitForFunction(()=>control.turn===0); + await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[.5,0,0,0]}]); + await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0); + await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter); + await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2); + await page.evaluate(()=>focusChanged(false));assert.equal(await page.evaluate(()=>control.turn),0); + await page.evaluate(()=>focusChanged(true));await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0); + await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter); + await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2); + await page.evaluate(()=>testPads[0].id='Different controller'); + await page.waitForFunction(()=>control.turn===0); + assert.equal(await page.locator('#input-keyboard').isDisabled(),true); + await setArmed(false); + await page.locator('#input-keyboard').click(); + await setArmed(true); + await page.keyboard.down('w');await page.waitForFunction(()=>control.forward>.9);await page.keyboard.up('w'); + await page.waitForFunction(()=>control.forward===0); + await page.evaluate(()=>{Object.assign(testBridge.state,{connected:false,armed_known:false});render(testBridge.state)}); + assert.equal(await page.evaluate(()=>canDrive()),false); + + await setArmed(false); + const deckLayout=await page.evaluate(()=>{ + const battery=document.querySelector('.battery-card'),sidebar=document.querySelector('.sidebar-deck'),actions=document.querySelector('.activity-actions'),heading=document.querySelector('.activity-heading'); + return {last:battery===document.querySelector('.telemetry-zone').lastElementChild,title:heading.textContent, + actionHeight:actions.querySelector('button').getBoundingClientRect().height, + batteryHeight:battery.getBoundingClientRect().height,connectHeight:document.querySelector('#connect').getBoundingClientRect().height, + bottomGap:sidebar.getBoundingClientRect().bottom-battery.getBoundingClientRect().bottom, + overlay:getComputedStyle(document.querySelector('.battery-copy')).zIndex, + actionRightGap:actions.getBoundingClientRect().right-actions.lastElementChild.getBoundingClientRect().right, + sidebarPadding:getComputedStyle(sidebar).paddingTop, + workspacePadding:getComputedStyle(document.querySelector('.controls-workspace')).paddingTop}; + }); + assert.equal(deckLayout.last,true);assert.equal(deckLayout.title,'Activity:'); + assert.equal(deckLayout.actionHeight,32);assert.equal(deckLayout.batteryHeight,62);assert.equal(deckLayout.connectHeight,62); + assert(deckLayout.bottomGap>=10&&deckLayout.bottomGap<=24);assert.equal(deckLayout.overlay,'1'); + assert.equal(deckLayout.actionRightGap,0);assert.equal(deckLayout.sidebarPadding,deckLayout.workspacePadding); + await page.screenshot({path:path.join(output,'ui-cleanup.png')}); assert.deepEqual(errors,[]); - console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, and five dialogs at two window sizes.'); + console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, five dialogs at two window sizes; controller conditioning/read-only graphics, actual bridge values, mapping, focus/unplug/identity safety and dock filtering; mode locking during hold/armed/pending/unknown states, centered SVG close icons and bottom battery layout.'); }catch(error){ const page=browser.contexts()[0]?.pages()[0]; if(page){ diff --git a/web/app.js b/web/app.js index 9772c33..5e704b1 100644 --- a/web/app.js +++ b/web/app.js @@ -14,7 +14,7 @@ function recordActivity(message){ if(!message||activity[0]===message)return; activity.unshift(String(message));activity.length=Math.min(activity.length,3); ['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{ - const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]} + const row=$(selector);if(row){const changed=row.textContent!==(activity[i]||'');row.textContent=activity[i]||'';row.hidden=!activity[i];if(changed&&!row.hidden&&!matchMedia('(prefers-reduced-motion:reduce)').matches)row.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'})} }); } function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})} @@ -22,7 +22,127 @@ let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehi bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(), localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now(); const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750; + window.canDrive=canDrive; +let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false; +const controllerMotion=new ControllerInput.Motion(); +const controllerId=pad=>pad.index+':'+pad.id; +function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}} +function loadControllerConfig(pad){ + let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){} + const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})}; + return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults; +} +function selectController(pad){ + resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad); +} +function setInputMode(mode){ + if(mode===inputMode||inputConfigurationLocked())return false; + resetInputs();inputMode=mode;document.body.dataset.inputMode=mode; + $('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard')); + $('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick')); + $('#controller-settings').hidden=mode!=='joystick'; + $('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys'); + $('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control'); + return true; +} +function inputConfigurationLocked(){ + return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known)); +} +function syncInputModeControls(){ + const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey); + $('.input-mode-switch').dataset.locked=String(locked); + for(const button of [$('#input-keyboard'),$('#input-joystick')]){ + button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode'; + } + $('#controller-settings').disabled=locked||!pads.length; + $('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup'; + if($('#controller-modal').open){ + const form=$('#controller-form'); + for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad; + $('#controller-device').disabled=locked||!pads.length; + const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.'; + if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback); + } +} +window.canTouchControl=()=>inputMode==='keyboard'&&canDrive(); +$('#input-keyboard').onclick=()=>setInputMode('keyboard'); +$('#input-joystick').onclick=()=>setInputMode('joystick'); +function fillControllerForm(){ + const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form'); + device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad)))); + if(!pads.length)device.add(new Option('No controller detected','')); + device.value=controllerKey||''; + const pad=pads.find(pad=>controllerId(pad)===controllerKey); + const config=controllerConfig||ControllerInput.defaults(pad); + for(const name of ['turn','forward','vertical']){ + const field=form.elements[name];field.replaceChildren(new Option('Off','-1')); + for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis))); + field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad; + const invert='invert'+name[0].toUpperCase()+name.slice(1); + form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad; + } + form.elements.deadZone.value=Math.round(config.deadZone*100); + device.disabled=inputConfigurationLocked()||!pads.length; + form.elements.deadZone.disabled=inputConfigurationLocked()||!pad; + form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad; +} +$('#controller-device').onchange=e=>{ + if(inputConfigurationLocked())return; + const pad=getControllers().find(pad=>controllerId(pad)===e.target.value); + if(pad){selectController(pad);fillControllerForm()} +}; +$('#controller-form').onsubmit=e=>{ + e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey); + if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return} + if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return} + const config={deadZone:Number(form.elements.deadZone.value)/100}; + for(const name of ['turn','forward','vertical']){ + config[name]=Number(form.elements[name].value); + const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked; + } + if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){ + setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return; + } + controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){} + resetInputs();closeModal($('#controller-modal')); +}; +function pollController(now){ + try{ + if(inputMode==='joystick'){ + const pads=getControllers(); + if(controllerKey===null&&pads.length)selectController(pads[0]); + const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig); + let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id; + $('#controller-settings').disabled=inputConfigurationLocked()||!pads.length; + if(!target||!canDrive()){ + if(controllerWasDriving)resetInputs(); + controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false; + }else{ + if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id} + if(!controllerNeedCenter){ + const demand=controllerMotion.step(target,now);controllerWasDriving=true; + control.turn=demand.turn;control.forward=demand.forward; + window.joystick3D?.setVisual(demand.turn,-demand.forward); + setHeave(demand.vertical,false);updateReadouts();sendAxes(); + } + } + if($('#controller-status').textContent!==status)$('#controller-status').textContent=status; + $('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready')); + if($('#controller-modal').open){ + const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected'; + if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes; + if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm(); + } + }else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'} + }catch(error){ + if(controllerWasDriving)resetInputs(); + $('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard'; + } + requestAnimationFrame(pollController); +} +document.body.dataset.inputMode=inputMode; + function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}} function updateDemo(patch){Object.assign(demoState,patch);render(demoState)} document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))}); @@ -34,7 +154,7 @@ function populatePorts(ports){ if(!p)return; const old=p.value||''; p.innerHTML=''; - + const allList=[]; const seen=new Set(); @@ -84,20 +204,21 @@ function sendAxes(){ setInterval(()=>{if(canDrive())sendAxes()},250); function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);} -window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();}; +window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();}; function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)} const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0; function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();} function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()} -heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)}); +heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)}); function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)} window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})}; -function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} -addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); +function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} +addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); function resetInputs(){ + controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false; if(gainPointer!==null)releaseGain({pointerId:gainPointer}); clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false); window.joystick3D?.reset(); @@ -120,9 +241,11 @@ function setFeedback(el,message=''){ function openModal(id){ const el=typeof id==='string'?document.getElementById(id):id; if(!el||el.open)return; + if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return} resetInputs(); el._returnFocus=document.activeElement; if(el.id==='battery-modal')fillBatteryForm(); + if(el.id==='controller-modal')fillControllerForm(); if(el.id==='link-modal'){ fillLinkForm(); $('#custom-port-status').textContent=''; @@ -393,12 +516,14 @@ function startEngageHold(e){ if(view.action!=='arm'||!inputFocused||engageStart)return; if(e&&e.button!==undefined&&e.button!==0)return; engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%'); + syncInputModeControls(); if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId); engageHoldTimer=requestAnimationFrame(updateEngageProgress); } function cancelEngageHold(showHint=true){ if(!engageStart)return; const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled'); + syncInputModeControls(); } if(engageButton){ @@ -409,7 +534,7 @@ if(engageButton){ engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null}); engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.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'); @@ -417,33 +542,55 @@ if(engageButton){ } $('#logs').onclick=()=>api('open_logs'); -const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output'); -let consoleRefreshBusy=false,consolePaused=false; +const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output'); +let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0; +const consolePulseTimers={}; +function pulseConsole(side){ + const dot=$('.'+side+'-dot'); + clearTimeout(consolePulseTimers[side]);dot.classList.add('lit'); + consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260); +} +function renderConsole(){ + const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache); + if(signature===consoleSignature)return; + consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter); +} +function setConsoleOpen(open){ + consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open); + if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')} + else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180); + $('#console').setAttribute('aria-expanded',String(open)); + if(open)refreshConsole();else $('#console').focus({preventScroll:true}); +} +$('#console').setAttribute('aria-controls','console-dock'); +$('#console').setAttribute('aria-expanded','false'); +$('#console').onclick=()=>setConsoleOpen(!consoleOpen); +$('#console-close').onclick=()=>setConsoleOpen(false); +$('#console-filter').onchange=renderConsole; $('#console-pause').onclick=()=>{ consolePaused=!consolePaused; $('#console-pause').textContent=consolePaused?'Resume':'Pause'; $('#console-pause').setAttribute('aria-pressed',String(consolePaused)); - $('#console-state').textContent=consolePaused?'Paused · session logging continues':'Live · follows new messages'; + $('#console-state').textContent=consolePaused?'Paused':''; + $('#console-state').hidden=!consolePaused; if(!consolePaused)refreshConsole(); }; async function refreshConsole(){ - if(!consoleDialog?.open||!bridgeReady||consoleRefreshBusy||consolePaused)return; + if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return; consoleRefreshBusy=true; try{ - const rxAtBottom=consoleRx.scrollHeight-consoleRx.scrollTop-consoleRx.clientHeight<36; - const txAtBottom=consoleTx.scrollHeight-consoleTx.scrollTop-consoleTx.clientHeight<36; - const sides=await api('read_console',150); - if(consolePaused||!consoleDialog.open)return; - consoleRx.textContent=sides.rx?.length?sides.rx.join('\n'):'Waiting for vehicle data…'; - consoleTx.textContent=sides.tx?.length?sides.tx.join('\n'):'Waiting for transmitted data…'; - if(rxAtBottom)consoleRx.scrollTop=consoleRx.scrollHeight; - if(txAtBottom)consoleTx.scrollTop=consoleTx.scrollHeight; - }catch(error){ - $('#console-state').textContent=`Console unavailable: ${error.message}`; - } + const sides=await withTimeout(api('read_console',150),1500); + if(consolePaused||!consoleOpen)return; + const filter=$('#console-filter').value; + for(const side of ['rx','tx']){ + const previous=new Set(consoleCache[side]||[]); + if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side); + } + consoleCache=sides;renderConsole(); + $('#console-state').textContent='';$('#console-state').hidden=true; + }catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}} finally{consoleRefreshBusy=false} } -$('#console').onclick=()=>{openModal(consoleDialog);refreshConsole()}; setInterval(refreshConsole,750); 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); @@ -564,6 +711,7 @@ function render(s,confirmed=false){ (s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null; const previous=view; view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError}); + syncInputModeControls(); if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs(); appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected'; renderHeavyQuality(s.heavy_quality,s.connected,s.armed); @@ -604,7 +752,7 @@ function render(s,confirmed=false){ 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)} @@ -617,11 +765,13 @@ function render(s,confirmed=false){ $('#battery-title').textContent=`${b.voltage.toFixed(2)} V`; $('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`; $('#battery-fill').style.width=`${b.percent}%`; - $('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'; + $('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)'; + $('.battery-card').dataset.low=String(!!b.low); }else{ $('#battery-title').textContent='—'; $('#battery-percent').textContent='waiting'; $('#battery-fill').style.width='0'; + $('.battery-card').dataset.low='false'; } const quality=s.link_quality,qualityCard=$('#link-quality'); if(qualityCard){ @@ -800,3 +950,6 @@ if(document.readyState==='loading'){ loadDefaultModel(); waitForBridge(); } + + +requestAnimationFrame(pollController); diff --git a/web/console-view.js b/web/console-view.js new file mode 100644 index 0000000..2bd12c0 --- /dev/null +++ b/web/console-view.js @@ -0,0 +1,70 @@ +/* Read-only presentation of the existing local RX/TX logs. */ +((root)=>{ + const quiet=/^(HEARTBEAT|TIMESYNC|LINK_PROBE|HEAVY_PROBE|GCS)/; + function parse(line){ + const match=String(line).match(/^(\S+) \[([^\]]+)\] ([\s\S]*)$/); + if(!match)return {time:'',type:'Message',payload:String(line),raw:String(line),tone:'normal',quiet:false}; + const [,timestamp,category,message]=match; + let payload=message,type=category.replace(/^(RX|TX)_?/,'')||category; + try{payload=JSON.parse(message);type=payload.mavpackettype||type}catch(_){} + const tone=/BAD_DATA|ERROR|TIMEOUT|FAIL/.test(category)||type==='STATUSTEXT'&&Number(payload.severity)<=4?'warning':/COMMAND|ACK|PARAM_SET|OPERATOR/.test(category+' '+type)?'command':'normal'; + const time=timestamp.match(/T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)?.[1]||timestamp; + const pairs=typeof payload==='string'?[...payload.matchAll(/(?:^|\s)([a-zA-Z_]\w*)=([^\s]+)/g)]:[]; + const fields=pairs.length?Object.fromEntries(pairs.map(([,key,value])=>[key,value])):payload; + return {time,type,payload,fields,raw:String(line),tone,quiet:tone!=='warning'&&(quiet.test(type)||/LINK_PROBE|HEAVY_PROBE|HEARTBEAT/.test(category))}; + } + function valueText(value){ + if(typeof value==='number')return Number.isInteger(value)?String(value):Number(value.toFixed(3)).toString(); + if(Array.isArray(value))return value.slice(0,6).map(valueText).join(', ')+(value.length>6?'…':''); + return typeof value==='object'?JSON.stringify(value):String(value); + } + function makeRow(row,previous){ + if(previous&&previous.dataset.key===row.raw&&previous.dataset.tone===row.tone)return previous; + const details=previous||document.createElement('details'); + details.className='console-row';details.dataset.key=row.raw;details.dataset.tone=row.tone; + let summary=details.querySelector('summary'),time,body,type,fields,raw; + if(!summary){ + details.replaceChildren();summary=document.createElement('summary');time=document.createElement('time'); + body=document.createElement('span');body.className='console-message';type=document.createElement('strong'); + fields=document.createElement('span');fields.className='console-fields';body.append(type,fields);summary.append(time,body); + raw=document.createElement('pre');details.append(summary,raw); + }else{ + time=summary.querySelector('time');body=summary.querySelector('.console-message'); + type=body.querySelector('strong');fields=body.querySelector('.console-fields');raw=details.querySelector('pre'); + } + time.textContent=row.time;type.textContent=row.type;fields.replaceChildren(); + const values=row.fields??row.payload; + if(typeof values==='object'&&values!==null){ + for(const [key,value] of Object.entries(values).filter(([key])=>key!=='mavpackettype').slice(0,6)){ + const item=document.createElement('span'),label=document.createElement('i'),text=document.createElement('b'); + label.textContent=key+' ';text.textContent=valueText(value);item.append(label,text);fields.append(item); + } + }else fields.textContent=row.payload; + raw.textContent=typeof row.payload==='object'?JSON.stringify(row.payload,null,2):row.raw; + return details; + } + function render(host,lines,filter){ + const atBottom=host.scrollHeight-host.scrollTop-host.clientHeight<36,scroll=host.scrollTop; + const rows=lines.map(parse).filter(row=>filter==='all'||!row.quiet); + const existing=new Map(); + for(const node of host.querySelectorAll('details.console-row')){ + const key=node.dataset.key,list=existing.get(key)||[];list.push(node);existing.set(key,list); + } + if(!rows.length){ + if(!host.querySelector('.console-empty')){ + const empty=document.createElement('p');empty.className='console-empty'; + empty.textContent='No messages'; + empty.title=filter==='all'?'Waiting for session messages.':'Routine heartbeat and probe traffic is hidden. Choose All traffic to see it.'; + host.replaceChildren(empty); + } + return; + } + const wanted=rows.map(row=>makeRow(row,existing.get(row.raw)?.shift()||null)); + let cursor=host.firstElementChild; + for(const node of wanted){if(node!==cursor)host.insertBefore(node,cursor);cursor=node.nextElementSibling} + while(cursor){const next=cursor.nextElementSibling;cursor.remove();cursor=next} + if(atBottom&&!host.querySelector('details[open]'))host.scrollTop=host.scrollHeight;else host.scrollTop=scroll; + } + root.ConsoleView={parse,render}; + if(typeof module!=='undefined')module.exports=root.ConsoleView; +})(typeof window!=='undefined'?window:globalThis); diff --git a/web/controller-input.js b/web/controller-input.js new file mode 100644 index 0000000..4948712 --- /dev/null +++ b/web/controller-input.js @@ -0,0 +1,53 @@ +/* Controller conditioning is independent of drawing and MAVLink transport. */ +((root)=>{ + const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0)); + function deadZone(value,zone=.08){ + value=clamp(value);zone=Math.max(0,Math.min(.4,zone)); + return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone); + } + function defaults(pad){ + return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1, + invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08}; + } + function read(pad,config){ + if(!pad?.connected||!pad.axes)return null; + const axis=(index,invert)=>{ + if(index===-1)return 0; + if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null; + return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1); + }; + let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical); + if(turn===null||forward===null||vertical===null)return null; + const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length} + return {turn,forward,vertical}; + } + class Smoother{ + constructor(){this.reset()} + reset(){this.value=0;this.last=null;this.release=null} + step(target,now){ + target=clamp(target); + const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now; + if(target===0){ + if(!this.release)this.release={at:now,start:this.value}; + const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t); + this.value=this.release.start*(1-ease); + }else{ + this.release=null; + this.value+=(target-this.value)*(1-Math.exp(-dt/.1)); + if(Math.abs(target-this.value)<.001)this.value=target; + } + return this.value; + } + } + class Motion{ + constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}} + reset(){Object.values(this.axes).forEach(axis=>axis.reset())} + step(target,now){ + const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)])); + const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length} + return demand; + } + } + root.ControllerInput={deadZone,defaults,read,Motion}; + if(typeof module!=='undefined')module.exports=root.ControllerInput; +})(typeof window!=='undefined'?window:globalThis); diff --git a/web/index.html b/web/index.html index 77b6747..1c0f964 100644 --- a/web/index.html +++ b/web/index.html @@ -4,11 +4,11 @@ Sub Control - +
- + @@ -72,6 +71,7 @@
+
@@ -80,6 +80,7 @@
+

Keyboard + touch

@@ -98,12 +99,12 @@
-
steer + throttle
- w a s d +
+
- +
@@ -115,13 +116,13 @@
-
vertical
- r / f +
+
rise -
+
@@ -147,21 +148,27 @@
+
- Connection - - Connection

Link quality

-

The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.

-
Round trip—
Probe loss—
FC → PC loss—
-

Extended test

Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.

+

Link quality

+ +
Round trip—
Probe loss—
FC → PC loss—
+

Extended test

Connect to run an extended test.
@@ -187,8 +194,8 @@
- Power

Battery settings

Charge is estimated from voltage. Capacity and C rating describe your battery.

-
+

Battery settings

+

Voltage per cell

@@ -196,20 +203,32 @@
- Configuration

Motor outputs

Waiting for motor assignments.

+

Motor outputs

Waiting for motor assignments.

OutputAssignmentDirection
- - Local session

Live console

-

Live · follows new messages

-
RXVehicle → PC
Waiting for vehicle data…
TXPC → Vehicle
Waiting for transmitted data…
+ +

Joystick setup

+
+ +

Move a stick or press a button to detect your controller.

+
MovementAxisInvert
+
Turn
+
Forward / back
+
Rise / dive
+ + + + +
- + - + + + diff --git a/web/joystick-3d.js b/web/joystick-3d.js index 703df4c..2a4f176 100644 --- a/web/joystick-3d.js +++ b/web/joystick-3d.js @@ -23,7 +23,9 @@ if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()} ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3; - ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore(); + const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819); + handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939'); + ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;ctx.fill();ctx.restore(); ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke(); ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill(); } @@ -40,10 +42,10 @@ function returnToCenter(){easeTo(0,0)} function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()} - canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()}); + canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canTouchControl?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()}); canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)}); canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)}); canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)}); - addEventListener('resize',resize);resize(); - window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}}; + addEventListener('resize',resize);new ResizeObserver(resize).observe(host);resize(); + window.joystick3D={setVisual(nextX,nextY){setPosition(nextX,nextY,false)},setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}}; })(); diff --git a/web/styles.css b/web/styles.css index a8f6c34..0fe96a9 100644 --- a/web/styles.css +++ b/web/styles.css @@ -756,7 +756,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; } background: #4e7140; border-color: #78ad5a; } -.danger { +.danger { --engage-progress: 0%; background: #ad3f38; color: #ffffff; @@ -947,7 +947,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; } border-radius: 24px; background: rgba(247, 248, 245, 0.96); color: #1a1e1f; - box-shadow: + box-shadow: 0 30px 80px -12px rgba(0, 0, 0, 0.42), 0 0 0 1px rgba(0, 0, 0, 0.06), 0 10px 24px -6px rgba(0, 0, 0, 0.16); @@ -1298,3 +1298,176 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; } @media (prefers-reduced-motion:reduce) { .modal[open],.modal.closing { animation:none; } } + +/* Input selection stays separate from vehicle arming and command ownership. */ +#engage:focus,#engage:focus-visible { outline:none!important; box-shadow:none!important; } +.controls-workspace { container-type:inline-size; } +.input-mode-controls { display:flex; align-items:center; gap:6px; flex-shrink:0; -webkit-app-region:no-drag; } +.input-mode-switch { display:flex; padding:3px; gap:2px; background:#dedede; border-radius:999px; } +.input-mode-switch button,.controller-settings { border:0; border-radius:999px; background:transparent; color:#686868; padding:7px 11px; font-size:12px; cursor:pointer; } +.input-mode-switch button[aria-pressed="true"] { background:#fafafa; color:#252525; box-shadow:0 1px 3px #0000000c; } +.controller-settings { background:#dedede; } +.controller-status { margin:0; height:24px; flex-shrink:0; font-size:12px; line-height:20px; color:#6d7478; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +body[data-input-mode="joystick"] .key-hint { visibility:hidden; } +body[data-input-mode="joystick"] :is(#joystick-canvas,#heave) { pointer-events:none; cursor:default; } +.controls-grid { --joy-size:min(52vh,calc(100cqw - 136px),460px); } +.control-toolbar { flex-wrap:wrap; gap:8px 14px; } +.primary-control-zone { grid-template-rows:120px minmax(0,1fr) auto; } +.control-toolbar { min-height:52px; } +.engage-face > span { font-size:clamp(52px,11cqw,90px); } +.controller-mapping-head,.controller-mapping-row { display:grid; grid-template-columns:1fr 1.25fr 50px; gap:14px; align-items:center; } +.controller-mapping-head { color:#777; font-size:12px; margin-bottom:8px; } +.controller-mapping-row { margin:8px 0; } +.modal .controller-mapping-row input[type="checkbox"] { width:22px; height:22px; min-height:0; margin:auto; padding:0; accent-color:#527f9b; } +.controller-mapping-row label { display:flex; align-items:center; } +#controller-axes { min-height:22px; margin:12px 0; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; color:#65737d; font-size:12px; } +#controller-form > label { margin-top:18px; } +.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } + +/* Read-only local console, not a modal: driving stays available while it is open. */ +.app-shell.console-open { grid-template-columns:260px minmax(0,1fr) 340px; } +.console-dock[hidden] { display:none!important; } +.console-dock { display:flex; flex-direction:column; min-width:0; min-height:0; padding:18px 16px; background:#f5f5f5; border-left:1px solid #d8dcde; color:#29343b; color-scheme:light; } +.console-dock-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:14px; } +.console-dock-header small { font-size:11px; color:#81888d; } +.console-dock-header h2 { font-size:22px; font-weight:400; margin:4px 0 0; letter-spacing:-.02em; } +.console-close { width:38px; height:38px; flex-shrink:0; border:0; border-radius:999px; background:#e7e8e9; color:#48525a; font-size:25px; cursor:pointer; } +.console-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; } +.console-toolbar label { flex:1; min-width:0; } +.console-toolbar select,.console-toolbar button { height:34px; border:0; border-radius:999px; background:#e7e9ea; color:#42525e; font-size:12px; padding:0 12px; } +.console-toolbar select { width:100%; } +.console-toolbar button { cursor:pointer; } +.console-state { min-height:28px; margin:10px 0 8px; font-size:11px; color:#7e868b; line-height:1.4; } +.console-streams { display:grid; grid-template-rows:1fr 1fr; gap:14px; min-height:0; flex:1; } +.console-side { display:flex; flex-direction:column; min-height:0; } +.console-side > header { display:flex; align-items:center; gap:6px; padding:0 4px 8px; font-size:12px; font-weight:500; color:#435966; } +.console-side > header small { margin-left:auto; font-size:11px; font-weight:300; color:#899198; } +.console-side > header i { width:6px; height:6px; border-radius:50%; background:#5888a3; } +.console-side > header .tx-dot { background:#659287; } +.console-output { min-height:0; flex:1; overflow:auto; overscroll-behavior:contain; padding:0 8px; border:1px solid #e0e3e5; border-radius:12px; background:#fafbfc; color:#343d43; font:12px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif; } +.console-row { border-bottom:1px solid #e9edef; } +.console-row:last-child { border-bottom:0; } +.console-row summary { display:flex; gap:8px; cursor:pointer; padding:10px 0; list-style:none; } +.console-row summary::-webkit-details-marker { display:none; } +.console-row time { flex-shrink:0; color:#929ba1; font:10px/1.8 ui-monospace,Consolas,monospace; font-variant-numeric:tabular-nums; } +.console-message { min-width:0; display:block; overflow-wrap:anywhere; } +.console-message strong { display:block; font-size:11px; font-weight:500; color:#517c97; letter-spacing:.025em; } +.console-row[data-tone="command"] strong { color:#467a69; } +.console-row[data-tone="warning"] strong { color:#aa664b; } +.console-fields { display:flex; flex-wrap:wrap; gap:2px 8px; font-size:11px; } +.console-fields i { color:#929a9f; font-style:normal; } +.console-fields b { font-weight:400; color:#45535d; } +.console-row pre { margin:0 0 10px; padding:10px; border-radius:8px; background:#eef1f3; color:#52616c; font:11px/1.5 ui-monospace,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; } +.console-empty { color:#959da3; font-size:12px; line-height:1.6; margin:14px 0; } +@media (max-height:610px) { .primary-control-zone { grid-template-rows:100px minmax(0,1fr) auto; } .controls-grid { --joy-size:min(46vh,calc(100cqw - 136px),420px); } } +@media (max-width:1000px) { + .app-shell.console-open { grid-template-columns:260px minmax(0,1fr); } + .console-dock { position:absolute; top:0; right:0; bottom:0; width:min(340px,calc(100vw - 32px)); z-index:12; box-shadow:-8px 0 24px #00000012; border-radius:12px 0 0 12px; } +} + +/* Three shared sizes: compact 32px, standard 40px, primary 62px. */ +:root { + --pill-sm:32px; --pill-md:40px; --pill-lg:62px; --pill-radius:999px; + --battery-charge:#63cb85; --battery-low:#e98a7b; +} +.port-row select,.icon-button { height:var(--pill-sm); } +.icon-button { width:var(--pill-sm); } +body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); } +.input-mode-switch { height:var(--pill-md); padding:4px; } +.input-mode-switch button { height:var(--pill-sm); padding:0 12px; } +.controller-settings { height:var(--pill-md); padding:0 14px; } +.input-mode-switch button:disabled { opacity:.65!important; } +.input-mode-switch button[aria-pressed="true"]:disabled { opacity:1!important; } +.control-toolbar .range-pill,.control-toolbar .config-btn { height:var(--pill-md); border-radius:var(--pill-radius); } +.control-toolbar .range-pill button { height:var(--pill-md); } +.window-actions button { width:var(--pill-md); height:var(--pill-md); } +.console-toolbar select,.console-toolbar button { height:var(--pill-sm); border-radius:var(--pill-radius); } +.modal input,.modal select,.modal .number-pill { height:var(--pill-md); border-radius:var(--pill-radius); } +.modal .number-pill input,.modal .number-pill button { height:calc(var(--pill-md) - 2px); } +.modal .button { min-height:var(--pill-md); padding:8px 18px; border-radius:var(--pill-radius); } +.modal .motor-row .button { min-height:var(--pill-sm); padding:6px 12px; } +.modal .controller-mapping-row input[type="checkbox"] { height:22px; border-radius:4px; } + +/* Fixed SVG geometry avoids font baselines shifting the close icon. */ +.modal-close,.modal-close:hover,.console-close,.console-close:hover,.window-actions .window-close,.window-actions .window-close:hover { + display:grid; place-items:center; width:var(--pill-md); height:var(--pill-md); + padding:0; border:0; border-radius:0; background:transparent; box-shadow:none; +} +.modal-close svg,.console-close svg { display:block; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; } +.modal-close:hover,.console-close:hover { color:#222; } +.heave-knob { height:var(--pill-md); background:linear-gradient(145deg,#737373,#454545); } + +/* Activity has a heading, compact event history, then explicit actions. */ +.telemetry-zone { gap:16px; } +.deck-status-bar { flex-shrink:0; margin-top:0; padding-top:12px; } +.activity-heading { display:block; margin:0 0 8px; color:#eee; font-size:14px; font-weight:650; } +.activity-history { gap:4px; margin:0 0 10px; } +.activity-actions { display:flex; gap:8px; } +.activity-actions .logs-link { + display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box; + height:var(--pill-sm); min-width:76px; padding:0 14px; margin:0; + border:0; border-radius:var(--pill-radius); background:#414141; color:#eee; + font-size:12px; font-weight:400; letter-spacing:0; line-height:1; cursor:pointer; +} +.activity-actions .logs-link:hover { background:#4b4b4b; } +.activity-actions .logs-link:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; } +.activity-actions .logs-link[aria-expanded="true"] { background:#555; } + +/* Battery is the final deck item: text floats above the charge fill. */ +.battery-card { + position:relative; display:block; flex-shrink:0; width:100%; height:var(--pill-lg); + min-height:var(--pill-lg); margin:0; padding:0; overflow:hidden; + border:1px solid #565956; border-radius:var(--pill-radius); background:#383c39; + color:#f2f5f2; isolation:isolate; +} +.battery-meter { position:absolute; inset:0; z-index:0; overflow:hidden; border-radius:inherit; pointer-events:none; } +.battery-meter #battery-fill { display:block; height:100%; width:0; border-radius:inherit; background:var(--battery-charge); opacity:.25; transition:width .35s ease,background .2s ease; } +.battery-card .battery-copy { position:absolute; inset:0; z-index:1; display:flex; flex-direction:row; align-items:center; justify-content:space-between; gap:8px; padding:0 18px; pointer-events:none; } +.battery-summary { display:flex; flex-direction:column; gap:3px; flex-shrink:0; } +.battery-card small { color:#c3cec5; font-size:11px; line-height:1; } +.battery-card strong { font-size:20px; font-weight:400; line-height:1.15; font-variant-numeric:tabular-nums; } +.battery-card i { align-self:auto; color:#d9e0da; text-align:right; font-size:12px; line-height:1.35; font-style:normal; } +.battery-card[data-low="true"] { border-color:#a87468; } +.battery-card:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; } + +/* The same slender, rounded scrollbar is used by every scrollable panel. */ +*::-webkit-scrollbar { width:8px; height:8px; } +*::-webkit-scrollbar-track,*::-webkit-scrollbar-corner { background:transparent; } +*::-webkit-scrollbar-thumb { min-height:28px; border:2px solid transparent; border-radius:var(--pill-radius); background:#aeb6ba; background-clip:padding-box; } +*::-webkit-scrollbar-thumb:hover { background:#87959e; background-clip:padding-box; } +*::-webkit-scrollbar-button { display:none; width:0; height:0; } +.sidebar-deck::-webkit-scrollbar-thumb { background:#666; background-clip:padding-box; } +.modal { scrollbar-gutter:stable; } +@supports not selector(::-webkit-scrollbar) { * { scrollbar-width:thin; scrollbar-color:#aeb6ba transparent; } } +@media (max-height:610px) { .telemetry-zone { gap:12px; } } + +/* Shared panel geometry and quiet, presentational motion. Never ease command data. */ +:root { --panel-inset:20px; --ui-gap:12px; --ui-duration:180ms; --ui-ease:cubic-bezier(.2,0,.2,1); } +.sidebar-deck,.controls-workspace,.console-dock { padding:var(--panel-inset); } +.sidebar-deck,.telemetry-zone,.deck-connection,.port-row,.activity-actions,.input-mode-controls,.console-toolbar,.console-streams { gap:var(--ui-gap); } +.deck-connection { padding-bottom:var(--ui-gap); } +.workspace-topbar,.console-dock-header { min-height:var(--pill-md); height:var(--pill-md); margin-bottom:var(--ui-gap); } +.port-row select,.port-row .icon-button { height:var(--pill-md); } +.port-row .icon-button { width:var(--pill-md); } +.activity-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); } +.activity-actions .logs-link { min-width:0; width:100%; } +.console-dock-header h2 { margin:0; } +.console-toolbar { margin-bottom:var(--ui-gap); } +.console-state { min-height:0; margin:0 0 var(--ui-gap); font-size:12px; } +.console-side > header { padding-left:0; padding-right:0; } +.console-side > header i { background:#b4bdc2; transition:background-color var(--ui-duration) ease-out,box-shadow var(--ui-duration) ease-out; } +.console-side > header .rx-dot.lit { background:#4fa4cf; box-shadow:0 0 5px #4fa4cf66; } +.console-side > header .tx-dot.lit { background:#56ae8a; box-shadow:0 0 5px #56ae8a66; } +:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::before, +:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::after, +:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card) { + transition-property:background-color,border-color,color,opacity,box-shadow,filter; + transition-duration:var(--ui-duration); transition-timing-function:var(--ui-ease); +} +/* Keep pointer positions immediate; the existing conditioning owns motor easing. */ +.modal[open],.console-dock:not([hidden]),.form-feedback:not([hidden]),.console-state:not([hidden]),.console-row pre { animation:ui-fade-in var(--ui-duration) var(--ui-ease); } +.modal.closing,.console-dock.closing { animation:ui-fade-out var(--ui-duration) ease-out forwards; pointer-events:none; } +@keyframes ui-fade-in { from { opacity:0; } to { opacity:1; } } +@keyframes ui-fade-out { from { opacity:1; } to { opacity:0; } } +.controller-status { transition:opacity var(--ui-duration) ease-out; } +.controller-status[data-quiet="true"] { opacity:0; pointer-events:none; }