Skip to content

Instantly share code, notes, and snippets.

@VictorXLR
Created October 18, 2025 01:44
Show Gist options
  • Select an option

  • Save VictorXLR/6a5aaf93204b5f4005acdfaee2a6860f to your computer and use it in GitHub Desktop.

Select an option

Save VictorXLR/6a5aaf93204b5f4005acdfaee2a6860f to your computer and use it in GitHub Desktop.
lidar-ros.html
<!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</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;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 1400px;
width: 100%;
overflow: hidden;
}
.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-danger:hover:not(:disabled) {
background: #c82333;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
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;
}
.status.disconnected .status-dot {
background: #dc3545;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.content {
display: grid;
grid-template-columns: 1fr 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: 12px;
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(2, 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;
}
.log-data {
color: #ce9178;
}
.point-cloud {
height: 400px;
background: #f8f9fa;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔬 Unitree Lidar Serial Decoder</h1>
<p>Real-time serial data decoder for Unitree L1/L2 LiDAR sensors</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">Points Decoded</div>
<div class="stat-value" id="pointCount">0</div>
</div>
</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 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,
points: 0,
startTime: null,
lastUpdate: Date.now()
};
// UI Elements
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const clearBtn = document.getElementById('clearBtn');
const statusEl = document.getElementById('status');
const statusText = document.getElementById('statusText');
const rawOutput = document.getElementById('rawOutput');
const decodedOutput = document.getElementById('decodedOutput');
const logOutput = document.getElementById('logOutput');
const packetCount = document.getElementById('packetCount');
const byteCount = document.getElementById('byteCount');
const pointCount = document.getElementById('pointCount');
const dataRate = document.getElementById('dataRate');
const statsTime = document.getElementById('statsTime');
// Event Listeners
connectBtn.addEventListener('click', connectToLidar);
disconnectBtn.addEventListener('click', disconnectFromLidar);
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"
});
connectBtn.disabled = true;
disconnectBtn.disabled = false;
statusEl.className = 'status connected';
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) {}
}
connectBtn.disabled = false;
disconnectBtn.disabled = true;
statusEl.className = 'status disconnected';
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() {
// Look for packet headers
// Unitree protocol typically starts with sync bytes
// Common patterns: 0xAA 0x55 or 0x55 0xAA
while (buffer.length >= 8) {
let headerFound = false;
let headerIndex = -1;
// Search for header pattern
for (let i = 0; i < buffer.length - 1; i++) {
if ((buffer[i] === 0xAA && buffer[i+1] === 0x55) ||
(buffer[i] === 0x55 && buffer[i+1] === 0xAA)) {
headerFound = true;
headerIndex = i;
break;
}
}
if (!headerFound) {
// No header found, keep last byte in case it's start of header
if (buffer.length > 0) {
buffer = buffer.slice(-1);
}
break;
}
// Move to header position
if (headerIndex > 0) {
buffer = buffer.slice(headerIndex);
}
// Check if we have enough data for header
if (buffer.length < 8) break;
// Try to parse packet
const packetLength = buffer[2] | (buffer[3] << 8);
const totalLength = packetLength + 8; // header + data + checksum
if (buffer.length < totalLength) {
// Wait for more data
break;
}
// Extract packet
const packet = buffer.slice(0, totalLength);
decodePacket(packet);
// Remove processed packet from buffer
buffer = buffer.slice(totalLength);
stats.packets++;
}
}
function decodePacket(packet) {
const header1 = packet[0];
const header2 = packet[1];
const length = packet[2] | (packet[3] << 8);
const msgType = packet[4];
const flags = packet[5];
let decoded = {
header: `0x${header1.toString(16).padStart(2, '0')} 0x${header2.toString(16).padStart(2, '0')}`,
length: length,
type: msgType,
flags: flags,
timestamp: Date.now()
};
// Decode message type
let typeName = 'Unknown';
if (msgType === 0x01) typeName = 'Point Cloud';
else if (msgType === 0x02) typeName = 'IMU Data';
else if (msgType === 0x03) typeName = 'Status';
else if (msgType === 0x10) typeName = 'Config';
decoded.typeName = typeName;
// Parse point cloud data if applicable
if (msgType === 0x01 && packet.length > 16) {
const numPoints = Math.floor((packet.length - 16) / 8);
stats.points += numPoints;
decoded.points = numPoints;
}
displayDecodedPacket(decoded);
}
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('');
rawOutput.innerHTML += html;
rawOutput.scrollTop = rawOutput.scrollHeight;
// Keep only last 50 lines
const allLines = rawOutput.querySelectorAll('div');
if (allLines.length > 50) {
for (let i = 0; i < allLines.length - 50; i++) {
allLines[i].remove();
}
}
}
function displayDecodedPacket(packet) {
const time = new Date().toLocaleTimeString();
const html = `
<div class="log-entry">
<span class="log-time">${time}</span>
<span class="log-type"> ${packet.typeName}</span>
<div style="padding-left: 20px; color: #d7ba7d;">
Length: ${packet.length} | Type: 0x${packet.type.toString(16)}
${packet.points ? ` | Points: ${packet.points}` : ''}
</div>
</div>
`;
decodedOutput.innerHTML += html;
decodedOutput.scrollTop = decodedOutput.scrollHeight;
// Keep only last 20 packets
const entries = decodedOutput.querySelectorAll('.log-entry');
if (entries.length > 20) {
entries[0].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>
`;
logOutput.innerHTML += html;
logOutput.scrollTop = logOutput.scrollHeight;
}
function updateStats() {
if (!keepReading) return;
packetCount.textContent = stats.packets.toLocaleString();
byteCount.textContent = stats.bytes.toLocaleString();
pointCount.textContent = stats.points.toLocaleString();
// Calculate data rate
const now = Date.now();
const elapsed = (now - stats.lastUpdate) / 1000;
if (elapsed > 1) {
const rate = (stats.bytes / (now - stats.startTime) * 1000 / 1024).toFixed(2);
dataRate.textContent = rate + ' KB/s';
}
// Update time
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;
statsTime.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
setTimeout(updateStats, 100);
}
function clearLogs() {
rawOutput.innerHTML = '<div style="color: #858585;">Logs cleared...</div>';
decodedOutput.innerHTML = '<div style="color: #858585;">Logs cleared...</div>';
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');
connectBtn.disabled = true;
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