sl-firmware 6dc7aea32f feat: modern HUD dashboard + telemetry expansion (#43)
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>
2026-02-28 22:41:02 -05:00

844 lines
36 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 (10002000, 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.016.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>