ui/index.html — full dashboard rewrite: - 3-column layout: LEFT telemetry gauges, CENTER 3D SaltyBot, RIGHT comms - LEFT: artificial horizon (canvas, pitch/roll/ladder/roll-arc), yaw compass tape, pitch/roll/yaw readouts, bidirectional motor bar, battery bar, BME280 environment section (auto-shows on data), MAG heading row - CENTER: Three.js SaltyBot model (PR#41) with ground plane + animated wheel rolling proportional to motor_cmd - RIGHT: USB tx/rx packet counters, mode badge (MANUAL/ASSISTED/AUTO), CRSF RSSI/LQ, dual RC stick overlay canvases (CH1–4), Jetson active dot - BOTTOM: KP/KI/KD/SP/MAX sliders with APPLY + QUERY, collapsible log console - Style: Tailwind CSS CDN, dark cyberpunk theme, neon cyan + orange accents src/main.c — telemetry JSON additions: - buf: 256 → 320 bytes (headroom for new fields) - ja: Jetson active flag (0/1) via jetson_cmd_is_active() - txc: TX telemetry frame counter (uint32, main-loop local) - rxc: RX CDC packet counter (cdc_rx_count from usbd_cdc_if) - ch1–ch4: CRSF channels mapped to µs (1000–2000) via crsf_to_range(), appended alongside rssi/lq when RC is alive lib/USB_CDC/src/usbd_cdc_if.c: - cdc_rx_count: volatile uint32_t, incremented in CDC_Receive on every packet; extern'd in main.c for telemetry Closes #43. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
844 lines
36 KiB
HTML
844 lines
36 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>SaltyLab HUD</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: 'Courier New', Courier, monospace; background: #050510; }
|
||
input[type=range] { cursor: pointer; }
|
||
#console-log { scrollbar-width: thin; scrollbar-color: #1a3a4a #020208; }
|
||
</style>
|
||
</head>
|
||
<body class="h-screen overflow-hidden flex flex-col text-gray-300" style="font-size:12px">
|
||
|
||
<!-- ── TITLE BAR ── -->
|
||
<div class="flex items-center justify-between px-4 py-1.5 bg-[#070712] border-b border-cyan-950 shrink-0">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-orange-500 font-bold tracking-widest" style="font-size:14px">⚡ SALTYLAB</span>
|
||
<span id="state-badge" class="px-2 py-0.5 rounded font-bold border bg-gray-800 border-gray-600 text-gray-400" style="font-size:10px">DISARMED</span>
|
||
</div>
|
||
<span id="status" class="text-gray-600" style="font-size:10px">WebSerial ready</span>
|
||
<div class="flex gap-1.5">
|
||
<button id="connect-btn" onclick="toggleSerial()" class="px-3 py-1 rounded font-bold bg-red-900 hover:bg-red-800 text-white border border-red-700" style="font-size:10px">CONNECT USB</button>
|
||
<button id="arm-btn" onclick="toggleArm()" class="px-3 py-1 rounded font-bold bg-amber-800 hover:bg-amber-700 text-white border border-amber-600 hidden" style="font-size:10px">ARM</button>
|
||
<button id="gyrocal-btn" onclick="gyroRecal()" class="px-3 py-1 rounded font-bold bg-cyan-950 hover:bg-cyan-900 text-cyan-300 border border-cyan-800 hidden" style="font-size:10px">GYRO CAL</button>
|
||
<button id="yaw-btn" onclick="resetYaw()" class="px-3 py-1 rounded font-bold bg-green-950 hover:bg-green-900 text-green-400 border border-green-800 hidden" style="font-size:10px">YAW RESET</button>
|
||
<button id="dfu-btn" onclick="enterDFU()" class="px-3 py-1 rounded font-bold bg-gray-800 hover:bg-gray-700 text-gray-500 border border-gray-700 hidden" style="font-size:10px">DFU</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── MAIN 3-COLUMN ── -->
|
||
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||
|
||
<!-- LEFT: Telemetry Gauges (w-56 = 224px, inner = 200px after p-3) -->
|
||
<div class="w-56 shrink-0 flex flex-col gap-2 p-3 bg-[#070712] border-r border-cyan-950 overflow-y-auto">
|
||
|
||
<div class="text-cyan-700 font-bold tracking-widest" style="font-size:9px">ATTITUDE</div>
|
||
<!-- Artificial Horizon: 200×100 -->
|
||
<canvas id="c-horizon" width="200" height="100" class="rounded border border-cyan-950 block"></canvas>
|
||
|
||
<!-- Pitch / Roll / Yaw -->
|
||
<div class="grid grid-cols-3 gap-1">
|
||
<div class="bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">PITCH</div>
|
||
<div id="v-pitch" class="text-cyan-400 font-bold">--</div>
|
||
<div class="text-gray-700" style="font-size:9px">°</div>
|
||
</div>
|
||
<div class="bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">ROLL</div>
|
||
<div id="v-roll" class="text-cyan-400 font-bold">--</div>
|
||
<div class="text-gray-700" style="font-size:9px">°</div>
|
||
</div>
|
||
<div class="bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">YAW</div>
|
||
<div id="v-yaw" class="text-cyan-400 font-bold">--</div>
|
||
<div class="text-gray-700" style="font-size:9px">°</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="text-cyan-700 font-bold tracking-widest" style="font-size:9px">HEADING</div>
|
||
<!-- Compass tape: 200×70 -->
|
||
<canvas id="c-compass" width="200" height="70" class="rounded border border-cyan-950 block"></canvas>
|
||
<div id="v-hdg-row" class="text-center hidden">
|
||
<span class="text-gray-600" style="font-size:9px">MAG</span>
|
||
<span id="v-hdg" class="text-cyan-400 font-bold ml-1">--</span>
|
||
<span class="text-gray-600" style="font-size:9px">°</span>
|
||
</div>
|
||
|
||
<div class="text-cyan-700 font-bold tracking-widest" style="font-size:9px">MOTOR</div>
|
||
<div class="flex items-center gap-2">
|
||
<span id="v-motor" class="text-orange-400 font-bold w-10 text-right">--</span>
|
||
<div class="flex-1 h-2.5 bg-gray-950 rounded relative overflow-hidden border border-gray-800">
|
||
<div class="absolute inset-y-0 left-1/2 w-px bg-gray-700"></div>
|
||
<div id="motor-bar" class="absolute inset-y-0 transition-all duration-75 rounded" style="left:50%;width:0;background:#f97316"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="text-cyan-700 font-bold tracking-widest" style="font-size:9px">BATTERY</div>
|
||
<div class="flex items-center gap-2">
|
||
<span id="v-bat" class="text-green-400 font-bold w-14">--</span>
|
||
<div class="flex-1 h-2 bg-gray-950 rounded overflow-hidden border border-gray-800">
|
||
<div id="bat-bar" class="h-full transition-all duration-1000" style="width:0%;background:#22c55e"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- BME280 (shown only when data arrives) -->
|
||
<div id="bme-section" class="hidden">
|
||
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">ENVIRONMENT</div>
|
||
<div class="grid grid-cols-2 gap-1">
|
||
<div id="row-temp" class="hidden bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">TEMP</div>
|
||
<div id="v-temp" class="text-cyan-400 font-bold">--</div>
|
||
<div class="text-gray-700" style="font-size:9px">°C</div>
|
||
</div>
|
||
<div id="row-hum" class="hidden bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">HUMIDITY</div>
|
||
<div id="v-hum" class="text-cyan-400 font-bold">--</div>
|
||
<div class="text-gray-700" style="font-size:9px">%RH</div>
|
||
</div>
|
||
<div id="row-pres" class="hidden bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">PRESSURE</div>
|
||
<div id="v-pres" class="text-cyan-400 font-bold">--</div>
|
||
<div class="text-gray-700" style="font-size:9px">hPa</div>
|
||
</div>
|
||
<div id="row-alt" class="hidden bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">ALTITUDE</div>
|
||
<div id="v-alt" class="text-cyan-400 font-bold">--</div>
|
||
<div class="text-gray-700" style="font-size:9px">m</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-auto text-gray-700" style="font-size:10px">PKT/s: <span id="v-hz" class="text-gray-500">--</span></div>
|
||
</div><!-- end LEFT -->
|
||
|
||
<!-- CENTER: Three.js viewport -->
|
||
<div id="three-container" class="flex-1 relative overflow-hidden" style="background:#050510"></div>
|
||
|
||
<!-- RIGHT: Comms Stats (w-56 = 224px) -->
|
||
<div class="w-56 shrink-0 flex flex-col gap-3 p-3 bg-[#070712] border-l border-cyan-950 overflow-y-auto">
|
||
|
||
<!-- USB Stats -->
|
||
<div>
|
||
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">USB STATS</div>
|
||
<div class="grid grid-cols-2 gap-1">
|
||
<div class="bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">TX PKT</div>
|
||
<div id="v-txc" class="text-cyan-400 font-bold">--</div>
|
||
</div>
|
||
<div class="bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">RX PKT</div>
|
||
<div id="v-rxc" class="text-cyan-400 font-bold">--</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode -->
|
||
<div>
|
||
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">MODE</div>
|
||
<div id="mode-badge" class="text-center py-1 rounded border font-bold bg-gray-900 border-gray-700 text-gray-400" style="font-size:11px">MANUAL</div>
|
||
</div>
|
||
|
||
<!-- CRSF / RC -->
|
||
<div>
|
||
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">RC / CRSF-ELRS</div>
|
||
<div class="grid grid-cols-2 gap-1 mb-2">
|
||
<div class="bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">RSSI</div>
|
||
<div id="v-rssi" class="text-cyan-400 font-bold">--</div>
|
||
<div class="text-gray-700" style="font-size:9px">dBm</div>
|
||
</div>
|
||
<div class="bg-gray-950 rounded p-1 text-center">
|
||
<div class="text-gray-600" style="font-size:9px">LINK Q</div>
|
||
<div id="v-lq" class="text-cyan-400 font-bold">--</div>
|
||
<div class="text-gray-700" style="font-size:9px">%</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RC Sticks: CH1·CH2 (left), CH3·CH4 (right) -->
|
||
<div class="text-gray-600 mb-1" style="font-size:9px">RC STICKS (µs)</div>
|
||
<div class="flex justify-around mb-2">
|
||
<div class="flex flex-col items-center gap-1">
|
||
<canvas id="c-stick-l" width="64" height="64" class="border border-cyan-950 rounded block"></canvas>
|
||
<span class="text-gray-700" style="font-size:9px">CH1·CH2</span>
|
||
</div>
|
||
<div class="flex flex-col items-center gap-1">
|
||
<canvas id="c-stick-r" width="64" height="64" class="border border-cyan-950 rounded block"></canvas>
|
||
<span class="text-gray-700" style="font-size:9px">CH3·CH4</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CH raw values -->
|
||
<div class="grid grid-cols-4 gap-1">
|
||
<div class="bg-gray-950 rounded py-1 text-center">
|
||
<div class="text-gray-700" style="font-size:9px">CH1</div>
|
||
<div id="v-ch1" class="text-cyan-500" style="font-size:10px">--</div>
|
||
</div>
|
||
<div class="bg-gray-950 rounded py-1 text-center">
|
||
<div class="text-gray-700" style="font-size:9px">CH2</div>
|
||
<div id="v-ch2" class="text-cyan-500" style="font-size:10px">--</div>
|
||
</div>
|
||
<div class="bg-gray-950 rounded py-1 text-center">
|
||
<div class="text-gray-700" style="font-size:9px">CH3</div>
|
||
<div id="v-ch3" class="text-cyan-500" style="font-size:10px">--</div>
|
||
</div>
|
||
<div class="bg-gray-950 rounded py-1 text-center">
|
||
<div class="text-gray-700" style="font-size:9px">CH4</div>
|
||
<div id="v-ch4" class="text-cyan-500" style="font-size:10px">--</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Jetson -->
|
||
<div>
|
||
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">JETSON ORIN</div>
|
||
<div class="flex items-center gap-2 bg-gray-950 rounded p-2">
|
||
<div id="jetson-dot" class="w-2.5 h-2.5 rounded-full shrink-0" style="background:#374151"></div>
|
||
<span id="jetson-status" class="text-gray-500" style="font-size:10px">NO SIGNAL</span>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- end RIGHT -->
|
||
</div><!-- end MAIN -->
|
||
|
||
<!-- ── BOTTOM: Controls bar ── -->
|
||
<div class="bg-[#070712] border-t border-cyan-950 shrink-0">
|
||
<div class="flex flex-wrap items-center gap-3 px-3 py-1.5">
|
||
|
||
<div class="flex items-center gap-1" style="font-size:10px">
|
||
<span class="text-gray-500 font-bold w-5">KP</span>
|
||
<input type="range" id="sl-kp" min="0" max="500000" step="100" value="0" class="w-16 accent-cyan-400" style="height:3px"
|
||
oninput="document.getElementById('lbl-kp').textContent=(this.value/1000).toFixed(1)">
|
||
<span id="lbl-kp" class="text-cyan-400 w-8 text-right">0.0</span>
|
||
</div>
|
||
<div class="flex items-center gap-1" style="font-size:10px">
|
||
<span class="text-gray-500 font-bold w-4">KI</span>
|
||
<input type="range" id="sl-ki" min="0" max="50000" step="10" value="0" class="w-16 accent-cyan-400" style="height:3px"
|
||
oninput="document.getElementById('lbl-ki').textContent=(this.value/1000).toFixed(3)">
|
||
<span id="lbl-ki" class="text-cyan-400 w-12 text-right">0.000</span>
|
||
</div>
|
||
<div class="flex items-center gap-1" style="font-size:10px">
|
||
<span class="text-gray-500 font-bold w-4">KD</span>
|
||
<input type="range" id="sl-kd" min="0" max="50000" step="10" value="0" class="w-16 accent-cyan-400" style="height:3px"
|
||
oninput="document.getElementById('lbl-kd').textContent=(this.value/1000).toFixed(3)">
|
||
<span id="lbl-kd" class="text-cyan-400 w-12 text-right">0.000</span>
|
||
</div>
|
||
<div class="flex items-center gap-1" style="font-size:10px">
|
||
<span class="text-gray-500 font-bold w-6">SP°</span>
|
||
<input type="range" id="sl-sp" min="-200" max="200" step="1" value="0" class="w-16 accent-orange-400" style="height:3px"
|
||
oninput="document.getElementById('lbl-sp').textContent=(this.value/10).toFixed(1)+'°'">
|
||
<span id="lbl-sp" class="text-orange-400 w-9 text-right">0.0°</span>
|
||
</div>
|
||
<div class="flex items-center gap-1" style="font-size:10px">
|
||
<span class="text-gray-500 font-bold w-8">MAX</span>
|
||
<input type="range" id="sl-ms" min="0" max="1000" step="10" value="200" class="w-16 accent-orange-400" style="height:3px"
|
||
oninput="document.getElementById('lbl-ms').textContent=this.value">
|
||
<span id="lbl-ms" class="text-orange-400 w-8 text-right">200</span>
|
||
</div>
|
||
|
||
<button onclick="sendPID()" class="px-2 py-1 rounded bg-cyan-950 hover:bg-cyan-900 text-cyan-300 border border-cyan-800" style="font-size:10px">APPLY</button>
|
||
<button onclick="queryPID()" class="px-2 py-1 rounded bg-gray-900 hover:bg-gray-800 text-gray-400 border border-gray-700" style="font-size:10px">QUERY</button>
|
||
|
||
<div class="ml-auto">
|
||
<button onclick="toggleConsole()" id="console-toggle" class="px-2 py-1 rounded bg-gray-900 hover:bg-gray-800 text-gray-500 border border-gray-800" style="font-size:10px">▲ LOG</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Collapsible console -->
|
||
<div id="console-panel" class="hidden px-3 pb-2">
|
||
<div id="console-log" class="h-24 overflow-y-auto rounded p-2 text-green-500 border border-gray-900" style="background:#020208;font-size:10px"></div>
|
||
</div>
|
||
</div><!-- end BOTTOM -->
|
||
|
||
<script type="importmap">
|
||
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js" } }
|
||
</script>
|
||
<script type="module">
|
||
import * as THREE from 'three';
|
||
|
||
// ── Three.js setup ────────────────────────────────────────────────────────────
|
||
const container = document.getElementById('three-container');
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 100);
|
||
camera.position.set(0, 1.2, 5);
|
||
camera.lookAt(0, 0.8, 0);
|
||
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setPixelRatio(window.devicePixelRatio);
|
||
renderer.setClearColor(0x050510);
|
||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||
renderer.domElement.style.display = 'block';
|
||
container.appendChild(renderer.domElement);
|
||
|
||
// Lighting
|
||
scene.add(new THREE.AmbientLight(0x404060, 2));
|
||
const dirLight = new THREE.DirectionalLight(0xffffff, 3);
|
||
dirLight.position.set(5, 10, 7);
|
||
scene.add(dirLight);
|
||
const ptLight = new THREE.PointLight(0x0066ff, 1.5, 20);
|
||
ptLight.position.set(-3, 3, 3);
|
||
scene.add(ptLight);
|
||
|
||
// Ground grid + plane
|
||
scene.add(new THREE.GridHelper(12, 24, 0x001a2a, 0x000a14));
|
||
const ground = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(12, 12),
|
||
new THREE.MeshBasicMaterial({ color: 0x020208, transparent: true, opacity: 0.85 }));
|
||
ground.rotation.x = -Math.PI / 2;
|
||
ground.position.y = -0.005;
|
||
scene.add(ground);
|
||
|
||
// ── SaltyBot model ────────────────────────────────────────────────────────────
|
||
const boardGroup = new THREE.Group();
|
||
|
||
// Body (1.2m tall, pivot at wheel axle y=0)
|
||
const body = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.7, 1.2, 0.3),
|
||
new THREE.MeshPhongMaterial({ color: 0x1a2a4a, specular: 0x334466 }));
|
||
body.position.set(0, 0.7, 0);
|
||
boardGroup.add(body);
|
||
|
||
// Wheels: each in a pivot group for rolling animation.
|
||
// Pivot has rotation.z=PI/2 (lays cylinder flat → axis along X).
|
||
// Inner mesh spins around its local Y axis = rolling around the axle.
|
||
const wheelGeo = new THREE.CylinderGeometry(0.4, 0.4, 0.14, 20);
|
||
const wheelMat = new THREE.MeshPhongMaterial({ color: 0x1a1a1a, specular: 0x333333 });
|
||
|
||
const lPivot = new THREE.Group();
|
||
lPivot.position.set(-0.47, 0, 0);
|
||
lPivot.rotation.z = Math.PI / 2;
|
||
boardGroup.add(lPivot);
|
||
const lWheel = new THREE.Mesh(wheelGeo, wheelMat);
|
||
lPivot.add(lWheel);
|
||
|
||
const rPivot = new THREE.Group();
|
||
rPivot.position.set(0.47, 0, 0);
|
||
rPivot.rotation.z = Math.PI / 2;
|
||
boardGroup.add(rPivot);
|
||
const rWheel = new THREE.Mesh(wheelGeo, wheelMat);
|
||
rPivot.add(rWheel);
|
||
|
||
// Wheel rims (blue accent)
|
||
const rimGeo = new THREE.TorusGeometry(0.37, 0.025, 8, 20);
|
||
const rimMat = new THREE.MeshPhongMaterial({ color: 0x0055cc, specular: 0x0088ff });
|
||
[[-0.54], [0.54]].forEach(([x]) => {
|
||
const rim = new THREE.Mesh(rimGeo, rimMat);
|
||
rim.rotation.z = Math.PI / 2;
|
||
rim.position.set(x, 0, 0);
|
||
boardGroup.add(rim);
|
||
});
|
||
|
||
// Display panel
|
||
const disp = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.48, 0.36, 0.02),
|
||
new THREE.MeshPhongMaterial({ color: 0x001133, specular: 0x003366, emissive: 0x000a1a }));
|
||
disp.position.set(0, 0.75, 0.16);
|
||
boardGroup.add(disp);
|
||
|
||
// Status LED
|
||
const ledMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
|
||
const led = new THREE.Mesh(new THREE.SphereGeometry(0.04, 8, 8), ledMat);
|
||
led.position.set(0.22, 1.1, 0.16);
|
||
boardGroup.add(led);
|
||
|
||
// Sensor stem + head
|
||
const stem = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.1, 0.28, 0.1),
|
||
new THREE.MeshPhongMaterial({ color: 0x2a2a2a }));
|
||
stem.position.set(0, 1.44, 0);
|
||
boardGroup.add(stem);
|
||
|
||
const sensHead = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.28, 0.16, 0.28),
|
||
new THREE.MeshPhongMaterial({ color: 0x111122, specular: 0x333355 }));
|
||
sensHead.position.set(0, 1.66, 0);
|
||
boardGroup.add(sensHead);
|
||
|
||
scene.add(boardGroup);
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
let targetPitch = 0, targetRoll = 0, targetYaw = 0;
|
||
let curPitch = 0, curRoll = 0, curYaw = 0;
|
||
let yawOffset = 0;
|
||
let wheelAngle = 0;
|
||
let lastMotorCmd = 0;
|
||
|
||
const stateNames = ['DISARMED', 'ARMED', 'TILT FAULT'];
|
||
const stateCls = [
|
||
'bg-gray-800 border-gray-600 text-gray-400',
|
||
'bg-amber-900 border-amber-600 text-amber-300',
|
||
'bg-red-900 border-red-600 text-red-300'
|
||
];
|
||
const modeNames = ['MANUAL', 'ASSISTED', 'AUTO'];
|
||
const modeCls = [
|
||
'bg-gray-900 border-gray-700 text-gray-400',
|
||
'bg-blue-950 border-blue-700 text-blue-300',
|
||
'bg-purple-950 border-purple-700 text-purple-300'
|
||
];
|
||
|
||
// ── 2D canvas drawing ─────────────────────────────────────────────────────────
|
||
const cH = document.getElementById('c-horizon').getContext('2d');
|
||
const cC = document.getElementById('c-compass').getContext('2d');
|
||
const cSL = document.getElementById('c-stick-l').getContext('2d');
|
||
const cSR = document.getElementById('c-stick-r').getContext('2d');
|
||
|
||
function drawHorizon(pitch, roll) {
|
||
const W = 200, H = 100, cx = W / 2, cy = H / 2;
|
||
const rollRad = roll * Math.PI / 180;
|
||
const pitchPx = pitch * (H / 60); // ±60° spans full canvas height
|
||
|
||
cH.clearRect(0, 0, W, H);
|
||
// Sky background
|
||
cH.fillStyle = '#051a30';
|
||
cH.fillRect(0, 0, W, H);
|
||
|
||
// Horizon: translate to center, rotate by roll, shift by pitch
|
||
cH.save();
|
||
cH.translate(cx, cy);
|
||
cH.rotate(-rollRad);
|
||
|
||
// Ground fill (below horizon line)
|
||
cH.fillStyle = '#1a0f00';
|
||
cH.fillRect(-W, pitchPx, W * 2, H * 2);
|
||
|
||
// Horizon line
|
||
cH.strokeStyle = '#00ffff';
|
||
cH.lineWidth = 1.5;
|
||
cH.beginPath();
|
||
cH.moveTo(-W, pitchPx);
|
||
cH.lineTo(W, pitchPx);
|
||
cH.stroke();
|
||
|
||
// Pitch ladder every 10°
|
||
for (let d = -30; d <= 30; d += 10) {
|
||
if (d === 0) continue;
|
||
const y = pitchPx + d * (H / 60);
|
||
const lw = (Math.abs(d) % 20 === 0) ? 22 : 14;
|
||
cH.strokeStyle = 'rgba(0,210,210,0.4)';
|
||
cH.lineWidth = 0.7;
|
||
cH.beginPath();
|
||
cH.moveTo(-lw, y); cH.lineTo(lw, y);
|
||
cH.stroke();
|
||
cH.fillStyle = 'rgba(0,210,210,0.5)';
|
||
cH.font = '7px monospace';
|
||
cH.textAlign = 'left';
|
||
cH.fillText((-d).toString(), lw + 2, y + 3);
|
||
}
|
||
cH.restore();
|
||
|
||
// Fixed center reticle (orange — not affected by roll)
|
||
cH.strokeStyle = '#f97316';
|
||
cH.lineWidth = 1.5;
|
||
cH.beginPath();
|
||
cH.moveTo(cx - 28, cy); cH.lineTo(cx - 8, cy);
|
||
cH.moveTo(cx + 8, cy); cH.lineTo(cx + 28, cy);
|
||
cH.moveTo(cx, cy - 4); cH.lineTo(cx, cy + 4);
|
||
cH.stroke();
|
||
|
||
// Roll arc + pointer at top
|
||
cH.save();
|
||
cH.translate(cx, 14);
|
||
cH.strokeStyle = 'rgba(0,255,255,0.35)';
|
||
cH.lineWidth = 1;
|
||
cH.beginPath();
|
||
cH.arc(0, 0, 11, Math.PI, 0);
|
||
cH.stroke();
|
||
cH.save();
|
||
cH.rotate(-rollRad);
|
||
cH.fillStyle = '#f97316';
|
||
cH.beginPath();
|
||
cH.moveTo(0, -12); cH.lineTo(-3, -7); cH.lineTo(3, -7);
|
||
cH.closePath(); cH.fill();
|
||
cH.restore();
|
||
cH.restore();
|
||
}
|
||
|
||
function drawCompass(yaw) {
|
||
const W = 200, H = 70, cx = W / 2;
|
||
cC.clearRect(0, 0, W, H);
|
||
cC.fillStyle = '#050510';
|
||
cC.fillRect(0, 0, W, H);
|
||
cC.fillStyle = 'rgba(0,255,255,0.04)';
|
||
cC.fillRect(0, 0, W, H);
|
||
|
||
const degPerPx = W / 70; // 70° visible window
|
||
const cardinals = { 0:'N', 45:'NE', 90:'E', 135:'SE', 180:'S', 225:'SW', 270:'W', 315:'NW' };
|
||
|
||
for (let i = -35; i <= 35; i++) {
|
||
const deg = ((Math.round(yaw) + i) % 360 + 360) % 360;
|
||
const x = cx + i * degPerPx;
|
||
const isMaj = deg % 45 === 0;
|
||
const isMed = deg % 15 === 0;
|
||
if (!isMed && !isMaj) continue;
|
||
|
||
cC.strokeStyle = isMaj ? '#00cccc' : 'rgba(0,200,200,0.3)';
|
||
cC.lineWidth = isMaj ? 1.5 : 0.5;
|
||
const tH = isMaj ? 18 : 8;
|
||
cC.beginPath();
|
||
cC.moveTo(x, 0); cC.lineTo(x, tH);
|
||
cC.stroke();
|
||
|
||
if (isMaj && cardinals[deg] !== undefined) {
|
||
cC.fillStyle = deg === 0 ? '#ff4444' : '#00cccc';
|
||
cC.font = 'bold 9px monospace';
|
||
cC.textAlign = 'center';
|
||
cC.fillText(cardinals[deg], x, 30);
|
||
}
|
||
}
|
||
|
||
// Heading value
|
||
const hdg = ((Math.round(yaw) % 360) + 360) % 360;
|
||
cC.fillStyle = '#00ffff';
|
||
cC.font = 'bold 13px monospace';
|
||
cC.textAlign = 'center';
|
||
cC.fillText(hdg + '°', cx, H - 6);
|
||
|
||
// Center pointer (orange)
|
||
cC.strokeStyle = '#f97316';
|
||
cC.lineWidth = 2;
|
||
cC.beginPath();
|
||
cC.moveTo(cx, 0); cC.lineTo(cx, 12);
|
||
cC.stroke();
|
||
cC.fillStyle = '#f97316';
|
||
cC.beginPath();
|
||
cC.moveTo(cx, 0); cC.lineTo(cx - 4, 7); cC.lineTo(cx + 4, 7);
|
||
cC.closePath(); cC.fill();
|
||
}
|
||
|
||
function drawStick(ctx, xVal, yVal) {
|
||
// xVal, yVal: channel value in µs (1000–2000, center 1500)
|
||
const W = 64, H = 64, cx = W / 2, cy = H / 2, r = 28;
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
ctx.fillStyle = '#070712';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Grid crosshair
|
||
ctx.strokeStyle = 'rgba(0,255,255,0.08)';
|
||
ctx.lineWidth = 0.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, 2); ctx.lineTo(cx, H - 2);
|
||
ctx.moveTo(2, cy); ctx.lineTo(W - 2, cy);
|
||
ctx.stroke();
|
||
|
||
// Boundary circle
|
||
ctx.strokeStyle = 'rgba(0,255,255,0.15)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
// Dot position (-1..+1 normalized)
|
||
const nx = (xVal - 1500) / 500;
|
||
const ny = -(yVal - 1500) / 500;
|
||
const dotX = cx + nx * r * 0.9;
|
||
const dotY = cy + ny * r * 0.9;
|
||
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.shadowBlur = 6;
|
||
ctx.shadowColor = '#00ffff';
|
||
ctx.beginPath();
|
||
ctx.arc(dotX, dotY, 3, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
|
||
// Initialize canvases
|
||
drawHorizon(0, 0);
|
||
drawCompass(0);
|
||
drawStick(cSL, 1500, 1500);
|
||
drawStick(cSR, 1500, 1500);
|
||
|
||
// ── IMU + telemetry update ────────────────────────────────────────────────────
|
||
window.updateIMU = function(data) {
|
||
const pitch = data.p !== undefined ? data.p / 10.0 : 0;
|
||
const roll = data.r !== undefined ? data.r / 10.0 : 0;
|
||
const yawRaw = data.y !== undefined ? data.y / 10.0 : 0;
|
||
const yaw = yawRaw - yawOffset;
|
||
const motorCmd = data.m !== undefined ? data.m : 0;
|
||
const state = data.s !== undefined ? data.s : 0;
|
||
const mode = data.md !== undefined ? data.md : 0;
|
||
|
||
// 3D rotation targets
|
||
targetPitch = pitch * Math.PI / 180;
|
||
targetRoll = -roll * Math.PI / 180; // negate: Three +z = left bank
|
||
targetYaw = yaw * Math.PI / 180;
|
||
lastMotorCmd = motorCmd;
|
||
|
||
// Left panel text
|
||
document.getElementById('v-pitch').textContent = pitch.toFixed(1) + '°';
|
||
document.getElementById('v-roll').textContent = roll.toFixed(1) + '°';
|
||
document.getElementById('v-yaw').textContent = yaw.toFixed(1) + '°';
|
||
document.getElementById('v-motor').textContent = motorCmd;
|
||
|
||
// Motor bar: bidirectional from center
|
||
const pct = motorCmd / 1000 * 50; // -50..+50%
|
||
const bar = document.getElementById('motor-bar');
|
||
bar.style.left = pct >= 0 ? '50%' : (50 + pct) + '%';
|
||
bar.style.width = Math.abs(pct) + '%';
|
||
bar.style.background = pct >= 0 ? '#f97316' : '#3b82f6';
|
||
|
||
// Battery (data.bat = V×100, 4S LiPo: 12.0–16.8V)
|
||
if (data.bat !== undefined && data.bat > 0) {
|
||
const v = data.bat / 100.0;
|
||
const p = Math.max(0, Math.min(100, (v - 12.0) / (16.8 - 12.0) * 100));
|
||
document.getElementById('v-bat').textContent = v.toFixed(2) + 'V';
|
||
const bb = document.getElementById('bat-bar');
|
||
bb.style.width = p + '%';
|
||
bb.style.background = p > 50 ? '#22c55e' : p > 20 ? '#f59e0b' : '#ef4444';
|
||
}
|
||
|
||
// State badge
|
||
const badge = document.getElementById('state-badge');
|
||
badge.textContent = stateNames[state] || 'UNKNOWN';
|
||
badge.className = 'px-2 py-0.5 rounded font-bold border ' + (stateCls[state] || stateCls[0]);
|
||
badge.style.fontSize = '10px';
|
||
|
||
// Arm button color
|
||
const armBtn = document.getElementById('arm-btn');
|
||
armBtn.textContent = state === 1 ? 'DISARM' : 'ARM';
|
||
armBtn.style.background = state === 1 ? '#7f1d1d' : '';
|
||
|
||
// Mode badge
|
||
const mb = document.getElementById('mode-badge');
|
||
mb.textContent = modeNames[mode] || 'MANUAL';
|
||
mb.className = 'text-center py-1 rounded border font-bold ' + (modeCls[mode] || modeCls[0]);
|
||
mb.style.fontSize = '11px';
|
||
|
||
// LED color by state
|
||
if (state === 1) {
|
||
ledMat.color.setRGB(0, 1, 0);
|
||
} else if (state === 2) {
|
||
const b = Math.sin(Date.now() / 80) > 0 ? 1 : 0;
|
||
ledMat.color.setRGB(b, 0, 0);
|
||
} else {
|
||
const v = 0.35 + 0.35 * Math.sin(Date.now() / 700);
|
||
ledMat.color.setRGB(v, 0, 0);
|
||
}
|
||
|
||
// Optional: heading
|
||
if (data.hd !== undefined && data.hd >= 0) {
|
||
document.getElementById('v-hdg-row').classList.remove('hidden');
|
||
document.getElementById('v-hdg').textContent = (data.hd / 10.0).toFixed(1);
|
||
}
|
||
|
||
// Optional: BME280 environment
|
||
const bme = document.getElementById('bme-section');
|
||
if (data.alt !== undefined) { bme.classList.remove('hidden'); document.getElementById('row-alt').classList.remove('hidden'); document.getElementById('v-alt').textContent = (data.alt / 100.0).toFixed(1); }
|
||
if (data.t !== undefined) { bme.classList.remove('hidden'); document.getElementById('row-temp').classList.remove('hidden'); document.getElementById('v-temp').textContent = (data.t / 10.0).toFixed(1); }
|
||
if (data.h !== undefined) { bme.classList.remove('hidden'); document.getElementById('row-hum').classList.remove('hidden'); document.getElementById('v-hum').textContent = (data.h / 10.0).toFixed(1); }
|
||
if (data.pa !== undefined) { bme.classList.remove('hidden'); document.getElementById('row-pres').classList.remove('hidden'); document.getElementById('v-pres').textContent = (data.pa / 10.0).toFixed(1); }
|
||
|
||
// CRSF link stats
|
||
if (data.rssi !== undefined) {
|
||
document.getElementById('v-rssi').textContent = data.rssi;
|
||
document.getElementById('v-lq').textContent = data.lq !== undefined ? data.lq : '--';
|
||
}
|
||
|
||
// RC channels (µs values from firmware)
|
||
if (data.ch1 !== undefined) {
|
||
const ch1 = data.ch1, ch2 = data.ch2 || 1500;
|
||
const ch3 = data.ch3 || 1500, ch4 = data.ch4 || 1500;
|
||
document.getElementById('v-ch1').textContent = ch1;
|
||
document.getElementById('v-ch2').textContent = ch2;
|
||
document.getElementById('v-ch3').textContent = ch3;
|
||
document.getElementById('v-ch4').textContent = ch4;
|
||
drawStick(cSL, ch1, ch2);
|
||
drawStick(cSR, ch3, ch4);
|
||
}
|
||
|
||
// USB telemetry counters
|
||
if (data.txc !== undefined) document.getElementById('v-txc').textContent = data.txc;
|
||
if (data.rxc !== undefined) document.getElementById('v-rxc').textContent = data.rxc;
|
||
|
||
// Jetson active
|
||
if (data.ja !== undefined) {
|
||
const alive = data.ja === 1;
|
||
document.getElementById('jetson-dot').style.background = alive ? '#22c55e' : '#374151';
|
||
document.getElementById('jetson-status').textContent = alive ? 'ACTIVE' : 'NO SIGNAL';
|
||
document.getElementById('jetson-status').style.color = alive ? '#4ade80' : '#6b7280';
|
||
}
|
||
|
||
// Redraw 2D gauges
|
||
drawHorizon(pitch, roll);
|
||
drawCompass(yaw);
|
||
};
|
||
|
||
// PID query response handler
|
||
window.updatePID = function(data) {
|
||
if (data.kp !== undefined) { document.getElementById('sl-kp').value = data.kp; document.getElementById('lbl-kp').textContent = (data.kp / 1000).toFixed(1); }
|
||
if (data.ki !== undefined) { document.getElementById('sl-ki').value = data.ki; document.getElementById('lbl-ki').textContent = (data.ki / 1000).toFixed(3); }
|
||
if (data.kd !== undefined) { document.getElementById('sl-kd').value = data.kd; document.getElementById('lbl-kd').textContent = (data.kd / 1000).toFixed(3); }
|
||
if (data.sp !== undefined) { document.getElementById('sl-sp').value = data.sp; document.getElementById('lbl-sp').textContent = (data.sp / 10).toFixed(1) + '°'; }
|
||
if (data.ms !== undefined) { document.getElementById('sl-ms').value = data.ms; document.getElementById('lbl-ms').textContent = data.ms; }
|
||
};
|
||
|
||
// ── Animation loop ────────────────────────────────────────────────────────────
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
|
||
// Smooth rotation interpolation
|
||
curPitch += (targetPitch - curPitch) * 0.15;
|
||
curRoll += (targetRoll - curRoll) * 0.15;
|
||
curYaw += (targetYaw - curYaw) * 0.15;
|
||
boardGroup.rotation.x = curPitch;
|
||
boardGroup.rotation.z = curRoll;
|
||
boardGroup.rotation.y = curYaw;
|
||
|
||
// Wheel rolling: spin inner mesh around local Y = axle axis
|
||
wheelAngle += (lastMotorCmd / 1000) * 0.05;
|
||
lWheel.rotation.y = wheelAngle;
|
||
rWheel.rotation.y = wheelAngle;
|
||
|
||
renderer.render(scene, camera);
|
||
}
|
||
animate();
|
||
|
||
// ── Yaw reset ─────────────────────────────────────────────────────────────────
|
||
window.resetYaw = function() {
|
||
// currentFirmwareYaw = yawOffset + (targetYaw in degrees)
|
||
const firmwareYaw = yawOffset + targetYaw * 180 / Math.PI;
|
||
yawOffset = firmwareYaw;
|
||
targetYaw = 0;
|
||
};
|
||
|
||
// ── Resize observer ───────────────────────────────────────────────────────────
|
||
new ResizeObserver(() => {
|
||
const w = container.clientWidth, h = container.clientHeight;
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(w, h);
|
||
}).observe(container);
|
||
|
||
// ── WebSerial ─────────────────────────────────────────────────────────────────
|
||
let port = null, reader = null, opening = false;
|
||
let lineBuffer = '', msgCount = 0, lastHzTime = Date.now();
|
||
let currentState = 0;
|
||
|
||
function logConsole(msg) {
|
||
const el = document.getElementById('console-log');
|
||
const d = document.createElement('div');
|
||
d.textContent = new Date().toLocaleTimeString() + ' ' + msg;
|
||
el.appendChild(d);
|
||
if (el.children.length > 300) el.removeChild(el.firstChild);
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
function setStatus(msg) {
|
||
document.getElementById('status').textContent = msg;
|
||
}
|
||
|
||
async function sendCmd(cmd) {
|
||
if (!port?.writable) return;
|
||
try {
|
||
const w = port.writable.getWriter();
|
||
await w.write(new TextEncoder().encode(cmd));
|
||
w.releaseLock();
|
||
} catch(e) { logConsole('TX err: ' + e.message); }
|
||
}
|
||
|
||
window.toggleSerial = async function() {
|
||
if (opening) return;
|
||
if (port) {
|
||
try { await sendCmd('S'); } catch(e) {}
|
||
try { reader?.cancel(); } catch(e) {}
|
||
try { await port.close(); } catch(e) {}
|
||
port = null; reader = null;
|
||
const btn = document.getElementById('connect-btn');
|
||
btn.textContent = 'CONNECT USB';
|
||
btn.className = btn.className.replace('bg-green-900', 'bg-red-900').replace('border-green-700', 'border-red-700');
|
||
['arm-btn', 'dfu-btn', 'yaw-btn', 'gyrocal-btn'].forEach(id =>
|
||
document.getElementById(id).classList.add('hidden'));
|
||
setStatus('Disconnected');
|
||
return;
|
||
}
|
||
try {
|
||
opening = true;
|
||
port = await navigator.serial.requestPort();
|
||
await port.open({ baudRate: 115200 });
|
||
opening = false;
|
||
const btn = document.getElementById('connect-btn');
|
||
btn.textContent = 'DISCONNECT';
|
||
btn.className = btn.className.replace('bg-red-900', 'bg-green-900').replace('border-red-700', 'border-green-700');
|
||
['arm-btn', 'dfu-btn', 'yaw-btn', 'gyrocal-btn'].forEach(id =>
|
||
document.getElementById(id).classList.remove('hidden'));
|
||
setStatus('Connected — streaming');
|
||
readLoop();
|
||
} catch(e) {
|
||
opening = false; port = null;
|
||
setStatus('Error: ' + e.message);
|
||
}
|
||
};
|
||
|
||
window.toggleArm = async function() { await sendCmd(currentState === 1 ? 'D' : 'A'); };
|
||
window.enterDFU = async function() {
|
||
if (!confirm('Reboot to DFU? Connection will be lost.')) return;
|
||
await sendCmd('R');
|
||
setStatus('Rebooting to DFU...');
|
||
};
|
||
window.gyroRecal = async function() {
|
||
await sendCmd('G');
|
||
setStatus('Gyro calibrating — hold still ~1s...');
|
||
};
|
||
window.sendPID = async function() {
|
||
const kp = (document.getElementById('sl-kp').value / 1000).toFixed(3);
|
||
const ki = (document.getElementById('sl-ki').value / 1000).toFixed(3);
|
||
const kd = (document.getElementById('sl-kd').value / 1000).toFixed(3);
|
||
const sp = (document.getElementById('sl-sp').value / 10).toFixed(1);
|
||
const ms = document.getElementById('sl-ms').value;
|
||
await sendCmd(`P${kp}`);
|
||
await sendCmd(`I${ki}`);
|
||
await sendCmd(`D${kd}`);
|
||
await sendCmd(`T${sp}`);
|
||
await sendCmd(`M${ms}`);
|
||
setStatus('PID applied');
|
||
};
|
||
window.queryPID = async function() { await sendCmd('?'); };
|
||
window.toggleConsole = function() {
|
||
const p = document.getElementById('console-panel');
|
||
const b = document.getElementById('console-toggle');
|
||
p.classList.toggle('hidden');
|
||
b.textContent = p.classList.contains('hidden') ? '▲ LOG' : '▼ LOG';
|
||
};
|
||
|
||
async function readLoop() {
|
||
const decoder = new TextDecoderStream();
|
||
port.readable.pipeTo(decoder.writable);
|
||
reader = decoder.readable.getReader();
|
||
try {
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
lineBuffer += value;
|
||
const lines = lineBuffer.split('\n');
|
||
lineBuffer = lines.pop();
|
||
for (const line of lines) {
|
||
const t = line.trim();
|
||
if (!t) continue;
|
||
logConsole(t);
|
||
try {
|
||
const d = JSON.parse(t);
|
||
// PID query reply: has kp/ki/kd but not p/r/y
|
||
if (d.kp !== undefined && d.p === undefined) { window.updatePID(d); continue; }
|
||
if (d.err !== undefined) { setStatus('IMU err: ' + d.err); continue; }
|
||
currentState = d.s || 0;
|
||
window.updateIMU(d);
|
||
msgCount++;
|
||
const now = Date.now();
|
||
if (now - lastHzTime >= 1000) {
|
||
document.getElementById('v-hz').textContent = msgCount;
|
||
msgCount = 0;
|
||
lastHzTime = now;
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
} catch(e) { setStatus('Read err: ' + e.message); }
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|