sl-firmware ca23407ceb feat: BME280 full readout — temp, humidity, pressure telemetry (#30)
- bmp280.c: detect BME280 (chip_id 0x60) vs BMP280 (0x58) at init
- bmp280.c: read humidity calibration (dig_H1–H6) from 0xA1 and 0xE1–0xE7
- bmp280.c: set ctrl_hum (0xF2, osrs_h=×16) before ctrl_meas — hardware req
- bmp280.c: add bmp280_read_humidity() — float compensation (FPv5-SP FPU),
  returns %RH × 10; -1 if chip is BMP280 or not initialised
- bmp280.h: add bmp280_read_humidity() declaration + timeout note
- main.c: baro_ok → baro_chip (stores chip_id for BME280 detection)
- main.c: telemetry adds t (°C×10), pa (hPa×10) for all barometers;
  adds h (%RH×10) for BME280 only; alt unchanged
- ui/index.html: hidden TEMP/HUMIDITY/PRESSURE rows, revealed on first
  packet containing t/h/pa fields; values shown with 1 dp

I2C hang safety: all HAL_I2C_Mem_Read/Write use 100ms timeouts, so
missing hardware (NAK) returns in <1ms, not after a hang.

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

399 lines
15 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" id="row-temp" style="display:none"><span class="label">TEMP</span> <span class="val" id="v-temp">--</span> °C</div>
<div class="stat" id="row-hum" style="display:none"><span class="label">HUMIDITY</span> <span class="val" id="v-hum">--</span> %</div>
<div class="stat" id="row-pres" style="display:none"><span class="label">PRESSURE</span> <span class="val" id="v-pres">--</span> hPa</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.t !== undefined) {
document.getElementById('row-temp').style.display = '';
document.getElementById('v-temp').textContent = (data.t / 10.0).toFixed(1);
}
if (data.h !== undefined) {
document.getElementById('row-hum').style.display = '';
document.getElementById('v-hum').textContent = (data.h / 10.0).toFixed(1);
}
if (data.pa !== undefined) {
document.getElementById('row-pres').style.display = '';
document.getElementById('v-pres').textContent = (data.pa / 10.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>