mini-finder / index.html
cduss's picture
wip
fbc565a
<!DOCTYPE html>
<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>