Compare commits

..

1 Commits

Author SHA1 Message Date
PitresAli 5e91c28f1e Save pre-V2 motor controls and UI checkpoint 2026-10-22 19:31:28 +02:00
15 changed files with 1041 additions and 236 deletions
+1
View File
@@ -5,3 +5,4 @@ logs/
model_settings.json
link_settings.json
runtime/
motor_settings.json
+4
View File
@@ -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.
+4
View File
@@ -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.
+154 -8
View File
@@ -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.
+104 -55
View File
@@ -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=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
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='<input class="motor-name" type="text" maxlength="24" autocomplete="off" aria-label="Motor name"><input class="motor-scale" type="range" min="0" max="100" step="1" aria-label="Motor output scale"><output class="motor-scale-value"></output><button type="button" class="button subtle motor-reverse">Reverse</button><span class="motor-channel"></span>';
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='<span class="motor-map-port"></span><select aria-label="Servo output motor assignment"></select><span class="motor-map-direction"></span>';
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();
}
+1 -1
View File
@@ -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;
+17 -6
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20261002-spacing-motion-v2">
<link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
</head>
<body>
<main class="app-shell">
@@ -140,7 +140,7 @@
<span>Output limit <b id="gain-value">20%</b></span>
<div class="range-pill">
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
<input id="gain" aria-label="Shared motor output limit" type="range" min="0" max="100" step="1" value="20">
<button id="gain-up" type="button" aria-label="Increase output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
</div>
</label>
@@ -172,6 +172,7 @@
<div class="form-grid two">
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Battery detail · Hz</span><select name="battery_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
@@ -194,7 +195,16 @@
</section>
</dialog>
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery settings</h2>
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery</h2>
<section class="battery-live" aria-label="Live battery readings">
<div class="battery-live-grid">
<div><span>Pack voltage</span><strong id="battery-live-voltage">—</strong></div>
<div><span>Current draw</span><strong id="battery-live-current">—</strong></div>
<div><span>Charge used</span><strong id="battery-live-consumed">—</strong><small id="battery-live-consumed-source"></small></div>
<div><span>Capacity left</span><strong id="battery-live-remaining">—</strong></div>
</div>
<p id="battery-live-note" class="note">Waiting for battery telemetry.</p>
</section>
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
@@ -203,9 +213,10 @@
</form>
</dialog>
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Offline</p>
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
<div class="motor-scale-heading"><span>Output</span><label class="switch-control"><input id="motor-scale-independent" type="checkbox"><span class="switch-track" aria-hidden="true"></span><span>Independent</span></label></div>
<div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
@@ -228,7 +239,7 @@
<script src="control-state.js?v=20260827-state-v1"></script>
<script src="controller-input.js?v=20261001-controller-v1"></script>
<script src="console-view.js?v=20261002-console-v2"></script>
<script src="app.js?v=20261002-spacing-motion-v2"></script>
<script src="app.js?v=20261017-engage-center-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body>
</html>
+76 -29
View File
@@ -713,23 +713,27 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
pointer-events: none;
}
.engage-button.charging { transition: background-color .24s ease, border-color .24s ease, color .2s ease; }
.engage-face { position: relative; display: grid; place-items: center; min-width: 74px; }
.engage-face > span { grid-area: 1/1; font-size: 11px; font-weight: 650; letter-spacing: .12em; }
.engage-face { position:relative; display:grid; place-items:center; width:7.5em; height:1em; min-width:0; text-align:center; font-size:clamp(52px,11cqw,90px); }
.engage-face > span { grid-area:1/1; justify-self:center; align-self:start; font-size:11px; font-weight:650; letter-spacing:.12em; white-space:nowrap; }
.engage-label {
position:relative;
color: transparent;
background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%);
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
text-align:center;
transition:transform 180ms cubic-bezier(.77,0,.175,1);
}
#engage .engage-label.is-appending { transform:translateX(var(--engage-append-shift,0px)); }
.arm:not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-label { animation: engage-shimmer 4.8s linear infinite; }
.engage-suffix { display:inline-block; opacity:0; clip-path:inset(0 100% 0 0); transform:translateX(-.08em); transition:clip-path 180ms cubic-bezier(.77,0,.175,1),opacity 120ms ease-out,transform 180ms cubic-bezier(.77,0,.175,1); }
.state-transition .engage-suffix,.engage-suffix.is-visible { opacity:1; clip-path:inset(0); transform:translateX(0); }
.engage-progress-label {
color: #a9ed72;
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
transition: opacity .18s ease;
}
.state-transition .engage-face { animation: state-label .24s ease-out; }
@keyframes state-label { from {opacity:.35} to {opacity:1} }
@keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} }
.engage-button:hover { transform:none; }
.engage-button.pending { background:#405346; border-color:#516e56; }
@@ -1015,21 +1019,14 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
color: #7a8280;
margin-top: 8px;
}
.motor-modal { width: min(600px, calc(100vw - 40px)); }
.motor-head, .motor-row {
display: grid;
grid-template-columns: 70px 1fr 80px 75px;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #e0e4df;
font-size: 10.5px;
}
.motor-head {
font-weight: 500;
color: #727a7c;
border-bottom: 1.5px solid #c8cec6;
}
.motor-modal { width: min(860px, calc(100vw - 40px)); }
.battery-live { margin:14px 0 20px; padding:14px; border-radius:16px; background:#f0f3ef; }
.battery-live-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
.battery-live-grid > div { min-width:0; padding:10px 11px; border-radius:12px; background:#fff; }
.battery-live-grid span,.battery-live-grid small { display:block; color:#727b77; font-size:11px; }
.battery-live-grid strong { display:block; margin-top:7px; color:#303936; font-size:18px; font-weight:500; font-variant-numeric:tabular-nums; }
.battery-live-grid small { margin-top:4px; }
.battery-live .note { margin:10px 0 0; line-height:1.5; font-size:12px; }
/* ── Toast ── */
.toast {
@@ -1158,7 +1155,7 @@ body #engage { width:calc(100% + 40px); margin-left:-20px; height:104px; padding
body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
#engage::before, #engage::after { display:none; }
body #engage { align-items:flex-end; padding-bottom:12px; }
.engage-face > span { font-family:inherit; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
.engage-face > span { font-family:inherit; font-size:clamp(52px,11cqw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
#engage { --engage-green:#4cce73; --engage-glow:drop-shadow(0 0 2px rgba(76,206,115,.3)); }
#engage .engage-label { color:#777; background:none; animation:none; }
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; }
@@ -1173,6 +1170,9 @@ body #engage { align-items:flex-end; padding-bottom:12px; }
.control-status[hidden] { display:none; }
@media (prefers-reduced-motion: reduce) {
.engage-sheen-label { display:none; }
#engage .engage-label.is-appending { transition:none; transform:none; }
.engage-suffix { transition:opacity var(--ui-duration,180ms) ease-out; }
.state-transition .engage-suffix { clip-path:inset(0); transform:none; }
}
.controls-grid {
--joy-size:min(54vh,calc(100vw - 480px),460px);
@@ -1265,12 +1265,8 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
.modal .form-feedback { margin:16px 0 0; padding:10px 12px; border-radius:12px; font-size:13px; line-height:1.5; color:#9b392e; background:#f5e6e3; }
.modal [hidden] { display:none !important; }
.modal [data-busy="true"] { cursor:progress; }
.motor-modal { width:min(650px,calc(100vw - 32px)); }
.modal .motor-head,.modal .motor-row { grid-template-columns:78px minmax(100px,1fr) 94px 86px; font-size:13px; gap:10px; padding:10px 0; }
.modal .motor-head { margin-top:18px; color:#727b80; font-size:12px; font-weight:400; }
.motor-row strong { font-size:12px; font-weight:500; }
.motor-row > span { font-size:12px; color:#727b80; }
.modal .motor-row .button { min-height:36px; font-size:12px; padding:8px 12px; }
.motor-modal { width:min(860px,calc(100vw - 32px)); }
@media (max-width:650px) { .battery-live-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.motor-other { margin-top:16px; }
.motor-other summary { cursor:pointer; padding:12px 0; color:#697278; font-size:13px; }
.quality-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin-top:18px; }
@@ -1289,8 +1285,6 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
@media (max-width:520px) {
.modal { padding:22px 18px; }
.modal .form-grid.two { grid-template-columns:1fr; }
.modal .motor-head,.modal .motor-row { grid-template-columns:56px minmax(80px,1fr) 74px 66px; gap:5px; font-size:12px; }
.modal .motor-row .button { padding:8px 6px; }
.quality-summary { gap:6px; }
.quality-summary div { padding:12px 8px; }
.quality-summary strong { font-size:17px; }
@@ -1385,7 +1379,6 @@ body #connect { height:var(--pill-lg); 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. */
@@ -1472,3 +1465,57 @@ body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); }
@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; }
/* Motor scaling and control-plane fit: keep interaction areas inside their rows. */
.primary-control-zone { grid-template-rows:clamp(76px,14vh,108px) minmax(0,1fr) auto; }
.engage-header-zone { height:auto; min-height:0; align-self:stretch; }
body #engage { height:100%; min-height:0; }
.controls-grid { --joy-size:min(40vh,calc(100cqw - 168px),380px); min-height:0; align-content:center; }
.joystick { min-width:0; min-height:0; max-width:100%; max-height:100%; }
.joystick-pad,.heave-pad { min-width:0; min-height:0; flex:1 1 auto; overflow:hidden; }
.control-card { min-width:0; min-height:0; overflow:hidden; }
.control-toolbar { flex-wrap:nowrap; min-height:48px; padding:5px 0 0; gap:clamp(8px,1.5vw,18px); }
.control-toolbar .output-limit { min-width:0; }
.control-toolbar .output-limit > span { flex:0 1 120px; width:120px; }
.control-toolbar .range-pill { width:clamp(112px,20cqw,190px); flex:0 1 190px; }
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
@media (max-height:610px) {
.primary-control-zone { grid-template-rows:clamp(64px,12vh,82px) minmax(0,1fr) auto; }
.controls-grid { --joy-size:min(34vh,calc(100cqw - 168px),300px); }
}
.motor-modal { width:min(720px,calc(100vw - 36px)); max-height:calc(100dvh - 28px); padding:clamp(18px,3vw,28px); overflow:auto; }
.motor-modal > h2 { margin:0 0 2px; }
.motor-scale-heading { display:flex; align-items:center; justify-content:space-between; padding:12px 0 7px; border-bottom:1px solid #e7eae7; color:#56635b; font-size:12px; }
.switch-control { display:flex; align-items:center; gap:9px; cursor:pointer; color:#34423a; font-size:12px; }
.switch-control input { position:absolute; opacity:0; width:1px; height:1px; }
.switch-track { position:relative; display:block; width:34px; height:20px; border-radius:999px; background:#d8ddda; transition:background-color 160ms ease-out; }
.switch-track::after { content:""; position:absolute; width:14px; height:14px; left:3px; top:3px; border-radius:50%; background:#fff; box-shadow:0 1px 2px #0002; transition:transform 160ms ease-out; }
.switch-control input:checked + .switch-track { background:#4d8767; }
.switch-control input:checked + .switch-track::after { transform:translateX(14px); }
.switch-control input:focus-visible + .switch-track { outline:2px solid #6598b5; outline-offset:3px; }
.motor-list { display:grid; }
.motor-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(100px,1.5fr) 42px 76px; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid #e7eae7; }
.motor-name { min-width:0; height:34px; padding:0 8px; border:1px solid transparent; border-radius:6px; background:transparent; font-size:13px; }
.motor-name:hover,.motor-name:focus { border-color:#d8dfda; background:#fff; }
.motor-row .motor-scale { width:100%; min-width:0; height:22px; padding:0; border:0; background:transparent; accent-color:#4d8767; }
.motor-scale-value { color:#344b3c; font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; text-align:right; }
.motor-reverse { min-height:30px; padding:5px 9px; font-size:11px; }
.motor-reverse:disabled { opacity:.42; cursor:not-allowed; }
.motor-channel { grid-column:1/-1; margin-top:-8px; color:#87918a; font-size:10px; }
.motor-mapping { margin-top:8px; }
.motor-mapping summary { padding:9px 0; color:#657168; font-size:12px; cursor:pointer; }
.motor-map-row { display:grid; grid-template-columns:65px minmax(0,1fr) 72px; align-items:center; gap:9px; padding:6px 0; border-bottom:1px solid #edf0ed; }
.motor-map-port { color:#4b5a50; font:11px ui-monospace,Consolas,monospace; }
.motor-map-row select { height:32px; font-size:12px; }
.motor-map-direction { color:#77827b; font-size:11px; text-align:right; }
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
.controls-grid { align-content:start; }
.joystick-pad,.heave-pad { padding-top:0; }
.joystick { transform:translateY(-7px); }
@media (max-width:640px) { .motor-row { grid-template-columns:minmax(0,1fr) minmax(75px,1.3fr) 38px 70px; gap:7px; } .motor-map-row { grid-template-columns:54px minmax(0,1fr) 64px; gap:6px; } }
@media (max-width:520px) { .motor-modal { padding:18px 14px; } .motor-row { grid-template-columns:minmax(0,1fr) minmax(64px,1fr) 36px 66px; gap:5px; } .motor-name { font-size:12px; } .motor-reverse { padding:5px 6px; } }
@media (prefers-reduced-motion:reduce) { .switch-track,.switch-track::after { transition:none; } }
+164 -19
View File
@@ -28,6 +28,9 @@ class ControlApi:
self.worker.require_input_lease = True
self.vehicle_state = VehicleState()
self.battery_received_at = None
self.battery_last_current = None
self.battery_last_current_at = None
self.battery_session_consumed_mah = 0.0
self.demo_mode = False
self.lock = threading.Lock()
self._poll_lock = threading.Lock()
@@ -43,10 +46,26 @@ class ControlApi:
"link_quality": None,
"heavy_quality": None,
"log_path": None,
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
"motor_test_active": False,
"servos": {str(i): {"function": None, "direction": None, "output_us": None} for i in range(1, 17)},
}
self.servo_functions: dict[int, int] = {}
self.motor_directions: dict[int, int] = {}
self.motor_settings_path = Path(__file__).resolve().parent / "motor_settings.json"
self.motor_settings = {
"independent": False,
"customized": False,
"motors": {str(i): {"name": f"Motor {i}", "scale": 20} for i in range(1, 13)},
}
try:
saved_motor_settings = json.loads(self.motor_settings_path.read_text(encoding="utf-8"))
self.motor_settings = self._clean_motor_settings(saved_motor_settings)
except (OSError, ValueError, TypeError):
pass
self.worker.set_motor_scaling(
self.motor_settings["independent"],
{int(i): entry["scale"] for i, entry in self.motor_settings["motors"].items()},
)
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.link_path = Path(__file__).resolve().parent / "link_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
@@ -67,7 +86,7 @@ class ControlApi:
pass
self.link_config = {
"baud": 57600, "control_hz": 20,
"ping_hz": 1, "system_hz": 1, "servo_hz": 2,
"ping_hz": 1, "system_hz": 1, "battery_hz": 1, "servo_hz": 2,
"rc_hz": 2, "esc_hz": 1,
}
try:
@@ -78,11 +97,10 @@ class ControlApi:
def initial_state(self):
return {
"ports": self.refresh_ports(),
"battery_config": self.battery_config,
"link_config": self.link_config,
"motor_settings": self.motor_settings,
"state": self._snapshot(),
"model_info": self.get_saved_model_info(),
}
def refresh_ports(self):
@@ -259,8 +277,12 @@ class ControlApi:
self.state["servos"][str(servo)] = {
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
"output_us": None,
}
return self._snapshot()
self.battery_last_current = None
self.battery_last_current_at = None
self.battery_session_consumed_mah = 0.0
self.worker.rate_hz = int(rate)
with self.lock:
self.vehicle_state.disconnect()
@@ -285,6 +307,9 @@ class ControlApi:
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self):
self.worker.stop_motor_test()
with self.lock:
self.state["motor_test_active"] = False
if self.worker.heavy_quality_test is not None:
self.worker.cancel_heavy_quality_test()
if self.demo_mode:
@@ -305,6 +330,34 @@ class ControlApi:
def cancel_heavy_quality_test(self):
return self.worker.cancel_heavy_quality_test()
def start_motor_test(self, motor, throttle_percent):
motor, throttle_percent = int(motor), int(throttle_percent)
if self.demo_mode:
raise RuntimeError("Motor tests require a real flight-controller connection")
with self.lock:
if not self.state.get("connected") or not self.state.get("armed"):
raise RuntimeError("Connect and arm the vehicle before motor testing")
assignment = next((item for item in self.state["servos"].values() if item.get("motor") == motor), None)
if assignment is None:
raise ValueError(f"Motor {motor} is not assigned to a servo output")
if self.state.get("motor_test_active"):
raise RuntimeError("A motor test is already active")
effective_percent = round(throttle_percent * self.worker.motor_output_scale(motor))
self.state["motor_test_active"] = True
self.state["status"] = f"Motor {motor} test active at {effective_percent}% output"
try:
return self.worker.start_motor_test(motor, effective_percent)
except Exception:
with self.lock:
self.state["motor_test_active"] = False
raise
def stop_motor_test(self):
self.worker.stop_motor_test()
with self.lock:
self.state["status"] = "Motor test released; waiting for flight-controller stop"
return True
def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode:
with self.lock:
@@ -330,10 +383,44 @@ class ControlApi:
return True
def set_gain(self, value):
value = max(5, min(50, int(value)))
self.worker.gain = value / 100.0
value = max(0, min(100, int(value)))
if self.motor_settings["independent"]:
raise RuntimeError("Use per-motor scales while independent scaling is enabled")
with self.worker.lock:
self.worker.gain = value / 100.0
return value
@staticmethod
def _clean_motor_settings(settings):
if not isinstance(settings, dict):
raise ValueError("Motor settings must be an object")
requested = settings.get("motors", {})
if not isinstance(requested, dict):
raise ValueError("Motor settings are invalid")
motors = {}
for motor in range(1, 13):
entry = requested.get(str(motor), {})
if not isinstance(entry, dict):
entry = {}
name = str(entry.get("name", f"Motor {motor}")).strip()[:24] or f"Motor {motor}"
try:
scale = max(0, min(100, int(entry.get("scale", 20))))
except (TypeError, ValueError):
scale = 20
motors[str(motor)] = {"name": name, "scale": scale}
return {"independent": bool(settings.get("independent", False)), "customized": bool(settings.get("customized", False)), "motors": motors}
def save_motor_settings(self, settings, persist=True):
clean = self._clean_motor_settings(settings)
if persist:
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.motor_settings = clean
self.worker.set_motor_scaling(
clean["independent"],
{int(i): entry["scale"] for i, entry in clean["motors"].items()},
)
return clean
def command(self, name, sequence=None, expires_at=None):
if self.demo_mode:
if name not in ("manual", "arm", "disarm"):
@@ -425,6 +512,7 @@ class ControlApi:
self.worker.rate_hz = self.link_config["control_hz"]
self.worker.set_link_rates(self.link_config["ping_hz"], {
"SYS_STATUS": self.link_config["system_hz"],
"BATTERY_STATUS": self.link_config["battery_hz"],
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
"RC_CHANNELS": self.link_config["rc_hz"],
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
@@ -439,7 +527,7 @@ class ControlApi:
raise ValueError("Control rate must be 10 or 20 Hz")
if clean["ping_hz"] not in {1, 2, 5, 10}:
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
if any(clean[key] not in allowed for key in ("system_hz", "servo_hz", "rc_hz", "esc_hz")):
if any(clean[key] not in allowed for key in ("system_hz", "battery_hz", "servo_hz", "rc_hz", "esc_hz")):
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
self.link_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.link_config = clean
@@ -488,6 +576,7 @@ class ControlApi:
def _snapshot(self):
with self.lock:
snap = json.loads(json.dumps(self.state))
snap["motor_settings"] = self.motor_settings
snap["app_state"] = self._derive_app_state()
if not self.demo_mode:
snap.update(self.vehicle_state.snapshot())
@@ -503,6 +592,9 @@ class ControlApi:
if kind == "heartbeat":
_, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
if not armed and self.state.get("motor_test_active"):
self.state["motor_test_active"] = False
self.state["status"] = "Flight controller reports disarmed after motor test"
self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed),
@@ -515,16 +607,19 @@ class ControlApi:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery":
self.battery_received_at = time.monotonic()
voltage = float(event[1])
cells = self.battery_config["cells"]
per_cell = voltage / cells
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
percent = max(0, min(100, (per_cell - low) * 100 / (high - low)))
self.state["battery"] = {
"voltage": voltage, "per_cell": per_cell, "percent": percent,
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
}
self._consume_battery(event[1])
elif kind == "servo_output":
for servo, output_us in event[1].items():
if servo in self.state["servos"]:
self.state["servos"][servo]["output_us"] = output_us
elif kind == "motor_test_ack":
self.state["status"] = "Motor test accepted by flight controller"
elif kind == "motor_test_rejected":
self.state["motor_test_active"] = False
self.state["status"] = f"Motor test rejected: {event[1]}"
elif kind == "motor_test_stopped":
self.state["motor_test_active"] = False
self.state["status"] = f"Motor test ended: {event[1]}"
elif kind == "link_quality":
self.state["link_quality"] = event[1]
elif kind == "heavy_quality":
@@ -551,9 +646,59 @@ class ControlApi:
last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
mode=None, link_quality=None, status=status_msg)
mode=None, link_quality=None, status=status_msg, motor_test_active=False)
self.state["last_error"] = None
def _consume_battery(self, data):
self.battery_received_at = time.monotonic()
now = self.battery_received_at
previous = self.state.get("battery") or {}
battery = dict(previous)
battery["cells"] = self.battery_config["cells"]
voltage = data.get("voltage")
if voltage is not None and float(voltage) > 0:
battery["voltage"] = float(voltage)
battery["per_cell"] = float(voltage) / self.battery_config["cells"]
current = data.get("current_a")
if current is not None and float(current) >= 0:
current = float(current)
if self.battery_last_current is not None and self.battery_last_current_at is not None:
elapsed = now - self.battery_last_current_at
if 0 < elapsed <= 10:
average = (current + self.battery_last_current) / 2
self.battery_session_consumed_mah += average * 1000 * elapsed / 3600
self.battery_last_current = current
self.battery_last_current_at = now
battery["current_a"] = current
consumed = data.get("consumed_mah")
if consumed is not None and int(consumed) >= 0:
battery["consumed_mah"] = int(consumed)
battery["consumed_source"] = "Flight controller"
elif self.battery_last_current is not None:
battery["consumed_mah"] = round(self.battery_session_consumed_mah)
battery["consumed_source"] = "Session estimate"
remaining = data.get("remaining_percent")
if remaining is not None and 0 <= int(remaining) <= 100:
battery["remaining_percent"] = int(remaining)
battery["charge_source"] = "Flight controller"
capacity = self.battery_config["capacity_mah"]
if battery.get("consumed_mah") is not None:
battery["remaining_mah"] = max(0, capacity - battery["consumed_mah"])
elif battery.get("remaining_percent") is not None:
battery["remaining_mah"] = round(capacity * battery["remaining_percent"] / 100)
if battery.get("remaining_percent") is not None:
percent = battery["remaining_percent"]
elif battery.get("per_cell") is not None:
low = self.battery_config["empty_v_per_cell"]
high = self.battery_config["full_v_per_cell"]
percent = max(0, min(100, (battery["per_cell"] - low) * 100 / (high - low)))
battery["charge_source"] = "Voltage estimate"
else:
percent = None
battery["percent"] = percent
battery["low"] = percent is not None and percent <= self.battery_config["warning_percent"]
self.state["battery"] = battery
def _update_servo(self, servo):
function = self.servo_functions.get(servo)
motor = motor_number_for_function(function)
@@ -562,6 +707,7 @@ class ControlApi:
"label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
"motor": motor,
"direction": self.motor_directions.get(motor) if motor else None,
"output_us": (self.state["servos"].get(str(servo)) or {}).get("output_us"),
}
def close(self):
@@ -653,7 +799,6 @@ def main():
from desktop_shortcut import ensure_desktop_shortcut
ensure_desktop_shortcut()
api = ControlApi()
api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent
port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
+154 -8
View File
@@ -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)
@@ -754,6 +872,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:
@@ -776,10 +896,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} "
@@ -852,6 +987,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.
+104 -55
View File
@@ -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=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
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='<input class="motor-name" type="text" maxlength="24" autocomplete="off" aria-label="Motor name"><input class="motor-scale" type="range" min="0" max="100" step="1" aria-label="Motor output scale"><output class="motor-scale-value"></output><button type="button" class="button subtle motor-reverse">Reverse</button><span class="motor-channel"></span>';
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='<span class="motor-map-port"></span><select aria-label="Servo output motor assignment"></select><span class="motor-map-direction"></span>';
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();
}
+1 -1
View File
@@ -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;
+17 -6
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20261002-spacing-motion-v2">
<link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
</head>
<body>
<main class="app-shell">
@@ -140,7 +140,7 @@
<span>Output limit <b id="gain-value">20%</b></span>
<div class="range-pill">
<button id="gain-down" type="button" aria-label="Decrease output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m14 6-6 6 6 6"/></svg></button>
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
<input id="gain" aria-label="Shared motor output limit" type="range" min="0" max="100" step="1" value="20">
<button id="gain-up" type="button" aria-label="Increase output limit"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m10 6 6 6-6 6"/></svg></button>
</div>
</label>
@@ -172,6 +172,7 @@
<div class="form-grid two">
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Battery detail · Hz</span><select name="battery_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
@@ -194,7 +195,16 @@
</section>
</dialog>
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery settings</h2>
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="battery-title-modal">Battery</h2>
<section class="battery-live" aria-label="Live battery readings">
<div class="battery-live-grid">
<div><span>Pack voltage</span><strong id="battery-live-voltage">—</strong></div>
<div><span>Current draw</span><strong id="battery-live-current">—</strong></div>
<div><span>Charge used</span><strong id="battery-live-consumed">—</strong><small id="battery-live-consumed-source"></small></div>
<div><span>Capacity left</span><strong id="battery-live-remaining">—</strong></div>
</div>
<p id="battery-live-note" class="note">Waiting for battery telemetry.</p>
</section>
<form id="battery-form" title="Charge is estimated from voltage; capacity and C rating describe the battery.">
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
@@ -203,9 +213,10 @@
</form>
</dialog>
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Offline</p>
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
<div class="motor-scale-heading"><span>Output</span><label class="switch-control"><input id="motor-scale-independent" type="checkbox"><span class="switch-track" aria-hidden="true"></span><span>Independent</span></label></div>
<div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
@@ -228,7 +239,7 @@
<script src="control-state.js?v=20260827-state-v1"></script>
<script src="controller-input.js?v=20261001-controller-v1"></script>
<script src="console-view.js?v=20261002-console-v2"></script>
<script src="app.js?v=20261002-spacing-motion-v2"></script>
<script src="app.js?v=20261017-engage-center-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body>
</html>
+76 -29
View File
@@ -713,23 +713,27 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
pointer-events: none;
}
.engage-button.charging { transition: background-color .24s ease, border-color .24s ease, color .2s ease; }
.engage-face { position: relative; display: grid; place-items: center; min-width: 74px; }
.engage-face > span { grid-area: 1/1; font-size: 11px; font-weight: 650; letter-spacing: .12em; }
.engage-face { position:relative; display:grid; place-items:center; width:7.5em; height:1em; min-width:0; text-align:center; font-size:clamp(52px,11cqw,90px); }
.engage-face > span { grid-area:1/1; justify-self:center; align-self:start; font-size:11px; font-weight:650; letter-spacing:.12em; white-space:nowrap; }
.engage-label {
position:relative;
color: transparent;
background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%);
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
text-align:center;
transition:transform 180ms cubic-bezier(.77,0,.175,1);
}
#engage .engage-label.is-appending { transform:translateX(var(--engage-append-shift,0px)); }
.arm:not(:disabled):not(.charging):not(.canceling):not(.state-transition) .engage-label { animation: engage-shimmer 4.8s linear infinite; }
.engage-suffix { display:inline-block; opacity:0; clip-path:inset(0 100% 0 0); transform:translateX(-.08em); transition:clip-path 180ms cubic-bezier(.77,0,.175,1),opacity 120ms ease-out,transform 180ms cubic-bezier(.77,0,.175,1); }
.state-transition .engage-suffix,.engage-suffix.is-visible { opacity:1; clip-path:inset(0); transform:translateX(0); }
.engage-progress-label {
color: #a9ed72;
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
transition: opacity .18s ease;
}
.state-transition .engage-face { animation: state-label .24s ease-out; }
@keyframes state-label { from {opacity:.35} to {opacity:1} }
@keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} }
.engage-button:hover { transform:none; }
.engage-button.pending { background:#405346; border-color:#516e56; }
@@ -1015,21 +1019,14 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
color: #7a8280;
margin-top: 8px;
}
.motor-modal { width: min(600px, calc(100vw - 40px)); }
.motor-head, .motor-row {
display: grid;
grid-template-columns: 70px 1fr 80px 75px;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #e0e4df;
font-size: 10.5px;
}
.motor-head {
font-weight: 500;
color: #727a7c;
border-bottom: 1.5px solid #c8cec6;
}
.motor-modal { width: min(860px, calc(100vw - 40px)); }
.battery-live { margin:14px 0 20px; padding:14px; border-radius:16px; background:#f0f3ef; }
.battery-live-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
.battery-live-grid > div { min-width:0; padding:10px 11px; border-radius:12px; background:#fff; }
.battery-live-grid span,.battery-live-grid small { display:block; color:#727b77; font-size:11px; }
.battery-live-grid strong { display:block; margin-top:7px; color:#303936; font-size:18px; font-weight:500; font-variant-numeric:tabular-nums; }
.battery-live-grid small { margin-top:4px; }
.battery-live .note { margin:10px 0 0; line-height:1.5; font-size:12px; }
/* ── Toast ── */
.toast {
@@ -1158,7 +1155,7 @@ body #engage { width:calc(100% + 40px); margin-left:-20px; height:104px; padding
body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
#engage::before, #engage::after { display:none; }
body #engage { align-items:flex-end; padding-bottom:12px; }
.engage-face > span { font-family:inherit; font-size:clamp(64px,7.3vw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
.engage-face > span { font-family:inherit; font-size:clamp(52px,11cqw,90px); font-weight:200; letter-spacing:.015em; line-height:1; padding-bottom:.22em; margin-bottom:-.22em; }
#engage { --engage-green:#4cce73; --engage-glow:drop-shadow(0 0 2px rgba(76,206,115,.3)); }
#engage .engage-label { color:#777; background:none; animation:none; }
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; }
@@ -1173,6 +1170,9 @@ body #engage { align-items:flex-end; padding-bottom:12px; }
.control-status[hidden] { display:none; }
@media (prefers-reduced-motion: reduce) {
.engage-sheen-label { display:none; }
#engage .engage-label.is-appending { transition:none; transform:none; }
.engage-suffix { transition:opacity var(--ui-duration,180ms) ease-out; }
.state-transition .engage-suffix { clip-path:inset(0); transform:none; }
}
.controls-grid {
--joy-size:min(54vh,calc(100vw - 480px),460px);
@@ -1265,12 +1265,8 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
.modal .form-feedback { margin:16px 0 0; padding:10px 12px; border-radius:12px; font-size:13px; line-height:1.5; color:#9b392e; background:#f5e6e3; }
.modal [hidden] { display:none !important; }
.modal [data-busy="true"] { cursor:progress; }
.motor-modal { width:min(650px,calc(100vw - 32px)); }
.modal .motor-head,.modal .motor-row { grid-template-columns:78px minmax(100px,1fr) 94px 86px; font-size:13px; gap:10px; padding:10px 0; }
.modal .motor-head { margin-top:18px; color:#727b80; font-size:12px; font-weight:400; }
.motor-row strong { font-size:12px; font-weight:500; }
.motor-row > span { font-size:12px; color:#727b80; }
.modal .motor-row .button { min-height:36px; font-size:12px; padding:8px 12px; }
.motor-modal { width:min(860px,calc(100vw - 32px)); }
@media (max-width:650px) { .battery-live-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.motor-other { margin-top:16px; }
.motor-other summary { cursor:pointer; padding:12px 0; color:#697278; font-size:13px; }
.quality-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin-top:18px; }
@@ -1289,8 +1285,6 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
@media (max-width:520px) {
.modal { padding:22px 18px; }
.modal .form-grid.two { grid-template-columns:1fr; }
.modal .motor-head,.modal .motor-row { grid-template-columns:56px minmax(80px,1fr) 74px 66px; gap:5px; font-size:12px; }
.modal .motor-row .button { padding:8px 6px; }
.quality-summary { gap:6px; }
.quality-summary div { padding:12px 8px; }
.quality-summary strong { font-size:17px; }
@@ -1385,7 +1379,6 @@ body #connect { height:var(--pill-lg); 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. */
@@ -1472,3 +1465,57 @@ body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); }
@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; }
/* Motor scaling and control-plane fit: keep interaction areas inside their rows. */
.primary-control-zone { grid-template-rows:clamp(76px,14vh,108px) minmax(0,1fr) auto; }
.engage-header-zone { height:auto; min-height:0; align-self:stretch; }
body #engage { height:100%; min-height:0; }
.controls-grid { --joy-size:min(40vh,calc(100cqw - 168px),380px); min-height:0; align-content:center; }
.joystick { min-width:0; min-height:0; max-width:100%; max-height:100%; }
.joystick-pad,.heave-pad { min-width:0; min-height:0; flex:1 1 auto; overflow:hidden; }
.control-card { min-width:0; min-height:0; overflow:hidden; }
.control-toolbar { flex-wrap:nowrap; min-height:48px; padding:5px 0 0; gap:clamp(8px,1.5vw,18px); }
.control-toolbar .output-limit { min-width:0; }
.control-toolbar .output-limit > span { flex:0 1 120px; width:120px; }
.control-toolbar .range-pill { width:clamp(112px,20cqw,190px); flex:0 1 190px; }
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
@media (max-height:610px) {
.primary-control-zone { grid-template-rows:clamp(64px,12vh,82px) minmax(0,1fr) auto; }
.controls-grid { --joy-size:min(34vh,calc(100cqw - 168px),300px); }
}
.motor-modal { width:min(720px,calc(100vw - 36px)); max-height:calc(100dvh - 28px); padding:clamp(18px,3vw,28px); overflow:auto; }
.motor-modal > h2 { margin:0 0 2px; }
.motor-scale-heading { display:flex; align-items:center; justify-content:space-between; padding:12px 0 7px; border-bottom:1px solid #e7eae7; color:#56635b; font-size:12px; }
.switch-control { display:flex; align-items:center; gap:9px; cursor:pointer; color:#34423a; font-size:12px; }
.switch-control input { position:absolute; opacity:0; width:1px; height:1px; }
.switch-track { position:relative; display:block; width:34px; height:20px; border-radius:999px; background:#d8ddda; transition:background-color 160ms ease-out; }
.switch-track::after { content:""; position:absolute; width:14px; height:14px; left:3px; top:3px; border-radius:50%; background:#fff; box-shadow:0 1px 2px #0002; transition:transform 160ms ease-out; }
.switch-control input:checked + .switch-track { background:#4d8767; }
.switch-control input:checked + .switch-track::after { transform:translateX(14px); }
.switch-control input:focus-visible + .switch-track { outline:2px solid #6598b5; outline-offset:3px; }
.motor-list { display:grid; }
.motor-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(100px,1.5fr) 42px 76px; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid #e7eae7; }
.motor-name { min-width:0; height:34px; padding:0 8px; border:1px solid transparent; border-radius:6px; background:transparent; font-size:13px; }
.motor-name:hover,.motor-name:focus { border-color:#d8dfda; background:#fff; }
.motor-row .motor-scale { width:100%; min-width:0; height:22px; padding:0; border:0; background:transparent; accent-color:#4d8767; }
.motor-scale-value { color:#344b3c; font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; text-align:right; }
.motor-reverse { min-height:30px; padding:5px 9px; font-size:11px; }
.motor-reverse:disabled { opacity:.42; cursor:not-allowed; }
.motor-channel { grid-column:1/-1; margin-top:-8px; color:#87918a; font-size:10px; }
.motor-mapping { margin-top:8px; }
.motor-mapping summary { padding:9px 0; color:#657168; font-size:12px; cursor:pointer; }
.motor-map-row { display:grid; grid-template-columns:65px minmax(0,1fr) 72px; align-items:center; gap:9px; padding:6px 0; border-bottom:1px solid #edf0ed; }
.motor-map-port { color:#4b5a50; font:11px ui-monospace,Consolas,monospace; }
.motor-map-row select { height:32px; font-size:12px; }
.motor-map-direction { color:#77827b; font-size:11px; text-align:right; }
body[data-motor-scaling="independent"] .control-toolbar .output-limit { opacity:.42; }
body[data-motor-scaling="independent"] .control-toolbar .range-pill { background:#dedfdf; }
.control-toolbar .range-pill :disabled { cursor:not-allowed; }
.controls-grid { align-content:start; }
.joystick-pad,.heave-pad { padding-top:0; }
.joystick { transform:translateY(-7px); }
@media (max-width:640px) { .motor-row { grid-template-columns:minmax(0,1fr) minmax(75px,1.3fr) 38px 70px; gap:7px; } .motor-map-row { grid-template-columns:54px minmax(0,1fr) 64px; gap:6px; } }
@media (max-width:520px) { .motor-modal { padding:18px 14px; } .motor-row { grid-template-columns:minmax(0,1fr) minmax(64px,1fr) 36px 66px; gap:5px; } .motor-name { font-size:12px; } .motor-reverse { padding:5px 6px; } }
@media (prefers-reduced-motion:reduce) { .switch-track,.switch-track::after { transition:none; } }
+164 -19
View File
@@ -28,6 +28,9 @@ class ControlApi:
self.worker.require_input_lease = True
self.vehicle_state = VehicleState()
self.battery_received_at = None
self.battery_last_current = None
self.battery_last_current_at = None
self.battery_session_consumed_mah = 0.0
self.demo_mode = False
self.lock = threading.Lock()
self._poll_lock = threading.Lock()
@@ -43,10 +46,26 @@ class ControlApi:
"link_quality": None,
"heavy_quality": None,
"log_path": None,
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
"motor_test_active": False,
"servos": {str(i): {"function": None, "direction": None, "output_us": None} for i in range(1, 17)},
}
self.servo_functions: dict[int, int] = {}
self.motor_directions: dict[int, int] = {}
self.motor_settings_path = Path(__file__).resolve().parent / "motor_settings.json"
self.motor_settings = {
"independent": False,
"customized": False,
"motors": {str(i): {"name": f"Motor {i}", "scale": 20} for i in range(1, 13)},
}
try:
saved_motor_settings = json.loads(self.motor_settings_path.read_text(encoding="utf-8"))
self.motor_settings = self._clean_motor_settings(saved_motor_settings)
except (OSError, ValueError, TypeError):
pass
self.worker.set_motor_scaling(
self.motor_settings["independent"],
{int(i): entry["scale"] for i, entry in self.motor_settings["motors"].items()},
)
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.link_path = Path(__file__).resolve().parent / "link_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
@@ -67,7 +86,7 @@ class ControlApi:
pass
self.link_config = {
"baud": 57600, "control_hz": 20,
"ping_hz": 1, "system_hz": 1, "servo_hz": 2,
"ping_hz": 1, "system_hz": 1, "battery_hz": 1, "servo_hz": 2,
"rc_hz": 2, "esc_hz": 1,
}
try:
@@ -78,11 +97,10 @@ class ControlApi:
def initial_state(self):
return {
"ports": self.refresh_ports(),
"battery_config": self.battery_config,
"link_config": self.link_config,
"motor_settings": self.motor_settings,
"state": self._snapshot(),
"model_info": self.get_saved_model_info(),
}
def refresh_ports(self):
@@ -259,8 +277,12 @@ class ControlApi:
self.state["servos"][str(servo)] = {
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
"output_us": None,
}
return self._snapshot()
self.battery_last_current = None
self.battery_last_current_at = None
self.battery_session_consumed_mah = 0.0
self.worker.rate_hz = int(rate)
with self.lock:
self.vehicle_state.disconnect()
@@ -285,6 +307,9 @@ class ControlApi:
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self):
self.worker.stop_motor_test()
with self.lock:
self.state["motor_test_active"] = False
if self.worker.heavy_quality_test is not None:
self.worker.cancel_heavy_quality_test()
if self.demo_mode:
@@ -305,6 +330,34 @@ class ControlApi:
def cancel_heavy_quality_test(self):
return self.worker.cancel_heavy_quality_test()
def start_motor_test(self, motor, throttle_percent):
motor, throttle_percent = int(motor), int(throttle_percent)
if self.demo_mode:
raise RuntimeError("Motor tests require a real flight-controller connection")
with self.lock:
if not self.state.get("connected") or not self.state.get("armed"):
raise RuntimeError("Connect and arm the vehicle before motor testing")
assignment = next((item for item in self.state["servos"].values() if item.get("motor") == motor), None)
if assignment is None:
raise ValueError(f"Motor {motor} is not assigned to a servo output")
if self.state.get("motor_test_active"):
raise RuntimeError("A motor test is already active")
effective_percent = round(throttle_percent * self.worker.motor_output_scale(motor))
self.state["motor_test_active"] = True
self.state["status"] = f"Motor {motor} test active at {effective_percent}% output"
try:
return self.worker.start_motor_test(motor, effective_percent)
except Exception:
with self.lock:
self.state["motor_test_active"] = False
raise
def stop_motor_test(self):
self.worker.stop_motor_test()
with self.lock:
self.state["status"] = "Motor test released; waiting for flight-controller stop"
return True
def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode:
with self.lock:
@@ -330,10 +383,44 @@ class ControlApi:
return True
def set_gain(self, value):
value = max(5, min(50, int(value)))
self.worker.gain = value / 100.0
value = max(0, min(100, int(value)))
if self.motor_settings["independent"]:
raise RuntimeError("Use per-motor scales while independent scaling is enabled")
with self.worker.lock:
self.worker.gain = value / 100.0
return value
@staticmethod
def _clean_motor_settings(settings):
if not isinstance(settings, dict):
raise ValueError("Motor settings must be an object")
requested = settings.get("motors", {})
if not isinstance(requested, dict):
raise ValueError("Motor settings are invalid")
motors = {}
for motor in range(1, 13):
entry = requested.get(str(motor), {})
if not isinstance(entry, dict):
entry = {}
name = str(entry.get("name", f"Motor {motor}")).strip()[:24] or f"Motor {motor}"
try:
scale = max(0, min(100, int(entry.get("scale", 20))))
except (TypeError, ValueError):
scale = 20
motors[str(motor)] = {"name": name, "scale": scale}
return {"independent": bool(settings.get("independent", False)), "customized": bool(settings.get("customized", False)), "motors": motors}
def save_motor_settings(self, settings, persist=True):
clean = self._clean_motor_settings(settings)
if persist:
self.motor_settings_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.motor_settings = clean
self.worker.set_motor_scaling(
clean["independent"],
{int(i): entry["scale"] for i, entry in clean["motors"].items()},
)
return clean
def command(self, name, sequence=None, expires_at=None):
if self.demo_mode:
if name not in ("manual", "arm", "disarm"):
@@ -425,6 +512,7 @@ class ControlApi:
self.worker.rate_hz = self.link_config["control_hz"]
self.worker.set_link_rates(self.link_config["ping_hz"], {
"SYS_STATUS": self.link_config["system_hz"],
"BATTERY_STATUS": self.link_config["battery_hz"],
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
"RC_CHANNELS": self.link_config["rc_hz"],
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
@@ -439,7 +527,7 @@ class ControlApi:
raise ValueError("Control rate must be 10 or 20 Hz")
if clean["ping_hz"] not in {1, 2, 5, 10}:
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
if any(clean[key] not in allowed for key in ("system_hz", "servo_hz", "rc_hz", "esc_hz")):
if any(clean[key] not in allowed for key in ("system_hz", "battery_hz", "servo_hz", "rc_hz", "esc_hz")):
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
self.link_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.link_config = clean
@@ -488,6 +576,7 @@ class ControlApi:
def _snapshot(self):
with self.lock:
snap = json.loads(json.dumps(self.state))
snap["motor_settings"] = self.motor_settings
snap["app_state"] = self._derive_app_state()
if not self.demo_mode:
snap.update(self.vehicle_state.snapshot())
@@ -503,6 +592,9 @@ class ControlApi:
if kind == "heartbeat":
_, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
if not armed and self.state.get("motor_test_active"):
self.state["motor_test_active"] = False
self.state["status"] = "Flight controller reports disarmed after motor test"
self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed),
@@ -515,16 +607,19 @@ class ControlApi:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery":
self.battery_received_at = time.monotonic()
voltage = float(event[1])
cells = self.battery_config["cells"]
per_cell = voltage / cells
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
percent = max(0, min(100, (per_cell - low) * 100 / (high - low)))
self.state["battery"] = {
"voltage": voltage, "per_cell": per_cell, "percent": percent,
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
}
self._consume_battery(event[1])
elif kind == "servo_output":
for servo, output_us in event[1].items():
if servo in self.state["servos"]:
self.state["servos"][servo]["output_us"] = output_us
elif kind == "motor_test_ack":
self.state["status"] = "Motor test accepted by flight controller"
elif kind == "motor_test_rejected":
self.state["motor_test_active"] = False
self.state["status"] = f"Motor test rejected: {event[1]}"
elif kind == "motor_test_stopped":
self.state["motor_test_active"] = False
self.state["status"] = f"Motor test ended: {event[1]}"
elif kind == "link_quality":
self.state["link_quality"] = event[1]
elif kind == "heavy_quality":
@@ -551,9 +646,59 @@ class ControlApi:
last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
mode=None, link_quality=None, status=status_msg)
mode=None, link_quality=None, status=status_msg, motor_test_active=False)
self.state["last_error"] = None
def _consume_battery(self, data):
self.battery_received_at = time.monotonic()
now = self.battery_received_at
previous = self.state.get("battery") or {}
battery = dict(previous)
battery["cells"] = self.battery_config["cells"]
voltage = data.get("voltage")
if voltage is not None and float(voltage) > 0:
battery["voltage"] = float(voltage)
battery["per_cell"] = float(voltage) / self.battery_config["cells"]
current = data.get("current_a")
if current is not None and float(current) >= 0:
current = float(current)
if self.battery_last_current is not None and self.battery_last_current_at is not None:
elapsed = now - self.battery_last_current_at
if 0 < elapsed <= 10:
average = (current + self.battery_last_current) / 2
self.battery_session_consumed_mah += average * 1000 * elapsed / 3600
self.battery_last_current = current
self.battery_last_current_at = now
battery["current_a"] = current
consumed = data.get("consumed_mah")
if consumed is not None and int(consumed) >= 0:
battery["consumed_mah"] = int(consumed)
battery["consumed_source"] = "Flight controller"
elif self.battery_last_current is not None:
battery["consumed_mah"] = round(self.battery_session_consumed_mah)
battery["consumed_source"] = "Session estimate"
remaining = data.get("remaining_percent")
if remaining is not None and 0 <= int(remaining) <= 100:
battery["remaining_percent"] = int(remaining)
battery["charge_source"] = "Flight controller"
capacity = self.battery_config["capacity_mah"]
if battery.get("consumed_mah") is not None:
battery["remaining_mah"] = max(0, capacity - battery["consumed_mah"])
elif battery.get("remaining_percent") is not None:
battery["remaining_mah"] = round(capacity * battery["remaining_percent"] / 100)
if battery.get("remaining_percent") is not None:
percent = battery["remaining_percent"]
elif battery.get("per_cell") is not None:
low = self.battery_config["empty_v_per_cell"]
high = self.battery_config["full_v_per_cell"]
percent = max(0, min(100, (battery["per_cell"] - low) * 100 / (high - low)))
battery["charge_source"] = "Voltage estimate"
else:
percent = None
battery["percent"] = percent
battery["low"] = percent is not None and percent <= self.battery_config["warning_percent"]
self.state["battery"] = battery
def _update_servo(self, servo):
function = self.servo_functions.get(servo)
motor = motor_number_for_function(function)
@@ -562,6 +707,7 @@ class ControlApi:
"label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
"motor": motor,
"direction": self.motor_directions.get(motor) if motor else None,
"output_us": (self.state["servos"].get(str(servo)) or {}).get("output_us"),
}
def close(self):
@@ -653,7 +799,6 @@ def main():
from desktop_shortcut import ensure_desktop_shortcut
ensure_desktop_shortcut()
api = ControlApi()
api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent
port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"