Skip to content

Instantly share code, notes, and snippets.

@VictorXLR
Created October 18, 2025 16:22
Show Gist options
  • Select an option

  • Save VictorXLR/93645d1a0a400c56e9c634637c26e929 to your computer and use it in GitHub Desktop.

Select an option

Save VictorXLR/93645d1a0a400c56e9c634637c26e929 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unitree Lidar Decoder - SDK2 Protocol</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 1600px;
width: 100%;
overflow: hidden;
margin: 0 auto;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2em;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 0.95em;
}
.controls {
padding: 20px 30px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
}
button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
margin-left: auto;
}
.status.connected {
background: #d4edda;
color: #155724;
}
.status.disconnected {
background: #f8d7da;
color: #721c24;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status.connected .status-dot {
background: #28a745;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
padding: 30px;
}
.panel {
background: white;
border: 1px solid #dee2e6;
border-radius: 12px;
padding: 20px;
}
.panel h2 {
font-size: 1.2em;
margin-bottom: 15px;
color: #333;
display: flex;
align-items: center;
gap: 10px;
}
.badge {
font-size: 0.7em;
padding: 4px 8px;
border-radius: 4px;
background: #667eea;
color: white;
}
.output-area {
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 11px;
height: 300px;
overflow-y: auto;
line-height: 1.6;
}
.output-area::-webkit-scrollbar {
width: 8px;
}
.output-area::-webkit-scrollbar-track {
background: #2d2d2d;
}
.output-area::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-card {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.stat-label {
font-size: 0.85em;
color: #6c757d;
margin-bottom: 5px;
}
.stat-value {
font-size: 1.5em;
font-weight: bold;
color: #333;
}
.full-width {
grid-column: 1 / -1;
}
.log-entry {
padding: 4px 0;
border-bottom: 1px solid #333;
}
.log-time {
color: #858585;
}
.log-type {
color: #4ec9b0;
font-weight: bold;
}
.protocol-info {
background: #e7f3ff;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
font-size: 0.9em;
}
.protocol-info h3 {
font-size: 1em;
margin-bottom: 8px;
color: #0066cc;
}
.protocol-info ul {
margin-left: 20px;
line-height: 1.8;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔬 Unitree Lidar Decoder (SDK2 Protocol)</h1>
<p>Real-time serial data decoder for Unitree L1/L2 LiDAR - Based on unilidar_sdk2</p>
</div>
<div class="controls">
<button id="connectBtn" class="btn-primary">Connect to Lidar</button>
<button id="disconnectBtn" class="btn-danger" disabled>Disconnect</button>
<button id="clearBtn" class="btn-secondary">Clear Logs</button>
<div class="status disconnected" id="status">
<span class="status-dot"></span>
<span id="statusText">Disconnected</span>
</div>
</div>
<div class="content">
<div class="panel full-width">
<h2>📊 Statistics <span class="badge" id="statsTime">--:--:--</span></h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Packets Received</div>
<div class="stat-value" id="packetCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Bytes Received</div>
<div class="stat-value" id="byteCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Data Rate</div>
<div class="stat-value" id="dataRate">0 KB/s</div>
</div>
<div class="stat-card">
<div class="stat-label">Valid Packets</div>
<div class="stat-value" id="validPackets">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Point Cloud Pkts</div>
<div class="stat-value" id="pointCloudCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">IMU Packets</div>
<div class="stat-value" id="imuCount">0</div>
</div>
</div>
</div>
<div class="panel">
<h2>ℹ️ Protocol Info</h2>
<div class="protocol-info">
<h3>Unitree Lidar Packet Structure:</h3>
<ul>
<li><strong>Header:</strong> 0xAA 0x55 (2 bytes)</li>
<li><strong>Version:</strong> Protocol version (1 byte)</li>
<li><strong>Length:</strong> Packet length (2 bytes, little-endian)</li>
<li><strong>Type:</strong> Message type (1 byte)</li>
<li><strong>Payload:</strong> Variable length data</li>
<li><strong>CRC:</strong> Checksum (2 bytes)</li>
</ul>
<h3 style="margin-top: 10px;">Message Types:</h3>
<ul>
<li>0x00: Point Cloud Data (3D)</li>
<li>0x01: Point Cloud Data (2D)</li>
<li>0x10: IMU Data</li>
<li>0x20: Status/Config</li>
<li>0xF0: Heartbeat</li>
</ul>
</div>
</div>
<div class="panel">
<h2>📡 Raw Data Stream</h2>
<div class="output-area" id="rawOutput">
<div style="color: #858585;">Waiting for connection...</div>
</div>
</div>
<div class="panel">
<h2>🔍 Decoded Packets</h2>
<div class="output-area" id="decodedOutput">
<div style="color: #858585;">Waiting for data...</div>
</div>
</div>
<div class="panel">
<h2>📍 Point Cloud Data</h2>
<div class="output-area" id="pointCloudOutput">
<div style="color: #858585;">No point cloud data yet...</div>
</div>
</div>
<div class="panel">
<h2>🎯 IMU Data</h2>
<div class="output-area" id="imuOutput">
<div style="color: #858585;">No IMU data yet...</div>
</div>
</div>
<div class="panel full-width">
<h2>💾 Activity Log</h2>
<div class="output-area" id="logOutput">
<div style="color: #858585;">Ready to log activity...</div>
</div>
</div>
</div>
</div>
<script>
let port;
let reader;
let keepReading = false;
let buffer = new Uint8Array(0);
// Statistics
let stats = {
packets: 0,
bytes: 0,
validPackets: 0,
pointCloud: 0,
imu: 0,
startTime: null,
lastUpdate: Date.now()
};
// UI Elements
const elements = {
connectBtn: document.getElementById('connectBtn'),
disconnectBtn: document.getElementById('disconnectBtn'),
clearBtn: document.getElementById('clearBtn'),
status: document.getElementById('status'),
statusText: document.getElementById('statusText'),
rawOutput: document.getElementById('rawOutput'),
decodedOutput: document.getElementById('decodedOutput'),
pointCloudOutput: document.getElementById('pointCloudOutput'),
imuOutput: document.getElementById('imuOutput'),
logOutput: document.getElementById('logOutput'),
packetCount: document.getElementById('packetCount'),
byteCount: document.getElementById('byteCount'),
validPackets: document.getElementById('validPackets'),
pointCloudCount: document.getElementById('pointCloudCount'),
imuCount: document.getElementById('imuCount'),
dataRate: document.getElementById('dataRate'),
statsTime: document.getElementById('statsTime')
};
// Event Listeners
elements.connectBtn.addEventListener('click', connectToLidar);
elements.disconnectBtn.addEventListener('click', disconnectFromLidar);
elements.clearBtn.addEventListener('click', clearLogs);
async function connectToLidar() {
try {
addLog('Requesting serial port...');
port = await navigator.serial.requestPort();
await port.open({
baudRate: 230400,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none"
});
elements.connectBtn.disabled = true;
elements.disconnectBtn.disabled = false;
elements.status.className = 'status connected';
elements.statusText.textContent = 'Connected';
stats.startTime = Date.now();
addLog('✓ Connected successfully! Baud: 230400, 8N1');
addLog('⟳ Starting data reception...');
keepReading = true;
readData();
updateStats();
} catch (error) {
addLog('✗ Error: ' + error.message, 'error');
console.error('Connection error:', error);
}
}
async function disconnectFromLidar() {
keepReading = false;
if (reader) {
try {
await reader.cancel();
} catch (e) {}
}
if (port) {
try {
await port.close();
} catch (e) {}
}
elements.connectBtn.disabled = false;
elements.disconnectBtn.disabled = true;
elements.status.className = 'status disconnected';
elements.statusText.textContent = 'Disconnected';
addLog('✓ Disconnected from Lidar');
}
async function readData() {
try {
reader = port.readable.getReader();
while (keepReading) {
const { value, done } = await reader.read();
if (done) break;
stats.bytes += value.length;
// Append to buffer
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
// Display raw data
displayRawData(value);
// Try to parse packets
parseBuffer();
}
} catch (error) {
addLog('✗ Read error: ' + error.message, 'error');
console.error('Read error:', error);
} finally {
if (reader) {
reader.releaseLock();
}
}
}
function parseBuffer() {
while (buffer.length >= 6) {
let headerIndex = -1;
// Look for 0xAA 0x55 header
for (let i = 0; i <= buffer.length - 2; i++) {
if (buffer[i] === 0xAA && buffer[i + 1] === 0x55) {
headerIndex = i;
break;
}
}
if (headerIndex === -1) {
// No header found, keep last byte
if (buffer.length > 0) {
buffer = buffer.slice(-1);
}
break;
}
// Move to header position
if (headerIndex > 0) {
buffer = buffer.slice(headerIndex);
}
// Check minimum packet size
if (buffer.length < 8) break;
// Parse packet header
const version = buffer[2];
const length = buffer[3] | (buffer[4] << 8);
const msgType = buffer[5];
// Validate packet length
if (length > 2000 || length < 8) {
// Invalid length, skip this header
buffer = buffer.slice(2);
continue;
}
const totalLength = length;
if (buffer.length < totalLength) {
// Wait for more data
break;
}
// Extract packet
const packet = buffer.slice(0, totalLength);
// Verify CRC if present
if (totalLength >= 8) {
const crcExpected = packet[totalLength - 2] | (packet[totalLength - 1] << 8);
const crcCalculated = calculateCRC16(packet.slice(0, totalLength - 2));
if (crcExpected === crcCalculated) {
stats.validPackets++;
decodePacket(packet, msgType, version);
} else {
addLog(`⚠ CRC mismatch: expected 0x${crcExpected.toString(16)}, got 0x${crcCalculated.toString(16)}`);
}
} else {
decodePacket(packet, msgType, version);
}
// Remove processed packet
buffer = buffer.slice(totalLength);
stats.packets++;
}
}
function calculateCRC16(data) {
let crc = 0xFFFF;
for (let i = 0; i < data.length; i++) {
crc ^= data[i];
for (let j = 0; j < 8; j++) {
if (crc & 0x0001) {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
function decodePacket(packet, msgType, version) {
const time = new Date().toLocaleTimeString();
let typeName = 'Unknown';
let typeColor = '#ce9178';
// Decode based on message type
if (msgType === 0x00 || msgType === 0x01) {
typeName = msgType === 0x00 ? '3D Point Cloud' : '2D Point Cloud';
typeColor = '#4ec9b0';
stats.pointCloud++;
decodePointCloud(packet, msgType);
} else if (msgType === 0x10) {
typeName = 'IMU Data';
typeColor = '#dcdcaa';
stats.imu++;
decodeIMU(packet);
} else if (msgType === 0x20) {
typeName = 'Status/Config';
typeColor = '#569cd6';
} else if (msgType === 0xF0) {
typeName = 'Heartbeat';
typeColor = '#858585';
}
const html = `
<div class="log-entry">
<span class="log-time">${time}</span>
<span style="color: ${typeColor}; font-weight: bold;"> ${typeName}</span>
<div style="padding-left: 20px; color: #d7ba7d;">
Ver: ${version} | Type: 0x${msgType.toString(16).padStart(2, '0')} | Len: ${packet.length}
</div>
</div>
`;
elements.decodedOutput.innerHTML += html;
elements.decodedOutput.scrollTop = elements.decodedOutput.scrollHeight;
const entries = elements.decodedOutput.querySelectorAll('.log-entry');
if (entries.length > 30) entries[0].remove();
}
function decodePointCloud(packet, msgType) {
if (packet.length < 16) return;
const dataStart = 6;
const dataEnd = packet.length - 2;
const payloadSize = dataEnd - dataStart;
// Each point typically: x(2), y(2), z(2), intensity(1) = 7 bytes for 3D
// or x(2), y(2), intensity(1) = 5 bytes for 2D
const pointSize = msgType === 0x00 ? 7 : 5;
const numPoints = Math.floor(payloadSize / pointSize);
if (numPoints > 0 && numPoints < 1000) {
const time = new Date().toLocaleTimeString();
const html = `
<div class="log-entry">
<span class="log-time">${time}</span>
<span style="color: #4ec9b0;"> ${msgType === 0x00 ? '3D' : '2D'} Cloud</span>
<div style="padding-left: 20px; color: #ce9178;">
Points: ${numPoints} | Size: ${payloadSize} bytes
</div>
</div>
`;
elements.pointCloudOutput.innerHTML += html;
elements.pointCloudOutput.scrollTop = elements.pointCloudOutput.scrollHeight;
const entries = elements.pointCloudOutput.querySelectorAll('.log-entry');
if (entries.length > 20) entries[0].remove();
}
}
function decodeIMU(packet) {
if (packet.length < 34) return;
const dataView = new DataView(packet.buffer, packet.byteOffset + 6);
// Assuming standard IMU format: quaternion (4 floats) + accel (3 floats) + gyro (3 floats)
try {
const qx = dataView.getFloat32(0, true);
const qy = dataView.getFloat32(4, true);
const qz = dataView.getFloat32(8, true);
const qw = dataView.getFloat32(12, true);
const time = new Date().toLocaleTimeString();
const html = `
<div class="log-entry">
<span class="log-time">${time}</span>
<span style="color: #dcdcaa;"> IMU Data</span>
<div style="padding-left: 20px; color: #ce9178;">
Q: [${qx.toFixed(3)}, ${qy.toFixed(3)}, ${qz.toFixed(3)}, ${qw.toFixed(3)}]
</div>
</div>
`;
elements.imuOutput.innerHTML += html;
elements.imuOutput.scrollTop = elements.imuOutput.scrollHeight;
const entries = elements.imuOutput.querySelectorAll('.log-entry');
if (entries.length > 15) entries[0].remove();
} catch (e) {
console.error('IMU decode error:', e);
}
}
function displayRawData(data) {
const hex = Array.from(data)
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
const lines = hex.match(/.{1,48}/g) || [];
const html = lines.map(line =>
`<div style="color: #4ec9b0;">${line}</div>`
).join('');
elements.rawOutput.innerHTML += html;
elements.rawOutput.scrollTop = elements.rawOutput.scrollHeight;
const allLines = elements.rawOutput.querySelectorAll('div');
if (allLines.length > 40) {
for (let i = 0; i < allLines.length - 40; i++) {
allLines[i].remove();
}
}
}
function addLog(message, type = 'info') {
const time = new Date().toLocaleTimeString();
const color = type === 'error' ? '#f48771' : '#4ec9b0';
const html = `
<div class="log-entry">
<span class="log-time">${time}</span>
<span style="color: ${color};"> ${message}</span>
</div>
`;
elements.logOutput.innerHTML += html;
elements.logOutput.scrollTop = elements.logOutput.scrollHeight;
}
function updateStats() {
if (!keepReading) return;
elements.packetCount.textContent = stats.packets.toLocaleString();
elements.byteCount.textContent = stats.bytes.toLocaleString();
elements.validPackets.textContent = stats.validPackets.toLocaleString();
elements.pointCloudCount.textContent = stats.pointCloud.toLocaleString();
elements.imuCount.textContent = stats.imu.toLocaleString();
const now = Date.now();
const elapsed = (now - stats.lastUpdate) / 1000;
if (elapsed > 1) {
const rate = (stats.bytes / (now - stats.startTime) * 1000 / 1024).toFixed(2);
elements.dataRate.textContent = rate + ' KB/s';
}
const totalSeconds = Math.floor((now - stats.startTime) / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
elements.statsTime.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
setTimeout(updateStats, 100);
}
function clearLogs() {
elements.rawOutput.innerHTML = '<div style="color: #858585;">Logs cleared...</div>';
elements.decodedOutput.innerHTML = '<div style="color: #858585;">Logs cleared...</div>';
elements.pointCloudOutput.innerHTML = '<div style="color: #858585;">Logs cleared...</div>';
elements.imuOutput.innerHTML = '<div style="color: #858585;">Logs cleared...</div>';
elements.logOutput.innerHTML = '<div style="color: #858585;">Logs cleared...</div>';
}
// Check for Web Serial API support
if (!('serial' in navigator)) {
addLog('✗ Web Serial API not supported in this browser', 'error');
elements.connectBtn.disabled = true;
elements.connectBtn.textContent = 'Not Supported';
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment