Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Reachy Mini Finder</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 20px; | |
| } | |
| .container { | |
| background: white; | |
| border-radius: 20px; | |
| padding: 40px; | |
| max-width: 500px; | |
| width: 100%; | |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); | |
| } | |
| h1 { | |
| color: #333; | |
| margin-bottom: 10px; | |
| font-size: 28px; | |
| } | |
| .subtitle { | |
| color: #666; | |
| margin-bottom: 30px; | |
| font-size: 14px; | |
| } | |
| .status { | |
| background: #f5f5f5; | |
| padding: 20px; | |
| border-radius: 10px; | |
| margin-bottom: 20px; | |
| min-height: 120px; | |
| } | |
| .status-text { | |
| color: #333; | |
| line-height: 1.6; | |
| } | |
| .progress-bar { | |
| width: 100%; | |
| height: 6px; | |
| background: #e0e0e0; | |
| border-radius: 3px; | |
| margin-top: 15px; | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #667eea, #764ba2); | |
| width: 0%; | |
| transition: width 0.3s ease; | |
| } | |
| button { | |
| width: 100%; | |
| padding: 15px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| border: none; | |
| border-radius: 10px; | |
| font-size: 16px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: transform 0.2s; | |
| } | |
| button:hover { | |
| transform: translateY(-2px); | |
| } | |
| button:active { | |
| transform: translateY(0); | |
| } | |
| button:disabled { | |
| opacity: 0.6; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| .manual-input { | |
| margin-top: 20px; | |
| padding-top: 20px; | |
| border-top: 1px solid #e0e0e0; | |
| } | |
| .manual-input input { | |
| width: 100%; | |
| padding: 12px; | |
| border: 2px solid #e0e0e0; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| margin-bottom: 10px; | |
| } | |
| .manual-input input:focus { | |
| outline: none; | |
| border-color: #667eea; | |
| } | |
| .ip-list { | |
| max-height: 200px; | |
| overflow-y: auto; | |
| margin-top: 10px; | |
| } | |
| .ip-item { | |
| padding: 8px; | |
| background: #f9f9f9; | |
| margin-bottom: 5px; | |
| border-radius: 5px; | |
| font-size: 12px; | |
| color: #666; | |
| font-family: monospace; | |
| } | |
| .ip-item.checking { | |
| background: #fff3cd; | |
| } | |
| .ip-item.found { | |
| background: #d4edda; | |
| color: #155724; | |
| font-weight: 600; | |
| } | |
| .robot-info { | |
| background: #e7f3ff; | |
| padding: 15px; | |
| border-radius: 8px; | |
| margin-top: 15px; | |
| font-size: 13px; | |
| color: #004085; | |
| } | |
| .robot-info strong { | |
| display: block; | |
| margin-bottom: 5px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>🤖 Reachy Mini Finder</h1> | |
| <p class="subtitle">Automatically detect your Reachy Mini robot on the network</p> | |
| <div class="status"> | |
| <div class="status-text" id="statusText"> | |
| Click "Find Reachy" to start scanning your network. | |
| </div> | |
| <div class="progress-bar"> | |
| <div class="progress-fill" id="progressBar"></div> | |
| </div> | |
| </div> | |
| <button id="scanBtn" onclick="startScan()">Find Reachy</button> | |
| <div class="manual-input"> | |
| <input type="text" id="manualIP" placeholder="Or enter IP manually (e.g., 192.168.1.100)" /> | |
| <button onclick="connectManual()">Connect</button> | |
| </div> | |
| <div class="ip-list" id="ipList"></div> | |
| <div id="robotInfo"></div> | |
| </div> | |
| <script> | |
| const PORT = 8000; | |
| const TIMEOUT_MS = 500; | |
| let scanning = false; | |
| async function getLocalIP() { | |
| return new Promise((resolve, reject) => { | |
| const pc = new RTCPeerConnection({ iceServers: [] }); | |
| pc.createDataChannel(''); | |
| pc.createOffer().then(offer => pc.setLocalDescription(offer)); | |
| pc.onicecandidate = (ice) => { | |
| if (!ice || !ice.candidate || !ice.candidate.candidate) return; | |
| const match = /([0-9]{1,3}\.){3}[0-9]{1,3}/.exec(ice.candidate.candidate); | |
| if (match) { | |
| pc.close(); | |
| resolve(match[0]); | |
| } | |
| }; | |
| setTimeout(() => { | |
| pc.close(); | |
| reject(new Error('Could not detect local IP')); | |
| }, 5000); | |
| }); | |
| } | |
| async function tryIP(ip) { | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); | |
| try { | |
| const response = await fetch(`http://${ip}:${PORT}/api/daemon/status`, { | |
| signal: controller.signal, | |
| mode: 'cors' | |
| }); | |
| clearTimeout(timeoutId); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| return { ip, data }; | |
| } | |
| } catch (err) { | |
| // Timeout or connection refused | |
| } | |
| clearTimeout(timeoutId); | |
| return null; | |
| } | |
| function updateStatus(text, progress = null) { | |
| document.getElementById('statusText').innerHTML = text; | |
| if (progress !== null) { | |
| document.getElementById('progressBar').style.width = progress + '%'; | |
| } | |
| } | |
| function addIPToList(ip, status = 'checking') { | |
| const ipList = document.getElementById('ipList'); | |
| const existing = document.getElementById('ip-' + ip.replace(/\./g, '-')); | |
| if (existing) { | |
| existing.className = 'ip-item ' + status; | |
| } else { | |
| const item = document.createElement('div'); | |
| item.id = 'ip-' + ip.replace(/\./g, '-'); | |
| item.className = 'ip-item ' + status; | |
| item.textContent = status === 'found' ? `✓ Found: ${ip}` : `Checking: ${ip}`; | |
| ipList.appendChild(item); | |
| } | |
| } | |
| function showRobotInfo(ip, data) { | |
| const infoDiv = document.getElementById('robotInfo'); | |
| infoDiv.innerHTML = ` | |
| <div class="robot-info"> | |
| <strong>✅ Reachy Mini Found!</strong> | |
| IP: ${ip}<br> | |
| Server ID: ${data.server_id || 'N/A'}<br> | |
| Version: ${data.version || 'N/A'} | |
| </div> | |
| `; | |
| } | |
| async function startScan() { | |
| if (scanning) return; | |
| scanning = true; | |
| const btn = document.getElementById('scanBtn'); | |
| btn.disabled = true; | |
| btn.textContent = 'Scanning...'; | |
| document.getElementById('ipList').innerHTML = ''; | |
| document.getElementById('robotInfo').innerHTML = ''; | |
| try { | |
| updateStatus('🔍 Detecting your phone\'s IP address...', 5); | |
| const phoneIP = await getLocalIP(); | |
| const subnet = phoneIP.split('.').slice(0, 3).join('.'); | |
| updateStatus(`📡 Phone IP: ${phoneIP}<br>Scanning subnet: ${subnet}.0/24`, 10); | |
| // Priority IPs to check first | |
| const priorityIPs = [ | |
| `${subnet}.1`, // Router | |
| `${subnet}.100`, // Common DHCP start | |
| ]; | |
| // Build full scan list, excluding phone's IP | |
| const allIPs = []; | |
| for (let i = 1; i < 255; i++) { | |
| const ip = `${subnet}.${i}`; | |
| if (ip !== phoneIP) { | |
| allIPs.push(ip); | |
| } | |
| } | |
| // Put priority IPs first | |
| const scanList = [ | |
| ...priorityIPs.filter(ip => allIPs.includes(ip)), | |
| ...allIPs.filter(ip => !priorityIPs.includes(ip)) | |
| ]; | |
| // Scan in batches of 20 for better responsiveness | |
| const BATCH_SIZE = 20; | |
| let scanned = 0; | |
| for (let i = 0; i < scanList.length; i += BATCH_SIZE) { | |
| const batch = scanList.slice(i, i + BATCH_SIZE); | |
| // Show which IPs we're checking | |
| batch.forEach(ip => addIPToList(ip, 'checking')); | |
| const results = await Promise.all(batch.map(tryIP)); | |
| const found = results.find(r => r !== null); | |
| scanned += batch.length; | |
| const progress = 10 + (scanned / scanList.length) * 90; | |
| updateStatus( | |
| `🔍 Scanning: ${scanned}/${scanList.length} IPs checked...`, | |
| progress | |
| ); | |
| if (found) { | |
| addIPToList(found.ip, 'found'); | |
| showRobotInfo(found.ip, found.data); | |
| updateStatus( | |
| `✅ Reachy Mini found at ${found.ip}!<br>Redirecting...`, | |
| 100 | |
| ); | |
| setTimeout(() => { | |
| window.location.href = `http://${found.ip}:${PORT}`; | |
| }, 2000); | |
| return; | |
| } | |
| } | |
| updateStatus( | |
| `❌ No Reachy Mini found on ${subnet}.0/24<br>Please check:<br> | |
| • Robot is powered on<br> | |
| • Connected to same WiFi<br> | |
| • Daemon running on port ${PORT}<br> | |
| • Try manual IP below`, | |
| 100 | |
| ); | |
| } catch (err) { | |
| updateStatus( | |
| `⚠️ Error: ${err.message}<br>Try entering IP manually below`, | |
| 0 | |
| ); | |
| } finally { | |
| scanning = false; | |
| btn.disabled = false; | |
| btn.textContent = 'Scan Again'; | |
| } | |
| } | |
| function connectManual() { | |
| const ip = document.getElementById('manualIP').value.trim(); | |
| if (!ip) { | |
| alert('Please enter an IP address'); | |
| return; | |
| } | |
| // Validate IP format | |
| const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/; | |
| if (!ipRegex.test(ip)) { | |
| alert('Invalid IP format. Example: 192.168.1.100'); | |
| return; | |
| } | |
| updateStatus(`🔗 Connecting to ${ip}...`, 50); | |
| window.location.href = `http://${ip}:${PORT}`; | |
| } | |
| // Allow Enter key in manual input | |
| document.getElementById('manualIP').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') { | |
| connectManual(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |