395 lines
15 KiB
HTML
395 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-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 = CCW but gz sign matches)
|
||
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; // no negate: measured yaw matches Three.js rotation.y
|
||
|
||
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 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>
|