Created
October 18, 2025 01:44
-
-
Save VictorXLR/6a5aaf93204b5f4005acdfaee2a6860f to your computer and use it in GitHub Desktop.
lidar-ros.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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