Protocol choice: implemented from spec (CRSFforArduino needs Arduino
framework; Betaflight extraction has deep scheduler dependencies).
Protocol verified against Betaflight src/main/rx/crsf.c + CRSF spec.
crsf.c:
- UART4 PA0=TX/PA1=RX (GPIO_AF8_UART4), 420000 baud 8N1, oversampling×8
APB1=54MHz → BRR=0x101 → 418604 baud (0.33% error, within spec)
- DMA1 Stream2 Channel4, circular 64-byte buffer, IDLE interrupt
DMA half/complete callbacks drain buffer; IDLE fires at frame boundary
- CRC8 DVB-S2 (polynomial 0xD5) validated on every frame
- Parser state machine: SYNC(0xC8)→LEN→DATA with length sanity check
- 11-bit channel unpack for all 16 channels from 22-byte payload
- RC channels frame (0x16): unpacks 16ch, updates last_rx_ms + armed
- Link stats frame (0x14): captures RSSI dBm, LQ%, SNR dB
crsf.h: added rssi_dbm, link_quality, snr fields to CRSFState
config.h: CRSF_ARM_THRESHOLD=1750, CRSF_STEER_MAX=400, CRSF_FAILSAFE_MS=300
main.c:
- crsf_init() called after motor_driver_init()
- RC failsafe: disarm if (now - last_rx_ms) > CRSF_FAILSAFE_MS, but only
after RC was first seen (last_rx_ms != 0) — USB-only mode unaffected
- RC arm: CH5 rising edge → safety_arm_start(); falling edge → disarm
Same ARMING_HOLD_MS interlock as USB arm command
- RC steer: CH1 → crsf_to_range() → ±CRSF_STEER_MAX → motor_driver steer
- RSSI/LQ: appended to JSON when safety_rc_alive() ("rssi","lq" fields)
ui/index.html: hidden RC RSSI row revealed on first packet with rssi/lq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
393 lines
15 KiB
HTML
393 lines
15 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 Balance Bot</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { background: #0a0a0f; color: #e0e0e0; font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden; }
|
||
#hud { position: fixed; top: 20px; left: 20px; z-index: 10; }
|
||
#hud h1 { font-size: 18px; color: #ff4444; margin-bottom: 8px; letter-spacing: 2px; }
|
||
.stat { font-size: 13px; line-height: 1.8; color: #888; }
|
||
.stat .val { color: #0ff; font-weight: bold; }
|
||
.stat .label { color: #666; width: 80px; display: inline-block; }
|
||
.btn {
|
||
margin-top: 8px; margin-right: 6px; padding: 10px 20px;
|
||
color: #fff; border: none;
|
||
font-family: inherit; font-size: 13px; cursor: pointer;
|
||
border-radius: 4px; letter-spacing: 1px;
|
||
}
|
||
#connect-btn { background: #ff4444; }
|
||
#connect-btn:hover { background: #ff6666; }
|
||
#connect-btn.connected { background: #00cc66; }
|
||
#arm-btn { background: #cc8800; display: none; }
|
||
#arm-btn:hover { background: #ddaa22; }
|
||
#arm-btn.armed { background: #ff2222; }
|
||
#dfu-btn { background: #555; display: none; }
|
||
#dfu-btn:hover { background: #777; }
|
||
#status { margin-top: 8px; font-size: 12px; color: #666; }
|
||
#state-badge {
|
||
display: inline-block; padding: 2px 8px; border-radius: 3px;
|
||
font-size: 11px; font-weight: bold; margin-left: 8px;
|
||
}
|
||
.state-disarmed { background: #333; color: #888; }
|
||
.state-armed { background: #442200; color: #ffaa00; }
|
||
.state-fault { background: #440000; color: #ff4444; }
|
||
#horizon { position: fixed; bottom: 20px; left: 20px; z-index: 10; }
|
||
.bar-container { margin: 4px 0; }
|
||
.bar-label { font-size: 11px; color: #666; width: 50px; display: inline-block; }
|
||
.bar-bg { display: inline-block; width: 200px; height: 8px; background: #1a1a2e; border-radius: 4px; vertical-align: middle; }
|
||
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.05s; }
|
||
.bar-pitch .bar-fill { background: #ff4444; }
|
||
.bar-motor .bar-fill { background: #44ff44; }
|
||
canvas { display: block; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="hud">
|
||
<h1>⚡ SALTYLAB <span id="state-badge" class="state-disarmed">DISARMED</span></h1>
|
||
<div class="stat"><span class="label">PITCH</span> <span class="val" id="v-pitch">--</span>°</div>
|
||
<div class="stat"><span class="label">ROLL</span> <span class="val" id="v-roll">--</span>°</div>
|
||
<div class="stat"><span class="label">YAW</span> <span class="val" id="v-yaw">--</span>°</div>
|
||
<div class="stat"><span class="label">MOTOR</span> <span class="val" id="v-motor">--</span></div>
|
||
<div class="stat" id="row-hdg" style="display:none"><span class="label">HEADING</span> <span class="val" id="v-hdg">--</span>°</div>
|
||
<div class="stat" id="row-alt" style="display:none"><span class="label">ALT</span> <span class="val" id="v-alt">--</span> m</div>
|
||
<div class="stat" id="row-rc" style="display:none">
|
||
<span class="label">RC RSSI</span> <span class="val" id="v-rssi">--</span> dBm
|
||
<span class="label" style="width:auto">LQ</span> <span class="val" id="v-lq">--</span>%
|
||
</div>
|
||
<div class="stat"><span class="label">HZ</span> <span class="val" id="v-hz">--</span></div>
|
||
<div>
|
||
<button class="btn" id="connect-btn" onclick="toggleSerial()">CONNECT USB</button>
|
||
<button class="btn" id="arm-btn" onclick="toggleArm()">ARM</button>
|
||
<button class="btn" id="dfu-btn" onclick="enterDFU()">DFU</button>
|
||
<button class="btn" id="yaw-btn" onclick="resetYaw()" style="background:#335533;display:none">YAW RESET</button>
|
||
</div>
|
||
<div id="status">WebSerial ready</div>
|
||
</div>
|
||
|
||
<div id="horizon">
|
||
<div class="bar-container bar-pitch">
|
||
<span class="bar-label">PITCH</span>
|
||
<span class="bar-bg"><span class="bar-fill" id="bar-pitch" style="width:50%"></span></span>
|
||
</div>
|
||
<div class="bar-container bar-motor">
|
||
<span class="bar-label">MOTOR</span>
|
||
<span class="bar-bg"><span class="bar-fill" id="bar-motor" style="width:50%"></span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<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 scene ---
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
|
||
camera.position.set(0, 2, 5);
|
||
camera.lookAt(0, 0, 0);
|
||
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
renderer.setPixelRatio(window.devicePixelRatio);
|
||
renderer.setClearColor(0x0a0a0f);
|
||
document.body.appendChild(renderer.domElement);
|
||
|
||
// Lighting
|
||
scene.add(new THREE.AmbientLight(0x404040, 2));
|
||
const dirLight = new THREE.DirectionalLight(0xffffff, 3);
|
||
dirLight.position.set(5, 10, 7);
|
||
scene.add(dirLight);
|
||
const pointLight = new THREE.PointLight(0xff4444, 2, 20);
|
||
pointLight.position.set(-3, 3, 3);
|
||
scene.add(pointLight);
|
||
|
||
// Grid
|
||
scene.add(new THREE.GridHelper(10, 20, 0x222244, 0x111122));
|
||
|
||
// FC Board model
|
||
const boardGroup = new THREE.Group();
|
||
|
||
// PCB
|
||
boardGroup.add(new THREE.Mesh(
|
||
new THREE.BoxGeometry(1.6, 0.08, 1.6),
|
||
new THREE.MeshPhongMaterial({ color: 0x1a1a1a, specular: 0x333333 })
|
||
));
|
||
|
||
// Copper traces
|
||
const traceMat = new THREE.MeshPhongMaterial({ color: 0xcc8833, specular: 0xffaa44 });
|
||
for (let i = -0.6; i <= 0.6; i += 0.3) {
|
||
const trace = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.005, 0.02), traceMat);
|
||
trace.position.set(0, 0.043, i);
|
||
boardGroup.add(trace);
|
||
}
|
||
|
||
// MCU chip
|
||
const mcu = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.5, 0.06, 0.5),
|
||
new THREE.MeshPhongMaterial({ color: 0x222222, specular: 0x444444 })
|
||
);
|
||
mcu.position.set(0, 0.07, 0);
|
||
boardGroup.add(mcu);
|
||
|
||
// IMU chip
|
||
const imuChip = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.2, 0.04, 0.2),
|
||
new THREE.MeshPhongMaterial({ color: 0x331111, specular: 0x442222 })
|
||
);
|
||
imuChip.position.set(-0.4, 0.06, -0.3);
|
||
boardGroup.add(imuChip);
|
||
|
||
// USB connector
|
||
const usb = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.35, 0.1, 0.15),
|
||
new THREE.MeshPhongMaterial({ color: 0x888888, specular: 0xaaaaaa })
|
||
);
|
||
usb.position.set(0, 0.06, -0.85);
|
||
boardGroup.add(usb);
|
||
|
||
// Status LED
|
||
const ledMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
|
||
const led = new THREE.Mesh(new THREE.SphereGeometry(0.03, 8, 8), ledMat);
|
||
led.position.set(0.5, 0.06, 0.5);
|
||
boardGroup.add(led);
|
||
|
||
// Mounting holes
|
||
const holeMat = new THREE.MeshPhongMaterial({ color: 0x444444 });
|
||
[[-0.55, -0.55], [-0.55, 0.55], [0.55, -0.55], [0.55, 0.55]].forEach(([x, z]) => {
|
||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.06, 0.015, 8, 16), holeMat);
|
||
ring.rotation.x = Math.PI / 2;
|
||
ring.position.set(x, 0.05, z);
|
||
boardGroup.add(ring);
|
||
});
|
||
|
||
// Forward arrow
|
||
const arrow = new THREE.Mesh(
|
||
new THREE.ConeGeometry(0.08, 0.2, 8),
|
||
new THREE.MeshBasicMaterial({ color: 0xff4444, transparent: true, opacity: 0.8 })
|
||
);
|
||
arrow.rotation.x = -Math.PI / 2;
|
||
arrow.position.set(0, 0.06, -0.65);
|
||
boardGroup.add(arrow);
|
||
|
||
scene.add(boardGroup);
|
||
|
||
// --- State ---
|
||
let targetPitch = 0, targetRoll = 0, targetYaw = 0;
|
||
let currentPitch = 0, currentRoll = 0, currentYaw = 0;
|
||
let yawOffset = 0; // subtracted from firmware yaw for reset-to-zero
|
||
|
||
const stateNames = ['DISARMED', 'ARMED', 'TILT FAULT'];
|
||
const stateClasses = ['state-disarmed', 'state-armed', 'state-fault'];
|
||
|
||
window.updateIMU = function(data) {
|
||
// Firmware sends: p=pitch×10, r=roll×10, y=yaw×10, m=motor, s=state
|
||
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 || 0;
|
||
const state = data.s || 0;
|
||
|
||
// Three.js rotation targets (radians) — CW270 IMU mount on MAMBA F722S:
|
||
// pitch → rotation.x positive = nose up (Three.js +x rotates -Z end upward ✓)
|
||
// roll → -rotation.z positive = right bank (Three.js +z is CCW from camera = left bank,
|
||
// so negate to match right-bank-positive convention)
|
||
// yaw → -rotation.y positive = CW from above (Three.js +y is CCW, sensor Z points down
|
||
// so gz+ = CW physical; negate so model spins correctly)
|
||
targetPitch = pitch * Math.PI / 180;
|
||
targetRoll = -roll * Math.PI / 180; // negate: Three.js +z = left bank, we want right bank+
|
||
targetYaw = -yaw * Math.PI / 180; // negate: Three.js +y = CCW, sensor gz+ = CW
|
||
|
||
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;
|
||
|
||
// Optional sensors — show row only when data is present
|
||
if (data.hd !== undefined && data.hd >= 0) {
|
||
document.getElementById('row-hdg').style.display = '';
|
||
document.getElementById('v-hdg').textContent = (data.hd / 10.0).toFixed(1);
|
||
}
|
||
if (data.alt !== undefined) {
|
||
document.getElementById('row-alt').style.display = '';
|
||
document.getElementById('v-alt').textContent = (data.alt / 100.0).toFixed(1);
|
||
}
|
||
if (data.rssi !== undefined) {
|
||
document.getElementById('row-rc').style.display = '';
|
||
document.getElementById('v-rssi').textContent = data.rssi;
|
||
document.getElementById('v-lq').textContent = (data.lq !== undefined) ? data.lq : '--';
|
||
}
|
||
|
||
// Pitch bar: center at 50%, ±90°
|
||
document.getElementById('bar-pitch').style.width = ((pitch + 90) / 180 * 100) + '%';
|
||
// Motor bar: center at 50%, ±1000
|
||
document.getElementById('bar-motor').style.width = ((motorCmd + 1000) / 2000 * 100) + '%';
|
||
|
||
// State badge
|
||
const badge = document.getElementById('state-badge');
|
||
badge.textContent = stateNames[state] || 'UNKNOWN';
|
||
badge.className = stateClasses[state] || 'state-disarmed';
|
||
|
||
// Arm button state
|
||
const armBtn = document.getElementById('arm-btn');
|
||
if (state === 1) {
|
||
armBtn.textContent = 'DISARM';
|
||
armBtn.classList.add('armed');
|
||
} else {
|
||
armBtn.textContent = 'ARM';
|
||
armBtn.classList.remove('armed');
|
||
}
|
||
|
||
// LED color based on state
|
||
if (state === 1) {
|
||
ledMat.color.setRGB(0, 1, 0); // green when armed
|
||
} else if (state === 2) {
|
||
const blink = Math.sin(Date.now() / 100) > 0 ? 1 : 0;
|
||
ledMat.color.setRGB(blink, 0, 0); // fast red blink on fault
|
||
} else {
|
||
const intensity = 0.5 + 0.5 * Math.sin(Date.now() / 500);
|
||
ledMat.color.setRGB(intensity, 0, 0); // slow red pulse disarmed
|
||
}
|
||
};
|
||
|
||
// Animation
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
currentPitch += (targetPitch - currentPitch) * 0.15;
|
||
currentRoll += (targetRoll - currentRoll) * 0.15;
|
||
currentYaw += (targetYaw - currentYaw) * 0.15;
|
||
boardGroup.rotation.x = currentPitch;
|
||
boardGroup.rotation.z = currentRoll;
|
||
boardGroup.rotation.y = currentYaw;
|
||
renderer.render(scene, camera);
|
||
}
|
||
animate();
|
||
|
||
window.resetYaw = function() {
|
||
// Capture current raw firmware yaw (before negate) as new zero reference.
|
||
// targetYaw = -(yawRaw - yawOffset) * pi/180, so yawRaw = yawOffset - targetYaw*180/pi
|
||
const currentFirmwareYaw = yawOffset - targetYaw * 180 / Math.PI;
|
||
yawOffset = currentFirmwareYaw;
|
||
targetYaw = 0;
|
||
};
|
||
|
||
window.addEventListener('resize', () => {
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
});
|
||
|
||
// --- WebSerial ---
|
||
let port = null, reader = null, writer = null, lineBuffer = '', opening = false;
|
||
let msgCount = 0, lastHzTime = Date.now();
|
||
let currentState = 0;
|
||
|
||
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) {
|
||
console.error('Send failed:', e);
|
||
}
|
||
}
|
||
|
||
window.toggleSerial = async function() {
|
||
if (opening) return;
|
||
if (port) {
|
||
try { await sendCmd('S'); } catch(e) {} // Stop streaming before disconnect
|
||
try { reader?.cancel(); } catch(e) {}
|
||
try { await port.close(); } catch(e) {}
|
||
port = null; reader = null;
|
||
document.getElementById('connect-btn').textContent = 'CONNECT USB';
|
||
document.getElementById('connect-btn').classList.remove('connected');
|
||
document.getElementById('arm-btn').style.display = 'none';
|
||
document.getElementById('dfu-btn').style.display = 'none';
|
||
document.getElementById('yaw-btn').style.display = 'none';
|
||
document.getElementById('status').textContent = 'Disconnected';
|
||
return;
|
||
}
|
||
try {
|
||
opening = true;
|
||
port = await navigator.serial.requestPort();
|
||
await port.open({ baudRate: 115200 });
|
||
|
||
opening = false;
|
||
document.getElementById('connect-btn').textContent = 'DISCONNECT';
|
||
document.getElementById('connect-btn').classList.add('connected');
|
||
document.getElementById('arm-btn').style.display = 'inline-block';
|
||
document.getElementById('dfu-btn').style.display = 'inline-block';
|
||
document.getElementById('yaw-btn').style.display = 'inline-block';
|
||
document.getElementById('status').textContent = 'Connected — streaming';
|
||
|
||
readLoop();
|
||
} catch (e) {
|
||
opening = false;
|
||
port = null; writer = null;
|
||
document.getElementById('status').textContent = 'Error: ' + e.message;
|
||
}
|
||
};
|
||
|
||
window.toggleArm = async function() {
|
||
if (currentState === 1) {
|
||
await sendCmd('D');
|
||
} else {
|
||
await sendCmd('A');
|
||
}
|
||
};
|
||
|
||
window.enterDFU = async function() {
|
||
if (!confirm('Reboot to DFU mode? You will lose connection.')) return;
|
||
await sendCmd('R');
|
||
document.getElementById('status').textContent = 'Rebooting to DFU...';
|
||
};
|
||
|
||
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) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const data = JSON.parse(line);
|
||
if (data.init) {
|
||
document.getElementById('status').textContent =
|
||
`Connected — IMU:${data.imu === 0 ? 'OK' : 'ERR'} WHO:0x${(data.who||0).toString(16)}`;
|
||
continue;
|
||
}
|
||
if (data.err !== undefined) {
|
||
document.getElementById('status').textContent = `IMU error: ${data.err}`;
|
||
continue;
|
||
}
|
||
currentState = data.s || 0;
|
||
window.updateIMU(data);
|
||
msgCount++;
|
||
const now = Date.now();
|
||
if (now - lastHzTime >= 1000) {
|
||
document.getElementById('v-hz').textContent = msgCount;
|
||
msgCount = 0;
|
||
lastHzTime = now;
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
document.getElementById('status').textContent = 'Read error: ' + e.message;
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|