Compare commits

...

3 Commits

25 changed files with 2484 additions and 403 deletions
+1
View File
@@ -5,3 +5,4 @@ logs/
model_settings.json model_settings.json
link_settings.json link_settings.json
runtime/ runtime/
motor_settings.json
+7 -1
View File
@@ -16,14 +16,20 @@ The script creates a virtual environment and installs dependencies automatically
On first launch, it also adds a **SUB Control** desktop shortcut. On first launch, it also adds a **SUB Control** desktop shortcut.
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware. Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
Connection settings are under **•••**. Connection settings are under **•••**.
Click **Data link** for an extended connection test, or **Console** to inspect RX/TX messages and pause the view. Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details.
## 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 ## Controls
- Joystick: forward/back and turning. Vertical slider: rise/dive. - Joystick: forward/back and turning. Vertical slider: rise/dive.
- Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive. - Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive.
- Physical controller: select **Joystick** while disarmed. Left stick drives/turns; right stick controls rise/dive. **Setup** changes axes, inversion, and dead zone while disarmed.
- Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation. - Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
- Movement controls ease back to neutral when released. - Movement controls ease back to neutral when released.
- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle.
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`. Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
+7 -1
View File
@@ -16,14 +16,20 @@ The script creates a virtual environment and installs dependencies automatically
On first launch, it also adds a **SUB Control** desktop shortcut. On first launch, it also adds a **SUB Control** desktop shortcut.
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware. Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
Connection settings are under **•••**. Connection settings are under **•••**.
Click **Data link** for an extended connection test, or **Console** to inspect RX/TX messages and pause the view. Click **Data link** for an extended connection test, or **Console** to open the right-side RX/TX panel. Filter routine traffic, pause the view, or expand a message for full details.
## 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 ## Controls
- Joystick: forward/back and turning. Vertical slider: rise/dive. - Joystick: forward/back and turning. Vertical slider: rise/dive.
- Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive. - Keyboard: **W/S** forward/back, **A/D** or **Q/E** turn, **R/F** rise/dive.
- Physical controller: select **Joystick** while disarmed. Left stick drives/turns; right stick controls rise/dive. **Setup** changes axes, inversion, and dead zone while disarmed.
- Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation. - Hold **engage** for 1.4 seconds to request arming; tap **disengage** to request disarming. The app waits for vehicle confirmation.
- Movement controls ease back to neutral when released. - Movement controls ease back to neutral when released.
- In Joystick mode the graphics show the conditioned control demand and are read-only. Center sticks after switching modes, reconnecting, or returning focus. Controller buttons do not arm the vehicle.
Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`. Includes adjustable output limit, motor setup, voltage-based battery status, an STL viewer, and session logs in `logs/`.
+154 -8
View File
@@ -87,6 +87,8 @@ class MavlinkWorker:
# At 20 Hz this produces five small steps from neutral to full command. # At 20 Hz this produces five small steps from neutral to full command.
self.ramp_seconds = 0.25 self.ramp_seconds = 0.25
self.gain = 0.20 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.rate_hz = 20
self.target_system = 0 self.target_system = 0
self.target_component = 0 self.target_component = 0
@@ -110,7 +112,7 @@ class MavlinkWorker:
self.heavy_pending_pings = {} self.heavy_pending_pings = {}
self.heavy_quality_latest = None self.heavy_quality_latest = None
self.telemetry_rates_hz = { 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, "RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1,
} }
self.ping_sequence = 0 self.ping_sequence = 0
@@ -128,6 +130,11 @@ class MavlinkWorker:
self.param_retries = 0 self.param_retries = 0
self.param_types = {} self.param_types = {}
self.pending_sets = {} 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_lock = threading.Lock()
self.log_file = None self.log_file = None
self.log_path = None self.log_path = None
@@ -200,8 +207,10 @@ class MavlinkWorker:
# ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a # ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a
# 20 Hz neutral stream alive here only competes with ARM and # 20 Hz neutral stream alive here only competes with ARM and
# its acknowledgement on constrained links. # its acknowledgement on constrained links.
motor_test_mode = self._tick_motor_test(now)
if self.target_system and self.armed and now >= next_control: 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. # The first disarm packet is preceded by a neutral command.
# Until disarm is confirmed, keep sending neutral at a # Until disarm is confirmed, keep sending neutral at a
# lower rate instead of consuming 20 Hz on the weak link. # 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.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0 self.arm_retry_deadline = 0.0
self.arm_confirmation = 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.stream_config.clear()
self.pending_pings.clear() self.pending_pings.clear()
self.ping_results.clear() self.ping_results.clear()
@@ -337,6 +350,13 @@ class MavlinkWorker:
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED) armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
self.armed = armed self.armed = armed
self.config_paused = 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: if self.pending_arm_state is not None and armed == self.pending_arm_state:
self.pending_arm_state = None self.pending_arm_state = None
if not armed: if not armed:
@@ -361,6 +381,18 @@ class MavlinkWorker:
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL: if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
self.log("RX_STREAM_CONFIG_ACK", result_name) self.log("RX_STREAM_CONFIG_ACK", result_name)
return 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 if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")): and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")):
self.pending_arm_state = None self.pending_arm_state = None
@@ -375,8 +407,33 @@ class MavlinkWorker:
self.events.put(("statustext", text)) self.events.put(("statustext", text))
elif msg_type == "SYS_STATUS": elif msg_type == "SYS_STATUS":
voltage_mv = int(msg.voltage_battery) voltage_mv = int(msg.voltage_battery)
if 0 < voltage_mv < 65535: current_ca = int(msg.current_battery)
self.events.put(("battery", voltage_mv / 1000.0)) 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": elif msg_type == "PARAM_VALUE":
param_id = msg.param_id param_id = msg.param_id
if isinstance(param_id, bytes): if isinstance(param_id, bytes):
@@ -479,6 +536,67 @@ class MavlinkWorker:
"probe_supported": total > 0, "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): def _tick_link_probe(self, now):
if self.heavy_quality_test is not None: if self.heavy_quality_test is not None:
self._tick_heavy_quality(now) self._tick_heavy_quality(now)
@@ -753,6 +871,8 @@ class MavlinkWorker:
active = not (force_neutral or expired) active = not (force_neutral or expired)
target = self.axes if active else Axes() target = self.axes if active else Axes()
gain = self.gain gain = self.gain
independent_motor_scaling = self.independent_motor_scaling
motor_gains = dict(self.motor_gains)
if force_neutral: if force_neutral:
self.output_axes = Axes() self.output_axes = Axes()
else: else:
@@ -775,10 +895,25 @@ class MavlinkWorker:
) )
limited_forward = axes.forward / horizontal_mix_peak limited_forward = axes.forward / horizontal_mix_peak
limited_yaw = axes.yaw / horizontal_mix_peak limited_yaw = axes.yaw / horizontal_mix_peak
x = round(limited_forward * gain * 1000) if independent_motor_scaling:
y = round(axes.lateral * gain * 1000) # The desktop controller's SimpleROV-3 profile maps motors 1/2
r = round(limited_yaw * gain * 1000) # to surge/turn and motor 3 to vertical thrust. Convert the two
z = round(NEUTRAL_Z + axes.vertical * gain * 500) # 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( self.log(
"TX_MANUAL_CONTROL", "TX_MANUAL_CONTROL",
f"x={x} y={y} z={z} r={r} active={active} " f"x={x} y={y} z={z} r={r} active={active} "
@@ -851,6 +986,17 @@ class MavlinkWorker:
manual, 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 set_axes(self, axes: Axes, sequence=None):
def conditioned(value): def conditioned(value):
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps. # Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
+31
View File
@@ -0,0 +1,31 @@
/* Pure conditioning and log parsing tests: no hardware or browser needed. */
const assert=require('node:assert/strict');
const {deadZone,defaults,read,Motion}=require('../web/controller-input.js');
const {parse}=require('../web/console-view.js');
const pad={connected:true,axes:[0,0,0,0]};
assert.equal(deadZone(.06),0);assert.equal(deadZone(-1),-1);assert.equal(deadZone(1),1);
assert.equal(defaults({axes:[0,0]}).vertical,-1);
assert.equal(read({...pad,connected:false},defaults(pad)),null);
assert.equal(read(pad,{...defaults(pad),vertical:10}),null);
const diagonal=read({...pad,axes:[1,-1,0,-1]},defaults(pad));
assert(Math.abs(Math.hypot(diagonal.turn,diagonal.forward)-1)<1e-10);
assert.equal(diagonal.vertical,1);
assert.equal(read({...pad,axes:[-.5,0,0,0]},{...defaults(pad),invertTurn:true}).turn,deadZone(.5));
const motion=new Motion(),target={turn:.6,forward:.8,vertical:1};
assert.deepEqual(motion.step(target,0),{turn:0,forward:0,vertical:0});
const partial=motion.step(target,16);assert(partial.vertical>0&&partial.vertical<.3);
let current;for(let time=32;time<=1000;time+=16){current=motion.step(target,time);assert(Math.hypot(current.turn,current.forward)<=1+1e-10)}
assert(current.vertical>.99);
const neutral={turn:0,forward:0,vertical:0};
assert(motion.step(neutral,1024).vertical>.99);
assert(motion.step(neutral,1200).vertical>0);
assert.deepEqual(motion.step(neutral,1400),neutral);
motion.reset();assert.deepEqual(motion.step(target,1500),neutral);
assert.equal(parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"HEARTBEAT","base_mode":128}').quiet,true);
const ack=parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0}');
assert.equal(ack.time,'10:11:12.123');assert.equal(ack.type,'COMMAND_ACK');assert.equal(ack.tone,'command');
assert.equal(parse('2026-10-01T10:11:12 [TX_LINK_PROBE_TIMEOUT] retry').quiet,false);
assert.equal(parse('2026-10-01T10:11:12 [RX] {"mavpackettype":"STATUSTEXT","severity":3,"text":"warning"}').tone,'warning');
assert.equal(parse('2026-10-01T10:11:12 [RX] null').type,'RX');
assert.equal(parse('unstructured text').payload,'unstructured text');
console.log('PASS: dead zone, mapping/inversion, diagonal cap, ramp, finite center return, resets, console parsing and warning visibility.');
+137 -7
View File
@@ -24,19 +24,23 @@ async function main(){
try{ try{
const page=await browser.newPage({viewport:{width:1080,height:720}}); const page=await browser.newPage({viewport:{width:1080,height:720}});
const errors=[];page.on('pageerror',error=>errors.push(error.message)); const errors=[];page.on('pageerror',error=>errors.push(error.message));
const setArmed=value=>page.evaluate(value=>{
Object.assign(testBridge.state,{connected:true,armed:value,armed_known:true,app_state:value?'armed':'connected_disarmed'});render(testBridge.state);
},value);
await page.addInitScript(()=>{ await page.addInitScript(()=>{
const clone=value=>JSON.parse(JSON.stringify(value)); const clone=value=>JSON.parse(JSON.stringify(value));
const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null}; const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null};
const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null}; const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null};
const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20}; const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20};
const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}; const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1};
const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[]}; const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[],axes:[],axisVisual:[],stops:0};
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false}; window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false};
window.testPads=[];Object.defineProperty(navigator,'getGamepads',{value:()=>window.testPads});
window.pywebview={api:{ window.pywebview={api:{
initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}), initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}),
refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state), refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state),
get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>true,set_axes:async()=>true, get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>{calls.stops++;return true},set_axes:async(...axes)=>{calls.axes.push(axes);calls.axisVisual.push({joy:joystick3D.getState(),vertical:verticalControl.getState().value});return true},
save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)}, save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)},
save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)}, save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)},
connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)}, connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)},
@@ -45,7 +49,7 @@ async function main(){
set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true}, set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true},
start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)}, start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)},
cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)}, cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)},
read_console:async()=>{calls.console++;return {rx:['RX sample '+calls.console],tx:['TX sample '+calls.console]}}, read_console:async()=>{calls.console++;return {rx:['2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0,"sample":1}', '2026-10-01T10:11:12.124 [RX_BAD_DATA] corrupt_frame='+calls.console, '2026-10-01T10:11:12.125 [RX] {"mavpackettype":"HEARTBEAT"}'],tx:['2026-10-01T10:11:12.124 [TX_COMMAND] ARM '+calls.console]}},
minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true
}}; }};
}); });
@@ -107,27 +111,153 @@ async function main(){
await page.waitForFunction(()=>!testBridge.state.heavy_quality.running); await page.waitForFunction(()=>!testBridge.state.heavy_quality.running);
await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal'); await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal');
await page.locator('#console').click(); await page.locator('#console').click();
await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('RX sample')); await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('COMMAND_ACK'));
await page.locator('#console-pause').click(); await page.locator('#console-pause').click();
const paused=await page.locator('#console-rx-output').textContent(); const paused=await page.locator('#console-rx-output').textContent();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused); await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused);
await page.locator('#console-pause').click(); await page.locator('#console-pause').click();
await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused); await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused);
await page.screenshot({path:path.join(output,'console.png')});await close('console-modal'); await page.screenshot({path:path.join(output,'console.png')});await page.locator('#console-close').click();
for(const viewport of [{width:900,height:560},{width:480,height:720}]){ for(const viewport of [{width:900,height:560},{width:480,height:720}]){
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','console-modal']){ for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','controller-modal']){
await page.evaluate(id=>openModal(id),id); await page.evaluate(id=>openModal(id),id);
const box=await page.locator('#'+id).boundingBox(); const box=await page.locator('#'+id).boundingBox();
assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport'); assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport');
assert.equal(await page.evaluate(()=>canDrive()),false); assert.equal(await page.evaluate(()=>canDrive()),false);
const closeGeometry=await page.locator('#'+id+' .modal-close').evaluate(button=>{
const b=button.getBoundingClientRect(),s=button.querySelector('svg').getBoundingClientRect();
return {dx:Math.abs(b.x+b.width/2-s.x-s.width/2),dy:Math.abs(b.y+b.height/2-s.y-s.height/2),bg:getComputedStyle(button).backgroundColor};
});
assert(closeGeometry.dx<.5&&closeGeometry.dy<.5,id+' close icon must be centered');
assert.equal(closeGeometry.bg,'rgba(0, 0, 0, 0)');
await close(id); await close(id);
} }
} }
await page.setViewportSize({width:1080,height:720});await openLink(); await page.setViewportSize({width:1080,height:720});await openLink();
await page.screenshot({path:path.join(output,'link.png')});await close('link-modal'); await page.screenshot({path:path.join(output,'link.png')});await close('link-modal');
await page.setViewportSize({width:1080,height:720});
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:true,armed:false,armed_known:true,app_state:'connected_disarmed',battery:{voltage:15.8,percent:72,cells:4,low:false}});focusChanged(true);render(testBridge.state)});
await page.locator('#engage').focus();
assert.equal(await page.locator('#engage').evaluate(el=>getComputedStyle(el).outlineStyle),'none');
await page.evaluate(()=>startEngageHold());
assert.equal(await page.locator('#input-joystick').isDisabled(),true);
await page.evaluate(()=>cancelEngageHold(false));
assert.equal(await page.locator('#input-joystick').isDisabled(),false);
await page.locator('#input-joystick').click();
for(const pending of ['arm','disarm']){
await page.evaluate(pending=>{testBridge.state.pending_command=pending;render(testBridge.state)},pending);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
assert.equal(await page.evaluate(()=>{setInputMode('keyboard');return inputMode}),'joystick');
}
await page.evaluate(()=>{delete testBridge.state.pending_command;testBridge.state.armed_known=false;render(testBridge.state)});
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await setArmed(true);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await page.waitForFunction(()=>document.getElementById('controller-status').textContent.includes('Waiting'));
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[1,-1,0,-1]}]);
await page.waitForFunction(()=>document.getElementById('controller-status').textContent==='Center sticks to resume');
assert.deepEqual(await page.evaluate(()=>({...control})),{turn:0,forward:0,vertical:0});
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
await page.waitForFunction(()=>!controllerNeedCenter);
await page.keyboard.down('w');await page.waitForTimeout(420);await page.keyboard.up('w');
assert.equal(await page.evaluate(()=>control.forward),0);
await page.evaluate(()=>testPads[0].axes=[1,-1,0,-1]);
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.8);
await page.waitForFunction(()=>control.vertical>.99);
const matching=await page.evaluate(()=>{
const joy=joystick3D.getState(),sent=testBridge.calls.axes.at(-1);
return {length:Math.hypot(control.turn,control.forward),joy,vertical:control.vertical,sent,visual:testBridge.calls.axisVisual.at(-1),control:{...control}};
});
assert(matching.length<=1.00001);
assert.equal(matching.joy.x,matching.control.turn);assert.equal(matching.joy.y,-matching.control.forward);
assert.equal(matching.sent[0],-matching.visual.joy.y);assert.equal(matching.sent[1],matching.visual.joy.x);assert.equal(matching.sent[2],matching.visual.vertical);
// Dispatch even though CSS excludes pointers, to exercise the handler gate.
await page.locator('#joystick-canvas').dispatchEvent('pointerdown',{pointerId:91,clientX:20,clientY:20,button:0});
await page.locator('#heave').dispatchEvent('pointerdown',{pointerId:92,clientX:20,clientY:20,button:0});
assert.equal(await page.evaluate(()=>joystick3D.getState().active||verticalControl.getState().active),false);
await page.locator('#console').click();
await page.waitForFunction(()=>!document.getElementById('console-dock').hidden);
await page.waitForFunction(()=>document.querySelector('.rx-dot').classList.contains('lit')&&document.querySelector('.tx-dot').classList.contains('lit'));
await page.locator('#console-pause').click();
await page.waitForTimeout(500);
assert.equal(await page.locator('.rx-dot').evaluate(el=>el.classList.contains('lit')),false);
assert.equal(await page.locator('.tx-dot').evaluate(el=>el.classList.contains('lit')),false);
await page.locator('#console-pause').click();
assert.equal(await page.evaluate(()=>canDrive()),true);
assert.equal(await page.locator('#console-rx-output .console-row').count(),2);
await page.locator('#console-filter').selectOption('all');
assert.equal(await page.locator('#console-rx-output .console-row').count(),3);
await page.locator('#console-rx-output details').first().locator('summary').click();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output details').first().evaluate(el=>el.open),true);
await page.screenshot({path:path.join(output,'controller-console.png')});await page.locator('#console-close').click();
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.95);
await page.waitForFunction(()=>control.vertical===0&&control.turn===0&&control.forward===0);
await setArmed(false);
assert.equal(await page.locator('#input-keyboard').isDisabled(),false);
await page.locator('#controller-settings').click();
await page.screenshot({path:path.join(output,'controller-setup.png')});
await page.locator('#controller-form [name="invertTurn"]').check();
await page.locator('#controller-form').getByRole('button',{name:'Cancel',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
await page.locator('#controller-settings').click();
assert.equal(await page.locator('#controller-form [name="invertTurn"]').isChecked(),false);
await page.locator('#controller-form [name="invertTurn"]').check();
await page.locator('#controller-form').getByRole('button',{name:'Save mapping',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
await setArmed(true);
await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);
await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>testPads=[]);
await page.waitForFunction(()=>control.turn===0);
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[.5,0,0,0]}]);
await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>focusChanged(false));assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>focusChanged(true));await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>testPads[0].id='Different controller');
await page.waitForFunction(()=>control.turn===0);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await setArmed(false);
await page.locator('#input-keyboard').click();
await setArmed(true);
await page.keyboard.down('w');await page.waitForFunction(()=>control.forward>.9);await page.keyboard.up('w');
await page.waitForFunction(()=>control.forward===0);
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:false,armed_known:false});render(testBridge.state)});
assert.equal(await page.evaluate(()=>canDrive()),false);
await setArmed(false);
const deckLayout=await page.evaluate(()=>{
const battery=document.querySelector('.battery-card'),sidebar=document.querySelector('.sidebar-deck'),actions=document.querySelector('.activity-actions'),heading=document.querySelector('.activity-heading');
return {last:battery===document.querySelector('.telemetry-zone').lastElementChild,title:heading.textContent,
actionHeight:actions.querySelector('button').getBoundingClientRect().height,
batteryHeight:battery.getBoundingClientRect().height,connectHeight:document.querySelector('#connect').getBoundingClientRect().height,
bottomGap:sidebar.getBoundingClientRect().bottom-battery.getBoundingClientRect().bottom,
overlay:getComputedStyle(document.querySelector('.battery-copy')).zIndex,
actionRightGap:actions.getBoundingClientRect().right-actions.lastElementChild.getBoundingClientRect().right,
sidebarPadding:getComputedStyle(sidebar).paddingTop,
workspacePadding:getComputedStyle(document.querySelector('.controls-workspace')).paddingTop};
});
assert.equal(deckLayout.last,true);assert.equal(deckLayout.title,'Activity:');
assert.equal(deckLayout.actionHeight,32);assert.equal(deckLayout.batteryHeight,62);assert.equal(deckLayout.connectHeight,62);
assert(deckLayout.bottomGap>=10&&deckLayout.bottomGap<=24);assert.equal(deckLayout.overlay,'1');
assert.equal(deckLayout.actionRightGap,0);assert.equal(deckLayout.sidebarPadding,deckLayout.workspacePadding);
const batteryReadout=await page.locator('.battery-card').evaluate(el=>{
const percent=el.querySelector('#battery-percent'),voltage=el.querySelector('#battery-title');
return {percent:percent.textContent,voltage:voltage.textContent,border:getComputedStyle(el).borderTopWidth,
mainSize:parseFloat(getComputedStyle(percent).fontSize),voltageSize:parseFloat(getComputedStyle(voltage).fontSize)};
});
assert.match(batteryReadout.percent,/^\d+%$/);assert.match(batteryReadout.voltage,/^\d+\.\d{2} V$/);
assert.equal(batteryReadout.border,'0px');assert(batteryReadout.mainSize>batteryReadout.voltageSize);
await page.screenshot({path:path.join(output,'ui-cleanup.png')});
assert.deepEqual(errors,[]); assert.deepEqual(errors,[]);
console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, and five dialogs at two window sizes.'); console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, five dialogs at two window sizes; controller conditioning/read-only graphics, actual bridge values, mapping, focus/unplug/identity safety and dock filtering; mode locking during hold/armed/pending/unknown states, centered SVG close icons and bottom battery layout.');
}catch(error){ }catch(error){
const page=browser.contexts()[0]?.pages()[0]; const page=browser.contexts()[0]?.pages()[0];
if(page){ if(page){
+287 -81
View File
@@ -6,7 +6,8 @@ const STATUS_LABELS = {
link_degraded: 'Signal lost — check tether', link_degraded: 'Signal lost — check tether',
}; };
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const control={forward:0,turn:0,vertical:0}, 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 api=(name,...args)=>window.pywebview.api[name](...args);
const activity=[]; const activity=[];
let lastReportedStatus=''; let lastReportedStatus='';
@@ -14,7 +15,7 @@ function recordActivity(message){
if(!message||activity[0]===message)return; if(!message||activity[0]===message)return;
activity.unshift(String(message));activity.length=Math.min(activity.length,3); activity.unshift(String(message));activity.length=Math.min(activity.length,3);
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{ ['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]} const row=$(selector);if(row){const changed=row.textContent!==(activity[i]||'');row.textContent=activity[i]||'';row.hidden=!activity[i];if(changed&&!row.hidden&&!matchMedia('(prefers-reduced-motion:reduce)').matches)row.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'})}
}); });
} }
function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})} function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})}
@@ -22,7 +23,127 @@ let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehi
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(), bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now(); localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750; const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive; window.canDrive=canDrive;
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
const controllerMotion=new ControllerInput.Motion();
const controllerId=pad=>pad.index+':'+pad.id;
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
function loadControllerConfig(pad){
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
}
function selectController(pad){
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
}
function setInputMode(mode){
if(mode===inputMode||inputConfigurationLocked())return false;
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
$('#controller-settings').hidden=mode!=='joystick';
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys');
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
return true;
}
function inputConfigurationLocked(){
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
}
function syncInputModeControls(){
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
$('.input-mode-switch').dataset.locked=String(locked);
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
}
$('#controller-settings').disabled=locked||!pads.length;
$('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup';
if($('#controller-modal').open){
const form=$('#controller-form');
for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad;
$('#controller-device').disabled=locked||!pads.length;
const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.';
if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback);
}
}
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
$('#input-joystick').onclick=()=>setInputMode('joystick');
function fillControllerForm(){
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
if(!pads.length)device.add(new Option('No controller detected',''));
device.value=controllerKey||'';
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
const config=controllerConfig||ControllerInput.defaults(pad);
for(const name of ['turn','forward','vertical']){
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
const invert='invert'+name[0].toUpperCase()+name.slice(1);
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
}
form.elements.deadZone.value=Math.round(config.deadZone*100);
device.disabled=inputConfigurationLocked()||!pads.length;
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
}
$('#controller-device').onchange=e=>{
if(inputConfigurationLocked())return;
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
if(pad){selectController(pad);fillControllerForm()}
};
$('#controller-form').onsubmit=e=>{
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
const config={deadZone:Number(form.elements.deadZone.value)/100};
for(const name of ['turn','forward','vertical']){
config[name]=Number(form.elements[name].value);
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
}
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
}
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
resetInputs();closeModal($('#controller-modal'));
};
function pollController(now){
try{
if(inputMode==='joystick'){
const pads=getControllers();
if(controllerKey===null&&pads.length)selectController(pads[0]);
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id;
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
if(!target||!canDrive()){
if(controllerWasDriving)resetInputs();
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
}else{
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
if(!controllerNeedCenter){
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
control.turn=demand.turn;control.forward=demand.forward;
window.joystick3D?.setVisual(demand.turn,-demand.forward);
setHeave(demand.vertical,false);updateReadouts();sendAxes();
}
}
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
if($('#controller-modal').open){
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes;
if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm();
}
}else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'}
}catch(error){
if(controllerWasDriving)resetInputs();
$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard';
}
requestAnimationFrame(pollController);
}
document.body.dataset.inputMode=inputMode;
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}} function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)} function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))}); document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
@@ -34,7 +155,7 @@ function populatePorts(ports){
if(!p)return; if(!p)return;
const old=p.value||''; const old=p.value||'';
p.innerHTML=''; p.innerHTML='';
const allList=[]; const allList=[];
const seen=new Set(); const seen=new Set();
@@ -84,20 +205,21 @@ function sendAxes(){
setInterval(()=>{if(canDrive())sendAxes()},250); setInterval(()=>{if(canDrive())sendAxes()},250);
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);} function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();}; window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)} function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0; const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();} function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();}
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()} function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)}); heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)} function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})}; window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
function resetInputs(){ function resetInputs(){
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
if(gainPointer!==null)releaseGain({pointerId:gainPointer}); if(gainPointer!==null)releaseGain({pointerId:gainPointer});
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false); clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
window.joystick3D?.reset(); window.joystick3D?.reset();
@@ -120,9 +242,11 @@ function setFeedback(el,message=''){
function openModal(id){ function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id; const el=typeof id==='string'?document.getElementById(id):id;
if(!el||el.open)return; if(!el||el.open)return;
if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return}
resetInputs(); resetInputs();
el._returnFocus=document.activeElement; el._returnFocus=document.activeElement;
if(el.id==='battery-modal')fillBatteryForm(); if(el.id==='battery-modal')fillBatteryForm();
if(el.id==='controller-modal')fillControllerForm();
if(el.id==='link-modal'){ if(el.id==='link-modal'){
fillLinkForm(); fillLinkForm();
$('#custom-port-status').textContent=''; $('#custom-port-status').textContent='';
@@ -325,31 +449,50 @@ if(customPortBtn&&customPortInput){
} }
const engageButton=$('#engage'); const engageButton=$('#engage');
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label'); const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null; let engageLabelValue=$('#engage-label')?.textContent||'engage';
const ENGAGE_HOLD_MS=1400; let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null,engageAppendFrame=null;
const ENGAGE_HOLD_MS=600;
function applyEngageVisual(next){ function applyEngageVisual(next){
if(!engageButton)return; if(!engageButton)return;
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase(); 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. // Update the state label without delaying the requested control action.
engageButton.disabled=!next.action; engageButton.disabled=!next.action;
engageButton.setAttribute('aria-busy',String(next.pending)); engageButton.setAttribute('aria-busy',String(next.pending));
engageButton.setAttribute('aria-label',label); 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}`; const key=`${next.label}/${next.tone}`;
if(engageVisualState===key)return; if(engageVisualState===key)return;
const previous=engageVisualState;engageVisualState=key; const previous=engageVisualState;engageVisualState=key;
if(engageAppendFrame)cancelAnimationFrame(engageAppendFrame);
clearTimeout(engageTransitionTimer); clearTimeout(engageTransitionTimer);
engageButton.classList.remove('arm','danger','subtle','charged','pending'); engageButton.classList.remove('arm','danger','subtle','charged','pending');
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm'); engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%'); 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; if(engageProgressLabel)engageProgressLabel.textContent=label;
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label; const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
if(previous){ if(previous){
engageButton.classList.remove('state-transition');void engageButton.offsetWidth; engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
engageButton.classList.add('state-transition'); 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(){ function completeEngage(){
@@ -393,12 +536,14 @@ function startEngageHold(e){
if(view.action!=='arm'||!inputFocused||engageStart)return; if(view.action!=='arm'||!inputFocused||engageStart)return;
if(e&&e.button!==undefined&&e.button!==0)return; if(e&&e.button!==undefined&&e.button!==0)return;
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%'); engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
syncInputModeControls();
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId); if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
engageHoldTimer=requestAnimationFrame(updateEngageProgress); engageHoldTimer=requestAnimationFrame(updateEngageProgress);
} }
function cancelEngageHold(showHint=true){ function cancelEngageHold(showHint=true){
if(!engageStart)return; if(!engageStart)return;
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled'); const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
syncInputModeControls();
} }
if(engageButton){ if(engageButton){
@@ -409,7 +554,7 @@ if(engageButton){
engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null}); engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null});
engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)}); engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)});
engageButton.addEventListener('contextmenu',e=>e.preventDefault()); engageButton.addEventListener('contextmenu',e=>e.preventDefault());
engageButton.onclick=()=>{ engageButton.onclick=()=>{
if(suppressEngageClick){suppressEngageClick=false;return} if(suppressEngageClick){suppressEngageClick=false;return}
if(view.action==='disarm')requestControl('disarm'); if(view.action==='disarm')requestControl('disarm');
@@ -417,35 +562,72 @@ if(engageButton){
} }
$('#logs').onclick=()=>api('open_logs'); $('#logs').onclick=()=>api('open_logs');
const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output'); const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false; let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0;
const consolePulseTimers={};
function pulseConsole(side){
const dot=$('.'+side+'-dot');
clearTimeout(consolePulseTimers[side]);dot.classList.add('lit');
consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260);
}
function renderConsole(){
const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache);
if(signature===consoleSignature)return;
consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter);
}
function setConsoleOpen(open){
consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open);
if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')}
else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180);
$('#console').setAttribute('aria-expanded',String(open));
if(open)refreshConsole();else $('#console').focus({preventScroll:true});
}
$('#console').setAttribute('aria-controls','console-dock');
$('#console').setAttribute('aria-expanded','false');
$('#console').onclick=()=>setConsoleOpen(!consoleOpen);
$('#console-close').onclick=()=>setConsoleOpen(false);
$('#console-filter').onchange=renderConsole;
$('#console-pause').onclick=()=>{ $('#console-pause').onclick=()=>{
consolePaused=!consolePaused; consolePaused=!consolePaused;
$('#console-pause').textContent=consolePaused?'Resume':'Pause'; $('#console-pause').textContent=consolePaused?'Resume':'Pause';
$('#console-pause').setAttribute('aria-pressed',String(consolePaused)); $('#console-pause').setAttribute('aria-pressed',String(consolePaused));
$('#console-state').textContent=consolePaused?'Paused · session logging continues':'Live · follows new messages'; $('#console-state').textContent=consolePaused?'Paused':'';
$('#console-state').hidden=!consolePaused;
if(!consolePaused)refreshConsole(); if(!consolePaused)refreshConsole();
}; };
async function refreshConsole(){ async function refreshConsole(){
if(!consoleDialog?.open||!bridgeReady||consoleRefreshBusy||consolePaused)return; if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
consoleRefreshBusy=true; consoleRefreshBusy=true;
try{ try{
const rxAtBottom=consoleRx.scrollHeight-consoleRx.scrollTop-consoleRx.clientHeight<36; const sides=await withTimeout(api('read_console',150),1500);
const txAtBottom=consoleTx.scrollHeight-consoleTx.scrollTop-consoleTx.clientHeight<36; if(consolePaused||!consoleOpen)return;
const sides=await api('read_console',150); const filter=$('#console-filter').value;
if(consolePaused||!consoleDialog.open)return; for(const side of ['rx','tx']){
consoleRx.textContent=sides.rx?.length?sides.rx.join('\n'):'Waiting for vehicle data…'; const previous=new Set(consoleCache[side]||[]);
consoleTx.textContent=sides.tx?.length?sides.tx.join('\n'):'Waiting for transmitted data…'; if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side);
if(rxAtBottom)consoleRx.scrollTop=consoleRx.scrollHeight; }
if(txAtBottom)consoleTx.scrollTop=consoleTx.scrollHeight; consoleCache=sides;renderConsole();
}catch(error){ $('#console-state').textContent='';$('#console-state').hidden=true;
$('#console-state').textContent=`Console unavailable: ${error.message}`; }catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}}
}
finally{consoleRefreshBusy=false} finally{consoleRefreshBusy=false}
} }
$('#console').onclick=()=>{openModal(consoleDialog);refreshConsole()};
setInterval(refreshConsole,750); 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); $('#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 // Explicit pointer ownership makes the full range rail a reliable touch target
// in the embedded window, without depending on native range drag defaults. // in the embedded window, without depending on native range drag defaults.
@@ -476,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')}; $('#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}} 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(); 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){ async function requestMotorEdit(servo,kind,value){
if(motorEdits.has(servo))return; if(motorEdits.has(servo))return;
const s=vehicleSnapshot.servos?.[String(servo)]||{}; const s=vehicleSnapshot.servos?.[String(servo)]||{};
@@ -498,41 +684,40 @@ async function requestMotorEdit(servo,kind,value){
} }
function renderMotors(servos){ function renderMotors(servos){
for(const [servo,edit] of motorEdits){ for(const [servo,edit] of motorEdits){
const s=servos[String(servo)]||{}; const state=servos[String(servo)]||{};
if((edit.kind==='function'?s.function:s.direction)===edit.expected){ if((edit.kind==='function'?state.function:state.direction)===edit.expected){motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`)}
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.`)}
}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 editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known); const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
const assigned=Object.values(servos).filter(s=>s.motor).length; const assignments=Object.entries(servos).filter(([,state])=>state.motor).sort((a,b)=>a[1].motor-b[1].motor);
$('#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 servoByMotor=new Map(assignments.map(([servo,state])=>[String(state.motor),{servo,...state}]));
const signature=JSON.stringify([servos,editable,[...motorEdits]]); $('#motor-status').textContent=!vehicleSnapshot.connected?'Offline':vehicleSnapshot.armed?'Armed':`${assignments.length} outputs`;
if(signature===servoSignature)return; const shownMotors=[...new Set(assignments.map(([,state])=>state.motor))].sort((a,b)=>a-b);
servoSignature=signature; 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)]);
const list=$('#motor-list'),expanded=list.querySelector('details')?.open; if(structure!==servoSignature){
list.replaceChildren(); servoSignature=structure;const list=$('#motor-list');list.replaceChildren();
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded; for(const motor of shownMotors){
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary); const row=document.createElement('article');row.className='motor-row';row.dataset.motor=String(motor);
for(let i=1;i<=16;i++){ 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 s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row'; 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(()=>{})};
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`; 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(()=>{});
const select=row.querySelector('select'); row.querySelector('.motor-reverse').onclick=()=>{const state=servoByMotor.get(String(motor));if(state?.servo)requestMotorEdit(state.servo,'direction')};list.append(row);
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))); const details=document.createElement('details');details.className='motor-mapping';const summary=document.createElement('summary');summary.textContent='Assignments';details.append(summary);
select.value=String(s.motor||0); for(let i=1;i<=16;i++){
select.disabled=!editable||!!pending||s.function==null; 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>';
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value); 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('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal'; row.querySelector('.motor-map-direction').textContent=pending?'Saving…':state.direction==null?'—':state.direction===-1?'Reversed':'Normal';details.append(row);
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null; }
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`); list.append(details);
reverse.onclick=()=>requestMotorEdit(i,'direction'); }
(s.motor?list:other).append(row); 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){ function renderHeavyQuality(test,connected,armed){
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel'); const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
@@ -564,11 +749,13 @@ function render(s,confirmed=false){
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null; (s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
const previous=view; const previous=view;
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError}); view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
syncInputModeControls();
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs(); if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected'; appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed); renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
document.body.dataset.appState=appState; 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); document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
$('#port').disabled=!!s.connected; $('#port').disabled=!!s.connected;
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true'; $('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
@@ -603,8 +790,8 @@ function render(s,confirmed=false){
controlStatus.classList.toggle('is-error',view.error); controlStatus.classList.toggle('is-error',view.error);
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused)); controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
const motorBtn=$('[data-open="motors-modal"]'); const motorBtn=$('[data-open="motors-modal"]');
if(motorBtn)motorBtn.disabled=(!isOnline); if(motorBtn)motorBtn.disabled=false;
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error; 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); const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)} if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
@@ -614,14 +801,29 @@ function render(s,confirmed=false){
updateReadouts(); updateReadouts();
const b=s.battery; const b=s.battery;
if(b){ if(b){
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`; $('#battery-title').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'Voltage unavailable';
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`; $('#battery-percent').textContent=Number.isFinite(b.percent)?`${Math.round(b.percent)}%`:'—';
$('#battery-fill').style.width=`${b.percent}%`; $('#battery-live-voltage').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'—';
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'; $('#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=`${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{ }else{
$('#battery-title').textContent='—'; $('#battery-title').textContent='No voltage';
$('#battery-percent').textContent='waiting'; $('#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'; $('#battery-fill').style.width='0';
$('.battery-card').dataset.low='false';
} }
const quality=s.link_quality,qualityCard=$('#link-quality'); const quality=s.link_quality,qualityCard=$('#link-quality');
if(qualityCard){ if(qualityCard){
@@ -758,20 +960,22 @@ async function boot(){
if(booted||booting||!hasDesktopApi())return; if(booted||booting||!hasDesktopApi())return;
booting=true;clearTimeout(bootRetry); booting=true;clearTimeout(bootRetry);
try{ 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); const init=await withTimeout(api('initial_state'),4000);
batteryConfig=init.battery_config||{}; batteryConfig=init.battery_config||{};
linkConfig=init.link_config||{}; linkConfig=init.link_config||{};
populatePorts(init.ports); motorSettings=init.motor_settings||motorSettings;
syncMotorScalingUI();
fillBatteryForm(); fillBatteryForm();
fillLinkForm(); fillLinkForm();
bridgeReady=true;booted=true;lastBridgeAt=performance.now(); bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state); if(!demoActive)render(init.state);
poll(); poll();
// Populate the serial selector before starting the optional model load. // COM discovery can block on Windows drivers; finish the first paint first.
// A large cached mesh must never delay an operator seeing USB COM ports. setTimeout(()=>withTimeout(api('refresh_ports'),4000).then(populatePorts).catch(()=>{}),0);
loadDefaultModel(); // 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(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
if(appState==='disconnected'||demoActive){ if(appState==='disconnected'||demoActive){
try{populatePorts(await withTimeout(api('refresh_ports'),1500))} try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
@@ -795,8 +999,10 @@ function waitForBridge(){
addEventListener('pywebviewready',boot); addEventListener('pywebviewready',boot);
render(vehicleSnapshot); render(vehicleSnapshot);
if(document.readyState==='loading'){ if(document.readyState==='loading'){
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()}); addEventListener('DOMContentLoaded',waitForBridge);
}else{ }else{
loadDefaultModel();
waitForBridge(); waitForBridge();
} }
requestAnimationFrame(pollController);
+70
View File
@@ -0,0 +1,70 @@
/* Read-only presentation of the existing local RX/TX logs. */
((root)=>{
const quiet=/^(HEARTBEAT|TIMESYNC|LINK_PROBE|HEAVY_PROBE|GCS)/;
function parse(line){
const match=String(line).match(/^(\S+) \[([^\]]+)\] ([\s\S]*)$/);
if(!match)return {time:'',type:'Message',payload:String(line),raw:String(line),tone:'normal',quiet:false};
const [,timestamp,category,message]=match;
let payload=message,type=category.replace(/^(RX|TX)_?/,'')||category;
try{payload=JSON.parse(message);type=payload.mavpackettype||type}catch(_){}
const tone=/BAD_DATA|ERROR|TIMEOUT|FAIL/.test(category)||type==='STATUSTEXT'&&Number(payload.severity)<=4?'warning':/COMMAND|ACK|PARAM_SET|OPERATOR/.test(category+' '+type)?'command':'normal';
const time=timestamp.match(/T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)?.[1]||timestamp;
const pairs=typeof payload==='string'?[...payload.matchAll(/(?:^|\s)([a-zA-Z_]\w*)=([^\s]+)/g)]:[];
const fields=pairs.length?Object.fromEntries(pairs.map(([,key,value])=>[key,value])):payload;
return {time,type,payload,fields,raw:String(line),tone,quiet:tone!=='warning'&&(quiet.test(type)||/LINK_PROBE|HEAVY_PROBE|HEARTBEAT/.test(category))};
}
function valueText(value){
if(typeof value==='number')return Number.isInteger(value)?String(value):Number(value.toFixed(3)).toString();
if(Array.isArray(value))return value.slice(0,6).map(valueText).join(', ')+(value.length>6?'…':'');
return typeof value==='object'?JSON.stringify(value):String(value);
}
function makeRow(row,previous){
if(previous&&previous.dataset.key===row.raw&&previous.dataset.tone===row.tone)return previous;
const details=previous||document.createElement('details');
details.className='console-row';details.dataset.key=row.raw;details.dataset.tone=row.tone;
let summary=details.querySelector('summary'),time,body,type,fields,raw;
if(!summary){
details.replaceChildren();summary=document.createElement('summary');time=document.createElement('time');
body=document.createElement('span');body.className='console-message';type=document.createElement('strong');
fields=document.createElement('span');fields.className='console-fields';body.append(type,fields);summary.append(time,body);
raw=document.createElement('pre');details.append(summary,raw);
}else{
time=summary.querySelector('time');body=summary.querySelector('.console-message');
type=body.querySelector('strong');fields=body.querySelector('.console-fields');raw=details.querySelector('pre');
}
time.textContent=row.time;type.textContent=row.type;fields.replaceChildren();
const values=row.fields??row.payload;
if(typeof values==='object'&&values!==null){
for(const [key,value] of Object.entries(values).filter(([key])=>key!=='mavpackettype').slice(0,6)){
const item=document.createElement('span'),label=document.createElement('i'),text=document.createElement('b');
label.textContent=key+' ';text.textContent=valueText(value);item.append(label,text);fields.append(item);
}
}else fields.textContent=row.payload;
raw.textContent=typeof row.payload==='object'?JSON.stringify(row.payload,null,2):row.raw;
return details;
}
function render(host,lines,filter){
const atBottom=host.scrollHeight-host.scrollTop-host.clientHeight<36,scroll=host.scrollTop;
const rows=lines.map(parse).filter(row=>filter==='all'||!row.quiet);
const existing=new Map();
for(const node of host.querySelectorAll('details.console-row')){
const key=node.dataset.key,list=existing.get(key)||[];list.push(node);existing.set(key,list);
}
if(!rows.length){
if(!host.querySelector('.console-empty')){
const empty=document.createElement('p');empty.className='console-empty';
empty.textContent='No messages';
empty.title=filter==='all'?'Waiting for session messages.':'Routine heartbeat and probe traffic is hidden. Choose All traffic to see it.';
host.replaceChildren(empty);
}
return;
}
const wanted=rows.map(row=>makeRow(row,existing.get(row.raw)?.shift()||null));
let cursor=host.firstElementChild;
for(const node of wanted){if(node!==cursor)host.insertBefore(node,cursor);cursor=node.nextElementSibling}
while(cursor){const next=cursor.nextElementSibling;cursor.remove();cursor=next}
if(atBottom&&!host.querySelector('details[open]'))host.scrollTop=host.scrollHeight;else host.scrollTop=scroll;
}
root.ConsoleView={parse,render};
if(typeof module!=='undefined')module.exports=root.ConsoleView;
})(typeof window!=='undefined'?window:globalThis);
+1 -1
View File
@@ -18,7 +18,7 @@
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null; label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.'; message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
} else if (known && online) { } 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.'; if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
const failure=error || state.command_error; const failure=error || state.command_error;
+53
View File
@@ -0,0 +1,53 @@
/* Controller conditioning is independent of drawing and MAVLink transport. */
((root)=>{
const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0));
function deadZone(value,zone=.08){
value=clamp(value);zone=Math.max(0,Math.min(.4,zone));
return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone);
}
function defaults(pad){
return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1,
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
}
function read(pad,config){
if(!pad?.connected||!pad.axes)return null;
const axis=(index,invert)=>{
if(index===-1)return 0;
if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null;
return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1);
};
let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical);
if(turn===null||forward===null||vertical===null)return null;
const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length}
return {turn,forward,vertical};
}
class Smoother{
constructor(){this.reset()}
reset(){this.value=0;this.last=null;this.release=null}
step(target,now){
target=clamp(target);
const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now;
if(target===0){
if(!this.release)this.release={at:now,start:this.value};
const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t);
this.value=this.release.start*(1-ease);
}else{
this.release=null;
this.value+=(target-this.value)*(1-Math.exp(-dt/.1));
if(Math.abs(target-this.value)<.001)this.value=target;
}
return this.value;
}
}
class Motion{
constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}}
reset(){Object.values(this.axes).forEach(axis=>axis.reset())}
step(target,now){
const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)]));
const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length}
return demand;
}
}
root.ControllerInput={deadZone,defaults,read,Motion};
if(typeof module!=='undefined')module.exports=root.ControllerInput;
})(typeof window!=='undefined'?window:globalThis);
+65 -35
View File
@@ -4,11 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title> <title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20260930-dialogs-v1"> <link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── --> <!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status"> <aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div> <div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
@@ -44,12 +44,6 @@
</div> </div>
</div> </div>
<!-- Battery Status -->
<button class="battery-card ghost" data-open="battery-modal">
<span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i>
<b><em id="battery-fill"></em></b>
</button>
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal"> <button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
<span class="link-quality-dot" aria-hidden="true"></span> <span class="link-quality-dot" aria-hidden="true"></span>
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span> <span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
@@ -57,13 +51,18 @@
</button> </button>
<div class="deck-status-bar"> <div class="deck-status-bar">
<header class="activity-heading">Activity:</header>
<ol class="activity-history" aria-label="Last three events" aria-live="polite"> <ol class="activity-history" aria-label="Last three events" aria-live="polite">
<li id="activity-oldest" hidden></li> <li id="activity-oldest" hidden></li>
<li id="activity-previous" hidden></li> <li id="activity-previous" hidden></li>
<li id="status-text">Not connected</li> <li id="status-text">Not connected</li>
</ol> </ol>
<header class="activity-heading"><span>Recent activity</span><span class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></span></header> <div class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></div>
</div> </div>
<button class="battery-card ghost" data-open="battery-modal" aria-label="Battery status and settings">
<span class="battery-meter" aria-hidden="true"><em id="battery-fill"></em></span>
<span class="battery-copy"><span class="battery-summary"><strong id="battery-percent">—</strong><small id="battery-title">No voltage</small></span></span>
</button>
</section> </section>
</aside> </aside>
@@ -72,6 +71,7 @@
<section class="controls-workspace" aria-label="Primary Vehicle Controls"> <section class="controls-workspace" aria-label="Primary Vehicle Controls">
<!-- Frameless Drag & Window Controls Header --> <!-- Frameless Drag & Window Controls Header -->
<div class="workspace-topbar"> <div class="workspace-topbar">
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
<div class="workspace-drag-area pywebview-drag-region"></div> <div class="workspace-drag-area pywebview-drag-region"></div>
<div class="window-actions"> <div class="window-actions">
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button> <button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
@@ -80,6 +80,7 @@
</div> </div>
</div> </div>
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
<section class="primary-control-zone" aria-label="Primary Control Zone"> <section class="primary-control-zone" aria-label="Primary Control Zone">
<!-- Reserved Engage / Disarm row --> <!-- Reserved Engage / Disarm row -->
<div class="engage-header-zone"> <div class="engage-header-zone">
@@ -98,12 +99,12 @@
<!-- Steering / Throttle Joystick --> <!-- Steering / Throttle Joystick -->
<article class="control-card steer-card"> <article class="control-card steer-card">
<header> <header>
<div><small>steer + throttle</small></div> <div title="Steer and throttle · W A S D"></div>
<span class="key-hint">w a s d</span>
</header> </header>
<div class="joystick-pad"> <div class="joystick-pad">
<div id="joystick" class="joystick" touch-action="none"> <div id="joystick" class="joystick" touch-action="none">
<canvas id="joystick-canvas" aria-label="Steering and throttle joystick; W A S D keys"></canvas> <canvas id="joystick-canvas" title="Steer and throttle · W A S D" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
</div> </div>
</div> </div>
<footer> <footer>
@@ -115,13 +116,13 @@
<!-- Vertical Thruster Slider --> <!-- Vertical Thruster Slider -->
<article class="control-card vertical-card"> <article class="control-card vertical-card">
<header> <header>
<div><small>vertical</small></div> <div title="Vertical thrust · R / F"></div>
<span class="key-hint">r / f</span>
</header> </header>
<div class="heave-pad"> <div class="heave-pad">
<div class="vertical-control"> <div class="vertical-control">
<span>rise</span> <span>rise</span>
<div id="heave" class="heave-track" touch-action="none"> <div id="heave" title="Vertical thrust · R / F" class="heave-track" touch-action="none">
<div class="heave-center"></div> <div class="heave-center"></div>
<div id="heave-fill" class="heave-fill"></div> <div id="heave-fill" class="heave-fill"></div>
<div id="heave-knob" class="heave-knob"></div> <div id="heave-knob" class="heave-knob"></div>
@@ -139,7 +140,7 @@
<span>Output limit <b id="gain-value">20%</b></span> <span>Output limit <b id="gain-value">20%</b></span>
<div class="range-pill"> <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> <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> <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> </div>
</label> </label>
@@ -147,39 +148,46 @@
</div> </div>
</section> </section>
</section> </section>
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
<header class="console-dock-header"><div><h2 id="console-title" title="Local session messages. Select a message to expand its details.">Console</h2></div><button id="console-close" type="button" class="console-close" aria-label="Hide console" title="Hide console"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
<div class="console-toolbar"><label><span class="visually-hidden">Console filter</span><select id="console-filter"><option value="useful">Useful traffic</option><option value="all">All traffic</option></select></label><button id="console-pause" type="button" aria-pressed="false">Pause</button></div>
<p id="console-state" class="console-state" role="status" title="Pausing the viewer does not stop session logging." hidden></p>
<div class="console-streams"><section class="console-side"><header><i class="rx-dot" aria-hidden="true" title="Vehicle to PC · new displayed message"></i><span>RX</span></header><div id="console-rx-output" class="console-output" tabindex="0" aria-label="Received messages"></div></section><section class="console-side"><header><i class="tx-dot" aria-hidden="true" title="PC to vehicle · new displayed message"></i><span>TX</span></header><div id="console-tx-output" class="console-output" tabindex="0" aria-label="Sent messages"></div></section></div>
</aside>
</main> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<!-- Modals --> <!-- Modals -->
<dialog id="link-modal" class="modal" aria-labelledby="link-title"> <dialog id="link-modal" class="modal" aria-labelledby="link-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="link-title">Link 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="link-title">Link settings</h2>
<form id="link-form"> <form id="link-form" title="Baud changes apply on the next connection.">
<section class="settings-section"><h3>Serial connection</h3> <section class="settings-section"><h3>Serial connection</h3>
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div> <div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
<p class="note">Baud changes apply on the next connection.</p>
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label> <label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
<p id="custom-port-status" class="note" role="status"></p> <p id="custom-port-status" class="note" role="status"></p>
</section> </section>
<section class="settings-section"><h3>Live updates</h3> <section class="settings-section"><h3 title="Lower rates leave more bandwidth for controls. Rates reset after a reboot.">Telemetry rates</h3>
<div class="form-grid two"> <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>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 / 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>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>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> <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>
</div> </div>
<p class="note">Lower rates leave more bandwidth for controls. Vehicle telemetry rates reset after a reboot.</p>
</section> </section>
<p class="form-feedback" role="alert" hidden></p> <p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div> <div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
</form> </form>
</dialog> </dialog>
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title"> <dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="quality-title">Link quality</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="quality-title">Link quality</h2>
<p class="note">The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.</p>
<div class="quality-summary"><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div> <div class="quality-summary" title="Round-trip replies, incoming packet loss, and heartbeat freshness."><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
<section class="heavy-test-panel"><h3>Extended test</h3><p class="note">Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.</p> <section class="heavy-test-panel"><h3 title="Tests 1, 5 and 10 Hz for about 35 seconds while disarmed. The live check resumes afterward.">Extended test</h3>
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div> <div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
<div id="heavy-quality-results" class="heavy-quality-results"></div> <div id="heavy-quality-results" class="heavy-quality-results"></div>
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p> <p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
@@ -187,8 +195,17 @@
</section> </section>
</dialog> </dialog>
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal"> <dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Power</small><h2 id="battery-title-modal">Battery settings</h2><p class="note">Charge is estimated from voltage. Capacity and C rating describe your battery.</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="battery-title-modal">Battery</h2>
<form id="battery-form"> <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> <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> <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>
<p class="form-feedback" role="alert" hidden></p> <p class="form-feedback" role="alert" hidden></p>
@@ -196,20 +213,33 @@
</form> </form>
</dialog> </dialog>
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title"> <dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Configuration</small><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> <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>
<dialog id="console-modal" class="modal console-modal" aria-labelledby="console-title"> <dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Local session</small><h2 id="console-title">Live console</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="controller-title">Joystick setup</h2>
<div class="console-toolbar"><p id="console-state" class="note" role="status">Live · follows new messages</p><button id="console-pause" type="button" class="button subtle" aria-pressed="false">Pause</button></div> <form id="controller-form" title="Center sticks after changing input mode or reconnecting before movement resumes.">
<div class="console-split"><section><header><i class="rx-dot"></i><span>RX</span><small>Vehicle → PC</small></header><pre id="console-rx-output" tabindex="0" aria-label="Received messages">Waiting for vehicle data…</pre></section><section><header><i class="tx-dot"></i><span>TX</span><small>PC → Vehicle</small></header><pre id="console-tx-output" tabindex="0" aria-label="Sent messages">Waiting for transmitted data…</pre></section></div> <label><span>Controller</span><select id="controller-device"></select></label>
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
<div class="controller-mapping-row"><span>Turn</span><select name="turn" aria-label="Turn axis"></select><input name="invertTurn" type="checkbox" aria-label="Invert Turn"></div>
<div class="controller-mapping-row"><span>Forward / back</span><select name="forward" aria-label="Forward / back axis"></select><input name="invertForward" type="checkbox" aria-label="Invert Forward / back"></div>
<div class="controller-mapping-row"><span>Rise / dive</span><select name="vertical" aria-label="Rise / dive axis"></select><input name="invertVertical" type="checkbox" aria-label="Invert Rise / dive"></div>
<label class="controller-deadzone"><span>Dead zone · %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save mapping</button></div>
</form>
</dialog> </dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script> <script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20260827-keyboard-v7"></script> <script src="joystick-3d.js?v=20261002-spacing-motion-v2"></script>
<script src="control-state.js?v=20260827-state-v1"></script> <script src="control-state.js?v=20260827-state-v1"></script>
<script src="app.js?v=20260930-dialogs-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=20261017-engage-center-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script> <script src="cursor-light.js?v=20260827-directional-v10"></script>
</body> </body>
</html> </html>
+6 -4
View File
@@ -23,7 +23,9 @@
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()} if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3; ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore(); const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819);
handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939');
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;ctx.fill();ctx.restore();
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke(); ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill(); ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
} }
@@ -40,10 +42,10 @@
function returnToCenter(){easeTo(0,0)} function returnToCenter(){easeTo(0,0)}
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()} function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()}); canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canTouchControl?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)}); canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)}); canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)}); canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
addEventListener('resize',resize);resize(); addEventListener('resize',resize);new ResizeObserver(resize).observe(host);resize();
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}}; window.joystick3D={setVisual(nextX,nextY){setPosition(nextX,nextY,false)},setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
})(); })();
+282 -61
View File
@@ -713,23 +713,27 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
pointer-events: none; pointer-events: none;
} }
.engage-button.charging { transition: background-color .24s ease, border-color .24s ease, color .2s ease; } .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 { 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; font-size: 11px; font-weight: 650; letter-spacing: .12em; } .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 { .engage-label {
position:relative;
color: transparent; color: transparent;
background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%); background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%);
background-size: 250% 100%; background-size: 250% 100%;
background-clip: text; background-clip: text;
-webkit-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; } .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 { .engage-progress-label {
color: #a9ed72; color: #a9ed72;
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0); clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
transition: opacity .18s ease; 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} } @keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} }
.engage-button:hover { transform:none; } .engage-button:hover { transform:none; }
.engage-button.pending { background:#405346; border-color:#516e56; } .engage-button.pending { background:#405346; border-color:#516e56; }
@@ -756,7 +760,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
background: #4e7140; background: #4e7140;
border-color: #78ad5a; border-color: #78ad5a;
} }
.danger { .danger {
--engage-progress: 0%; --engage-progress: 0%;
background: #ad3f38; background: #ad3f38;
color: #ffffff; color: #ffffff;
@@ -947,7 +951,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
border-radius: 24px; border-radius: 24px;
background: rgba(247, 248, 245, 0.96); background: rgba(247, 248, 245, 0.96);
color: #1a1e1f; color: #1a1e1f;
box-shadow: box-shadow:
0 30px 80px -12px rgba(0, 0, 0, 0.42), 0 30px 80px -12px rgba(0, 0, 0, 0.42),
0 0 0 1px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.06),
0 10px 24px -6px rgba(0, 0, 0, 0.16); 0 10px 24px -6px rgba(0, 0, 0, 0.16);
@@ -1015,52 +1019,14 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
color: #7a8280; color: #7a8280;
margin-top: 8px; margin-top: 8px;
} }
.motor-modal { width: min(600px, calc(100vw - 40px)); } .motor-modal { width: min(860px, calc(100vw - 40px)); }
.motor-head, .motor-row { .battery-live { margin:14px 0 20px; padding:14px; border-radius:16px; background:#f0f3ef; }
display: grid; .battery-live-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
grid-template-columns: 70px 1fr 80px 75px; .battery-live-grid > div { min-width:0; padding:10px 11px; border-radius:12px; background:#fff; }
align-items: center; .battery-live-grid span,.battery-live-grid small { display:block; color:#727b77; font-size:11px; }
gap: 8px; .battery-live-grid strong { display:block; margin-top:7px; color:#303936; font-size:18px; font-weight:500; font-variant-numeric:tabular-nums; }
padding: 6px 0; .battery-live-grid small { margin-top:4px; }
border-bottom: 1px solid #e0e4df; .battery-live .note { margin:10px 0 0; line-height:1.5; font-size:12px; }
font-size: 10.5px;
}
.motor-head {
font-weight: 500;
color: #727a7c;
border-bottom: 1.5px solid #c8cec6;
}
.console-modal {
width: min(1040px, calc(100vw - 64px));
max-width: 1040px;
}
.console-split { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; min-width:0; }
.console-split section { min-width:0; }
.console-split header { display:flex; align-items:center; gap:7px; margin:0 2px 7px; }
.console-split header i { width:7px; height:7px; border-radius:50%; flex:0 0 auto; }
.console-split header .rx-dot { background:#79c8eb; }
.console-split header .tx-dot { background:#bce96c; }
.console-split header span { color:#303836; font-size:12px; font-weight:600; }
.console-split header small { margin-left:auto; color:#7d8683; font-size:10px; }
.console-split pre {
height: min(55vh, 480px);
margin: 0;
padding: 14px;
overflow: auto;
border: 1px solid #d9ddda;
border-radius: 12px;
background: #171b1d;
color: #dce4e1;
font: 11px/1.45 Consolas, "SFMono-Regular", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
@media (max-width: 760px) {
.console-split { grid-template-columns:1fr; }
.console-split pre { height:220px; }
}
/* ── Toast ── */ /* ── Toast ── */
.toast { .toast {
@@ -1110,6 +1076,37 @@ body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
cursor: not-allowed; cursor: not-allowed;
} }
.console-modal {
width: min(1040px, calc(100vw - 64px));
max-width: 1040px;
}
.console-split { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; min-width:0; }
.console-split section { min-width:0; }
.console-split header { display:flex; align-items:center; gap:7px; margin:0 2px 7px; }
.console-split header i { width:7px; height:7px; border-radius:50%; flex:0 0 auto; }
.console-split header .rx-dot { background:#79c8eb; }
.console-split header .tx-dot { background:#bce96c; }
.console-split header span { color:#303836; font-size:12px; font-weight:600; }
.console-split header small { margin-left:auto; color:#7d8683; font-size:10px; }
.console-split pre {
height: min(55vh, 480px);
margin: 0;
padding: 14px;
overflow: auto;
border: 1px solid #d9ddda;
border-radius: 12px;
background: #171b1d;
color: #dce4e1;
font: 11px/1.45 Consolas, "SFMono-Regular", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
@media (max-width: 760px) {
.console-split { grid-template-columns:1fr; }
.console-split pre { height:220px; }
}
/* A pending or unknown armed state must stay legible, even without a link. */ /* A pending or unknown armed state must stay legible, even without a link. */
#engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; } #engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -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; } body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
#engage::before, #engage::after { display:none; } #engage::before, #engage::after { display:none; }
body #engage { align-items:flex-end; padding-bottom:12px; } 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-green:#4cce73; --engage-glow:drop-shadow(0 0 2px rgba(76,206,115,.3)); }
#engage .engage-label { color:#777; background:none; animation:none; } #engage .engage-label { color:#777; background:none; animation:none; }
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; } .engage-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; } .control-status[hidden] { display:none; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.engage-sheen-label { display:none; } .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 { .controls-grid {
--joy-size:min(54vh,calc(100vw - 480px),460px); --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 .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 [hidden] { display:none !important; }
.modal [data-busy="true"] { cursor:progress; } .modal [data-busy="true"] { cursor:progress; }
.motor-modal { width:min(650px,calc(100vw - 32px)); } .motor-modal { width:min(860px,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; } @media (max-width:650px) { .battery-live-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.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-other { margin-top:16px; } .motor-other { margin-top:16px; }
.motor-other summary { cursor:pointer; padding:12px 0; color:#697278; font-size:13px; } .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; } .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) { @media (max-width:520px) {
.modal { padding:22px 18px; } .modal { padding:22px 18px; }
.modal .form-grid.two { grid-template-columns:1fr; } .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 { gap:6px; }
.quality-summary div { padding:12px 8px; } .quality-summary div { padding:12px 8px; }
.quality-summary strong { font-size:17px; } .quality-summary strong { font-size:17px; }
@@ -1298,3 +1292,230 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
@media (prefers-reduced-motion:reduce) { @media (prefers-reduced-motion:reduce) {
.modal[open],.modal.closing { animation:none; } .modal[open],.modal.closing { animation:none; }
} }
/* Input selection stays separate from vehicle arming and command ownership. */
#engage:focus,#engage:focus-visible { outline:none!important; box-shadow:none!important; }
.controls-workspace { container-type:inline-size; }
.input-mode-controls { display:flex; align-items:center; gap:6px; flex-shrink:0; -webkit-app-region:no-drag; }
.input-mode-switch { display:flex; padding:3px; gap:2px; background:#dedede; border-radius:999px; }
.input-mode-switch button,.controller-settings { border:0; border-radius:999px; background:transparent; color:#686868; padding:7px 11px; font-size:12px; cursor:pointer; }
.input-mode-switch button[aria-pressed="true"] { background:#fafafa; color:#252525; box-shadow:0 1px 3px #0000000c; }
.controller-settings { background:#dedede; }
.controller-status { margin:0; height:24px; flex-shrink:0; font-size:12px; line-height:20px; color:#6d7478; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
body[data-input-mode="joystick"] .key-hint { visibility:hidden; }
body[data-input-mode="joystick"] :is(#joystick-canvas,#heave) { pointer-events:none; cursor:default; }
.controls-grid { --joy-size:min(52vh,calc(100cqw - 136px),460px); }
.control-toolbar { flex-wrap:wrap; gap:8px 14px; }
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) auto; }
.control-toolbar { min-height:52px; }
.engage-face > span { font-size:clamp(52px,11cqw,90px); }
.controller-mapping-head,.controller-mapping-row { display:grid; grid-template-columns:1fr 1.25fr 50px; gap:14px; align-items:center; }
.controller-mapping-head { color:#777; font-size:12px; margin-bottom:8px; }
.controller-mapping-row { margin:8px 0; }
.modal .controller-mapping-row input[type="checkbox"] { width:22px; height:22px; min-height:0; margin:auto; padding:0; accent-color:#527f9b; }
.controller-mapping-row label { display:flex; align-items:center; }
#controller-axes { min-height:22px; margin:12px 0; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; color:#65737d; font-size:12px; }
#controller-form > label { margin-top:18px; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
/* Read-only local console, not a modal: driving stays available while it is open. */
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr) 340px; }
.console-dock[hidden] { display:none!important; }
.console-dock { display:flex; flex-direction:column; min-width:0; min-height:0; padding:18px 16px; background:#f5f5f5; border-left:1px solid #d8dcde; color:#29343b; color-scheme:light; }
.console-dock-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:14px; }
.console-dock-header small { font-size:11px; color:#81888d; }
.console-dock-header h2 { font-size:22px; font-weight:400; margin:4px 0 0; letter-spacing:-.02em; }
.console-close { width:38px; height:38px; flex-shrink:0; border:0; border-radius:999px; background:#e7e8e9; color:#48525a; font-size:25px; cursor:pointer; }
.console-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.console-toolbar label { flex:1; min-width:0; }
.console-toolbar select,.console-toolbar button { height:34px; border:0; border-radius:999px; background:#e7e9ea; color:#42525e; font-size:12px; padding:0 12px; }
.console-toolbar select { width:100%; }
.console-toolbar button { cursor:pointer; }
.console-state { min-height:28px; margin:10px 0 8px; font-size:11px; color:#7e868b; line-height:1.4; }
.console-streams { display:grid; grid-template-rows:1fr 1fr; gap:14px; min-height:0; flex:1; }
.console-side { display:flex; flex-direction:column; min-height:0; }
.console-side > header { display:flex; align-items:center; gap:6px; padding:0 4px 8px; font-size:12px; font-weight:500; color:#435966; }
.console-side > header small { margin-left:auto; font-size:11px; font-weight:300; color:#899198; }
.console-side > header i { width:6px; height:6px; border-radius:50%; background:#5888a3; }
.console-side > header .tx-dot { background:#659287; }
.console-output { min-height:0; flex:1; overflow:auto; overscroll-behavior:contain; padding:0 8px; border:1px solid #e0e3e5; border-radius:12px; background:#fafbfc; color:#343d43; font:12px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif; }
.console-row { border-bottom:1px solid #e9edef; }
.console-row:last-child { border-bottom:0; }
.console-row summary { display:flex; gap:8px; cursor:pointer; padding:10px 0; list-style:none; }
.console-row summary::-webkit-details-marker { display:none; }
.console-row time { flex-shrink:0; color:#929ba1; font:10px/1.8 ui-monospace,Consolas,monospace; font-variant-numeric:tabular-nums; }
.console-message { min-width:0; display:block; overflow-wrap:anywhere; }
.console-message strong { display:block; font-size:11px; font-weight:500; color:#517c97; letter-spacing:.025em; }
.console-row[data-tone="command"] strong { color:#467a69; }
.console-row[data-tone="warning"] strong { color:#aa664b; }
.console-fields { display:flex; flex-wrap:wrap; gap:2px 8px; font-size:11px; }
.console-fields i { color:#929a9f; font-style:normal; }
.console-fields b { font-weight:400; color:#45535d; }
.console-row pre { margin:0 0 10px; padding:10px; border-radius:8px; background:#eef1f3; color:#52616c; font:11px/1.5 ui-monospace,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
.console-empty { color:#959da3; font-size:12px; line-height:1.6; margin:14px 0; }
@media (max-height:610px) { .primary-control-zone { grid-template-rows:100px minmax(0,1fr) auto; } .controls-grid { --joy-size:min(46vh,calc(100cqw - 136px),420px); } }
@media (max-width:1000px) {
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr); }
.console-dock { position:absolute; top:0; right:0; bottom:0; width:min(340px,calc(100vw - 32px)); z-index:12; box-shadow:-8px 0 24px #00000012; border-radius:12px 0 0 12px; }
}
/* Three shared sizes: compact 32px, standard 40px, primary 62px. */
:root {
--pill-sm:32px; --pill-md:40px; --pill-lg:62px; --pill-radius:999px;
--battery-charge:#63cb85; --battery-low:#e98a7b;
}
.port-row select,.icon-button { height:var(--pill-sm); }
.icon-button { width:var(--pill-sm); }
body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); }
.input-mode-switch { height:var(--pill-md); padding:4px; }
.input-mode-switch button { height:var(--pill-sm); padding:0 12px; }
.controller-settings { height:var(--pill-md); padding:0 14px; }
.input-mode-switch button:disabled { opacity:.65!important; }
.input-mode-switch button[aria-pressed="true"]:disabled { opacity:1!important; }
.control-toolbar .range-pill,.control-toolbar .config-btn { height:var(--pill-md); border-radius:var(--pill-radius); }
.control-toolbar .range-pill button { height:var(--pill-md); }
.window-actions button { width:var(--pill-md); height:var(--pill-md); }
.console-toolbar select,.console-toolbar button { height:var(--pill-sm); border-radius:var(--pill-radius); }
.modal input,.modal select,.modal .number-pill { height:var(--pill-md); border-radius:var(--pill-radius); }
.modal .number-pill input,.modal .number-pill button { height:calc(var(--pill-md) - 2px); }
.modal .button { min-height:var(--pill-md); padding:8px 18px; border-radius:var(--pill-radius); }
.modal .controller-mapping-row input[type="checkbox"] { height:22px; border-radius:4px; }
/* Fixed SVG geometry avoids font baselines shifting the close icon. */
.modal-close,.modal-close:hover,.console-close,.console-close:hover,.window-actions .window-close,.window-actions .window-close:hover {
display:grid; place-items:center; width:var(--pill-md); height:var(--pill-md);
padding:0; border:0; border-radius:0; background:transparent; box-shadow:none;
}
.modal-close svg,.console-close svg { display:block; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; }
.modal-close:hover,.console-close:hover { color:#222; }
.heave-knob { height:var(--pill-md); background:linear-gradient(145deg,#737373,#454545); }
/* Activity has a heading, compact event history, then explicit actions. */
.telemetry-zone { gap:16px; }
.deck-status-bar { flex-shrink:0; margin-top:0; padding-top:12px; }
.activity-heading { display:block; margin:0 0 8px; color:#eee; font-size:14px; font-weight:650; }
.activity-history { gap:4px; margin:0 0 10px; }
.activity-actions { display:flex; gap:8px; }
.activity-actions .logs-link {
display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box;
height:var(--pill-sm); min-width:76px; padding:0 14px; margin:0;
border:0; border-radius:var(--pill-radius); background:#414141; color:#eee;
font-size:12px; font-weight:400; letter-spacing:0; line-height:1; cursor:pointer;
}
.activity-actions .logs-link:hover { background:#4b4b4b; }
.activity-actions .logs-link:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
.activity-actions .logs-link[aria-expanded="true"] { background:#555; }
/* Battery is the final deck item: text floats above the charge fill. */
.battery-card {
position:relative; display:block; flex-shrink:0; width:100%; height:var(--pill-lg);
min-height:var(--pill-lg); margin:0; padding:0; overflow:hidden;
border:0; border-radius:var(--pill-radius); background:#314b3a;
color:#f2f5f2; isolation:isolate;
}
.battery-meter { position:absolute; inset:0; z-index:0; overflow:hidden; border-radius:inherit; pointer-events:none; }
.battery-meter #battery-fill { display:block; height:100%; width:0; border-radius:inherit; background:var(--battery-charge); opacity:.55; transition:width .35s ease,background .2s ease,opacity .18s ease-out; }
.battery-card .battery-copy { position:absolute; inset:0; z-index:1; display:flex; flex-direction:row; align-items:center; justify-content:space-between; gap:8px; padding:0 18px; pointer-events:none; }
.battery-summary { display:flex; flex-direction:column; gap:3px; flex-shrink:0; }
.battery-card small { color:#d7e8dc; font-size:12px; line-height:1; font-variant-numeric:tabular-nums; }
.battery-card strong { font-size:26px; font-weight:400; line-height:1.15; font-variant-numeric:tabular-nums; }
.battery-card i { align-self:auto; color:#d9e0da; text-align:right; font-size:12px; line-height:1.35; font-style:normal; }
.battery-card[data-low="true"] { background:#4a3734; }
.battery-card[data-stale="true"] #battery-fill { opacity:.25; }
.battery-card:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
/* The same slender, rounded scrollbar is used by every scrollable panel. */
*::-webkit-scrollbar { width:8px; height:8px; }
*::-webkit-scrollbar-track,*::-webkit-scrollbar-corner { background:transparent; }
*::-webkit-scrollbar-thumb { min-height:28px; border:2px solid transparent; border-radius:var(--pill-radius); background:#aeb6ba; background-clip:padding-box; }
*::-webkit-scrollbar-thumb:hover { background:#87959e; background-clip:padding-box; }
*::-webkit-scrollbar-button { display:none; width:0; height:0; }
.sidebar-deck::-webkit-scrollbar-thumb { background:#666; background-clip:padding-box; }
.modal { scrollbar-gutter:stable; }
@supports not selector(::-webkit-scrollbar) { * { scrollbar-width:thin; scrollbar-color:#aeb6ba transparent; } }
@media (max-height:610px) { .telemetry-zone { gap:12px; } }
/* Shared panel geometry and quiet, presentational motion. Never ease command data. */
:root { --panel-inset:20px; --ui-gap:12px; --ui-duration:180ms; --ui-ease:cubic-bezier(.2,0,.2,1); }
.sidebar-deck,.controls-workspace,.console-dock { padding:var(--panel-inset); }
.sidebar-deck,.telemetry-zone,.deck-connection,.port-row,.activity-actions,.input-mode-controls,.console-toolbar,.console-streams { gap:var(--ui-gap); }
.deck-connection { padding-bottom:var(--ui-gap); }
.workspace-topbar,.console-dock-header { min-height:var(--pill-md); height:var(--pill-md); margin-bottom:var(--ui-gap); }
.port-row select,.port-row .icon-button { height:var(--pill-md); }
.port-row .icon-button { width:var(--pill-md); }
.activity-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
.activity-actions .logs-link { min-width:0; width:100%; }
.console-dock-header h2 { margin:0; }
.console-toolbar { margin-bottom:var(--ui-gap); }
.console-state { min-height:0; margin:0 0 var(--ui-gap); font-size:12px; }
.console-side > header { padding-left:0; padding-right:0; }
.console-side > header i { background:#b4bdc2; transition:background-color var(--ui-duration) ease-out,box-shadow var(--ui-duration) ease-out; }
.console-side > header .rx-dot.lit { background:#4fa4cf; box-shadow:0 0 5px #4fa4cf66; }
.console-side > header .tx-dot.lit { background:#56ae8a; box-shadow:0 0 5px #56ae8a66; }
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::before,
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::after,
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card) {
transition-property:background-color,border-color,color,opacity,box-shadow,filter;
transition-duration:var(--ui-duration); transition-timing-function:var(--ui-ease);
}
/* Keep pointer positions immediate; the existing conditioning owns motor easing. */
.modal[open],.console-dock:not([hidden]),.form-feedback:not([hidden]),.console-state:not([hidden]),.console-row pre { animation:ui-fade-in var(--ui-duration) var(--ui-ease); }
.modal.closing,.console-dock.closing { animation:ui-fade-out var(--ui-duration) ease-out forwards; pointer-events:none; }
@keyframes ui-fade-in { from { opacity:0; } to { opacity:1; } }
@keyframes ui-fade-out { from { opacity:1; } to { opacity:0; } }
.controller-status { transition:opacity var(--ui-duration) ease-out; }
.controller-status[data-quiet="true"] { opacity:0; pointer-events:none; }
/* 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.worker.require_input_lease = True
self.vehicle_state = VehicleState() self.vehicle_state = VehicleState()
self.battery_received_at = None 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.demo_mode = False
self.lock = threading.Lock() self.lock = threading.Lock()
self._poll_lock = threading.Lock() self._poll_lock = threading.Lock()
@@ -43,10 +46,26 @@ class ControlApi:
"link_quality": None, "link_quality": None,
"heavy_quality": None, "heavy_quality": None,
"log_path": 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.servo_functions: dict[int, int] = {}
self.motor_directions: 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.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.link_path = Path(__file__).resolve().parent / "link_settings.json" self.link_path = Path(__file__).resolve().parent / "link_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json" self.model_path = Path(__file__).resolve().parent / "model_settings.json"
@@ -67,7 +86,7 @@ class ControlApi:
pass pass
self.link_config = { self.link_config = {
"baud": 57600, "control_hz": 20, "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, "rc_hz": 2, "esc_hz": 1,
} }
try: try:
@@ -78,11 +97,10 @@ class ControlApi:
def initial_state(self): def initial_state(self):
return { return {
"ports": self.refresh_ports(),
"battery_config": self.battery_config, "battery_config": self.battery_config,
"link_config": self.link_config, "link_config": self.link_config,
"motor_settings": self.motor_settings,
"state": self._snapshot(), "state": self._snapshot(),
"model_info": self.get_saved_model_info(),
} }
def refresh_ports(self): def refresh_ports(self):
@@ -259,8 +277,12 @@ class ControlApi:
self.state["servos"][str(servo)] = { self.state["servos"][str(servo)] = {
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"), "function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None, "motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
"output_us": None,
} }
return self._snapshot() 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) self.worker.rate_hz = int(rate)
with self.lock: with self.lock:
self.vehicle_state.disconnect() self.vehicle_state.disconnect()
@@ -285,6 +307,9 @@ class ControlApi:
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds") raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self): 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: if self.worker.heavy_quality_test is not None:
self.worker.cancel_heavy_quality_test() self.worker.cancel_heavy_quality_test()
if self.demo_mode: if self.demo_mode:
@@ -305,6 +330,34 @@ class ControlApi:
def cancel_heavy_quality_test(self): def cancel_heavy_quality_test(self):
return self.worker.cancel_heavy_quality_test() 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): def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode: if self.demo_mode:
with self.lock: with self.lock:
@@ -330,10 +383,44 @@ class ControlApi:
return True return True
def set_gain(self, value): def set_gain(self, value):
value = max(5, min(50, int(value))) value = max(0, min(100, int(value)))
self.worker.gain = value / 100.0 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 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): def command(self, name, sequence=None, expires_at=None):
if self.demo_mode: if self.demo_mode:
if name not in ("manual", "arm", "disarm"): if name not in ("manual", "arm", "disarm"):
@@ -425,6 +512,7 @@ class ControlApi:
self.worker.rate_hz = self.link_config["control_hz"] self.worker.rate_hz = self.link_config["control_hz"]
self.worker.set_link_rates(self.link_config["ping_hz"], { self.worker.set_link_rates(self.link_config["ping_hz"], {
"SYS_STATUS": self.link_config["system_hz"], "SYS_STATUS": self.link_config["system_hz"],
"BATTERY_STATUS": self.link_config["battery_hz"],
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"], "SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
"RC_CHANNELS": self.link_config["rc_hz"], "RC_CHANNELS": self.link_config["rc_hz"],
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_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") raise ValueError("Control rate must be 10 or 20 Hz")
if clean["ping_hz"] not in {1, 2, 5, 10}: if clean["ping_hz"] not in {1, 2, 5, 10}:
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz") 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") 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_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.link_config = clean self.link_config = clean
@@ -488,6 +576,7 @@ class ControlApi:
def _snapshot(self): def _snapshot(self):
with self.lock: with self.lock:
snap = json.loads(json.dumps(self.state)) snap = json.loads(json.dumps(self.state))
snap["motor_settings"] = self.motor_settings
snap["app_state"] = self._derive_app_state() snap["app_state"] = self._derive_app_state()
if not self.demo_mode: if not self.demo_mode:
snap.update(self.vehicle_state.snapshot()) snap.update(self.vehicle_state.snapshot())
@@ -503,6 +592,9 @@ class ControlApi:
if kind == "heartbeat": if kind == "heartbeat":
_, system, autopilot, mode, armed = event[:5] _, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None) 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( self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}", connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed), armed=bool(armed),
@@ -515,16 +607,19 @@ class ControlApi:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None) self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}" self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery": elif kind == "battery":
self.battery_received_at = time.monotonic() self._consume_battery(event[1])
voltage = float(event[1]) elif kind == "servo_output":
cells = self.battery_config["cells"] for servo, output_us in event[1].items():
per_cell = voltage / cells if servo in self.state["servos"]:
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"] self.state["servos"][servo]["output_us"] = output_us
percent = max(0, min(100, (per_cell - low) * 100 / (high - low))) elif kind == "motor_test_ack":
self.state["battery"] = { self.state["status"] = "Motor test accepted by flight controller"
"voltage": voltage, "per_cell": per_cell, "percent": percent, elif kind == "motor_test_rejected":
"cells": cells, "low": percent <= self.battery_config["warning_percent"], 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": elif kind == "link_quality":
self.state["link_quality"] = event[1] self.state["link_quality"] = event[1]
elif kind == "heavy_quality": elif kind == "heavy_quality":
@@ -551,9 +646,59 @@ class ControlApi:
last_err = self.state.get("last_error") last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected" status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
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 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): def _update_servo(self, servo):
function = self.servo_functions.get(servo) function = self.servo_functions.get(servo)
motor = motor_number_for_function(function) 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…", "label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
"motor": motor, "motor": motor,
"direction": self.motor_directions.get(motor) if motor else None, "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): def close(self):
@@ -653,7 +799,6 @@ def main():
from desktop_shortcut import ensure_desktop_shortcut from desktop_shortcut import ensure_desktop_shortcut
ensure_desktop_shortcut() ensure_desktop_shortcut()
api = ControlApi() api = ControlApi()
api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent project_root = Path(__file__).resolve().parent
port = start_local_server(project_root) port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}" 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. # At 20 Hz this produces five small steps from neutral to full command.
self.ramp_seconds = 0.25 self.ramp_seconds = 0.25
self.gain = 0.20 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.rate_hz = 20
self.target_system = 0 self.target_system = 0
self.target_component = 0 self.target_component = 0
@@ -110,7 +112,7 @@ class MavlinkWorker:
self.heavy_pending_pings = {} self.heavy_pending_pings = {}
self.heavy_quality_latest = None self.heavy_quality_latest = None
self.telemetry_rates_hz = { 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, "RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1,
} }
self.ping_sequence = 0 self.ping_sequence = 0
@@ -128,6 +130,11 @@ class MavlinkWorker:
self.param_retries = 0 self.param_retries = 0
self.param_types = {} self.param_types = {}
self.pending_sets = {} 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_lock = threading.Lock()
self.log_file = None self.log_file = None
self.log_path = None self.log_path = None
@@ -200,8 +207,10 @@ class MavlinkWorker:
# ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a # ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a
# 20 Hz neutral stream alive here only competes with ARM and # 20 Hz neutral stream alive here only competes with ARM and
# its acknowledgement on constrained links. # its acknowledgement on constrained links.
motor_test_mode = self._tick_motor_test(now)
if self.target_system and self.armed and now >= next_control: 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. # The first disarm packet is preceded by a neutral command.
# Until disarm is confirmed, keep sending neutral at a # Until disarm is confirmed, keep sending neutral at a
# lower rate instead of consuming 20 Hz on the weak link. # 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.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0 self.arm_retry_deadline = 0.0
self.arm_confirmation = 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.stream_config.clear()
self.pending_pings.clear() self.pending_pings.clear()
self.ping_results.clear() self.ping_results.clear()
@@ -337,6 +350,13 @@ class MavlinkWorker:
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED) armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
self.armed = armed self.armed = armed
self.config_paused = 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: if self.pending_arm_state is not None and armed == self.pending_arm_state:
self.pending_arm_state = None self.pending_arm_state = None
if not armed: if not armed:
@@ -361,6 +381,18 @@ class MavlinkWorker:
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL: if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
self.log("RX_STREAM_CONFIG_ACK", result_name) self.log("RX_STREAM_CONFIG_ACK", result_name)
return 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 if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")): and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")):
self.pending_arm_state = None self.pending_arm_state = None
@@ -375,8 +407,33 @@ class MavlinkWorker:
self.events.put(("statustext", text)) self.events.put(("statustext", text))
elif msg_type == "SYS_STATUS": elif msg_type == "SYS_STATUS":
voltage_mv = int(msg.voltage_battery) voltage_mv = int(msg.voltage_battery)
if 0 < voltage_mv < 65535: current_ca = int(msg.current_battery)
self.events.put(("battery", voltage_mv / 1000.0)) 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": elif msg_type == "PARAM_VALUE":
param_id = msg.param_id param_id = msg.param_id
if isinstance(param_id, bytes): if isinstance(param_id, bytes):
@@ -479,6 +536,67 @@ class MavlinkWorker:
"probe_supported": total > 0, "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): def _tick_link_probe(self, now):
if self.heavy_quality_test is not None: if self.heavy_quality_test is not None:
self._tick_heavy_quality(now) self._tick_heavy_quality(now)
@@ -754,6 +872,8 @@ class MavlinkWorker:
active = not (force_neutral or expired) active = not (force_neutral or expired)
target = self.axes if active else Axes() target = self.axes if active else Axes()
gain = self.gain gain = self.gain
independent_motor_scaling = self.independent_motor_scaling
motor_gains = dict(self.motor_gains)
if force_neutral: if force_neutral:
self.output_axes = Axes() self.output_axes = Axes()
else: else:
@@ -776,10 +896,25 @@ class MavlinkWorker:
) )
limited_forward = axes.forward / horizontal_mix_peak limited_forward = axes.forward / horizontal_mix_peak
limited_yaw = axes.yaw / horizontal_mix_peak limited_yaw = axes.yaw / horizontal_mix_peak
x = round(limited_forward * gain * 1000) if independent_motor_scaling:
y = round(axes.lateral * gain * 1000) # The desktop controller's SimpleROV-3 profile maps motors 1/2
r = round(limited_yaw * gain * 1000) # to surge/turn and motor 3 to vertical thrust. Convert the two
z = round(NEUTRAL_Z + axes.vertical * gain * 500) # 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( self.log(
"TX_MANUAL_CONTROL", "TX_MANUAL_CONTROL",
f"x={x} y={y} z={z} r={r} active={active} " f"x={x} y={y} z={z} r={r} active={active} "
@@ -852,6 +987,17 @@ class MavlinkWorker:
manual, 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 set_axes(self, axes: Axes, sequence=None):
def conditioned(value): def conditioned(value):
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps. # Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
+31
View File
@@ -0,0 +1,31 @@
/* Pure conditioning and log parsing tests: no hardware or browser needed. */
const assert=require('node:assert/strict');
const {deadZone,defaults,read,Motion}=require('../web/controller-input.js');
const {parse}=require('../web/console-view.js');
const pad={connected:true,axes:[0,0,0,0]};
assert.equal(deadZone(.06),0);assert.equal(deadZone(-1),-1);assert.equal(deadZone(1),1);
assert.equal(defaults({axes:[0,0]}).vertical,-1);
assert.equal(read({...pad,connected:false},defaults(pad)),null);
assert.equal(read(pad,{...defaults(pad),vertical:10}),null);
const diagonal=read({...pad,axes:[1,-1,0,-1]},defaults(pad));
assert(Math.abs(Math.hypot(diagonal.turn,diagonal.forward)-1)<1e-10);
assert.equal(diagonal.vertical,1);
assert.equal(read({...pad,axes:[-.5,0,0,0]},{...defaults(pad),invertTurn:true}).turn,deadZone(.5));
const motion=new Motion(),target={turn:.6,forward:.8,vertical:1};
assert.deepEqual(motion.step(target,0),{turn:0,forward:0,vertical:0});
const partial=motion.step(target,16);assert(partial.vertical>0&&partial.vertical<.3);
let current;for(let time=32;time<=1000;time+=16){current=motion.step(target,time);assert(Math.hypot(current.turn,current.forward)<=1+1e-10)}
assert(current.vertical>.99);
const neutral={turn:0,forward:0,vertical:0};
assert(motion.step(neutral,1024).vertical>.99);
assert(motion.step(neutral,1200).vertical>0);
assert.deepEqual(motion.step(neutral,1400),neutral);
motion.reset();assert.deepEqual(motion.step(target,1500),neutral);
assert.equal(parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"HEARTBEAT","base_mode":128}').quiet,true);
const ack=parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0}');
assert.equal(ack.time,'10:11:12.123');assert.equal(ack.type,'COMMAND_ACK');assert.equal(ack.tone,'command');
assert.equal(parse('2026-10-01T10:11:12 [TX_LINK_PROBE_TIMEOUT] retry').quiet,false);
assert.equal(parse('2026-10-01T10:11:12 [RX] {"mavpackettype":"STATUSTEXT","severity":3,"text":"warning"}').tone,'warning');
assert.equal(parse('2026-10-01T10:11:12 [RX] null').type,'RX');
assert.equal(parse('unstructured text').payload,'unstructured text');
console.log('PASS: dead zone, mapping/inversion, diagonal cap, ramp, finite center return, resets, console parsing and warning visibility.');
+137 -7
View File
@@ -24,19 +24,23 @@ async function main(){
try{ try{
const page=await browser.newPage({viewport:{width:1080,height:720}}); const page=await browser.newPage({viewport:{width:1080,height:720}});
const errors=[];page.on('pageerror',error=>errors.push(error.message)); const errors=[];page.on('pageerror',error=>errors.push(error.message));
const setArmed=value=>page.evaluate(value=>{
Object.assign(testBridge.state,{connected:true,armed:value,armed_known:true,app_state:value?'armed':'connected_disarmed'});render(testBridge.state);
},value);
await page.addInitScript(()=>{ await page.addInitScript(()=>{
const clone=value=>JSON.parse(JSON.stringify(value)); const clone=value=>JSON.parse(JSON.stringify(value));
const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null}; const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null};
const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null}; const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null};
const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20}; const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20};
const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}; const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1};
const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[]}; const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[],axes:[],axisVisual:[],stops:0};
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false}; window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false};
window.testPads=[];Object.defineProperty(navigator,'getGamepads',{value:()=>window.testPads});
window.pywebview={api:{ window.pywebview={api:{
initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}), initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}),
refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state), refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state),
get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>true,set_axes:async()=>true, get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>{calls.stops++;return true},set_axes:async(...axes)=>{calls.axes.push(axes);calls.axisVisual.push({joy:joystick3D.getState(),vertical:verticalControl.getState().value});return true},
save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)}, save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)},
save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)}, save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)},
connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)}, connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)},
@@ -45,7 +49,7 @@ async function main(){
set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true}, set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true},
start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)}, start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)},
cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)}, cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)},
read_console:async()=>{calls.console++;return {rx:['RX sample '+calls.console],tx:['TX sample '+calls.console]}}, read_console:async()=>{calls.console++;return {rx:['2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0,"sample":1}', '2026-10-01T10:11:12.124 [RX_BAD_DATA] corrupt_frame='+calls.console, '2026-10-01T10:11:12.125 [RX] {"mavpackettype":"HEARTBEAT"}'],tx:['2026-10-01T10:11:12.124 [TX_COMMAND] ARM '+calls.console]}},
minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true
}}; }};
}); });
@@ -107,27 +111,153 @@ async function main(){
await page.waitForFunction(()=>!testBridge.state.heavy_quality.running); await page.waitForFunction(()=>!testBridge.state.heavy_quality.running);
await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal'); await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal');
await page.locator('#console').click(); await page.locator('#console').click();
await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('RX sample')); await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('COMMAND_ACK'));
await page.locator('#console-pause').click(); await page.locator('#console-pause').click();
const paused=await page.locator('#console-rx-output').textContent(); const paused=await page.locator('#console-rx-output').textContent();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused); await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused);
await page.locator('#console-pause').click(); await page.locator('#console-pause').click();
await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused); await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused);
await page.screenshot({path:path.join(output,'console.png')});await close('console-modal'); await page.screenshot({path:path.join(output,'console.png')});await page.locator('#console-close').click();
for(const viewport of [{width:900,height:560},{width:480,height:720}]){ for(const viewport of [{width:900,height:560},{width:480,height:720}]){
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','console-modal']){ for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','controller-modal']){
await page.evaluate(id=>openModal(id),id); await page.evaluate(id=>openModal(id),id);
const box=await page.locator('#'+id).boundingBox(); const box=await page.locator('#'+id).boundingBox();
assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport'); assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport');
assert.equal(await page.evaluate(()=>canDrive()),false); assert.equal(await page.evaluate(()=>canDrive()),false);
const closeGeometry=await page.locator('#'+id+' .modal-close').evaluate(button=>{
const b=button.getBoundingClientRect(),s=button.querySelector('svg').getBoundingClientRect();
return {dx:Math.abs(b.x+b.width/2-s.x-s.width/2),dy:Math.abs(b.y+b.height/2-s.y-s.height/2),bg:getComputedStyle(button).backgroundColor};
});
assert(closeGeometry.dx<.5&&closeGeometry.dy<.5,id+' close icon must be centered');
assert.equal(closeGeometry.bg,'rgba(0, 0, 0, 0)');
await close(id); await close(id);
} }
} }
await page.setViewportSize({width:1080,height:720});await openLink(); await page.setViewportSize({width:1080,height:720});await openLink();
await page.screenshot({path:path.join(output,'link.png')});await close('link-modal'); await page.screenshot({path:path.join(output,'link.png')});await close('link-modal');
await page.setViewportSize({width:1080,height:720});
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:true,armed:false,armed_known:true,app_state:'connected_disarmed',battery:{voltage:15.8,percent:72,cells:4,low:false}});focusChanged(true);render(testBridge.state)});
await page.locator('#engage').focus();
assert.equal(await page.locator('#engage').evaluate(el=>getComputedStyle(el).outlineStyle),'none');
await page.evaluate(()=>startEngageHold());
assert.equal(await page.locator('#input-joystick').isDisabled(),true);
await page.evaluate(()=>cancelEngageHold(false));
assert.equal(await page.locator('#input-joystick').isDisabled(),false);
await page.locator('#input-joystick').click();
for(const pending of ['arm','disarm']){
await page.evaluate(pending=>{testBridge.state.pending_command=pending;render(testBridge.state)},pending);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
assert.equal(await page.evaluate(()=>{setInputMode('keyboard');return inputMode}),'joystick');
}
await page.evaluate(()=>{delete testBridge.state.pending_command;testBridge.state.armed_known=false;render(testBridge.state)});
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await setArmed(true);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await page.waitForFunction(()=>document.getElementById('controller-status').textContent.includes('Waiting'));
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[1,-1,0,-1]}]);
await page.waitForFunction(()=>document.getElementById('controller-status').textContent==='Center sticks to resume');
assert.deepEqual(await page.evaluate(()=>({...control})),{turn:0,forward:0,vertical:0});
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
await page.waitForFunction(()=>!controllerNeedCenter);
await page.keyboard.down('w');await page.waitForTimeout(420);await page.keyboard.up('w');
assert.equal(await page.evaluate(()=>control.forward),0);
await page.evaluate(()=>testPads[0].axes=[1,-1,0,-1]);
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.8);
await page.waitForFunction(()=>control.vertical>.99);
const matching=await page.evaluate(()=>{
const joy=joystick3D.getState(),sent=testBridge.calls.axes.at(-1);
return {length:Math.hypot(control.turn,control.forward),joy,vertical:control.vertical,sent,visual:testBridge.calls.axisVisual.at(-1),control:{...control}};
});
assert(matching.length<=1.00001);
assert.equal(matching.joy.x,matching.control.turn);assert.equal(matching.joy.y,-matching.control.forward);
assert.equal(matching.sent[0],-matching.visual.joy.y);assert.equal(matching.sent[1],matching.visual.joy.x);assert.equal(matching.sent[2],matching.visual.vertical);
// Dispatch even though CSS excludes pointers, to exercise the handler gate.
await page.locator('#joystick-canvas').dispatchEvent('pointerdown',{pointerId:91,clientX:20,clientY:20,button:0});
await page.locator('#heave').dispatchEvent('pointerdown',{pointerId:92,clientX:20,clientY:20,button:0});
assert.equal(await page.evaluate(()=>joystick3D.getState().active||verticalControl.getState().active),false);
await page.locator('#console').click();
await page.waitForFunction(()=>!document.getElementById('console-dock').hidden);
await page.waitForFunction(()=>document.querySelector('.rx-dot').classList.contains('lit')&&document.querySelector('.tx-dot').classList.contains('lit'));
await page.locator('#console-pause').click();
await page.waitForTimeout(500);
assert.equal(await page.locator('.rx-dot').evaluate(el=>el.classList.contains('lit')),false);
assert.equal(await page.locator('.tx-dot').evaluate(el=>el.classList.contains('lit')),false);
await page.locator('#console-pause').click();
assert.equal(await page.evaluate(()=>canDrive()),true);
assert.equal(await page.locator('#console-rx-output .console-row').count(),2);
await page.locator('#console-filter').selectOption('all');
assert.equal(await page.locator('#console-rx-output .console-row').count(),3);
await page.locator('#console-rx-output details').first().locator('summary').click();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output details').first().evaluate(el=>el.open),true);
await page.screenshot({path:path.join(output,'controller-console.png')});await page.locator('#console-close').click();
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.95);
await page.waitForFunction(()=>control.vertical===0&&control.turn===0&&control.forward===0);
await setArmed(false);
assert.equal(await page.locator('#input-keyboard').isDisabled(),false);
await page.locator('#controller-settings').click();
await page.screenshot({path:path.join(output,'controller-setup.png')});
await page.locator('#controller-form [name="invertTurn"]').check();
await page.locator('#controller-form').getByRole('button',{name:'Cancel',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
await page.locator('#controller-settings').click();
assert.equal(await page.locator('#controller-form [name="invertTurn"]').isChecked(),false);
await page.locator('#controller-form [name="invertTurn"]').check();
await page.locator('#controller-form').getByRole('button',{name:'Save mapping',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
await setArmed(true);
await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);
await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>testPads=[]);
await page.waitForFunction(()=>control.turn===0);
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[.5,0,0,0]}]);
await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>focusChanged(false));assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>focusChanged(true));await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
await page.evaluate(()=>testPads[0].id='Different controller');
await page.waitForFunction(()=>control.turn===0);
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
await setArmed(false);
await page.locator('#input-keyboard').click();
await setArmed(true);
await page.keyboard.down('w');await page.waitForFunction(()=>control.forward>.9);await page.keyboard.up('w');
await page.waitForFunction(()=>control.forward===0);
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:false,armed_known:false});render(testBridge.state)});
assert.equal(await page.evaluate(()=>canDrive()),false);
await setArmed(false);
const deckLayout=await page.evaluate(()=>{
const battery=document.querySelector('.battery-card'),sidebar=document.querySelector('.sidebar-deck'),actions=document.querySelector('.activity-actions'),heading=document.querySelector('.activity-heading');
return {last:battery===document.querySelector('.telemetry-zone').lastElementChild,title:heading.textContent,
actionHeight:actions.querySelector('button').getBoundingClientRect().height,
batteryHeight:battery.getBoundingClientRect().height,connectHeight:document.querySelector('#connect').getBoundingClientRect().height,
bottomGap:sidebar.getBoundingClientRect().bottom-battery.getBoundingClientRect().bottom,
overlay:getComputedStyle(document.querySelector('.battery-copy')).zIndex,
actionRightGap:actions.getBoundingClientRect().right-actions.lastElementChild.getBoundingClientRect().right,
sidebarPadding:getComputedStyle(sidebar).paddingTop,
workspacePadding:getComputedStyle(document.querySelector('.controls-workspace')).paddingTop};
});
assert.equal(deckLayout.last,true);assert.equal(deckLayout.title,'Activity:');
assert.equal(deckLayout.actionHeight,32);assert.equal(deckLayout.batteryHeight,62);assert.equal(deckLayout.connectHeight,62);
assert(deckLayout.bottomGap>=10&&deckLayout.bottomGap<=24);assert.equal(deckLayout.overlay,'1');
assert.equal(deckLayout.actionRightGap,0);assert.equal(deckLayout.sidebarPadding,deckLayout.workspacePadding);
const batteryReadout=await page.locator('.battery-card').evaluate(el=>{
const percent=el.querySelector('#battery-percent'),voltage=el.querySelector('#battery-title');
return {percent:percent.textContent,voltage:voltage.textContent,border:getComputedStyle(el).borderTopWidth,
mainSize:parseFloat(getComputedStyle(percent).fontSize),voltageSize:parseFloat(getComputedStyle(voltage).fontSize)};
});
assert.match(batteryReadout.percent,/^\d+%$/);assert.match(batteryReadout.voltage,/^\d+\.\d{2} V$/);
assert.equal(batteryReadout.border,'0px');assert(batteryReadout.mainSize>batteryReadout.voltageSize);
await page.screenshot({path:path.join(output,'ui-cleanup.png')});
assert.deepEqual(errors,[]); assert.deepEqual(errors,[]);
console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, and five dialogs at two window sizes.'); console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, five dialogs at two window sizes; controller conditioning/read-only graphics, actual bridge values, mapping, focus/unplug/identity safety and dock filtering; mode locking during hold/armed/pending/unknown states, centered SVG close icons and bottom battery layout.');
}catch(error){ }catch(error){
const page=browser.contexts()[0]?.pages()[0]; const page=browser.contexts()[0]?.pages()[0];
if(page){ if(page){
+287 -81
View File
@@ -6,7 +6,8 @@ const STATUS_LABELS = {
link_degraded: 'Signal lost — check tether', link_degraded: 'Signal lost — check tether',
}; };
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const control={forward:0,turn:0,vertical:0}, 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 api=(name,...args)=>window.pywebview.api[name](...args);
const activity=[]; const activity=[];
let lastReportedStatus=''; let lastReportedStatus='';
@@ -14,7 +15,7 @@ function recordActivity(message){
if(!message||activity[0]===message)return; if(!message||activity[0]===message)return;
activity.unshift(String(message));activity.length=Math.min(activity.length,3); activity.unshift(String(message));activity.length=Math.min(activity.length,3);
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{ ['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]} const row=$(selector);if(row){const changed=row.textContent!==(activity[i]||'');row.textContent=activity[i]||'';row.hidden=!activity[i];if(changed&&!row.hidden&&!matchMedia('(prefers-reduced-motion:reduce)').matches)row.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'})}
}); });
} }
function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})} function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})}
@@ -22,7 +23,127 @@ let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehi
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(), bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now(); localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750; const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive; window.canDrive=canDrive;
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
const controllerMotion=new ControllerInput.Motion();
const controllerId=pad=>pad.index+':'+pad.id;
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
function loadControllerConfig(pad){
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
}
function selectController(pad){
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
}
function setInputMode(mode){
if(mode===inputMode||inputConfigurationLocked())return false;
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
$('#controller-settings').hidden=mode!=='joystick';
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys');
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
return true;
}
function inputConfigurationLocked(){
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
}
function syncInputModeControls(){
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
$('.input-mode-switch').dataset.locked=String(locked);
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
}
$('#controller-settings').disabled=locked||!pads.length;
$('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup';
if($('#controller-modal').open){
const form=$('#controller-form');
for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad;
$('#controller-device').disabled=locked||!pads.length;
const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.';
if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback);
}
}
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
$('#input-joystick').onclick=()=>setInputMode('joystick');
function fillControllerForm(){
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
if(!pads.length)device.add(new Option('No controller detected',''));
device.value=controllerKey||'';
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
const config=controllerConfig||ControllerInput.defaults(pad);
for(const name of ['turn','forward','vertical']){
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
const invert='invert'+name[0].toUpperCase()+name.slice(1);
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
}
form.elements.deadZone.value=Math.round(config.deadZone*100);
device.disabled=inputConfigurationLocked()||!pads.length;
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
}
$('#controller-device').onchange=e=>{
if(inputConfigurationLocked())return;
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
if(pad){selectController(pad);fillControllerForm()}
};
$('#controller-form').onsubmit=e=>{
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
const config={deadZone:Number(form.elements.deadZone.value)/100};
for(const name of ['turn','forward','vertical']){
config[name]=Number(form.elements[name].value);
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
}
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
}
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
resetInputs();closeModal($('#controller-modal'));
};
function pollController(now){
try{
if(inputMode==='joystick'){
const pads=getControllers();
if(controllerKey===null&&pads.length)selectController(pads[0]);
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id;
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
if(!target||!canDrive()){
if(controllerWasDriving)resetInputs();
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
}else{
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
if(!controllerNeedCenter){
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
control.turn=demand.turn;control.forward=demand.forward;
window.joystick3D?.setVisual(demand.turn,-demand.forward);
setHeave(demand.vertical,false);updateReadouts();sendAxes();
}
}
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
if($('#controller-modal').open){
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes;
if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm();
}
}else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'}
}catch(error){
if(controllerWasDriving)resetInputs();
$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard';
}
requestAnimationFrame(pollController);
}
document.body.dataset.inputMode=inputMode;
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}} function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)} function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))}); document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
@@ -34,7 +155,7 @@ function populatePorts(ports){
if(!p)return; if(!p)return;
const old=p.value||''; const old=p.value||'';
p.innerHTML=''; p.innerHTML='';
const allList=[]; const allList=[];
const seen=new Set(); const seen=new Set();
@@ -84,20 +205,21 @@ function sendAxes(){
setInterval(()=>{if(canDrive())sendAxes()},250); setInterval(()=>{if(canDrive())sendAxes()},250);
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);} function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();}; window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)} function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0; const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();} function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&canDrive())sendAxes();}
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()} function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)}); heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)} function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})}; window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
function resetInputs(){ function resetInputs(){
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
if(gainPointer!==null)releaseGain({pointerId:gainPointer}); if(gainPointer!==null)releaseGain({pointerId:gainPointer});
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false); clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
window.joystick3D?.reset(); window.joystick3D?.reset();
@@ -120,9 +242,11 @@ function setFeedback(el,message=''){
function openModal(id){ function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id; const el=typeof id==='string'?document.getElementById(id):id;
if(!el||el.open)return; if(!el||el.open)return;
if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return}
resetInputs(); resetInputs();
el._returnFocus=document.activeElement; el._returnFocus=document.activeElement;
if(el.id==='battery-modal')fillBatteryForm(); if(el.id==='battery-modal')fillBatteryForm();
if(el.id==='controller-modal')fillControllerForm();
if(el.id==='link-modal'){ if(el.id==='link-modal'){
fillLinkForm(); fillLinkForm();
$('#custom-port-status').textContent=''; $('#custom-port-status').textContent='';
@@ -325,31 +449,50 @@ if(customPortBtn&&customPortInput){
} }
const engageButton=$('#engage'); const engageButton=$('#engage');
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label'); const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null; let engageLabelValue=$('#engage-label')?.textContent||'engage';
const ENGAGE_HOLD_MS=1400; let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null,engageAppendFrame=null;
const ENGAGE_HOLD_MS=600;
function applyEngageVisual(next){ function applyEngageVisual(next){
if(!engageButton)return; if(!engageButton)return;
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase(); 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. // Update the state label without delaying the requested control action.
engageButton.disabled=!next.action; engageButton.disabled=!next.action;
engageButton.setAttribute('aria-busy',String(next.pending)); engageButton.setAttribute('aria-busy',String(next.pending));
engageButton.setAttribute('aria-label',label); 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}`; const key=`${next.label}/${next.tone}`;
if(engageVisualState===key)return; if(engageVisualState===key)return;
const previous=engageVisualState;engageVisualState=key; const previous=engageVisualState;engageVisualState=key;
if(engageAppendFrame)cancelAnimationFrame(engageAppendFrame);
clearTimeout(engageTransitionTimer); clearTimeout(engageTransitionTimer);
engageButton.classList.remove('arm','danger','subtle','charged','pending'); engageButton.classList.remove('arm','danger','subtle','charged','pending');
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm'); engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%'); 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; if(engageProgressLabel)engageProgressLabel.textContent=label;
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label; const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
if(previous){ if(previous){
engageButton.classList.remove('state-transition');void engageButton.offsetWidth; engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
engageButton.classList.add('state-transition'); 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(){ function completeEngage(){
@@ -393,12 +536,14 @@ function startEngageHold(e){
if(view.action!=='arm'||!inputFocused||engageStart)return; if(view.action!=='arm'||!inputFocused||engageStart)return;
if(e&&e.button!==undefined&&e.button!==0)return; if(e&&e.button!==undefined&&e.button!==0)return;
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%'); engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
syncInputModeControls();
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId); if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
engageHoldTimer=requestAnimationFrame(updateEngageProgress); engageHoldTimer=requestAnimationFrame(updateEngageProgress);
} }
function cancelEngageHold(showHint=true){ function cancelEngageHold(showHint=true){
if(!engageStart)return; if(!engageStart)return;
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled'); const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
syncInputModeControls();
} }
if(engageButton){ if(engageButton){
@@ -409,7 +554,7 @@ if(engageButton){
engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null}); engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null});
engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)}); engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)});
engageButton.addEventListener('contextmenu',e=>e.preventDefault()); engageButton.addEventListener('contextmenu',e=>e.preventDefault());
engageButton.onclick=()=>{ engageButton.onclick=()=>{
if(suppressEngageClick){suppressEngageClick=false;return} if(suppressEngageClick){suppressEngageClick=false;return}
if(view.action==='disarm')requestControl('disarm'); if(view.action==='disarm')requestControl('disarm');
@@ -417,35 +562,72 @@ if(engageButton){
} }
$('#logs').onclick=()=>api('open_logs'); $('#logs').onclick=()=>api('open_logs');
const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output'); const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false; let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0;
const consolePulseTimers={};
function pulseConsole(side){
const dot=$('.'+side+'-dot');
clearTimeout(consolePulseTimers[side]);dot.classList.add('lit');
consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260);
}
function renderConsole(){
const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache);
if(signature===consoleSignature)return;
consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter);
}
function setConsoleOpen(open){
consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open);
if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')}
else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180);
$('#console').setAttribute('aria-expanded',String(open));
if(open)refreshConsole();else $('#console').focus({preventScroll:true});
}
$('#console').setAttribute('aria-controls','console-dock');
$('#console').setAttribute('aria-expanded','false');
$('#console').onclick=()=>setConsoleOpen(!consoleOpen);
$('#console-close').onclick=()=>setConsoleOpen(false);
$('#console-filter').onchange=renderConsole;
$('#console-pause').onclick=()=>{ $('#console-pause').onclick=()=>{
consolePaused=!consolePaused; consolePaused=!consolePaused;
$('#console-pause').textContent=consolePaused?'Resume':'Pause'; $('#console-pause').textContent=consolePaused?'Resume':'Pause';
$('#console-pause').setAttribute('aria-pressed',String(consolePaused)); $('#console-pause').setAttribute('aria-pressed',String(consolePaused));
$('#console-state').textContent=consolePaused?'Paused · session logging continues':'Live · follows new messages'; $('#console-state').textContent=consolePaused?'Paused':'';
$('#console-state').hidden=!consolePaused;
if(!consolePaused)refreshConsole(); if(!consolePaused)refreshConsole();
}; };
async function refreshConsole(){ async function refreshConsole(){
if(!consoleDialog?.open||!bridgeReady||consoleRefreshBusy||consolePaused)return; if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
consoleRefreshBusy=true; consoleRefreshBusy=true;
try{ try{
const rxAtBottom=consoleRx.scrollHeight-consoleRx.scrollTop-consoleRx.clientHeight<36; const sides=await withTimeout(api('read_console',150),1500);
const txAtBottom=consoleTx.scrollHeight-consoleTx.scrollTop-consoleTx.clientHeight<36; if(consolePaused||!consoleOpen)return;
const sides=await api('read_console',150); const filter=$('#console-filter').value;
if(consolePaused||!consoleDialog.open)return; for(const side of ['rx','tx']){
consoleRx.textContent=sides.rx?.length?sides.rx.join('\n'):'Waiting for vehicle data…'; const previous=new Set(consoleCache[side]||[]);
consoleTx.textContent=sides.tx?.length?sides.tx.join('\n'):'Waiting for transmitted data…'; if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side);
if(rxAtBottom)consoleRx.scrollTop=consoleRx.scrollHeight; }
if(txAtBottom)consoleTx.scrollTop=consoleTx.scrollHeight; consoleCache=sides;renderConsole();
}catch(error){ $('#console-state').textContent='';$('#console-state').hidden=true;
$('#console-state').textContent=`Console unavailable: ${error.message}`; }catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}}
}
finally{consoleRefreshBusy=false} finally{consoleRefreshBusy=false}
} }
$('#console').onclick=()=>{openModal(consoleDialog);refreshConsole()};
setInterval(refreshConsole,750); 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); $('#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 // Explicit pointer ownership makes the full range rail a reliable touch target
// in the embedded window, without depending on native range drag defaults. // in the embedded window, without depending on native range drag defaults.
@@ -476,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')}; $('#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}} 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(); 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){ async function requestMotorEdit(servo,kind,value){
if(motorEdits.has(servo))return; if(motorEdits.has(servo))return;
const s=vehicleSnapshot.servos?.[String(servo)]||{}; const s=vehicleSnapshot.servos?.[String(servo)]||{};
@@ -498,41 +684,40 @@ async function requestMotorEdit(servo,kind,value){
} }
function renderMotors(servos){ function renderMotors(servos){
for(const [servo,edit] of motorEdits){ for(const [servo,edit] of motorEdits){
const s=servos[String(servo)]||{}; const state=servos[String(servo)]||{};
if((edit.kind==='function'?s.function:s.direction)===edit.expected){ if((edit.kind==='function'?state.function:state.direction)===edit.expected){motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`)}
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.`)}
}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 editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known); const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
const assigned=Object.values(servos).filter(s=>s.motor).length; const assignments=Object.entries(servos).filter(([,state])=>state.motor).sort((a,b)=>a[1].motor-b[1].motor);
$('#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 servoByMotor=new Map(assignments.map(([servo,state])=>[String(state.motor),{servo,...state}]));
const signature=JSON.stringify([servos,editable,[...motorEdits]]); $('#motor-status').textContent=!vehicleSnapshot.connected?'Offline':vehicleSnapshot.armed?'Armed':`${assignments.length} outputs`;
if(signature===servoSignature)return; const shownMotors=[...new Set(assignments.map(([,state])=>state.motor))].sort((a,b)=>a-b);
servoSignature=signature; 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)]);
const list=$('#motor-list'),expanded=list.querySelector('details')?.open; if(structure!==servoSignature){
list.replaceChildren(); servoSignature=structure;const list=$('#motor-list');list.replaceChildren();
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded; for(const motor of shownMotors){
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary); const row=document.createElement('article');row.className='motor-row';row.dataset.motor=String(motor);
for(let i=1;i<=16;i++){ 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 s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row'; 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(()=>{})};
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`; 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(()=>{});
const select=row.querySelector('select'); row.querySelector('.motor-reverse').onclick=()=>{const state=servoByMotor.get(String(motor));if(state?.servo)requestMotorEdit(state.servo,'direction')};list.append(row);
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))); const details=document.createElement('details');details.className='motor-mapping';const summary=document.createElement('summary');summary.textContent='Assignments';details.append(summary);
select.value=String(s.motor||0); for(let i=1;i<=16;i++){
select.disabled=!editable||!!pending||s.function==null; 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>';
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value); 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('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal'; row.querySelector('.motor-map-direction').textContent=pending?'Saving…':state.direction==null?'—':state.direction===-1?'Reversed':'Normal';details.append(row);
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null; }
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`); list.append(details);
reverse.onclick=()=>requestMotorEdit(i,'direction'); }
(s.motor?list:other).append(row); 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){ function renderHeavyQuality(test,connected,armed){
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel'); const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
@@ -564,11 +749,13 @@ function render(s,confirmed=false){
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null; (s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
const previous=view; const previous=view;
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError}); view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
syncInputModeControls();
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs(); if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected'; appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed); renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
document.body.dataset.appState=appState; 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); document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
$('#port').disabled=!!s.connected; $('#port').disabled=!!s.connected;
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true'; $('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
@@ -603,8 +790,8 @@ function render(s,confirmed=false){
controlStatus.classList.toggle('is-error',view.error); controlStatus.classList.toggle('is-error',view.error);
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused)); controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
const motorBtn=$('[data-open="motors-modal"]'); const motorBtn=$('[data-open="motors-modal"]');
if(motorBtn)motorBtn.disabled=(!isOnline); if(motorBtn)motorBtn.disabled=false;
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error; 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); const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)} if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
@@ -614,14 +801,29 @@ function render(s,confirmed=false){
updateReadouts(); updateReadouts();
const b=s.battery; const b=s.battery;
if(b){ if(b){
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`; $('#battery-title').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'Voltage unavailable';
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`; $('#battery-percent').textContent=Number.isFinite(b.percent)?`${Math.round(b.percent)}%`:'—';
$('#battery-fill').style.width=`${b.percent}%`; $('#battery-live-voltage').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'—';
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'; $('#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=`${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{ }else{
$('#battery-title').textContent='—'; $('#battery-title').textContent='No voltage';
$('#battery-percent').textContent='waiting'; $('#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'; $('#battery-fill').style.width='0';
$('.battery-card').dataset.low='false';
} }
const quality=s.link_quality,qualityCard=$('#link-quality'); const quality=s.link_quality,qualityCard=$('#link-quality');
if(qualityCard){ if(qualityCard){
@@ -758,20 +960,22 @@ async function boot(){
if(booted||booting||!hasDesktopApi())return; if(booted||booting||!hasDesktopApi())return;
booting=true;clearTimeout(bootRetry); booting=true;clearTimeout(bootRetry);
try{ 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); const init=await withTimeout(api('initial_state'),4000);
batteryConfig=init.battery_config||{}; batteryConfig=init.battery_config||{};
linkConfig=init.link_config||{}; linkConfig=init.link_config||{};
populatePorts(init.ports); motorSettings=init.motor_settings||motorSettings;
syncMotorScalingUI();
fillBatteryForm(); fillBatteryForm();
fillLinkForm(); fillLinkForm();
bridgeReady=true;booted=true;lastBridgeAt=performance.now(); bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state); if(!demoActive)render(init.state);
poll(); poll();
// Populate the serial selector before starting the optional model load. // COM discovery can block on Windows drivers; finish the first paint first.
// A large cached mesh must never delay an operator seeing USB COM ports. setTimeout(()=>withTimeout(api('refresh_ports'),4000).then(populatePorts).catch(()=>{}),0);
loadDefaultModel(); // 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(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
if(appState==='disconnected'||demoActive){ if(appState==='disconnected'||demoActive){
try{populatePorts(await withTimeout(api('refresh_ports'),1500))} try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
@@ -795,8 +999,10 @@ function waitForBridge(){
addEventListener('pywebviewready',boot); addEventListener('pywebviewready',boot);
render(vehicleSnapshot); render(vehicleSnapshot);
if(document.readyState==='loading'){ if(document.readyState==='loading'){
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()}); addEventListener('DOMContentLoaded',waitForBridge);
}else{ }else{
loadDefaultModel();
waitForBridge(); waitForBridge();
} }
requestAnimationFrame(pollController);
+70
View File
@@ -0,0 +1,70 @@
/* Read-only presentation of the existing local RX/TX logs. */
((root)=>{
const quiet=/^(HEARTBEAT|TIMESYNC|LINK_PROBE|HEAVY_PROBE|GCS)/;
function parse(line){
const match=String(line).match(/^(\S+) \[([^\]]+)\] ([\s\S]*)$/);
if(!match)return {time:'',type:'Message',payload:String(line),raw:String(line),tone:'normal',quiet:false};
const [,timestamp,category,message]=match;
let payload=message,type=category.replace(/^(RX|TX)_?/,'')||category;
try{payload=JSON.parse(message);type=payload.mavpackettype||type}catch(_){}
const tone=/BAD_DATA|ERROR|TIMEOUT|FAIL/.test(category)||type==='STATUSTEXT'&&Number(payload.severity)<=4?'warning':/COMMAND|ACK|PARAM_SET|OPERATOR/.test(category+' '+type)?'command':'normal';
const time=timestamp.match(/T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)?.[1]||timestamp;
const pairs=typeof payload==='string'?[...payload.matchAll(/(?:^|\s)([a-zA-Z_]\w*)=([^\s]+)/g)]:[];
const fields=pairs.length?Object.fromEntries(pairs.map(([,key,value])=>[key,value])):payload;
return {time,type,payload,fields,raw:String(line),tone,quiet:tone!=='warning'&&(quiet.test(type)||/LINK_PROBE|HEAVY_PROBE|HEARTBEAT/.test(category))};
}
function valueText(value){
if(typeof value==='number')return Number.isInteger(value)?String(value):Number(value.toFixed(3)).toString();
if(Array.isArray(value))return value.slice(0,6).map(valueText).join(', ')+(value.length>6?'…':'');
return typeof value==='object'?JSON.stringify(value):String(value);
}
function makeRow(row,previous){
if(previous&&previous.dataset.key===row.raw&&previous.dataset.tone===row.tone)return previous;
const details=previous||document.createElement('details');
details.className='console-row';details.dataset.key=row.raw;details.dataset.tone=row.tone;
let summary=details.querySelector('summary'),time,body,type,fields,raw;
if(!summary){
details.replaceChildren();summary=document.createElement('summary');time=document.createElement('time');
body=document.createElement('span');body.className='console-message';type=document.createElement('strong');
fields=document.createElement('span');fields.className='console-fields';body.append(type,fields);summary.append(time,body);
raw=document.createElement('pre');details.append(summary,raw);
}else{
time=summary.querySelector('time');body=summary.querySelector('.console-message');
type=body.querySelector('strong');fields=body.querySelector('.console-fields');raw=details.querySelector('pre');
}
time.textContent=row.time;type.textContent=row.type;fields.replaceChildren();
const values=row.fields??row.payload;
if(typeof values==='object'&&values!==null){
for(const [key,value] of Object.entries(values).filter(([key])=>key!=='mavpackettype').slice(0,6)){
const item=document.createElement('span'),label=document.createElement('i'),text=document.createElement('b');
label.textContent=key+' ';text.textContent=valueText(value);item.append(label,text);fields.append(item);
}
}else fields.textContent=row.payload;
raw.textContent=typeof row.payload==='object'?JSON.stringify(row.payload,null,2):row.raw;
return details;
}
function render(host,lines,filter){
const atBottom=host.scrollHeight-host.scrollTop-host.clientHeight<36,scroll=host.scrollTop;
const rows=lines.map(parse).filter(row=>filter==='all'||!row.quiet);
const existing=new Map();
for(const node of host.querySelectorAll('details.console-row')){
const key=node.dataset.key,list=existing.get(key)||[];list.push(node);existing.set(key,list);
}
if(!rows.length){
if(!host.querySelector('.console-empty')){
const empty=document.createElement('p');empty.className='console-empty';
empty.textContent='No messages';
empty.title=filter==='all'?'Waiting for session messages.':'Routine heartbeat and probe traffic is hidden. Choose All traffic to see it.';
host.replaceChildren(empty);
}
return;
}
const wanted=rows.map(row=>makeRow(row,existing.get(row.raw)?.shift()||null));
let cursor=host.firstElementChild;
for(const node of wanted){if(node!==cursor)host.insertBefore(node,cursor);cursor=node.nextElementSibling}
while(cursor){const next=cursor.nextElementSibling;cursor.remove();cursor=next}
if(atBottom&&!host.querySelector('details[open]'))host.scrollTop=host.scrollHeight;else host.scrollTop=scroll;
}
root.ConsoleView={parse,render};
if(typeof module!=='undefined')module.exports=root.ConsoleView;
})(typeof window!=='undefined'?window:globalThis);
+1 -1
View File
@@ -18,7 +18,7 @@
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null; label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.'; message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
} else if (known && online) { } 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.'; if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
const failure=error || state.command_error; const failure=error || state.command_error;
+53
View File
@@ -0,0 +1,53 @@
/* Controller conditioning is independent of drawing and MAVLink transport. */
((root)=>{
const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0));
function deadZone(value,zone=.08){
value=clamp(value);zone=Math.max(0,Math.min(.4,zone));
return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone);
}
function defaults(pad){
return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1,
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
}
function read(pad,config){
if(!pad?.connected||!pad.axes)return null;
const axis=(index,invert)=>{
if(index===-1)return 0;
if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null;
return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1);
};
let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical);
if(turn===null||forward===null||vertical===null)return null;
const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length}
return {turn,forward,vertical};
}
class Smoother{
constructor(){this.reset()}
reset(){this.value=0;this.last=null;this.release=null}
step(target,now){
target=clamp(target);
const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now;
if(target===0){
if(!this.release)this.release={at:now,start:this.value};
const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t);
this.value=this.release.start*(1-ease);
}else{
this.release=null;
this.value+=(target-this.value)*(1-Math.exp(-dt/.1));
if(Math.abs(target-this.value)<.001)this.value=target;
}
return this.value;
}
}
class Motion{
constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}}
reset(){Object.values(this.axes).forEach(axis=>axis.reset())}
step(target,now){
const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)]));
const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length}
return demand;
}
}
root.ControllerInput={deadZone,defaults,read,Motion};
if(typeof module!=='undefined')module.exports=root.ControllerInput;
})(typeof window!=='undefined'?window:globalThis);
+65 -35
View File
@@ -4,11 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title> <title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20260930-dialogs-v1"> <link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">
<!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── --> <!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
<aside class="sidebar-deck" aria-label="Vehicle Deck and Status"> <aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div> <div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
@@ -44,12 +44,6 @@
</div> </div>
</div> </div>
<!-- Battery Status -->
<button class="battery-card ghost" data-open="battery-modal">
<span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i>
<b><em id="battery-fill"></em></b>
</button>
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal"> <button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
<span class="link-quality-dot" aria-hidden="true"></span> <span class="link-quality-dot" aria-hidden="true"></span>
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span> <span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
@@ -57,13 +51,18 @@
</button> </button>
<div class="deck-status-bar"> <div class="deck-status-bar">
<header class="activity-heading">Activity:</header>
<ol class="activity-history" aria-label="Last three events" aria-live="polite"> <ol class="activity-history" aria-label="Last three events" aria-live="polite">
<li id="activity-oldest" hidden></li> <li id="activity-oldest" hidden></li>
<li id="activity-previous" hidden></li> <li id="activity-previous" hidden></li>
<li id="status-text">Not connected</li> <li id="status-text">Not connected</li>
</ol> </ol>
<header class="activity-heading"><span>Recent activity</span><span class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></span></header> <div class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></div>
</div> </div>
<button class="battery-card ghost" data-open="battery-modal" aria-label="Battery status and settings">
<span class="battery-meter" aria-hidden="true"><em id="battery-fill"></em></span>
<span class="battery-copy"><span class="battery-summary"><strong id="battery-percent">—</strong><small id="battery-title">No voltage</small></span></span>
</button>
</section> </section>
</aside> </aside>
@@ -72,6 +71,7 @@
<section class="controls-workspace" aria-label="Primary Vehicle Controls"> <section class="controls-workspace" aria-label="Primary Vehicle Controls">
<!-- Frameless Drag & Window Controls Header --> <!-- Frameless Drag & Window Controls Header -->
<div class="workspace-topbar"> <div class="workspace-topbar">
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
<div class="workspace-drag-area pywebview-drag-region"></div> <div class="workspace-drag-area pywebview-drag-region"></div>
<div class="window-actions"> <div class="window-actions">
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button> <button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
@@ -80,6 +80,7 @@
</div> </div>
</div> </div>
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
<section class="primary-control-zone" aria-label="Primary Control Zone"> <section class="primary-control-zone" aria-label="Primary Control Zone">
<!-- Reserved Engage / Disarm row --> <!-- Reserved Engage / Disarm row -->
<div class="engage-header-zone"> <div class="engage-header-zone">
@@ -98,12 +99,12 @@
<!-- Steering / Throttle Joystick --> <!-- Steering / Throttle Joystick -->
<article class="control-card steer-card"> <article class="control-card steer-card">
<header> <header>
<div><small>steer + throttle</small></div> <div title="Steer and throttle · W A S D"></div>
<span class="key-hint">w a s d</span>
</header> </header>
<div class="joystick-pad"> <div class="joystick-pad">
<div id="joystick" class="joystick" touch-action="none"> <div id="joystick" class="joystick" touch-action="none">
<canvas id="joystick-canvas" aria-label="Steering and throttle joystick; W A S D keys"></canvas> <canvas id="joystick-canvas" title="Steer and throttle · W A S D" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
</div> </div>
</div> </div>
<footer> <footer>
@@ -115,13 +116,13 @@
<!-- Vertical Thruster Slider --> <!-- Vertical Thruster Slider -->
<article class="control-card vertical-card"> <article class="control-card vertical-card">
<header> <header>
<div><small>vertical</small></div> <div title="Vertical thrust · R / F"></div>
<span class="key-hint">r / f</span>
</header> </header>
<div class="heave-pad"> <div class="heave-pad">
<div class="vertical-control"> <div class="vertical-control">
<span>rise</span> <span>rise</span>
<div id="heave" class="heave-track" touch-action="none"> <div id="heave" title="Vertical thrust · R / F" class="heave-track" touch-action="none">
<div class="heave-center"></div> <div class="heave-center"></div>
<div id="heave-fill" class="heave-fill"></div> <div id="heave-fill" class="heave-fill"></div>
<div id="heave-knob" class="heave-knob"></div> <div id="heave-knob" class="heave-knob"></div>
@@ -139,7 +140,7 @@
<span>Output limit <b id="gain-value">20%</b></span> <span>Output limit <b id="gain-value">20%</b></span>
<div class="range-pill"> <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> <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> <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> </div>
</label> </label>
@@ -147,39 +148,46 @@
</div> </div>
</section> </section>
</section> </section>
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
<header class="console-dock-header"><div><h2 id="console-title" title="Local session messages. Select a message to expand its details.">Console</h2></div><button id="console-close" type="button" class="console-close" aria-label="Hide console" title="Hide console"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button></header>
<div class="console-toolbar"><label><span class="visually-hidden">Console filter</span><select id="console-filter"><option value="useful">Useful traffic</option><option value="all">All traffic</option></select></label><button id="console-pause" type="button" aria-pressed="false">Pause</button></div>
<p id="console-state" class="console-state" role="status" title="Pausing the viewer does not stop session logging." hidden></p>
<div class="console-streams"><section class="console-side"><header><i class="rx-dot" aria-hidden="true" title="Vehicle to PC · new displayed message"></i><span>RX</span></header><div id="console-rx-output" class="console-output" tabindex="0" aria-label="Received messages"></div></section><section class="console-side"><header><i class="tx-dot" aria-hidden="true" title="PC to vehicle · new displayed message"></i><span>TX</span></header><div id="console-tx-output" class="console-output" tabindex="0" aria-label="Sent messages"></div></section></div>
</aside>
</main> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<!-- Modals --> <!-- Modals -->
<dialog id="link-modal" class="modal" aria-labelledby="link-title"> <dialog id="link-modal" class="modal" aria-labelledby="link-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="link-title">Link 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="link-title">Link settings</h2>
<form id="link-form"> <form id="link-form" title="Baud changes apply on the next connection.">
<section class="settings-section"><h3>Serial connection</h3> <section class="settings-section"><h3>Serial connection</h3>
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div> <div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
<p class="note">Baud changes apply on the next connection.</p>
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label> <label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
<p id="custom-port-status" class="note" role="status"></p> <p id="custom-port-status" class="note" role="status"></p>
</section> </section>
<section class="settings-section"><h3>Live updates</h3> <section class="settings-section"><h3 title="Lower rates leave more bandwidth for controls. Rates reset after a reboot.">Telemetry rates</h3>
<div class="form-grid two"> <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>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 / 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>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>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> <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>
</div> </div>
<p class="note">Lower rates leave more bandwidth for controls. Vehicle telemetry rates reset after a reboot.</p>
</section> </section>
<p class="form-feedback" role="alert" hidden></p> <p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div> <div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
</form> </form>
</dialog> </dialog>
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title"> <dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="quality-title">Link quality</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="quality-title">Link quality</h2>
<p class="note">The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.</p>
<div class="quality-summary"><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div> <div class="quality-summary" title="Round-trip replies, incoming packet loss, and heartbeat freshness."><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
<section class="heavy-test-panel"><h3>Extended test</h3><p class="note">Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.</p> <section class="heavy-test-panel"><h3 title="Tests 1, 5 and 10 Hz for about 35 seconds while disarmed. The live check resumes afterward.">Extended test</h3>
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div> <div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
<div id="heavy-quality-results" class="heavy-quality-results"></div> <div id="heavy-quality-results" class="heavy-quality-results"></div>
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p> <p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
@@ -187,8 +195,17 @@
</section> </section>
</dialog> </dialog>
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal"> <dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Power</small><h2 id="battery-title-modal">Battery settings</h2><p class="note">Charge is estimated from voltage. Capacity and C rating describe your battery.</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="battery-title-modal">Battery</h2>
<form id="battery-form"> <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> <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> <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>
<p class="form-feedback" role="alert" hidden></p> <p class="form-feedback" role="alert" hidden></p>
@@ -196,20 +213,33 @@
</form> </form>
</dialog> </dialog>
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title"> <dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Configuration</small><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> <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>
<dialog id="console-modal" class="modal console-modal" aria-labelledby="console-title"> <dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Local session</small><h2 id="console-title">Live console</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="controller-title">Joystick setup</h2>
<div class="console-toolbar"><p id="console-state" class="note" role="status">Live · follows new messages</p><button id="console-pause" type="button" class="button subtle" aria-pressed="false">Pause</button></div> <form id="controller-form" title="Center sticks after changing input mode or reconnecting before movement resumes.">
<div class="console-split"><section><header><i class="rx-dot"></i><span>RX</span><small>Vehicle → PC</small></header><pre id="console-rx-output" tabindex="0" aria-label="Received messages">Waiting for vehicle data…</pre></section><section><header><i class="tx-dot"></i><span>TX</span><small>PC → Vehicle</small></header><pre id="console-tx-output" tabindex="0" aria-label="Sent messages">Waiting for transmitted data…</pre></section></div> <label><span>Controller</span><select id="controller-device"></select></label>
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
<div class="controller-mapping-row"><span>Turn</span><select name="turn" aria-label="Turn axis"></select><input name="invertTurn" type="checkbox" aria-label="Invert Turn"></div>
<div class="controller-mapping-row"><span>Forward / back</span><select name="forward" aria-label="Forward / back axis"></select><input name="invertForward" type="checkbox" aria-label="Invert Forward / back"></div>
<div class="controller-mapping-row"><span>Rise / dive</span><select name="vertical" aria-label="Rise / dive axis"></select><input name="invertVertical" type="checkbox" aria-label="Invert Rise / dive"></div>
<label class="controller-deadzone"><span>Dead zone · %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save mapping</button></div>
</form>
</dialog> </dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script> <script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20260827-keyboard-v7"></script> <script src="joystick-3d.js?v=20261002-spacing-motion-v2"></script>
<script src="control-state.js?v=20260827-state-v1"></script> <script src="control-state.js?v=20260827-state-v1"></script>
<script src="app.js?v=20260930-dialogs-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=20261017-engage-center-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script> <script src="cursor-light.js?v=20260827-directional-v10"></script>
</body> </body>
</html> </html>
+6 -4
View File
@@ -23,7 +23,9 @@
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()} if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3; ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore(); const handle=ctx.createLinearGradient(kx-knob*.574,ky-knob*.819,kx+knob*.574,ky+knob*.819);
handle.addColorStop(0,activePointer===null?'#737373':'#606060');handle.addColorStop(1,activePointer===null?'#454545':'#393939');
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=handle;ctx.fill();ctx.restore();
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke(); ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill(); ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
} }
@@ -40,10 +42,10 @@
function returnToCenter(){easeTo(0,0)} function returnToCenter(){easeTo(0,0)}
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()} function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()}); canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canTouchControl?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)}); canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)}); canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)}); canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
addEventListener('resize',resize);resize(); addEventListener('resize',resize);new ResizeObserver(resize).observe(host);resize();
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}}; window.joystick3D={setVisual(nextX,nextY){setPosition(nextX,nextY,false)},setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeTo(nextX,nextY){if(activePointer===null)easeTo(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
})(); })();
+251 -30
View File
@@ -713,23 +713,27 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
pointer-events: none; pointer-events: none;
} }
.engage-button.charging { transition: background-color .24s ease, border-color .24s ease, color .2s ease; } .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 { 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; font-size: 11px; font-weight: 650; letter-spacing: .12em; } .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 { .engage-label {
position:relative;
color: transparent; color: transparent;
background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%); background: linear-gradient(105deg,#f4f6f3 20%,#ffffff 43%,#9da6a2 50%,#ffffff 57%,#f4f6f3 80%);
background-size: 250% 100%; background-size: 250% 100%;
background-clip: text; background-clip: text;
-webkit-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; } .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 { .engage-progress-label {
color: #a9ed72; color: #a9ed72;
clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0); clip-path: inset(0 calc(100% - var(--engage-progress)) 0 0);
transition: opacity .18s ease; 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} } @keyframes engage-shimmer { 0%,35%{background-position:160% 0} 80%,100%{background-position:-90% 0} }
.engage-button:hover { transform:none; } .engage-button:hover { transform:none; }
.engage-button.pending { background:#405346; border-color:#516e56; } .engage-button.pending { background:#405346; border-color:#516e56; }
@@ -756,7 +760,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
background: #4e7140; background: #4e7140;
border-color: #78ad5a; border-color: #78ad5a;
} }
.danger { .danger {
--engage-progress: 0%; --engage-progress: 0%;
background: #ad3f38; background: #ad3f38;
color: #ffffff; color: #ffffff;
@@ -947,7 +951,7 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
border-radius: 24px; border-radius: 24px;
background: rgba(247, 248, 245, 0.96); background: rgba(247, 248, 245, 0.96);
color: #1a1e1f; color: #1a1e1f;
box-shadow: box-shadow:
0 30px 80px -12px rgba(0, 0, 0, 0.42), 0 30px 80px -12px rgba(0, 0, 0, 0.42),
0 0 0 1px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.06),
0 10px 24px -6px rgba(0, 0, 0, 0.16); 0 10px 24px -6px rgba(0, 0, 0, 0.16);
@@ -1015,21 +1019,14 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
color: #7a8280; color: #7a8280;
margin-top: 8px; margin-top: 8px;
} }
.motor-modal { width: min(600px, calc(100vw - 40px)); } .motor-modal { width: min(860px, calc(100vw - 40px)); }
.motor-head, .motor-row { .battery-live { margin:14px 0 20px; padding:14px; border-radius:16px; background:#f0f3ef; }
display: grid; .battery-live-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
grid-template-columns: 70px 1fr 80px 75px; .battery-live-grid > div { min-width:0; padding:10px 11px; border-radius:12px; background:#fff; }
align-items: center; .battery-live-grid span,.battery-live-grid small { display:block; color:#727b77; font-size:11px; }
gap: 8px; .battery-live-grid strong { display:block; margin-top:7px; color:#303936; font-size:18px; font-weight:500; font-variant-numeric:tabular-nums; }
padding: 6px 0; .battery-live-grid small { margin-top:4px; }
border-bottom: 1px solid #e0e4df; .battery-live .note { margin:10px 0 0; line-height:1.5; font-size:12px; }
font-size: 10.5px;
}
.motor-head {
font-weight: 500;
color: #727a7c;
border-bottom: 1.5px solid #c8cec6;
}
/* ── Toast ── */ /* ── Toast ── */
.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; } body #engage:hover, body #engage.pending, body #engage.danger { background:transparent; border:0; }
#engage::before, #engage::after { display:none; } #engage::before, #engage::after { display:none; }
body #engage { align-items:flex-end; padding-bottom:12px; } 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-green:#4cce73; --engage-glow:drop-shadow(0 0 2px rgba(76,206,115,.3)); }
#engage .engage-label { color:#777; background:none; animation:none; } #engage .engage-label { color:#777; background:none; animation:none; }
.engage-sheen-label { color:#fff; pointer-events:none; opacity:0; mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); mask-size:300% 100%; mask-repeat:no-repeat; -webkit-mask-image:linear-gradient(105deg,transparent 40%,#000 49%,#000 51%,transparent 60%); -webkit-mask-size:300% 100%; -webkit-mask-repeat:no-repeat; } .engage-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; } .control-status[hidden] { display:none; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.engage-sheen-label { display:none; } .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 { .controls-grid {
--joy-size:min(54vh,calc(100vw - 480px),460px); --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 .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 [hidden] { display:none !important; }
.modal [data-busy="true"] { cursor:progress; } .modal [data-busy="true"] { cursor:progress; }
.motor-modal { width:min(650px,calc(100vw - 32px)); } .motor-modal { width:min(860px,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; } @media (max-width:650px) { .battery-live-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.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-other { margin-top:16px; } .motor-other { margin-top:16px; }
.motor-other summary { cursor:pointer; padding:12px 0; color:#697278; font-size:13px; } .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; } .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) { @media (max-width:520px) {
.modal { padding:22px 18px; } .modal { padding:22px 18px; }
.modal .form-grid.two { grid-template-columns:1fr; } .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 { gap:6px; }
.quality-summary div { padding:12px 8px; } .quality-summary div { padding:12px 8px; }
.quality-summary strong { font-size:17px; } .quality-summary strong { font-size:17px; }
@@ -1298,3 +1292,230 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
@media (prefers-reduced-motion:reduce) { @media (prefers-reduced-motion:reduce) {
.modal[open],.modal.closing { animation:none; } .modal[open],.modal.closing { animation:none; }
} }
/* Input selection stays separate from vehicle arming and command ownership. */
#engage:focus,#engage:focus-visible { outline:none!important; box-shadow:none!important; }
.controls-workspace { container-type:inline-size; }
.input-mode-controls { display:flex; align-items:center; gap:6px; flex-shrink:0; -webkit-app-region:no-drag; }
.input-mode-switch { display:flex; padding:3px; gap:2px; background:#dedede; border-radius:999px; }
.input-mode-switch button,.controller-settings { border:0; border-radius:999px; background:transparent; color:#686868; padding:7px 11px; font-size:12px; cursor:pointer; }
.input-mode-switch button[aria-pressed="true"] { background:#fafafa; color:#252525; box-shadow:0 1px 3px #0000000c; }
.controller-settings { background:#dedede; }
.controller-status { margin:0; height:24px; flex-shrink:0; font-size:12px; line-height:20px; color:#6d7478; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
body[data-input-mode="joystick"] .key-hint { visibility:hidden; }
body[data-input-mode="joystick"] :is(#joystick-canvas,#heave) { pointer-events:none; cursor:default; }
.controls-grid { --joy-size:min(52vh,calc(100cqw - 136px),460px); }
.control-toolbar { flex-wrap:wrap; gap:8px 14px; }
.primary-control-zone { grid-template-rows:120px minmax(0,1fr) auto; }
.control-toolbar { min-height:52px; }
.engage-face > span { font-size:clamp(52px,11cqw,90px); }
.controller-mapping-head,.controller-mapping-row { display:grid; grid-template-columns:1fr 1.25fr 50px; gap:14px; align-items:center; }
.controller-mapping-head { color:#777; font-size:12px; margin-bottom:8px; }
.controller-mapping-row { margin:8px 0; }
.modal .controller-mapping-row input[type="checkbox"] { width:22px; height:22px; min-height:0; margin:auto; padding:0; accent-color:#527f9b; }
.controller-mapping-row label { display:flex; align-items:center; }
#controller-axes { min-height:22px; margin:12px 0; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; color:#65737d; font-size:12px; }
#controller-form > label { margin-top:18px; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
/* Read-only local console, not a modal: driving stays available while it is open. */
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr) 340px; }
.console-dock[hidden] { display:none!important; }
.console-dock { display:flex; flex-direction:column; min-width:0; min-height:0; padding:18px 16px; background:#f5f5f5; border-left:1px solid #d8dcde; color:#29343b; color-scheme:light; }
.console-dock-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:14px; }
.console-dock-header small { font-size:11px; color:#81888d; }
.console-dock-header h2 { font-size:22px; font-weight:400; margin:4px 0 0; letter-spacing:-.02em; }
.console-close { width:38px; height:38px; flex-shrink:0; border:0; border-radius:999px; background:#e7e8e9; color:#48525a; font-size:25px; cursor:pointer; }
.console-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.console-toolbar label { flex:1; min-width:0; }
.console-toolbar select,.console-toolbar button { height:34px; border:0; border-radius:999px; background:#e7e9ea; color:#42525e; font-size:12px; padding:0 12px; }
.console-toolbar select { width:100%; }
.console-toolbar button { cursor:pointer; }
.console-state { min-height:28px; margin:10px 0 8px; font-size:11px; color:#7e868b; line-height:1.4; }
.console-streams { display:grid; grid-template-rows:1fr 1fr; gap:14px; min-height:0; flex:1; }
.console-side { display:flex; flex-direction:column; min-height:0; }
.console-side > header { display:flex; align-items:center; gap:6px; padding:0 4px 8px; font-size:12px; font-weight:500; color:#435966; }
.console-side > header small { margin-left:auto; font-size:11px; font-weight:300; color:#899198; }
.console-side > header i { width:6px; height:6px; border-radius:50%; background:#5888a3; }
.console-side > header .tx-dot { background:#659287; }
.console-output { min-height:0; flex:1; overflow:auto; overscroll-behavior:contain; padding:0 8px; border:1px solid #e0e3e5; border-radius:12px; background:#fafbfc; color:#343d43; font:12px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif; }
.console-row { border-bottom:1px solid #e9edef; }
.console-row:last-child { border-bottom:0; }
.console-row summary { display:flex; gap:8px; cursor:pointer; padding:10px 0; list-style:none; }
.console-row summary::-webkit-details-marker { display:none; }
.console-row time { flex-shrink:0; color:#929ba1; font:10px/1.8 ui-monospace,Consolas,monospace; font-variant-numeric:tabular-nums; }
.console-message { min-width:0; display:block; overflow-wrap:anywhere; }
.console-message strong { display:block; font-size:11px; font-weight:500; color:#517c97; letter-spacing:.025em; }
.console-row[data-tone="command"] strong { color:#467a69; }
.console-row[data-tone="warning"] strong { color:#aa664b; }
.console-fields { display:flex; flex-wrap:wrap; gap:2px 8px; font-size:11px; }
.console-fields i { color:#929a9f; font-style:normal; }
.console-fields b { font-weight:400; color:#45535d; }
.console-row pre { margin:0 0 10px; padding:10px; border-radius:8px; background:#eef1f3; color:#52616c; font:11px/1.5 ui-monospace,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
.console-empty { color:#959da3; font-size:12px; line-height:1.6; margin:14px 0; }
@media (max-height:610px) { .primary-control-zone { grid-template-rows:100px minmax(0,1fr) auto; } .controls-grid { --joy-size:min(46vh,calc(100cqw - 136px),420px); } }
@media (max-width:1000px) {
.app-shell.console-open { grid-template-columns:260px minmax(0,1fr); }
.console-dock { position:absolute; top:0; right:0; bottom:0; width:min(340px,calc(100vw - 32px)); z-index:12; box-shadow:-8px 0 24px #00000012; border-radius:12px 0 0 12px; }
}
/* Three shared sizes: compact 32px, standard 40px, primary 62px. */
:root {
--pill-sm:32px; --pill-md:40px; --pill-lg:62px; --pill-radius:999px;
--battery-charge:#63cb85; --battery-low:#e98a7b;
}
.port-row select,.icon-button { height:var(--pill-sm); }
.icon-button { width:var(--pill-sm); }
body #connect { height:var(--pill-lg); border-radius:var(--pill-radius); }
.input-mode-switch { height:var(--pill-md); padding:4px; }
.input-mode-switch button { height:var(--pill-sm); padding:0 12px; }
.controller-settings { height:var(--pill-md); padding:0 14px; }
.input-mode-switch button:disabled { opacity:.65!important; }
.input-mode-switch button[aria-pressed="true"]:disabled { opacity:1!important; }
.control-toolbar .range-pill,.control-toolbar .config-btn { height:var(--pill-md); border-radius:var(--pill-radius); }
.control-toolbar .range-pill button { height:var(--pill-md); }
.window-actions button { width:var(--pill-md); height:var(--pill-md); }
.console-toolbar select,.console-toolbar button { height:var(--pill-sm); border-radius:var(--pill-radius); }
.modal input,.modal select,.modal .number-pill { height:var(--pill-md); border-radius:var(--pill-radius); }
.modal .number-pill input,.modal .number-pill button { height:calc(var(--pill-md) - 2px); }
.modal .button { min-height:var(--pill-md); padding:8px 18px; border-radius:var(--pill-radius); }
.modal .controller-mapping-row input[type="checkbox"] { height:22px; border-radius:4px; }
/* Fixed SVG geometry avoids font baselines shifting the close icon. */
.modal-close,.modal-close:hover,.console-close,.console-close:hover,.window-actions .window-close,.window-actions .window-close:hover {
display:grid; place-items:center; width:var(--pill-md); height:var(--pill-md);
padding:0; border:0; border-radius:0; background:transparent; box-shadow:none;
}
.modal-close svg,.console-close svg { display:block; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; }
.modal-close:hover,.console-close:hover { color:#222; }
.heave-knob { height:var(--pill-md); background:linear-gradient(145deg,#737373,#454545); }
/* Activity has a heading, compact event history, then explicit actions. */
.telemetry-zone { gap:16px; }
.deck-status-bar { flex-shrink:0; margin-top:0; padding-top:12px; }
.activity-heading { display:block; margin:0 0 8px; color:#eee; font-size:14px; font-weight:650; }
.activity-history { gap:4px; margin:0 0 10px; }
.activity-actions { display:flex; gap:8px; }
.activity-actions .logs-link {
display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box;
height:var(--pill-sm); min-width:76px; padding:0 14px; margin:0;
border:0; border-radius:var(--pill-radius); background:#414141; color:#eee;
font-size:12px; font-weight:400; letter-spacing:0; line-height:1; cursor:pointer;
}
.activity-actions .logs-link:hover { background:#4b4b4b; }
.activity-actions .logs-link:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
.activity-actions .logs-link[aria-expanded="true"] { background:#555; }
/* Battery is the final deck item: text floats above the charge fill. */
.battery-card {
position:relative; display:block; flex-shrink:0; width:100%; height:var(--pill-lg);
min-height:var(--pill-lg); margin:0; padding:0; overflow:hidden;
border:0; border-radius:var(--pill-radius); background:#314b3a;
color:#f2f5f2; isolation:isolate;
}
.battery-meter { position:absolute; inset:0; z-index:0; overflow:hidden; border-radius:inherit; pointer-events:none; }
.battery-meter #battery-fill { display:block; height:100%; width:0; border-radius:inherit; background:var(--battery-charge); opacity:.55; transition:width .35s ease,background .2s ease,opacity .18s ease-out; }
.battery-card .battery-copy { position:absolute; inset:0; z-index:1; display:flex; flex-direction:row; align-items:center; justify-content:space-between; gap:8px; padding:0 18px; pointer-events:none; }
.battery-summary { display:flex; flex-direction:column; gap:3px; flex-shrink:0; }
.battery-card small { color:#d7e8dc; font-size:12px; line-height:1; font-variant-numeric:tabular-nums; }
.battery-card strong { font-size:26px; font-weight:400; line-height:1.15; font-variant-numeric:tabular-nums; }
.battery-card i { align-self:auto; color:#d9e0da; text-align:right; font-size:12px; line-height:1.35; font-style:normal; }
.battery-card[data-low="true"] { background:#4a3734; }
.battery-card[data-stale="true"] #battery-fill { opacity:.25; }
.battery-card:focus-visible { outline:2px solid #a5c0d0; outline-offset:2px; }
/* The same slender, rounded scrollbar is used by every scrollable panel. */
*::-webkit-scrollbar { width:8px; height:8px; }
*::-webkit-scrollbar-track,*::-webkit-scrollbar-corner { background:transparent; }
*::-webkit-scrollbar-thumb { min-height:28px; border:2px solid transparent; border-radius:var(--pill-radius); background:#aeb6ba; background-clip:padding-box; }
*::-webkit-scrollbar-thumb:hover { background:#87959e; background-clip:padding-box; }
*::-webkit-scrollbar-button { display:none; width:0; height:0; }
.sidebar-deck::-webkit-scrollbar-thumb { background:#666; background-clip:padding-box; }
.modal { scrollbar-gutter:stable; }
@supports not selector(::-webkit-scrollbar) { * { scrollbar-width:thin; scrollbar-color:#aeb6ba transparent; } }
@media (max-height:610px) { .telemetry-zone { gap:12px; } }
/* Shared panel geometry and quiet, presentational motion. Never ease command data. */
:root { --panel-inset:20px; --ui-gap:12px; --ui-duration:180ms; --ui-ease:cubic-bezier(.2,0,.2,1); }
.sidebar-deck,.controls-workspace,.console-dock { padding:var(--panel-inset); }
.sidebar-deck,.telemetry-zone,.deck-connection,.port-row,.activity-actions,.input-mode-controls,.console-toolbar,.console-streams { gap:var(--ui-gap); }
.deck-connection { padding-bottom:var(--ui-gap); }
.workspace-topbar,.console-dock-header { min-height:var(--pill-md); height:var(--pill-md); margin-bottom:var(--ui-gap); }
.port-row select,.port-row .icon-button { height:var(--pill-md); }
.port-row .icon-button { width:var(--pill-md); }
.activity-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
.activity-actions .logs-link { min-width:0; width:100%; }
.console-dock-header h2 { margin:0; }
.console-toolbar { margin-bottom:var(--ui-gap); }
.console-state { min-height:0; margin:0 0 var(--ui-gap); font-size:12px; }
.console-side > header { padding-left:0; padding-right:0; }
.console-side > header i { background:#b4bdc2; transition:background-color var(--ui-duration) ease-out,box-shadow var(--ui-duration) ease-out; }
.console-side > header .rx-dot.lit { background:#4fa4cf; box-shadow:0 0 5px #4fa4cf66; }
.console-side > header .tx-dot.lit { background:#56ae8a; box-shadow:0 0 5px #56ae8a66; }
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::before,
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card)::after,
:is(button,select,input,.input-mode-switch,.console-row summary,.heave-knob,.heave-fill,.control-status,.battery-card) {
transition-property:background-color,border-color,color,opacity,box-shadow,filter;
transition-duration:var(--ui-duration); transition-timing-function:var(--ui-ease);
}
/* Keep pointer positions immediate; the existing conditioning owns motor easing. */
.modal[open],.console-dock:not([hidden]),.form-feedback:not([hidden]),.console-state:not([hidden]),.console-row pre { animation:ui-fade-in var(--ui-duration) var(--ui-ease); }
.modal.closing,.console-dock.closing { animation:ui-fade-out var(--ui-duration) ease-out forwards; pointer-events:none; }
@keyframes ui-fade-in { from { opacity:0; } to { opacity:1; } }
@keyframes ui-fade-out { from { opacity:1; } to { opacity:0; } }
.controller-status { transition:opacity var(--ui-duration) ease-out; }
.controller-status[data-quiet="true"] { opacity:0; pointer-events:none; }
/* 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.worker.require_input_lease = True
self.vehicle_state = VehicleState() self.vehicle_state = VehicleState()
self.battery_received_at = None 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.demo_mode = False
self.lock = threading.Lock() self.lock = threading.Lock()
self._poll_lock = threading.Lock() self._poll_lock = threading.Lock()
@@ -43,10 +46,26 @@ class ControlApi:
"link_quality": None, "link_quality": None,
"heavy_quality": None, "heavy_quality": None,
"log_path": 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.servo_functions: dict[int, int] = {}
self.motor_directions: 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.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.link_path = Path(__file__).resolve().parent / "link_settings.json" self.link_path = Path(__file__).resolve().parent / "link_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json" self.model_path = Path(__file__).resolve().parent / "model_settings.json"
@@ -67,7 +86,7 @@ class ControlApi:
pass pass
self.link_config = { self.link_config = {
"baud": 57600, "control_hz": 20, "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, "rc_hz": 2, "esc_hz": 1,
} }
try: try:
@@ -78,11 +97,10 @@ class ControlApi:
def initial_state(self): def initial_state(self):
return { return {
"ports": self.refresh_ports(),
"battery_config": self.battery_config, "battery_config": self.battery_config,
"link_config": self.link_config, "link_config": self.link_config,
"motor_settings": self.motor_settings,
"state": self._snapshot(), "state": self._snapshot(),
"model_info": self.get_saved_model_info(),
} }
def refresh_ports(self): def refresh_ports(self):
@@ -259,8 +277,12 @@ class ControlApi:
self.state["servos"][str(servo)] = { self.state["servos"][str(servo)] = {
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"), "function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None, "motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
"output_us": None,
} }
return self._snapshot() 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) self.worker.rate_hz = int(rate)
with self.lock: with self.lock:
self.vehicle_state.disconnect() self.vehicle_state.disconnect()
@@ -285,6 +307,9 @@ class ControlApi:
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds") raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self): 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: if self.worker.heavy_quality_test is not None:
self.worker.cancel_heavy_quality_test() self.worker.cancel_heavy_quality_test()
if self.demo_mode: if self.demo_mode:
@@ -305,6 +330,34 @@ class ControlApi:
def cancel_heavy_quality_test(self): def cancel_heavy_quality_test(self):
return self.worker.cancel_heavy_quality_test() 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): def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode: if self.demo_mode:
with self.lock: with self.lock:
@@ -330,10 +383,44 @@ class ControlApi:
return True return True
def set_gain(self, value): def set_gain(self, value):
value = max(5, min(50, int(value))) value = max(0, min(100, int(value)))
self.worker.gain = value / 100.0 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 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): def command(self, name, sequence=None, expires_at=None):
if self.demo_mode: if self.demo_mode:
if name not in ("manual", "arm", "disarm"): if name not in ("manual", "arm", "disarm"):
@@ -425,6 +512,7 @@ class ControlApi:
self.worker.rate_hz = self.link_config["control_hz"] self.worker.rate_hz = self.link_config["control_hz"]
self.worker.set_link_rates(self.link_config["ping_hz"], { self.worker.set_link_rates(self.link_config["ping_hz"], {
"SYS_STATUS": self.link_config["system_hz"], "SYS_STATUS": self.link_config["system_hz"],
"BATTERY_STATUS": self.link_config["battery_hz"],
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"], "SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
"RC_CHANNELS": self.link_config["rc_hz"], "RC_CHANNELS": self.link_config["rc_hz"],
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_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") raise ValueError("Control rate must be 10 or 20 Hz")
if clean["ping_hz"] not in {1, 2, 5, 10}: if clean["ping_hz"] not in {1, 2, 5, 10}:
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz") 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") 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_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.link_config = clean self.link_config = clean
@@ -488,6 +576,7 @@ class ControlApi:
def _snapshot(self): def _snapshot(self):
with self.lock: with self.lock:
snap = json.loads(json.dumps(self.state)) snap = json.loads(json.dumps(self.state))
snap["motor_settings"] = self.motor_settings
snap["app_state"] = self._derive_app_state() snap["app_state"] = self._derive_app_state()
if not self.demo_mode: if not self.demo_mode:
snap.update(self.vehicle_state.snapshot()) snap.update(self.vehicle_state.snapshot())
@@ -503,6 +592,9 @@ class ControlApi:
if kind == "heartbeat": if kind == "heartbeat":
_, system, autopilot, mode, armed = event[:5] _, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None) 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( self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}", connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed), armed=bool(armed),
@@ -515,16 +607,19 @@ class ControlApi:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None) self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}" self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery": elif kind == "battery":
self.battery_received_at = time.monotonic() self._consume_battery(event[1])
voltage = float(event[1]) elif kind == "servo_output":
cells = self.battery_config["cells"] for servo, output_us in event[1].items():
per_cell = voltage / cells if servo in self.state["servos"]:
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"] self.state["servos"][servo]["output_us"] = output_us
percent = max(0, min(100, (per_cell - low) * 100 / (high - low))) elif kind == "motor_test_ack":
self.state["battery"] = { self.state["status"] = "Motor test accepted by flight controller"
"voltage": voltage, "per_cell": per_cell, "percent": percent, elif kind == "motor_test_rejected":
"cells": cells, "low": percent <= self.battery_config["warning_percent"], 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": elif kind == "link_quality":
self.state["link_quality"] = event[1] self.state["link_quality"] = event[1]
elif kind == "heavy_quality": elif kind == "heavy_quality":
@@ -551,9 +646,59 @@ class ControlApi:
last_err = self.state.get("last_error") last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected" status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
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 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): def _update_servo(self, servo):
function = self.servo_functions.get(servo) function = self.servo_functions.get(servo)
motor = motor_number_for_function(function) 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…", "label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
"motor": motor, "motor": motor,
"direction": self.motor_directions.get(motor) if motor else None, "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): def close(self):
@@ -653,7 +799,6 @@ def main():
from desktop_shortcut import ensure_desktop_shortcut from desktop_shortcut import ensure_desktop_shortcut
ensure_desktop_shortcut() ensure_desktop_shortcut()
api = ControlApi() api = ControlApi()
api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent project_root = Path(__file__).resolve().parent
port = start_local_server(project_root) port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}" app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"