Created
October 18, 2025 16:22
-
-
Save VictorXLR/93645d1a0a400c56e9c634637c26e929 to your computer and use it in GitHub Desktop.
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 - 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