| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <meta http-equiv="Permissions-Policy" content="bluetooth=*"> |
| <title>ReachyMini Controller</title> |
| <style> |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 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; |
| padding: 40px; |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); |
| max-width: 500px; |
| width: 100%; |
| } |
| |
| h1 { |
| color: #333; |
| margin-bottom: 10px; |
| font-size: 28px; |
| } |
| |
| .status { |
| padding: 12px; |
| border-radius: 8px; |
| margin: 20px 0; |
| font-weight: 500; |
| text-align: center; |
| } |
| |
| .status.disconnected { |
| background: #fee; |
| color: #c33; |
| } |
| |
| .status.connected { |
| background: #efe; |
| color: #3c3; |
| } |
| |
| .status.connecting { |
| background: #ffeaa7; |
| color: #d63031; |
| } |
| |
| .connect-btn { |
| width: 100%; |
| padding: 15px; |
| background: #667eea; |
| color: white; |
| border: none; |
| border-radius: 10px; |
| font-size: 16px; |
| font-weight: 600; |
| cursor: pointer; |
| transition: all 0.3s; |
| margin-bottom: 30px; |
| } |
| |
| .connect-btn:hover { |
| background: #5568d3; |
| transform: translateY(-2px); |
| box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); |
| } |
| |
| .connect-btn:disabled { |
| background: #ccc; |
| cursor: not-allowed; |
| transform: none; |
| } |
| |
| .commands { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); |
| gap: 15px; |
| } |
| |
| .command-btn { |
| padding: 20px; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| color: white; |
| border: none; |
| border-radius: 10px; |
| font-size: 14px; |
| font-weight: 600; |
| cursor: pointer; |
| transition: all 0.3s; |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| } |
| |
| .command-btn:hover:not(:disabled) { |
| transform: translateY(-3px); |
| box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); |
| } |
| |
| .command-btn:active:not(:disabled) { |
| transform: translateY(-1px); |
| } |
| |
| .command-btn:disabled { |
| opacity: 0.5; |
| cursor: not-allowed; |
| transform: none; |
| } |
| |
| .command-btn.danger { |
| background: linear-gradient(135deg, #ee5a6f 0%, #f29263 100%); |
| } |
| |
| .command-btn.warning { |
| background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); |
| } |
| |
| .log { |
| margin-top: 30px; |
| padding: 15px; |
| background: #f8f9fa; |
| border-radius: 8px; |
| max-height: 200px; |
| overflow-y: auto; |
| font-size: 12px; |
| font-family: 'Courier New', monospace; |
| } |
| |
| .log-entry { |
| padding: 5px 0; |
| border-bottom: 1px solid #e9ecef; |
| } |
| |
| .log-entry:last-child { |
| border-bottom: none; |
| } |
| |
| .log-entry.error { |
| color: #c33; |
| } |
| |
| .log-entry.success { |
| color: #3c3; |
| } |
| |
| .response-box { |
| margin-top: 20px; |
| padding: 15px; |
| background: #e7f3ff; |
| border-left: 4px solid #2196F3; |
| border-radius: 4px; |
| font-size: 14px; |
| font-family: 'Courier New', monospace; |
| display: none; |
| } |
| |
| .response-box.show { |
| display: block; |
| } |
| |
| .note { |
| margin-top: 20px; |
| padding: 15px; |
| background: #fff3cd; |
| border-left: 4px solid #ffc107; |
| border-radius: 4px; |
| font-size: 14px; |
| color: #856404; |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <div class="container"> |
| <h1>🤖 ReachyMini Controller</h1> |
|
|
| <div id="status" class="status disconnected"> |
| Disconnected |
| </div> |
|
|
| <button id="connectBtn" class="connect-btn"> |
| Connect to ReachyMini |
| </button> |
|
|
| <div class="note"> |
| <strong>PIN Code Required:</strong> Enter the last 5 digits of your robot's serial number. |
| <br><small>To find your serial number, run: <code>dfu-util -l</code> on the robot.</small> |
| <br><small>Default PIN for testing: 00018</small> |
| </div> |
|
|
| <div style="margin: 20px 0;"> |
| <label for="pinInput" style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;"> |
| PIN Code (5 digits): |
| </label> |
| <input |
| type="text" |
| id="pinInput" |
| placeholder="e.g., 00018" |
| maxlength="5" |
| pattern="[0-9]{5}" |
| style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 8px; font-size: 16px; font-family: 'Courier New', monospace;" |
| > |
| <small style="color: #666; display: block; margin-top: 5px;">PIN must be sent before each command</small> |
| </div> |
|
|
| <div class="commands"> |
| <button class="command-btn" data-command="PING" disabled>Ping</button> |
| <button class="command-btn" data-command="STATUS" disabled>Status</button> |
| <button class="command-btn" data-command="CMD_HOTSPOT" disabled>Hotspot</button> |
| <button class="command-btn danger" data-command="CMD_RESTART_DAEMON" disabled>Restart Daemon</button> |
| <button class="command-btn warning" data-command="CMD_SOFTWARE_RESET" disabled>Software Reset</button> |
| </div> |
|
|
| <div id="responseBox" class="response-box"></div> |
|
|
| <div class="log" id="log"></div> |
|
|
| <div class="note" style="margin-top: 20px;"> |
| <strong>Note:</strong> This requires HTTPS and a Chromium-based browser with Web Bluetooth enabled. Access |
| from your phone's hotspot for best results. |
| </div> |
|
|
| <div class="note" style="margin-top: 10px; background: #e7f3ff; border-left-color: #2196F3;"> |
| <strong>Expected Response Values:</strong> |
| <ul style="margin: 10px 0 0 20px; font-size: 13px;"> |
| <li><code>OK</code> or <code>SUCCESS</code> - Command executed successfully</li> |
| <li><code>INVALID_PIN</code> or <code>WRONG_PIN</code> - Incorrect PIN code</li> |
| <li><code>ERROR</code> or <code>FAIL</code> - Command failed</li> |
| </ul> |
| </div> |
| </div> |
|
|
| <script> |
| let device = null; |
| let commandCharacteristic = null; |
| let responseCharacteristic = null; |
| const statusEl = document.getElementById('status'); |
| const connectBtn = document.getElementById('connectBtn'); |
| const commandBtns = document.querySelectorAll('.command-btn'); |
| const logEl = document.getElementById('log'); |
| const responseBox = document.getElementById('responseBox'); |
| const pinInput = document.getElementById('pinInput'); |
| |
| |
| const SERVICE_UUID = '12345678-1234-5678-1234-56789abcdef0'; |
| const COMMAND_CHAR_UUID = '12345678-1234-5678-1234-56789abcdef1'; |
| const RESPONSE_CHAR_UUID = '12345678-1234-5678-1234-56789abcdef2'; |
| |
| |
| if (!navigator.bluetooth) { |
| updateStatus('Web Bluetooth not supported', 'disconnected'); |
| addLog('ERROR: Web Bluetooth API not available in this browser', 'error'); |
| connectBtn.disabled = true; |
| } |
| |
| function addLog(message, type = '') { |
| const entry = document.createElement('div'); |
| entry.className = `log-entry ${type}`; |
| const timestamp = new Date().toLocaleTimeString(); |
| entry.textContent = `[${timestamp}] ${message}`; |
| logEl.appendChild(entry); |
| logEl.scrollTop = logEl.scrollHeight; |
| } |
| |
| function updateStatus(message, state) { |
| statusEl.textContent = message; |
| statusEl.className = `status ${state}`; |
| } |
| |
| function showResponse(message) { |
| responseBox.textContent = `Response: ${message}`; |
| responseBox.classList.add('show'); |
| setTimeout(() => { |
| responseBox.classList.remove('show'); |
| }, 5000); |
| } |
| |
| async function connectToDevice() { |
| try { |
| updateStatus('Scanning for devices...', 'connecting'); |
| addLog('Requesting Bluetooth device...'); |
| |
| device = await navigator.bluetooth.requestDevice({ |
| filters: [{ name: 'ReachyMini' }], |
| optionalServices: [SERVICE_UUID] |
| }); |
| |
| addLog(`Found device: ${device.name}`); |
| updateStatus('Connecting...', 'connecting'); |
| |
| const server = await device.gatt.connect(); |
| addLog('Connected to GATT server'); |
| |
| |
| const service = await server.getPrimaryService(SERVICE_UUID); |
| addLog('Got service'); |
| |
| |
| commandCharacteristic = await service.getCharacteristic(COMMAND_CHAR_UUID); |
| addLog('Got command characteristic'); |
| |
| |
| responseCharacteristic = await service.getCharacteristic(RESPONSE_CHAR_UUID); |
| addLog('Got response characteristic'); |
| |
| |
| await responseCharacteristic.startNotifications(); |
| responseCharacteristic.addEventListener('characteristicvaluechanged', handleResponse); |
| addLog('Notifications enabled for responses'); |
| |
| updateStatus('Connected to ReachyMini', 'connected'); |
| addLog('Successfully connected!', 'success'); |
| |
| connectBtn.textContent = 'Disconnect'; |
| commandBtns.forEach(btn => btn.disabled = false); |
| |
| device.addEventListener('gattserverdisconnected', onDisconnected); |
| |
| } catch (error) { |
| addLog(`Connection failed: ${error.message}`, 'error'); |
| updateStatus('Connection failed', 'disconnected'); |
| console.error('Connection error:', error); |
| } |
| } |
| |
| function handleResponse(event) { |
| const value = event.target.value; |
| const decoder = new TextDecoder('utf-8'); |
| const response = decoder.decode(value); |
| |
| |
| if (response.includes('INVALID_PIN') || response.includes('WRONG_PIN')) { |
| addLog(`⚠️ Response: ${response} - Check your PIN code!`, 'error'); |
| showResponse(`❌ ${response} - Verify your PIN is correct`); |
| pinInput.style.borderColor = '#c33'; |
| setTimeout(() => pinInput.style.borderColor = '#ddd', 2000); |
| } else if (response.includes('ERROR') || response.includes('FAIL')) { |
| addLog(`⚠️ Response: ${response}`, 'error'); |
| showResponse(`❌ ${response}`); |
| } else if (response.includes('OK') || response.includes('SUCCESS')) { |
| addLog(`✓ Response: ${response}`, 'success'); |
| showResponse(`✓ ${response}`); |
| } else { |
| addLog(`Response: ${response}`, 'success'); |
| showResponse(response); |
| } |
| } |
| |
| function onDisconnected() { |
| updateStatus('Disconnected', 'disconnected'); |
| addLog('Device disconnected', 'error'); |
| connectBtn.textContent = 'Connect to ReachyMini'; |
| commandBtns.forEach(btn => btn.disabled = true); |
| commandCharacteristic = null; |
| responseCharacteristic = null; |
| device = null; |
| } |
| |
| async function disconnect() { |
| if (device && device.gatt.connected) { |
| device.gatt.disconnect(); |
| addLog('Manually disconnected'); |
| } |
| } |
| |
| async function sendCommand(command) { |
| if (!commandCharacteristic) { |
| addLog('Not connected to device', 'error'); |
| return; |
| } |
| |
| |
| const pin = pinInput.value.trim(); |
| if (!pin || pin.length !== 5 || !/^\d{5}$/.test(pin)) { |
| addLog('Invalid PIN: Must be exactly 5 digits', 'error'); |
| pinInput.style.borderColor = '#c33'; |
| setTimeout(() => pinInput.style.borderColor = '#ddd', 2000); |
| return; |
| } |
| |
| try { |
| const encoder = new TextEncoder(); |
| |
| |
| const pinCommand = `PIN_${pin}`; |
| addLog(`Sending PIN authentication: ${pinCommand}`, ''); |
| const pinData = encoder.encode(pinCommand); |
| await commandCharacteristic.writeValue(pinData); |
| |
| |
| await new Promise(resolve => setTimeout(resolve, 200)); |
| |
| |
| const data = encoder.encode(command); |
| await commandCharacteristic.writeValue(data); |
| addLog(`Sent command: ${command}`, 'success'); |
| |
| |
| try { |
| const value = await responseCharacteristic.readValue(); |
| const decoder = new TextDecoder('utf-8'); |
| const response = decoder.decode(value); |
| if (response) { |
| |
| if (response.includes('INVALID_PIN') || response.includes('WRONG_PIN') || response.includes('ERROR')) { |
| addLog(`Response: ${response}`, 'error'); |
| showResponse(`❌ ${response}`); |
| } else { |
| addLog(`Response: ${response}`, 'success'); |
| showResponse(`✓ ${response}`); |
| } |
| } |
| } catch (readError) { |
| |
| addLog('Waiting for response notification...', ''); |
| } |
| } catch (error) { |
| addLog(`Failed to send command: ${error.message}`, 'error'); |
| console.error('Send error:', error); |
| } |
| } |
| |
| connectBtn.addEventListener('click', async () => { |
| if (device && device.gatt.connected) { |
| await disconnect(); |
| } else { |
| await connectToDevice(); |
| } |
| }); |
| |
| commandBtns.forEach(btn => { |
| btn.addEventListener('click', () => { |
| const command = btn.dataset.command; |
| sendCommand(command); |
| }); |
| }); |
| |
| addLog('Ready. Click "Connect to ReachyMini" to start.'); |
| </script> |
| </body> |
|
|
| </html> |