|
|
<!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="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"> |
|
|
<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> |
|
|
|
|
|
<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 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); |
|
|
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; |
|
|
} |
|
|
|
|
|
try { |
|
|
const encoder = new TextEncoder(); |
|
|
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) { |
|
|
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> |