diff --git a/.gitignore b/.gitignore index 93d7cf0..4696deb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ logs/ model_settings.json link_settings.json runtime/ +motor_settings.json diff --git a/README.md b/README.md index 20091f1..b4998c0 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ Select a COM port and **Connect**, or choose **Demo Mode** to try it without har Connection settings are under **•••**. Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details. +## UI design rule + +Keep UI changes minimal and consistent with the existing app. Do not add explanatory text. Use labels of at most two words, prefer flat rows over nested containers, and keep controls from overlapping. + ## Controls - Joystick: forward/back and turning. Vertical slider: rise/dive. diff --git a/SUB Control/README.md b/SUB Control/README.md index 20091f1..b4998c0 100644 --- a/SUB Control/README.md +++ b/SUB Control/README.md @@ -18,6 +18,10 @@ Select a COM port and **Connect**, or choose **Demo Mode** to try it without har Connection settings are under **•••**. Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details. +## UI design rule + +Keep UI changes minimal and consistent with the existing app. Do not add explanatory text. Use labels of at most two words, prefer flat rows over nested containers, and keep controls from overlapping. + ## Controls - Joystick: forward/back and turning. Vertical slider: rise/dive. diff --git a/SUB Control/app.py b/SUB Control/app.py index b231de0..7333049 100644 --- a/SUB Control/app.py +++ b/SUB Control/app.py @@ -87,6 +87,8 @@ class MavlinkWorker: # At 20 Hz this produces five small steps from neutral to full command. self.ramp_seconds = 0.25 self.gain = 0.20 + self.independent_motor_scaling = False + self.motor_gains = {motor: 20 for motor in range(1, 13)} self.rate_hz = 20 self.target_system = 0 self.target_component = 0 @@ -110,7 +112,7 @@ class MavlinkWorker: self.heavy_pending_pings = {} self.heavy_quality_latest = None self.telemetry_rates_hz = { - "SYS_STATUS": 1, "SERVO_OUTPUT_RAW": 2, + "SYS_STATUS": 1, "BATTERY_STATUS": 1, "SERVO_OUTPUT_RAW": 2, "RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1, } self.ping_sequence = 0 @@ -128,6 +130,11 @@ class MavlinkWorker: self.param_retries = 0 self.param_types = {} self.pending_sets = {} + self.motor_test_lock = threading.Lock() + self.motor_test_request = None + self.motor_test_mode = False + self.motor_test_acknowledged = False + self.motor_test_last_motor = None self.log_lock = threading.Lock() self.log_file = None self.log_path = None @@ -200,8 +207,10 @@ class MavlinkWorker: # ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a # 20 Hz neutral stream alive here only competes with ARM and # its acknowledgement on constrained links. + motor_test_mode = self._tick_motor_test(now) if self.target_system and self.armed and now >= next_control: - self._send_control() + if not motor_test_mode: + self._send_control() # The first disarm packet is preceded by a neutral command. # Until disarm is confirmed, keep sending neutral at a # lower rate instead of consuming 20 Hz on the weak link. @@ -245,6 +254,10 @@ class MavlinkWorker: self.next_arm_retry_at = 0.0 self.arm_retry_deadline = 0.0 self.arm_confirmation = 0 + with self.motor_test_lock: + self.motor_test_request = None + self.motor_test_mode = False + self.motor_test_last_motor = None self.stream_config.clear() self.pending_pings.clear() self.ping_results.clear() @@ -337,6 +350,13 @@ class MavlinkWorker: armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED) self.armed = armed self.config_paused = armed + if not armed: + with self.motor_test_lock: + was_testing = self.motor_test_mode + self.motor_test_request = None + self.motor_test_mode = False + if was_testing: + self.events.put(("motor_test_stopped", "Vehicle disarmed")) if self.pending_arm_state is not None and armed == self.pending_arm_state: self.pending_arm_state = None if not armed: @@ -361,6 +381,18 @@ class MavlinkWorker: if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL: self.log("RX_STREAM_CONFIG_ACK", result_name) return + if int(msg.command) == mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST: + self.log("RX_MOTOR_TEST_ACK", result_name) + if result_name in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"): + if not self.motor_test_acknowledged: + self.motor_test_acknowledged = True + self.events.put(("motor_test_ack", result_name)) + else: + with self.motor_test_lock: + self.motor_test_request = None + self.motor_test_mode = False + self.events.put(("motor_test_rejected", result_name)) + return if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")): self.pending_arm_state = None @@ -375,8 +407,33 @@ class MavlinkWorker: self.events.put(("statustext", text)) elif msg_type == "SYS_STATUS": voltage_mv = int(msg.voltage_battery) - if 0 < voltage_mv < 65535: - self.events.put(("battery", voltage_mv / 1000.0)) + current_ca = int(msg.current_battery) + remaining = int(msg.battery_remaining) + self.events.put(("battery", { + "voltage": voltage_mv / 1000.0 if 0 < voltage_mv < 65535 else None, + "current_a": current_ca / 100.0 if current_ca >= 0 else None, + "remaining_percent": remaining if 0 <= remaining <= 100 else None, + })) + elif msg_type == "BATTERY_STATUS": + voltages = [int(value) for value in getattr(msg, "voltages", [])] + valid_voltages = [value for value in voltages if 0 < value < 65535] + voltage = sum(valid_voltages) / 1000.0 if valid_voltages else None + current_ca = int(msg.current_battery) + consumed = int(msg.current_consumed) + remaining = int(msg.battery_remaining) + self.events.put(("battery", { + "voltage": voltage, + "current_a": current_ca / 100.0 if current_ca >= 0 else None, + "consumed_mah": consumed if consumed >= 0 else None, + "remaining_percent": remaining if 0 <= remaining <= 100 else None, + })) + elif msg_type == "SERVO_OUTPUT_RAW": + base = int(getattr(msg, "port", 0)) * 8 + 1 + outputs = {} + for index in range(1, 9): + raw = int(getattr(msg, f"servo{index}_raw", 0)) + outputs[str(base + index - 1)] = raw if 0 < raw < 65535 else None + self.events.put(("servo_output", outputs)) elif msg_type == "PARAM_VALUE": param_id = msg.param_id if isinstance(param_id, bytes): @@ -479,6 +536,67 @@ class MavlinkWorker: "probe_supported": total > 0, })) + def _send_motor_test(self, motor, throttle_percent): + if not self.connection or not self.target_system: + raise RuntimeError("No autopilot heartbeat") + self.log("TX_MOTOR_TEST", f"motor={motor} throttle={throttle_percent}%") + self._send_mav(self.connection, "command_int_send", + self.target_system, self.target_component, + mavutil.mavlink.MAV_FRAME_GLOBAL, + mavutil.mavlink.MAV_CMD_DO_MOTOR_TEST, 0, 0, + float(motor), 0.0, float(throttle_percent), 1.0, 1, 0, 0, + ) + + def start_motor_test(self, motor, throttle_percent): + motor, throttle_percent = int(motor), int(throttle_percent) + if not 1 <= motor <= 12 or not 0 <= throttle_percent <= 100: + raise ValueError("Choose a motor and a test throttle from 0% to 100%") + if not self.connection or not self.target_system: + raise RuntimeError("Connect to the vehicle before testing a motor") + if not self.armed or time.monotonic() - self.last_heartbeat > 2.0: + raise RuntimeError("Arm the vehicle and confirm fresh telemetry before motor testing") + with self.motor_test_lock: + if self.motor_test_mode: + raise RuntimeError("Finish the current motor test before starting another") + self.motor_test_request = {"motor": motor, "throttle": throttle_percent, "next_at": 0.0} + self.motor_test_last_motor = motor + self.motor_test_mode = True + self.motor_test_acknowledged = False + self.set_axes(Axes()) + self.log("OPERATOR", f"held motor test for motor {motor} at {throttle_percent}%") + return True + + def stop_motor_test(self): + with self.motor_test_lock: + request = self.motor_test_request + if request is not None: + self.motor_test_last_motor = request["motor"] + motor = self.motor_test_last_motor + was_testing = self.motor_test_mode + self.motor_test_request = None + if was_testing and motor and self.connection and self.target_system: + self._send_motor_test(motor, 0) + return True + + def _tick_motor_test(self, now): + motor = throttle = None + with self.motor_test_lock: + request = self.motor_test_request + active = self.motor_test_mode + if request is not None and not self.armed: + self.motor_test_request = None + self.motor_test_mode = False + active = False + self.events.put(("motor_test_stopped", "Vehicle disarmed")) + elif request is not None and now >= request["next_at"]: + request["next_at"] = now + 0.25 + motor, throttle = request["motor"], request["throttle"] + else: + motor = throttle = None + if motor is not None: + self._send_motor_test(motor, throttle) + return active + def _tick_link_probe(self, now): if self.heavy_quality_test is not None: self._tick_heavy_quality(now) @@ -753,6 +871,8 @@ class MavlinkWorker: active = not (force_neutral or expired) target = self.axes if active else Axes() gain = self.gain + independent_motor_scaling = self.independent_motor_scaling + motor_gains = dict(self.motor_gains) if force_neutral: self.output_axes = Axes() else: @@ -775,10 +895,25 @@ class MavlinkWorker: ) limited_forward = axes.forward / horizontal_mix_peak limited_yaw = axes.yaw / horizontal_mix_peak - x = round(limited_forward * gain * 1000) - y = round(axes.lateral * gain * 1000) - r = round(limited_yaw * gain * 1000) - z = round(NEUTRAL_Z + axes.vertical * gain * 500) + if independent_motor_scaling: + # The desktop controller's SimpleROV-3 profile maps motors 1/2 + # to surge/turn and motor 3 to vertical thrust. Convert the two + # independently scaled horizontal demands back into MANUAL_CONTROL + # axes so ArduSub's configured mixer remains in charge. + motor_1 = (limited_forward + limited_yaw) * motor_gains.get(1, 100) / 100.0 + motor_2 = (limited_forward - limited_yaw) * motor_gains.get(2, 100) / 100.0 + limited_forward = (motor_1 + motor_2) / 2.0 + limited_yaw = (motor_1 - motor_2) / 2.0 + vertical_gain = motor_gains.get(3, 100) / 100.0 + x = round(limited_forward * 1000) + y = round(axes.lateral * 1000) + r = round(limited_yaw * 1000) + z = round(NEUTRAL_Z + axes.vertical * vertical_gain * 500) + else: + x = round(limited_forward * gain * 1000) + y = round(axes.lateral * gain * 1000) + r = round(limited_yaw * gain * 1000) + z = round(NEUTRAL_Z + axes.vertical * gain * 500) self.log( "TX_MANUAL_CONTROL", f"x={x} y={y} z={z} r={r} active={active} " @@ -851,6 +986,17 @@ class MavlinkWorker: manual, ) + def set_motor_scaling(self, independent, gains): + clean = {motor: max(0, min(100, int(gains.get(motor, 100)))) for motor in range(1, 13)} + with self.lock: + self.independent_motor_scaling = bool(independent) + self.motor_gains = clean + + def motor_output_scale(self, motor): + with self.lock: + return (self.motor_gains.get(int(motor), 100) / 100.0 + if self.independent_motor_scaling else self.gain) + def set_axes(self, axes: Axes, sequence=None): def conditioned(value): # Ignore tiny pointer/key return noise, then quantize to stable 1% steps. diff --git a/SUB Control/web/app.js b/SUB Control/web/app.js index a47c9bb..ff5feaf 100644 --- a/SUB Control/web/app.js +++ b/SUB Control/web/app.js @@ -6,7 +6,8 @@ const STATUS_LABELS = { link_degraded: 'Signal lost — check tether', }; const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; -const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null; +const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,battery_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null; +let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve(); const api=(name,...args)=>window.pywebview.api[name](...args); const activity=[]; let lastReportedStatus=''; @@ -448,31 +449,50 @@ if(customPortBtn&&customPortInput){ } const engageButton=$('#engage'); const engageProgressLabel=engageButton?.querySelector('.engage-progress-label'); -let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null; -const ENGAGE_HOLD_MS=1400; +let engageLabelValue=$('#engage-label')?.textContent||'engage'; +let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null,engageAppendFrame=null; +const ENGAGE_HOLD_MS=600; function applyEngageVisual(next){ if(!engageButton)return; - const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase(); - // Interaction updates immediately; the short fade never gates emergency access. + const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging','DISARMING…':'disengaging','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase(); + // Update the state label without delaying the requested control action. engageButton.disabled=!next.action; engageButton.setAttribute('aria-busy',String(next.pending)); engageButton.setAttribute('aria-label',label); - engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message; + engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 0.6 seconds to engage (F2)':next.message; const key=`${next.label}/${next.tone}`; if(engageVisualState===key)return; const previous=engageVisualState;engageVisualState=key; + if(engageAppendFrame)cancelAnimationFrame(engageAppendFrame); clearTimeout(engageTransitionTimer); engageButton.classList.remove('arm','danger','subtle','charged','pending'); engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm'); engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%'); - $('#engage-label').textContent=label; + const mainLabel=$('#engage-label'),previousLabel=engageLabelValue; + const appended=previousLabel==='engage'&&label==='engaging'||previousLabel==='disengage'&&label==='disengaging'; + let suffix=null; + if(appended){ + const previousWidth=mainLabel.getBoundingClientRect().width; + const stem=previousLabel.slice(0,-1); + mainLabel.replaceChildren(document.createTextNode(stem)); + suffix=document.createElement('span');suffix.className='engage-suffix';suffix.textContent=label.slice(stem.length);mainLabel.append(suffix); + const appendedWidth=mainLabel.getBoundingClientRect().width; + mainLabel.style.setProperty('--engage-append-shift',`${(appendedWidth-previousWidth)/2}px`); + mainLabel.classList.add('is-appending'); + }else{ + mainLabel.classList.remove('is-appending'); + mainLabel.style.removeProperty('--engage-append-shift'); + mainLabel.textContent=label; + } + engageLabelValue=label; if(engageProgressLabel)engageProgressLabel.textContent=label; const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label; if(previous){ engageButton.classList.remove('state-transition');void engageButton.offsetWidth; engageButton.classList.add('state-transition'); - engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240); + if(suffix)engageAppendFrame=requestAnimationFrame(()=>suffix.isConnected&&suffix.classList.add('is-visible')); + engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),200); } } function completeEngage(){ @@ -592,7 +612,22 @@ async function refreshConsole(){ finally{consoleRefreshBusy=false} } 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)} +function syncMotorScalingUI(){ + const independent=!!motorSettings.independent; + document.body.dataset.motorScaling=independent?'independent':'shared'; + const toggle=$('#motor-scale-independent');if(toggle)toggle.checked=independent; + const gain=$('#gain'),down=$('#gain-down'),up=$('#gain-up'); + if(gain){gain.disabled=independent;gain.title=independent?'Set motor scales in Motor setup':'Shared limit for every motor';} + if(down)down.disabled=independent;if(up)up.disabled=independent; +} +function pushMotorSettings(persist=true){ + if(!bridgeReady)return Promise.resolve(motorSettings); + const payload=JSON.parse(JSON.stringify(motorSettings)); + motorSettingsQueue=motorSettingsQueue.catch(()=>{}).then(()=>api('save_motor_settings',payload,persist)).then(saved=>{motorSettings=saved;syncMotorScalingUI();if(persist){servoSignature='';renderMotors(vehicleSnapshot.servos||{});toast('Motor settings saved')}return saved}).catch(error=>{setFeedback($('#motor-feedback'),error.message||'Could not save motor settings.');throw error}); + return motorSettingsQueue; +} +function motorScale(motor){return motorSettings.independent?(motorSettings.motors[String(motor)]?.scale??100):+$('#gain').value} +function setGain(value){if(motorSettings.independent)return;const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;servoSignature='';renderMotors(vehicleSnapshot.servos||{});if(bridgeReady)api('set_gain',+gain.value)} $('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5); // Explicit pointer ownership makes the full range rail a reliable touch target // in the embedded window, without depending on native range drag defaults. @@ -623,6 +658,10 @@ function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){con $('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')}; function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}} const motorEdits=new Map(); +$('#motor-scale-independent').onchange=e=>{ + if(e.target.checked&&!motorSettings.customized){for(let motor=1;motor<=12;motor++)motorSettings.motors[String(motor)].scale=+$('#gain').value;motorSettings.customized=true} + motorSettings={...motorSettings,independent:e.target.checked};syncMotorScalingUI();servoSignature='';renderMotors(vehicleSnapshot.servos||{});pushMotorSettings(true).catch(()=>{}); +}; async function requestMotorEdit(servo,kind,value){ if(motorEdits.has(servo))return; const s=vehicleSnapshot.servos?.[String(servo)]||{}; @@ -645,41 +684,40 @@ async function requestMotorEdit(servo,kind,value){ } function renderMotors(servos){ for(const [servo,edit] of motorEdits){ - const s=servos[String(servo)]||{}; - if((edit.kind==='function'?s.function:s.direction)===edit.expected){ - motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`); - }else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){ - motorEdits.delete(servo); - setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`); - } + const state=servos[String(servo)]||{}; + if((edit.kind==='function'?state.function:state.direction)===edit.expected){motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`)} + else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){motorEdits.delete(servo);setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received.`)} } const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known); - const assigned=Object.values(servos).filter(s=>s.motor).length; - $('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…'; - const signature=JSON.stringify([servos,editable,[...motorEdits]]); - if(signature===servoSignature)return; - servoSignature=signature; - const list=$('#motor-list'),expanded=list.querySelector('details')?.open; - list.replaceChildren(); - const other=document.createElement('details');other.className='motor-other';other.open=!!expanded; - const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary); - for(let i=1;i<=16;i++){ - const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row'; - row.innerHTML=`SERVO${i}`; - const select=row.querySelector('select'); - select.add(new Option(s.function==null?'Reading…':'Unassigned','0')); - for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor))); - select.value=String(s.motor||0); - select.disabled=!editable||!!pending||s.function==null; - select.onchange=e=>requestMotorEdit(i,'function',+e.target.value); - row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal'; - const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null; - reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`); - reverse.onclick=()=>requestMotorEdit(i,'direction'); - (s.motor?list:other).append(row); + const assignments=Object.entries(servos).filter(([,state])=>state.motor).sort((a,b)=>a[1].motor-b[1].motor); + const servoByMotor=new Map(assignments.map(([servo,state])=>[String(state.motor),{servo,...state}])); + $('#motor-status').textContent=!vehicleSnapshot.connected?'Offline':vehicleSnapshot.armed?'Armed':`${assignments.length} outputs`; + const shownMotors=[...new Set(assignments.map(([,state])=>state.motor))].sort((a,b)=>a-b); + const structure=JSON.stringify([assignments.map(([port,state])=>[port,state.function,state.motor,state.direction]),editable,[...motorEdits],motorSettings.independent,shownMotors,Object.values(motorSettings.motors).map(m=>m.name)]); + if(structure!==servoSignature){ + servoSignature=structure;const list=$('#motor-list');list.replaceChildren(); + for(const motor of shownMotors){ + const row=document.createElement('article');row.className='motor-row';row.dataset.motor=String(motor); + row.innerHTML=''; + const name=row.querySelector('.motor-name');name.onchange=()=>{motorSettings.motors[String(motor)].name=name.value.trim().slice(0,24)||`Motor ${motor}`;name.value=motorSettings.motors[String(motor)].name;pushMotorSettings(true).catch(()=>{})}; + const scale=row.querySelector('.motor-scale');scale.oninput=()=>{motorSettings.customized=true;motorSettings.motors[String(motor)].scale=+scale.value;row.querySelector('output').textContent=`${scale.value}%`;pushMotorSettings(false).catch(()=>{})};scale.onchange=()=>pushMotorSettings(true).catch(()=>{}); + row.querySelector('.motor-reverse').onclick=()=>{const state=servoByMotor.get(String(motor));if(state?.servo)requestMotorEdit(state.servo,'direction')};list.append(row); + } + const details=document.createElement('details');details.className='motor-mapping';const summary=document.createElement('summary');summary.textContent='Assignments';details.append(summary); + for(let i=1;i<=16;i++){ + const state=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-map-row';row.innerHTML=''; + row.querySelector('.motor-map-port').textContent=`SERVO${i}`;const select=row.querySelector('select');select.add(new Option(state.function==null?'Reading…':'Unassigned','0'));for(let motor=1;motor<=12;motor++)select.add(new Option(motorSettings.motors[String(motor)]?.name||`Motor ${motor}`,String(motor)));select.value=String(state.motor||0);select.disabled=!editable||!!pending||state.function==null;select.onchange=e=>requestMotorEdit(i,'function',+e.target.value); + row.querySelector('.motor-map-direction').textContent=pending?'Saving…':state.direction==null?'—':state.direction===-1?'Reversed':'Normal';details.append(row); + } + list.append(details); + } + for(const motor of shownMotors){ + const row=$(`#motor-list .motor-row[data-motor="${motor}"]`),state=servoByMotor.get(String(motor))||{},settings=motorSettings.motors[String(motor)]||{name:`Motor ${motor}`,scale:20};if(!row)continue; + const servo=state.servo||null,name=row.querySelector('.motor-name');if(document.activeElement!==name)name.value=settings.name; + const scale=row.querySelector('.motor-scale');scale.disabled=!motorSettings.independent;if(document.activeElement!==scale)scale.value=motorScale(motor); + row.querySelector('.motor-scale-value').textContent=`${motorScale(motor)}%`;row.querySelector('.motor-channel').textContent=servo?`SERVO${servo} · ${state.output_us||'—'} µs`:'Unassigned'; + const reverse=row.querySelector('.motor-reverse');reverse.disabled=!editable||!servo||state.direction==null||motorEdits.has(servo);reverse.textContent=state.direction===-1?'Restore':'Reverse';reverse.setAttribute('aria-label',`${reverse.textContent} ${settings.name}`); } - if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)} - if(assigned<16)list.append(other); } function renderHeavyQuality(test,connected,armed){ const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel'); @@ -716,7 +754,8 @@ function render(s,confirmed=false){ appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected'; renderHeavyQuality(s.heavy_quality,s.connected,s.armed); document.body.dataset.appState=appState; - document.body.dataset.controlsEnabled=String(view.drive); + const driveEnabled=view.drive; + document.body.dataset.controlsEnabled=String(driveEnabled); document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale); $('#port').disabled=!!s.connected; $('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true'; @@ -751,7 +790,7 @@ function render(s,confirmed=false){ controlStatus.classList.toggle('is-error',view.error); controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused)); const motorBtn=$('[data-open="motors-modal"]'); - if(motorBtn)motorBtn.disabled=(!isOnline); + if(motorBtn)motorBtn.disabled=false; 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); @@ -762,16 +801,25 @@ function render(s,confirmed=false){ updateReadouts(); const b=s.battery; if(b){ - $('#battery-title').textContent=`${b.voltage.toFixed(2)} V`; - $('#battery-percent').textContent=`${Math.round(b.percent)}%`; + $('#battery-title').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'Voltage unavailable'; + $('#battery-percent').textContent=Number.isFinite(b.percent)?`${Math.round(b.percent)}%`:'—'; + $('#battery-live-voltage').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'—'; + $('#battery-live-current').textContent=Number.isFinite(b.current_a)?`${b.current_a.toFixed(1)} A`:'Unavailable'; + $('#battery-live-consumed').textContent=Number.isFinite(b.consumed_mah)?`${Math.round(b.consumed_mah).toLocaleString()} mAh`:'Unavailable'; + $('#battery-live-consumed-source').textContent=b.consumed_source||''; + $('#battery-live-remaining').textContent=Number.isFinite(b.remaining_mah)?`${Math.round(b.remaining_mah).toLocaleString()} mAh`:Number.isFinite(b.remaining_percent)?`${Math.round(b.remaining_percent)}%`:'Unavailable'; + $('#battery-live-note').textContent=s.battery_stale?'Battery telemetry is stale. Check the telemetry rate and battery monitor.':b.charge_source==='Voltage estimate'?'Charge percentage is estimated from voltage. Add a calibrated current sensor for consumption readings.':'Live values come from the flight controller; accuracy depends on its battery monitor setup.'; $('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale); - $('.battery-card').title=`Estimated charge from voltage · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`; - $('#battery-fill').style.width=`${b.percent}%`; + $('.battery-card').title=`${b.charge_source||'Battery status'} · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`; + $('#battery-fill').style.width=`${Number.isFinite(b.percent)?b.percent:0}%`; $('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)'; $('.battery-card').dataset.low=String(!!b.low); }else{ $('#battery-title').textContent='No voltage'; $('#battery-percent').textContent='—'; + $('#battery-live-voltage').textContent='—';$('#battery-live-current').textContent='—'; + $('#battery-live-consumed').textContent='—';$('#battery-live-consumed-source').textContent=''; + $('#battery-live-remaining').textContent='—';$('#battery-live-note').textContent='Waiting for battery telemetry.'; $('.battery-card').dataset.stale='false'; $('.battery-card').title='Waiting for battery voltage · Battery settings'; $('#battery-fill').style.width='0'; @@ -912,20 +960,22 @@ async function boot(){ if(booted||booting||!hasDesktopApi())return; booting=true;clearTimeout(bootRetry); try{ - // OS port discovery must not depend on model/config initialization succeeding. - populatePorts(await withTimeout(api('refresh_ports'),4000)); const init=await withTimeout(api('initial_state'),4000); batteryConfig=init.battery_config||{}; linkConfig=init.link_config||{}; - populatePorts(init.ports); + motorSettings=init.motor_settings||motorSettings; + syncMotorScalingUI(); fillBatteryForm(); fillLinkForm(); bridgeReady=true;booted=true;lastBridgeAt=performance.now(); if(!demoActive)render(init.state); poll(); - // Populate the serial selector before starting the optional model load. - // A large cached mesh must never delay an operator seeing USB COM ports. - loadDefaultModel(); + // COM discovery can block on Windows drivers; finish the first paint first. + setTimeout(()=>withTimeout(api('refresh_ports'),4000).then(populatePorts).catch(()=>{}),0); + // Let the controls paint before fetching and parsing the large STL mesh. + const startModelLoad=()=>{void loadDefaultModel()}; + if('requestIdleCallback' in window)requestIdleCallback(startModelLoad,{timeout:2500}); + else setTimeout(startModelLoad,1500); if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{ if(appState==='disconnected'||demoActive){ try{populatePorts(await withTimeout(api('refresh_ports'),1500))} @@ -949,9 +999,8 @@ function waitForBridge(){ addEventListener('pywebviewready',boot); render(vehicleSnapshot); if(document.readyState==='loading'){ - addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()}); + addEventListener('DOMContentLoaded',waitForBridge); }else{ - loadDefaultModel(); waitForBridge(); } diff --git a/SUB Control/web/control-state.js b/SUB Control/web/control-state.js index 9c72c4b..c8a328b 100644 --- a/SUB Control/web/control-state.js +++ b/SUB Control/web/control-state.js @@ -18,7 +18,7 @@ label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null; message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.'; } else if (known && online) { - action=focused?'arm':null; message='Disarmed · hold 1.4 seconds to engage.'; + action=focused?'arm':null; message='Disarmed · hold 0.6 seconds to engage.'; } if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.'; const failure=error || state.command_error; diff --git a/SUB Control/web/index.html b/SUB Control/web/index.html index 1b80a0d..db2060b 100644 --- a/SUB Control/web/index.html +++ b/SUB Control/web/index.html @@ -4,7 +4,7 @@