Initial SUB Control application

This commit is contained in:
2026-08-25 22:13:54 +02:00
commit 2f6077f5d8
11 changed files with 1894 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.py[cod]
logs/
model_settings.json
+102
View File
@@ -0,0 +1,102 @@
# ArduSub Minimal Control
A deliberately small, low-bandwidth MAVLink controller for bench-testing an
ArduSub vehicle over a USB serial connection. It sends no parameter-list
request and no telemetry-stream request.
The desktop window uses a local HTML/CSS/JavaScript interface rendered by the
Windows WebView. Python remains the only launcher and owns the MAVLink worker,
arming, logging, output limiting, and parameter writes. It does not run a web
server, open a browser tab, or require an internet connection.
The WebView is frameless and uses the compact title bar drawn by the interface.
The vehicle rail includes a local STL viewer: choose **Load STL**, then drag to
orbit, use the mouse wheel to zoom, or switch between shaded and edge-focused
rendering. The chosen file is remembered in `model_settings.json`; only its
local path is stored. STEP/STP CAD solids must first be tessellated/exported as
STL because the embedded renderer intentionally does not include a full CAD
kernel.
## Safety first
- Remove propellers for initial tests and restrain the vehicle.
- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range,
leak/failsafe, and emergency-stop arrangements before arming.
- The application starts disarmed and limits pilot demand to 20% by default.
Once armed, control movement is sent directly with no additional gate.
- Closing or disconnecting sends neutral controls and a disarm request, but a
physical power cutoff is still required.
The controls are pilot-demand axes, not direct PWM values. ArduSub performs
the frame mixing and generates the configured ESC outputs. For ArduSub,
`MANUAL_CONTROL.z = 500` is neutral vertical thrust.
## Run on Windows
1. Install Python 3.11 or newer.
2. Open PowerShell in this folder and run `./run.ps1`.
3. Select the autopilot's COM port, then click **Connect**. Baud and control-rate
settings are under the small `•••` link button (normally 57600 baud / 20 Hz).
4. Confirm `ArduSub` and a live heartbeat are shown.
5. Click **Set MANUAL**, then **ARM** only when the bench is safe.
6. Move the controls. Once armed, no additional enable button is needed.
Keyboard controls for SimpleROV-3: W/S forward/back, A/D yaw left/right,
Q/E alternative yaw control, and R/F rise/dive.
Releasing movement keys eases their controls back to neutral.
## Three-thruster limitation
The common ArduSub `SimpleROV-3` arrangement controls forward, yaw, and depth.
It cannot independently control lateral, roll, or pitch. Accordingly, the main
pad is mapped to forward/yaw, while the vertical slider controls depth thrust.
## Link behavior
- MAVLink GCS heartbeat: 1 Hz
- `MANUAL_CONTROL`: selectable 10 or 20 Hz (default 20 Hz). Lower rates are
intentionally excluded because they make short return ramps become abrupt
steps at the flight controller.
- No full parameter download
- On connection, inherited bulk telemetry streams are stopped. Only
`SYS_STATUS` at 1 Hz and `SERVO_OUTPUT_RAW` / `RC_CHANNELS` at 2 Hz are
requested, in addition to the normal heartbeat and event-driven status text.
- Arm/disarm uses `MAV_CMD_COMPONENT_ARM_DISARM` and displays `COMMAND_ACK`
- Link health is based on received autopilot heartbeats
- Battery voltage is read from `SYS_STATUS` at 1 Hz and shown as pack voltage,
estimated per-cell voltage, and a voltage-only percentage. Cell count,
capacity, C rating, full/empty cell voltages, and warning percentage are
configurable and saved locally in `battery_settings.json`. Capacity consumed
cannot be measured without a current sensor.
- Servo configuration scans `SERVO1_FUNCTION` through `SERVO16_FUNCTION` and
ArduSub's `MOT_1_DIRECTION` through `MOT_12_DIRECTION` one at a time, waiting
for each response and retrying missed replies. A selected output can be
assigned to Motor 1–12 or set unassigned. Motor direction is toggled with the
corresponding `MOT_n_DIRECTION` value while disarmed; `SERVOx_REVERSED` is
intentionally not used for Motor functions. It never downloads the full
parameter list.
The diagnostic scan also reads `SERVO1`–`SERVO3` MIN/TRIM/MAX and ArduSub's
joystick gain values into the local session log; these values remain
read-only in the application.
- Every session creates a local text log under `logs/`, including operator
actions, outgoing manual-control values and commands, acknowledgements,
status text, and all received MAVLink messages. This does not use ArduPilot's
onboard logger or add any link traffic.
The car-style virtual joystick maps only to forward/reverse and continuous
differential turning; it never sends lateral motion. The joystick and vertical
slider ease back to center over 400 ms after mouse/key release, so neither can
be left commanding motion.
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are
intentionally not eased.
In addition, transmitted pilot demands have a small 4% input deadband and a
0.8-second full-range slew limit. This makes both acceleration and deceleration
arrive at the flight controller as a sequence of stable intermediate values.
Forward and yaw are normalized as a SimpleROV-3 motor pair before the output
limit is applied, preventing their mixer contributions from adding beyond the
selected horizontal limit.
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this
application's system ID (default 255), or when `MAV_OPTIONS` does not allow
MAVLink input. Those parameters must already be correctly configured.
+1219
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"cells": 4,
"capacity_mah": 5500,
"c_rating": 35.0,
"full_v_per_cell": 4.2,
"empty_v_per_cell": 3.3,
"warning_percent": 20
}
+4
View File
@@ -0,0 +1,4 @@
pymavlink>=2.4.49,<3
pyserial>=3.5,<4
customtkinter==5.2.2
pywebview==6.2.1
+8
View File
@@ -0,0 +1,8 @@
$ErrorActionPreference = "Stop"
if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) {
py -m venv .venv
}
& .venv\Scripts\python.exe -m pip install -r requirements.txt
& .venv\Scripts\python.exe app.py
+40
View File
@@ -0,0 +1,40 @@
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0;
const api=(name,...args)=>window.pywebview.api[name](...args);
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
window.showToast=toast;
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
const joy=$('#joystick'),stick=$('#stick');let joyDrag=false,returnAnim=0;
function setJoy(x,y,send=true){const r=joy.clientWidth*.34,n=Math.hypot(x,y);if(n>1){x/=n;y/=n}control.turn=x;control.forward=-y;stick.style.transform=`translate(calc(-50% + ${x*r}px),calc(-50% + ${y*r}px))`;updateReadouts();if(send)sendAxes()}
function joyEvent(e){const b=joy.getBoundingClientRect();setJoy((e.clientX-b.left-b.width/2)/(b.width*.34),(e.clientY-b.top-b.height/2)/(b.height*.34))}
joy.addEventListener('pointerdown',e=>{joyDrag=true;cancelAnimationFrame(returnAnim);joy.setPointerCapture(e.pointerId);joyEvent(e)});joy.addEventListener('pointermove',e=>{if(joyDrag)joyEvent(e)});joy.addEventListener('pointerup',()=>{joyDrag=false;easeJoy()});
function easeJoy(){const sx=control.turn,sy=-control.forward,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setJoy(sx*(1-smooth),sy*(1-smooth));if(p<1)returnAnim=requestAnimationFrame(frame)}returnAnim=requestAnimationFrame(frame)}
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes()}
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
heave.addEventListener('pointerdown',e=>{heaveDrag=true;cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;easeHeave()});
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
function applyKeys(){const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){cancelAnimationFrame(returnAnim);setJoy(turn,-forward)}else if(!joyDrag)easeJoy();if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()}
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
addEventListener('blur',()=>{keys.clear();if(!joyDrag)easeJoy();if(!heaveDrag)easeHeave()});
function openModal(id){document.getElementById(id)?.showModal()}$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));$$('[data-close]').forEach(b=>b.onclick=()=>b.closest('dialog').close());
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=()=>api('toggle_maximize_window');$('#window-close').onclick=()=>api('close_window');$('.window-drag').ondblclick=()=>api('toggle_maximize_window');
$('#load-model').onclick=async()=>{try{const model=await api('choose_model');if(model)window.rovModel.load(model)}catch(e){toast(e.message,true)}};$('#model-style').onclick=e=>{const edges=window.rovModel.toggleStyle();e.currentTarget.textContent=edges?'Shaded':'Edges'};
$('#refresh').onclick=async()=>populatePorts(await api('refresh_ports'));$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}};
$('#manual').onclick=()=>api('command','manual').catch(e=>toast(e.message,true));$('#arm').onclick=()=>openModal('arm-dialog');$('#confirm-arm').onclick=()=>{api('command','arm').catch(e=>toast(e.message,true));$('#arm-dialog').close()};$('#disarm').onclick=()=>api('command','disarm').catch(e=>toast(e.message,true));$('#logs').onclick=()=>api('open_logs');
$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)};
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);$('#battery-modal').close();toast('Battery settings saved')}catch(err){toast(err.message,true)}};
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}}
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';$('#link').textContent=s.link;$('.link-state').classList.toggle('online',s.link==='LINK OK');$('#vehicle').textContent=s.vehicle;$('#status').textContent=s.status;const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}S`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='Waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)}
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
let booted=false;
async function boot(){if(booted||!window.pywebview?.api)return;booted=true;bridgeReady=true;try{const init=await api('initial_state');batteryConfig=init.battery_config;populatePorts(init.ports);fillBatteryForm();render(init.state);const saved=await api('saved_model');if(saved)window.rovModel.load(saved);poll();setInterval(async()=>{if(!connected)try{populatePorts(await api('refresh_ports'))}catch(_){}},2000)}catch(e){booted=false;bridgeReady=false;toast(`Python bridge: ${e.message}`,true);setTimeout(waitForBridge,500)}}
function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)}
addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge();
+119
View File
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Simple ROV Control</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="window-bar">
<div class="window-drag pywebview-drag-region"><span class="mini-mark">S</span><strong>Simple ROV Control</strong><i id="model-file-name">Built-in vehicle</i></div>
<div class="window-actions">
<button id="window-min" aria-label="Minimize">—</button>
<button id="window-max" aria-label="Maximize">□</button>
<button id="window-close" class="window-close" aria-label="Close">×</button>
</div>
</header>
<main class="app-shell">
<aside class="vehicle-rail">
<header class="brand"><span class="brand-mark">S</span><span>SIMPLE ROV</span><small>03</small></header>
<div class="eyebrow">LIVE VEHICLE</div>
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
<div class="water-ring r1"></div><div class="water-ring r2"></div>
<svg id="default-model" viewBox="0 0 260 310" role="img">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#566168"/><stop offset="1" stop-color="#22292d"/>
</linearGradient>
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="13" flood-opacity=".45"/></filter>
</defs>
<g filter="url(#shadow)">
<rect x="73" y="34" width="114" height="226" rx="55" fill="url(#body)" stroke="#707b81" stroke-width="2"/>
<rect x="93" y="60" width="74" height="168" rx="35" fill="#30383c" stroke="#49545a"/>
<circle cx="130" cy="145" r="28" fill="#7d898f" stroke="#a3adb1" stroke-width="3"/>
<circle cx="130" cy="145" r="12" fill="#d9e0df"/>
<path d="M78 98 48 80M182 98l30-18" stroke="#7e8a90" stroke-width="9" stroke-linecap="round"/>
<g fill="#191f22" stroke="#7b878c" stroke-width="3">
<ellipse cx="38" cy="74" rx="27" ry="34"/><ellipse cx="222" cy="74" rx="27" ry="34"/>
</g>
<g stroke="#59656a" stroke-width="2">
<path d="M22 54l32 40M54 54 22 94M206 54l32 40M238 54l-32 40"/>
</g>
</g>
</svg>
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<div class="model-tools">
<button id="load-model">Load STL</button>
<button id="model-style" disabled>Edges</button>
</div>
</div>
<div class="vehicle-name"><small>VEHICLE</small><strong id="vehicle">No heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
<div class="rail-bottom">
<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>
<div class="link-state"><span id="link-dot"></span><strong id="link">DISCONNECTED</strong></div>
</div>
</aside>
<section class="workspace">
<header class="topbar">
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div>
<div class="connection">
<select id="port" aria-label="Serial port"><option value="">No COM ports</option></select>
<button id="refresh" class="icon-button" title="Refresh ports">↻</button>
<button class="icon-button" data-open="link-modal" title="Link settings">•••</button>
<button id="connect" class="button connect-button">Connect</button>
</div>
</header>
<section class="drive-grid">
<article class="control-card steer-card">
<header><div><small>STEER + THROTTLE</small><strong>Differential drive</strong></div><span class="key-hint">W A S D</span></header>
<div id="joystick" class="joystick" touch-action="none">
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div>
<span class="axis-label forward">FORWARD</span><span class="axis-label reverse">REVERSE</span>
<span class="axis-label left">TURN LEFT</span><span class="axis-label right">TURN RIGHT</span>
<div class="stick-shadow"></div><div id="stick" class="stick"><span></span></div>
</div>
<footer><span><i class="dot surge"></i><b id="surge-readout">0</b>% surge</span><span><i class="dot yaw"></i><b id="yaw-readout">0</b>% turn</span></footer>
</article>
<article class="control-card vertical-card">
<header><div><small>VERTICAL</small><strong>Center thruster</strong></div><span class="key-hint">R / F</span></header>
<div class="vertical-control">
<span>RISE</span>
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div>
<span>DIVE</span>
</div>
<footer><span><i class="dot depth"></i><b id="heave-readout">0</b>% vertical</span></footer>
</article>
</section>
<section class="command-bar">
<div class="commands">
<button id="manual" class="button subtle">Manual</button>
<button id="arm" class="button arm">Arm</button>
<button id="disarm" class="button danger">Disarm</button>
</div>
<label class="output-limit"><span>Output limit <b id="gain-value">20%</b></span><input id="gain" type="range" min="5" max="50" value="20"></label>
<button class="button subtle" data-open="motors-modal">Motor setup</button>
<button id="logs" class="icon-button" title="Open session logs">⌁</button>
</section>
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
</section>
</main>
<div id="toast" class="toast"></div>
<dialog id="arm-dialog" class="modal confirm-modal"><button class="modal-close" data-close>×</button><small>SAFETY CHECK</small><h2>Arm the vehicle?</h2><p>Remove propellers or securely restrain the ROV before enabling motor output.</p><div class="modal-actions"><button class="button subtle" data-close>Cancel</button><button id="confirm-arm" class="button arm">Confirm arm</button></div></dialog>
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>CONNECTION</small><h2>Link settings</h2><div class="form-grid two"><label>Baud rate<select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label>Control rate<select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>POWER</small><h2>Battery settings</h2><form id="battery-form" class="form-grid three"><label>Cells (S)<input name="cells" type="number" min="1"></label><label>Capacity (mAh)<input name="capacity_mah" type="number" min="1"></label><label>C rating<input name="c_rating" type="number" min="1" step=".1"></label><label>Full V / cell<input name="full_v_per_cell" type="number" step=".01"></label><label>Empty V / cell<input name="empty_v_per_cell" type="number" step=".01"></label><label>Warning %<input name="warning_percent" type="number" min="0" max="100"></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save settings</button></div></form></dialog>
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>CONFIGURATION</small><h2>Motor outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
<script src="model-viewer.js"></script>
<script src="app.js"></script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
(() => {
const canvas = document.querySelector('#model-canvas');
const stage = document.querySelector('.rov-stage');
const ctx = canvas.getContext('2d');
let vertices = null, yaw = -.55, pitch = -.42, zoom = 1, edgesOnly = false;
let dragging = false, lastX = 0, lastY = 0, frame = 0;
function parseStl(buffer) {
const view = new DataView(buffer);
let raw = [];
const binaryCount = buffer.byteLength >= 84 ? view.getUint32(80, true) : 0;
const isBinary = binaryCount > 0 && 84 + binaryCount * 50 <= buffer.byteLength;
if (isBinary) {
const stride = Math.max(1, Math.ceil(binaryCount / 14000));
for (let t = 0; t < binaryCount; t += stride) {
const offset = 84 + t * 50 + 12;
for (let v = 0; v < 3; v++) {
raw.push(view.getFloat32(offset + v * 12, true));
raw.push(view.getFloat32(offset + v * 12 + 4, true));
raw.push(view.getFloat32(offset + v * 12 + 8, true));
}
}
} else {
const text = new TextDecoder().decode(buffer);
const matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)];
if (matches.length < 3) throw new Error('This file does not contain a readable STL mesh');
const stride = Math.max(1, Math.ceil(matches.length / 42000));
for (let i = 0; i + 2 < matches.length; i += 3 * stride) {
for (let j = 0; j < 3; j++) raw.push(+matches[i+j][1], +matches[i+j][2], +matches[i+j][3]);
}
}
if (raw.length < 9) throw new Error('The STL mesh is empty');
let minX=Infinity,minY=Infinity,minZ=Infinity,maxX=-Infinity,maxY=-Infinity,maxZ=-Infinity;
for(let i=0;i<raw.length;i+=3){minX=Math.min(minX,raw[i]);maxX=Math.max(maxX,raw[i]);minY=Math.min(minY,raw[i+1]);maxY=Math.max(maxY,raw[i+1]);minZ=Math.min(minZ,raw[i+2]);maxZ=Math.max(maxZ,raw[i+2])}
const cx=(minX+maxX)/2,cy=(minY+maxY)/2,cz=(minZ+maxZ)/2,scale=2/Math.max(maxX-minX,maxY-minY,maxZ-minZ,1e-9);
for(let i=0;i<raw.length;i+=3){raw[i]=(raw[i]-cx)*scale;raw[i+1]=(raw[i+1]-cy)*scale;raw[i+2]=(raw[i+2]-cz)*scale}
return new Float32Array(raw);
}
function rotated(x,y,z) {
const cy=Math.cos(yaw),sy=Math.sin(yaw),cp=Math.cos(pitch),sp=Math.sin(pitch);
const x1=x*cy+z*sy,z1=-x*sy+z*cy;
return [x1,y*cp-z1*sp,y*sp+z1*cp];
}
function draw() {
frame=0;if(!vertices)return;
const rect=canvas.getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2);
const width=Math.max(1,Math.round(rect.width*dpr)),height=Math.max(1,Math.round(rect.height*dpr));
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,rect.width,rect.height);
const size=Math.min(rect.width,rect.height)*.39*zoom,cx=rect.width/2,cy=rect.height/2;
const tris=[];
for(let i=0;i<vertices.length;i+=9){const a=rotated(vertices[i],vertices[i+1],vertices[i+2]),b=rotated(vertices[i+3],vertices[i+4],vertices[i+5]),c=rotated(vertices[i+6],vertices[i+7],vertices[i+8]);tris.push({a,b,c,z:(a[2]+b[2]+c[2])/3})}
tris.sort((a,b)=>a.z-b.z);
ctx.lineJoin='round';ctx.lineWidth=.65;
for(const t of tris){const ax=cx+t.a[0]*size,ay=cy-t.a[1]*size,bx=cx+t.b[0]*size,by=cy-t.b[1]*size,cx2=cx+t.c[0]*size,cy2=cy-t.c[1]*size;const nz=(bx-ax)*(cy2-ay)-(by-ay)*(cx2-ax),light=Math.max(0,Math.min(1,.25+Math.abs(nz)/(size*size)*5));ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(bx,by);ctx.lineTo(cx2,cy2);ctx.closePath();ctx.fillStyle=edgesOnly?'rgba(79,96,102,.035)':`rgba(${55+light*80},${67+light*82},${72+light*84},.88)`;ctx.strokeStyle=edgesOnly?'rgba(183,210,216,.72)':'rgba(171,193,198,.25)';ctx.fill();ctx.stroke()}
}
function requestDraw(){if(!frame)frame=requestAnimationFrame(draw)}
canvas.addEventListener('pointerdown',e=>{dragging=true;lastX=e.clientX;lastY=e.clientY;canvas.setPointerCapture(e.pointerId)});
canvas.addEventListener('pointermove',e=>{if(!dragging)return;yaw+=(e.clientX-lastX)*.012;pitch=Math.max(-1.45,Math.min(1.45,pitch+(e.clientY-lastY)*.012));lastX=e.clientX;lastY=e.clientY;requestDraw()});
canvas.addEventListener('pointerup',()=>dragging=false);
canvas.addEventListener('wheel',e=>{e.preventDefault();zoom=Math.max(.55,Math.min(2.2,zoom*(e.deltaY>0?.9:1.1)));requestDraw()},{passive:false});
addEventListener('resize',requestDraw);
window.rovModel = {
load(payload) {
try {
const bytes=Uint8Array.from(atob(payload.data),c=>c.charCodeAt(0));
vertices=parseStl(bytes.buffer);stage.classList.add('model-loaded');
document.querySelector('#model-style').disabled=false;
document.querySelector('#model-file-name').textContent=payload.name;
yaw=-.55;pitch=-.42;zoom=1;requestDraw();
} catch (error) { window.showToast?.(error.message,true); }
},
toggleStyle() { edgesOnly=!edgesOnly;requestDraw();return edgesOnly; }
};
})();
+12
View File
@@ -0,0 +1,12 @@
:root{color-scheme:dark;--bg:#0d1113;--rail:#161c1f;--panel:#f1f3f0;--card:#fff;--ink:#141719;--muted:#747a7d;--line:#dfe3de;--acid:#c9f277;--cyan:#72d8e8;--amber:#f2c675;--red:#f0958b;--shadow:0 25px 70px #0007;font-family:Inter,"SF Pro Display","Helvetica Neue",Arial,sans-serif}
.window-bar{height:38px;display:flex;align-items:center;justify-content:space-between;background:#111719;color:#aeb6b8;border-bottom:1px solid #ffffff10;user-select:none}.window-drag{display:flex;align-items:center;align-self:stretch;flex:1;gap:9px;padding-left:13px}.window-drag strong{font-size:10px;letter-spacing:.04em;color:#d8dddc}.window-drag i{font-size:9px;font-style:normal;color:#667175}.mini-mark{display:grid;place-items:center;width:19px;height:19px;border-radius:5px;background:var(--acid);color:#1d2818;font-size:9px;font-weight:900}.window-actions{display:flex;height:100%}.window-actions button{width:46px;height:100%;border:0;border-radius:0;background:transparent;color:#9ca5a7;cursor:pointer;font-size:15px}.window-actions button:hover{background:#ffffff0c;color:#fff}.window-actions .window-close:hover{background:#c84c43;color:#fff}
*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:#111719;font-size:14px}button,select,input{font:inherit}.app-shell{width:100%;height:calc(100% - 38px);margin:0;display:grid;grid-template-columns:270px 1fr;border:0;border-radius:0;overflow:hidden;background:var(--panel)}
.vehicle-rail{position:relative;display:flex;flex-direction:column;padding:25px 24px 22px;background:linear-gradient(155deg,#1c2428,#111618);color:#f4f6f3;overflow:hidden}.brand{display:flex;align-items:center;gap:10px;letter-spacing:.08em;font-size:12px;font-weight:700}.brand small{margin-left:auto;color:#758086}.brand-mark{display:grid;place-items:center;width:27px;height:27px;border-radius:9px;background:var(--acid);color:#1d2818;font-weight:900}.eyebrow{font-size:9px;letter-spacing:.16em;color:#899295;font-weight:750}.vehicle-rail>.eyebrow{margin-top:30px}.rov-stage{position:relative;height:min(41vh,330px);min-height:240px;display:grid;place-items:center}.rov-stage svg{position:relative;width:210px;height:270px;z-index:2}.water-ring{position:absolute;border:1px solid #78939d24;border-radius:50%;transform:scaleY(.5)}.water-ring.r1{width:230px;height:230px}.water-ring.r2{width:165px;height:165px;border-color:#9ec7d235}.vehicle-name{display:flex;flex-direction:column;gap:5px}.vehicle-name small,.battery-card small{font-size:9px;letter-spacing:.14em;color:#778186;font-style:normal}.vehicle-name strong{font-size:17px}.vehicle-name span{font-size:10px;color:#7d878b}.rail-bottom{margin-top:auto}.battery-card{display:grid;grid-template-columns:1fr auto;width:100%;padding:0 0 19px;text-align:left;color:inherit;border:0;border-bottom:1px solid #ffffff17;cursor:pointer}.battery-card>span{display:flex;flex-direction:column;gap:3px}.battery-card strong{font-size:19px}.battery-card i{align-self:end;color:#8c979b;font-size:11px;font-style:normal}.battery-card b{grid-column:1/-1;height:5px;margin-top:10px;background:#353d40;border-radius:20px;overflow:hidden}.battery-card em{display:block;width:0;height:100%;background:var(--acid);border-radius:inherit;transition:width .35s}.ghost{background:none}.link-state{display:flex;align-items:center;gap:9px;margin-top:17px;font-size:10px;letter-spacing:.08em;color:#a9b0b2}.link-state span{width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 0 4px #f0958b18}.link-state.online span{background:var(--acid);box-shadow:0 0 0 4px #c9f27718}
.workspace{min-width:0;display:flex;flex-direction:column;padding:26px 30px 18px;color:var(--ink);background:linear-gradient(145deg,#f7f8f6,#ecefeb)}.topbar{display:flex;align-items:center;justify-content:space-between;padding-bottom:20px;border-bottom:1px solid var(--line)}h1{margin:3px 0 0;font-size:30px;letter-spacing:-.04em}.connection{display:flex;align-items:center;gap:7px}select,input{border:1px solid #d7dbd6;background:#fff;color:#25292b;border-radius:11px;outline:0}.connection select{width:155px;height:38px;padding:0 12px}.button,.icon-button{height:38px;border:1px solid transparent;border-radius:12px;padding:0 16px;color:#28302a;background:#e9ece8;cursor:pointer;transition:transform .12s,background .12s,box-shadow .12s}.button:hover,.icon-button:hover{transform:translateY(-1px);box-shadow:0 5px 14px #28322c18}.icon-button{width:39px;padding:0;font-weight:700}.connect-button{background:#1d2421;color:white}.drive-grid{display:grid;grid-template-columns:minmax(380px,1fr) 230px;gap:16px;min-height:0;flex:1;padding:18px 0}.control-card{display:flex;flex-direction:column;min-height:0;padding:18px 20px 14px;border:1px solid #e0e3df;border-radius:22px;background:#fff;box-shadow:0 12px 34px #3039340b}.control-card header{display:flex;justify-content:space-between;align-items:start}.control-card header div{display:flex;flex-direction:column;gap:3px}.control-card small,.modal>small{font-size:9px;letter-spacing:.14em;color:#8b9190;font-weight:750}.control-card header strong{font-size:15px}.key-hint{padding:6px 9px;border-radius:8px;background:#f0f2ef;color:#838887;font-size:9px;font-weight:700;letter-spacing:.08em}.control-card footer{display:flex;justify-content:center;gap:25px;color:#7e8482;font-size:10px}.control-card footer span{display:flex;align-items:center;gap:4px}.control-card footer b{color:#353a38}.dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)}.dot.yaw{background:var(--amber)}.dot.depth{background:#bc9feb}
.joystick{--size:min(36vh,300px);position:relative;width:var(--size);height:var(--size);min-width:220px;min-height:220px;margin:auto;border:1px solid #d5d9d5;border-radius:50%;background:radial-gradient(circle at 45% 38%,#f8f9f7 0 20%,#ecefeb 70%);box-shadow:inset 0 3px 8px #fff,inset 0 -8px 18px #2630280c;user-select:none;touch-action:none}.joystick:after{content:"";position:absolute;inset:23%;border:1px solid #dce0dc;border-radius:50%}.axis{position:absolute;background:#ccd1cc}.vertical-axis{top:8%;bottom:8%;left:50%;width:1px}.horizontal-axis{left:8%;right:8%;top:50%;height:1px}.axis-label{position:absolute;color:#979c99;font-size:8px;font-weight:750;letter-spacing:.12em}.forward{top:7px;left:50%;transform:translateX(-50%)}.reverse{bottom:7px;left:50%;transform:translateX(-50%)}.left{left:6px;top:50%;transform:translateY(-50%) rotate(-90deg)}.right{right:3px;top:50%;transform:translateY(-50%) rotate(90deg)}.stick-shadow{position:absolute;left:50%;top:50%;width:68px;height:68px;border-radius:50%;background:#21282422;filter:blur(9px);transform:translate(-45%,-35%)}.stick{position:absolute;z-index:3;left:50%;top:50%;width:62px;height:62px;border-radius:50%;transform:translate(-50%,-50%);background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #111719;box-shadow:inset 0 2px 2px #ffffff38,0 10px 18px #18201d40;will-change:transform}.stick span{position:absolute;inset:18px;border-radius:50%;border:1px solid #a9b3b544}
.vertical-control{display:flex;flex:1;min-height:240px;flex-direction:column;align-items:center;gap:8px;padding:16px 0 12px;color:#989d9a;font-size:8px;font-weight:750;letter-spacing:.12em}.heave-track{position:relative;width:38px;flex:1;min-height:180px;border-radius:22px;background:linear-gradient(90deg,#e3e6e2,#f3f4f2 50%,#dfe3de);border:1px solid #d8dcd7;box-shadow:inset 0 3px 7px #9ca59f25;touch-action:none}.heave-center{position:absolute;left:7px;right:7px;top:50%;height:1px;background:#adb4af}.heave-fill{position:absolute;left:12px;right:12px;top:50%;height:0;background:#bc9feb88}.heave-knob{position:absolute;left:50%;top:50%;width:56px;height:35px;border-radius:18px;background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #151a1d;box-shadow:0 8px 14px #1d252238,inset 0 1px #ffffff36;transform:translate(-50%,-50%);will-change:top}
.command-bar{display:flex;align-items:center;gap:10px;min-height:57px;padding:10px 12px;border:1px solid #dde1dc;border-radius:17px;background:#fff}.commands{display:flex;gap:7px}.subtle{background:#f0f2ef;border-color:#e4e7e2}.arm{background:#ddf1bd;color:#29371f}.danger{background:#f7dfdc;color:#7b3029}.output-limit{display:flex;flex:1;align-items:center;gap:12px;margin:0 10px;color:#747a77;font-size:10px}.output-limit span{white-space:nowrap}.output-limit b{color:#222724}.output-limit input{width:100%;accent-color:#27302b}.status-line{display:flex;align-items:center;gap:9px;min-height:27px;color:#747b78;font-size:10px}.status-line span{width:6px;height:6px;border-radius:50%;background:#96a09a}.status-line p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.modal{width:min(620px,calc(100vw - 40px));padding:27px;border:1px solid #ffffff24;border-radius:24px;background:#f7f8f5;color:#1c2021;box-shadow:0 35px 100px #000b}.modal::backdrop{background:#071012b5;backdrop-filter:blur(8px)}.modal h2{margin:5px 0 17px;font-size:25px}.modal-close{position:absolute;top:18px;right:18px;width:34px;height:34px;border:0;border-radius:50%;background:#e8ebe6;color:#555;cursor:pointer;font-size:20px}.form-grid{display:grid;gap:13px}.form-grid.two{grid-template-columns:1fr 1fr}.form-grid.three{grid-template-columns:repeat(3,1fr)}.form-grid label{display:flex;flex-direction:column;gap:6px;color:#737976;font-size:10px}.form-grid input,.form-grid select{width:100%;height:40px;padding:0 11px}.note{color:#7a807d;font-size:11px;line-height:1.5}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.modal-actions.wide{grid-column:1/-1}.motor-modal{width:min(760px,calc(100vw - 40px))}.motor-head,.motor-row{display:grid;grid-template-columns:70px 1fr 110px 94px;align-items:center;gap:9px}.motor-head{padding:8px 10px;color:#969b99;font-size:9px;letter-spacing:.1em}.motor-list{max-height:390px;overflow:auto}.motor-row{min-height:47px;padding:5px 10px;border-top:1px solid #e3e6e1}.motor-row select{height:34px;padding:0 9px}.motor-row .button{height:32px;padding:0 10px}.toast{position:fixed;z-index:20;left:50%;bottom:28px;padding:11px 16px;border-radius:12px;background:#202725;color:#fff;box-shadow:0 10px 35px #0006;transform:translate(-50%,25px);opacity:0;pointer-events:none;transition:.2s}.toast.show{transform:translate(-50%,0);opacity:1}.toast.error{background:#77352f}
@media(max-width:950px){.app-shell{grid-template-columns:225px minmax(0,1fr)}.vehicle-rail{padding-left:18px;padding-right:18px}.drive-grid{grid-template-columns:minmax(330px,1fr) 190px}.workspace{padding-left:22px;padding-right:22px}.connection select{width:120px}}@media(max-height:700px){.rov-stage{height:240px}.drive-grid{padding:12px 0}.joystick{--size:min(32vh,250px)}.workspace{padding-top:18px}}
.control-card{border-radius:14px}.command-bar{border-radius:12px}.button,.icon-button,.connection select,select,input{border-radius:9px}.modal{border-radius:16px}.brand-mark{border-radius:7px}
.rov-stage #model-canvas{display:none;position:absolute;inset:0;width:100%;height:100%;z-index:3;cursor:grab;touch-action:none}.rov-stage #model-canvas:active{cursor:grabbing}.rov-stage.model-loaded #default-model{display:none}.rov-stage.model-loaded #model-canvas{display:block}.model-tools{position:absolute;z-index:5;left:50%;bottom:5px;display:flex;gap:5px;transform:translateX(-50%);opacity:0;transition:opacity .18s}.rov-stage:hover .model-tools{opacity:1}.model-tools button{height:27px;padding:0 9px;border:1px solid #ffffff18;border-radius:7px;background:#20282bcc;color:#aab3b5;font-size:8px;letter-spacing:.06em;cursor:pointer;backdrop-filter:blur(7px)}.model-tools button:hover{color:#fff;background:#303a3e}.model-tools button:disabled{opacity:.35;cursor:default}
+298
View File
@@ -0,0 +1,298 @@
from __future__ import annotations
import json
import os
import queue
import threading
import time
import base64
from pathlib import Path
import webview
from serial.tools import list_ports
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
class ControlApi:
"""Small, local-only bridge between the CSS UI and the MAVLink worker."""
def __init__(self):
self.events: queue.Queue = queue.Queue()
self.worker = MavlinkWorker(self.events)
self.lock = threading.Lock()
self.state = {
"connected": False,
"link": "DISCONNECTED",
"vehicle": "No heartbeat",
"armed": False,
"status": "Ready. Select a USB port to begin.",
"battery": None,
"log_path": None,
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
}
self.servo_functions: dict[int, int] = {}
self.motor_directions: dict[int, int] = {}
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
self.window = None
self.maximized = False
self.battery_config = {
"cells": 4, "capacity_mah": 5500, "c_rating": 35,
"full_v_per_cell": 4.2, "empty_v_per_cell": 3.3,
"warning_percent": 20,
}
try:
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
def initial_state(self):
return {
"ports": [p.device for p in list_ports.comports()],
"battery_config": self.battery_config,
"state": self._snapshot(),
}
def refresh_ports(self):
return [p.device for p in list_ports.comports()]
def minimize_window(self):
if self.window:
self.window.minimize()
return True
def toggle_maximize_window(self):
if not self.window:
return False
if self.maximized:
self.window.restore()
else:
self.window.maximize()
self.maximized = not self.maximized
return self.maximized
def close_window(self):
if self.window:
self.window.destroy()
return True
def choose_model(self):
if not self.window:
raise RuntimeError("Window is not ready")
selected = self.window.create_file_dialog(
webview.OPEN_DIALOG,
allow_multiple=False,
file_types=("STL mesh (*.stl)", "STEP model (*.step;*.stp)"),
)
if not selected:
return None
path = Path(selected[0])
if path.suffix.lower() in (".step", ".stp"):
raise ValueError(
"STEP is a CAD solid and must be tessellated first. Export it as a binary STL, then load that file."
)
if path.suffix.lower() != ".stl":
raise ValueError("Choose an STL mesh")
if path.stat().st_size > 30 * 1024 * 1024:
raise ValueError("The STL is larger than 30 MB; export a lighter mesh")
self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8")
return self._model_payload(path)
def saved_model(self):
try:
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
return self._model_payload(path)
except (OSError, ValueError, TypeError, KeyError):
pass
return None
@staticmethod
def _model_payload(path):
return {"name": path.name, "data": base64.b64encode(path.read_bytes()).decode("ascii")}
def connect(self, port, baud=57600, rate=20):
if not port:
raise ValueError("Select a USB serial port")
self.worker.rate_hz = int(rate)
self.worker.connect(str(port), int(baud))
with self.lock:
self.state.update(
connected=True, link="CONNECTING",
status=f"Opening {port} at {baud} baud…",
)
return True
def disconnect(self):
self.worker.disconnect()
return True
def set_axes(self, forward, turn, vertical):
self.worker.set_axes(Axes(
max(-1.0, min(1.0, float(forward))),
0.0,
max(-1.0, min(1.0, float(vertical))),
max(-1.0, min(1.0, float(turn))),
))
return True
def set_gain(self, value):
value = max(5, min(50, int(value)))
self.worker.gain = value / 100.0
return value
def command(self, name):
if name == "manual":
self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode()
elif name == "arm":
self.worker.log("OPERATOR", "clicked ARM")
self.worker.command_arm(True)
elif name == "disarm":
self.worker.log("OPERATOR", "clicked DISARM")
self.worker.command_arm(False)
else:
raise ValueError("Unknown command")
return True
def set_servo_function(self, servo, motor):
servo, motor = int(servo), int(motor)
if not 1 <= servo <= 16 or not 0 <= motor <= 12:
raise ValueError("Invalid servo or motor")
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
return True
def reverse_motor(self, servo):
servo = int(servo)
motor = motor_number_for_function(self.servo_functions.get(servo))
if motor is None:
raise ValueError(f"SERVO{servo} is not assigned to a motor")
current = self.motor_directions.get(motor)
if current is None:
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
return True
def save_battery(self, config):
clean = {
"cells": int(config["cells"]),
"capacity_mah": int(config["capacity_mah"]),
"c_rating": float(config["c_rating"]),
"full_v_per_cell": float(config["full_v_per_cell"]),
"empty_v_per_cell": float(config["empty_v_per_cell"]),
"warning_percent": int(config["warning_percent"]),
}
if clean["cells"] < 1 or clean["capacity_mah"] <= 0 or clean["c_rating"] <= 0:
raise ValueError("Battery cells, capacity and C rating must be positive")
if not clean["full_v_per_cell"] > clean["empty_v_per_cell"] > 0:
raise ValueError("Full voltage per cell must be greater than empty voltage")
if not 0 <= clean["warning_percent"] <= 100:
raise ValueError("Warning percentage must be between 0 and 100")
self.battery_config = clean
self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
return clean
def open_logs(self):
path = Path(__file__).resolve().parent / "logs"
path.mkdir(exist_ok=True)
os.startfile(path)
return True
def poll(self):
while True:
try:
event = self.events.get_nowait()
except queue.Empty:
break
self._consume(event)
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5:
with self.lock:
self.state["link"] = (
"DATA OK / HEARTBEAT DELAYED"
if time.monotonic() - self.worker.last_rx < 2 else "ALL LINK DATA LOST"
)
return self._snapshot()
def _snapshot(self):
with self.lock:
return json.loads(json.dumps(self.state))
def _consume(self, event):
kind = event[0]
with self.lock:
if kind == "heartbeat":
_, system, autopilot, mode, armed = event
self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed),
)
elif kind in ("status", "statustext"):
self.state["status"] = event[1]
elif kind == "ack":
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery":
voltage = float(event[1])
cells = self.battery_config["cells"]
per_cell = voltage / cells
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
percent = max(0, min(100, (per_cell - low) * 100 / (high - low)))
self.state["battery"] = {
"voltage": voltage, "per_cell": per_cell, "percent": percent,
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
}
elif kind == "servo_param":
_, param_id, value = event
servo = int(param_id[5:param_id.index("_")])
self.servo_functions[servo] = int(value)
self._update_servo(servo)
elif kind == "motor_direction":
_, motor, value = event
self.motor_directions[int(motor)] = int(value)
for servo in range(1, 17):
self._update_servo(servo)
elif kind == "param_saved":
self.state["status"] = f"Confirmed: {event[1]}={event[2]}"
elif kind == "log_path":
self.state["log_path"] = event[1]
elif kind == "error":
self.state["status"] = event[1]
elif kind == "disconnected":
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, battery=None, status="Disconnected")
def _update_servo(self, servo):
function = self.servo_functions.get(servo)
motor = motor_number_for_function(function)
self.state["servos"][str(servo)] = {
"function": function,
"label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
"motor": motor,
"direction": self.motor_directions.get(motor) if motor else None,
}
def close(self):
self.worker.disconnect()
def main():
api = ControlApi()
ui_path = Path(__file__).resolve().parent / "web" / "index.html"
window = webview.create_window(
"Simple ROV Control", url=str(ui_path), js_api=api,
width=1120, height=760, min_size=(900, 650),
background_color="#111719", frameless=True, easy_drag=False,
shadow=True,
)
api.window = window
window.events.maximized += lambda: setattr(api, "maximized", True)
window.events.restored += lambda: setattr(api, "maximized", False)
try:
webview.start(debug=False)
finally:
api.close()
if __name__ == "__main__":
main()