saltylab-firmware/ui/index.html
sl-firmware 91fc54c3d7 feat: SaltyBot 3D robot model in web UI (#37)
Replace generic flat PCB with a standing two-wheeled balancing robot:
- Vertical navy body (1.2 tall) rising above wheel axle at y=0
- Two wheels with blue rim accents, aligned to axle
- Front display panel and status LED
- Sensor stem + head on top

Camera repositioned to frame the taller robot.

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

396 lines
16 KiB
HTML
Raw 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, 1.2, 5);
camera.lookAt(0, 0.8, 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));
// SaltyBot robot model — two-wheeled self-balancing robot
// Pivot at wheel axle (y=0); body rises upward; pitches ±pitch_deg around X axis.
const boardGroup = new THREE.Group();
// Main body (vertical rectangle, ~1.2 tall, center above wheel axle)
boardGroup.add(new THREE.Mesh(
new THREE.BoxGeometry(0.7, 1.2, 0.3),
new THREE.MeshPhongMaterial({ color: 0x1a2a4a, specular: 0x334466 })
));
boardGroup.children[boardGroup.children.length - 1].position.set(0, 0.7, 0);
// Left wheel
const wheelGeo = new THREE.CylinderGeometry(0.4, 0.4, 0.14, 20);
const wheelMat = new THREE.MeshPhongMaterial({ color: 0x1a1a1a, specular: 0x333333 });
const leftWheel = new THREE.Mesh(wheelGeo, wheelMat);
leftWheel.rotation.z = Math.PI / 2;
leftWheel.position.set(-0.47, 0, 0);
boardGroup.add(leftWheel);
// Right wheel
const rightWheel = new THREE.Mesh(wheelGeo, wheelMat);
rightWheel.rotation.z = Math.PI / 2;
rightWheel.position.set(0.47, 0, 0);
boardGroup.add(rightWheel);
// 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 on front face
boardGroup.add(new THREE.Mesh(
new THREE.BoxGeometry(0.48, 0.36, 0.02),
new THREE.MeshPhongMaterial({ color: 0x001133, specular: 0x003366, emissive: 0x000a1a })
));
boardGroup.children[boardGroup.children.length - 1].position.set(0, 0.75, 0.16);
// 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 (thin post above body)
boardGroup.add(new THREE.Mesh(
new THREE.BoxGeometry(0.1, 0.28, 0.1),
new THREE.MeshPhongMaterial({ color: 0x2a2a2a, specular: 0x444444 })
));
boardGroup.children[boardGroup.children.length - 1].position.set(0, 1.44, 0);
// Sensor head (camera/LIDAR box at top)
boardGroup.add(new THREE.Mesh(
new THREE.BoxGeometry(0.28, 0.16, 0.28),
new THREE.MeshPhongMaterial({ color: 0x111122, specular: 0x333355 })
));
boardGroup.children[boardGroup.children.length - 1].position.set(0, 1.66, 0);
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>