sl-firmware e21526327b feat: Auto-detect magnetometer + barometer (#24)
Shared I2C1 bus (i2c1.c/h, PB8=SCL PB9=SDA 100kHz):
- i2c1_init() called once in main() before sensor probes.
- hi2c1 exported globally; baro and mag drivers use it directly.

Barometer (bmp280.c):
- Probes I2C1 at 0x76 then 0x77 (covers both SDO options).
- bmp280_init() returns chip_id (0x58/0x60) on success, neg if absent.
- Added bmp280_pressure_to_alt_cm() — ISA barometric formula.
- Added bmp280.h (was missing).

Magnetometer (mag.c / mag.h):
- Auto-detects QMC5883L (0x0D, id=0xFF), HMC5883L (0x1E, id='H43'),
  IST8310 (0x0E, id=0x10) in that order.
- mag_read_heading() returns degrees×10 (0–3599) or -1 if not ready.
- HMC5883L: correct XZY byte order applied.
- IST8310: single-measurement trigger mode.

main.c:
- i2c1_init() + bmp280_init() + mag_init() after all other inits.
- Both skip gracefully (baro_ok=0, mag_type=MAG_NONE) if not present.
- Telemetry JSON: incremental builder appends ",\"hd\":<n>" when mag
  found and ",\"alt\":<n>" when baro found. No extra bytes when absent.

UI (index.html):
- HEADING and ALT rows hidden until first packet with that field.
- Heading shown in degrees, alt in metres (firmware sends cm).

Closes #24.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:48:53 -05:00

384 lines
14 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 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"><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);
}
// 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>