Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Web Bluetooth FF1 pairing prototype

+636
+636
system/public/kidlisp.com/bluetooth.html
··· 1 + <!DOCTYPE html> 2 + <!-- KidLisp.com Web Bluetooth FF1 Pairing Prototype --> 3 + <!-- Test page for pairing FF1 devices via Web Bluetooth API --> 4 + <html> 5 + <head> 6 + <meta charset="utf-8"> 7 + <title>KidLisp · FF1 Bluetooth Pairing</title> 8 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 9 + <style> 10 + * { margin: 0; padding: 0; box-sizing: border-box; } 11 + 12 + body { 13 + font-family: system-ui, -apple-system, sans-serif; 14 + background: #111; 15 + color: #fff; 16 + min-height: 100vh; 17 + padding: 2rem; 18 + } 19 + 20 + h1 { 21 + font-size: 1.5rem; 22 + margin-bottom: 0.5rem; 23 + color: #8ff; 24 + } 25 + 26 + .subtitle { 27 + color: #888; 28 + margin-bottom: 2rem; 29 + font-size: 0.9rem; 30 + } 31 + 32 + .warning { 33 + background: #331; 34 + border: 1px solid #663; 35 + padding: 1rem; 36 + border-radius: 8px; 37 + margin-bottom: 2rem; 38 + font-size: 0.85rem; 39 + color: #fc8; 40 + } 41 + 42 + .section { 43 + background: #1a1a1a; 44 + border-radius: 12px; 45 + padding: 1.5rem; 46 + margin-bottom: 1.5rem; 47 + } 48 + 49 + .section h2 { 50 + font-size: 1rem; 51 + color: #aaa; 52 + margin-bottom: 1rem; 53 + text-transform: uppercase; 54 + letter-spacing: 0.05em; 55 + } 56 + 57 + button { 58 + background: #333; 59 + color: #fff; 60 + border: none; 61 + padding: 0.75rem 1.5rem; 62 + border-radius: 8px; 63 + font-size: 1rem; 64 + cursor: pointer; 65 + transition: all 0.15s; 66 + margin-right: 0.5rem; 67 + margin-bottom: 0.5rem; 68 + } 69 + 70 + button:hover:not(:disabled) { 71 + background: #444; 72 + } 73 + 74 + button:disabled { 75 + opacity: 0.4; 76 + cursor: not-allowed; 77 + } 78 + 79 + button.primary { 80 + background: #28a; 81 + color: #fff; 82 + } 83 + 84 + button.primary:hover:not(:disabled) { 85 + background: #39b; 86 + } 87 + 88 + button.success { 89 + background: #282; 90 + } 91 + 92 + button.danger { 93 + background: #822; 94 + } 95 + 96 + .status { 97 + display: flex; 98 + align-items: center; 99 + gap: 0.5rem; 100 + margin-top: 1rem; 101 + font-size: 0.9rem; 102 + } 103 + 104 + .status-dot { 105 + width: 10px; 106 + height: 10px; 107 + border-radius: 50%; 108 + background: #444; 109 + } 110 + 111 + .status-dot.connected { background: #4c4; } 112 + .status-dot.connecting { background: #cc4; animation: pulse 1s infinite; } 113 + .status-dot.error { background: #c44; } 114 + 115 + @keyframes pulse { 116 + 0%, 100% { opacity: 1; } 117 + 50% { opacity: 0.4; } 118 + } 119 + 120 + #log { 121 + background: #0a0a0a; 122 + border-radius: 8px; 123 + padding: 1rem; 124 + font-family: 'SF Mono', Monaco, monospace; 125 + font-size: 0.8rem; 126 + line-height: 1.6; 127 + max-height: 300px; 128 + overflow-y: auto; 129 + white-space: pre-wrap; 130 + word-break: break-all; 131 + } 132 + 133 + .log-entry { 134 + padding: 2px 0; 135 + } 136 + 137 + .log-info { color: #8cf; } 138 + .log-success { color: #8f8; } 139 + .log-error { color: #f88; } 140 + .log-data { color: #fc8; } 141 + 142 + input, select { 143 + background: #222; 144 + border: 1px solid #444; 145 + color: #fff; 146 + padding: 0.5rem 0.75rem; 147 + border-radius: 6px; 148 + font-size: 0.9rem; 149 + width: 100%; 150 + margin-bottom: 0.75rem; 151 + } 152 + 153 + input:focus, select:focus { 154 + outline: none; 155 + border-color: #28a; 156 + } 157 + 158 + label { 159 + display: block; 160 + color: #888; 161 + font-size: 0.8rem; 162 + margin-bottom: 0.25rem; 163 + } 164 + 165 + .input-group { 166 + margin-bottom: 1rem; 167 + } 168 + 169 + .device-info { 170 + background: #0a0a0a; 171 + border-radius: 8px; 172 + padding: 1rem; 173 + font-size: 0.85rem; 174 + } 175 + 176 + .device-info div { 177 + display: flex; 178 + justify-content: space-between; 179 + padding: 0.25rem 0; 180 + border-bottom: 1px solid #222; 181 + } 182 + 183 + .device-info div:last-child { 184 + border-bottom: none; 185 + } 186 + 187 + .device-info .label { color: #888; } 188 + .device-info .value { color: #fff; font-family: monospace; } 189 + 190 + .hidden { display: none !important; } 191 + </style> 192 + </head> 193 + <body> 194 + <h1>🔵 FF1 Bluetooth Pairing</h1> 195 + <p class="subtitle">Web Bluetooth API prototype for Feral File FF1 devices</p> 196 + 197 + <div class="warning"> 198 + ⚠️ <strong>Chrome/Edge only</strong> — Web Bluetooth is not supported in Safari or Firefox. 199 + Device must be in pairing mode (showing setup screen). 200 + </div> 201 + 202 + <!-- Connection Section --> 203 + <div class="section"> 204 + <h2>1. Connect to FF1</h2> 205 + <button id="btn-scan" class="primary">🔍 Scan for FF1 Devices</button> 206 + <button id="btn-disconnect" disabled>Disconnect</button> 207 + 208 + <div class="status"> 209 + <div class="status-dot" id="status-dot"></div> 210 + <span id="status-text">Not connected</span> 211 + </div> 212 + 213 + <div id="device-info" class="device-info hidden" style="margin-top: 1rem;"> 214 + <div><span class="label">Device Name</span><span class="value" id="info-name">—</span></div> 215 + <div><span class="label">Device ID</span><span class="value" id="info-id">—</span></div> 216 + <div><span class="label">Service UUID</span><span class="value" id="info-service">—</span></div> 217 + </div> 218 + </div> 219 + 220 + <!-- WiFi Configuration Section --> 221 + <div class="section" id="wifi-section"> 222 + <h2>2. Configure WiFi</h2> 223 + <div class="input-group"> 224 + <label for="wifi-ssid">WiFi Network (SSID)</label> 225 + <input type="text" id="wifi-ssid" placeholder="Enter WiFi name"> 226 + </div> 227 + <div class="input-group"> 228 + <label for="wifi-password">Password</label> 229 + <input type="password" id="wifi-password" placeholder="Enter WiFi password"> 230 + </div> 231 + <button id="btn-scan-wifi" disabled>📶 Scan Networks</button> 232 + <button id="btn-send-wifi" class="primary" disabled>Send WiFi Credentials</button> 233 + </div> 234 + 235 + <!-- Commands Section --> 236 + <div class="section" id="commands-section"> 237 + <h2>3. Device Commands</h2> 238 + <button id="btn-get-info" disabled>📊 Get Info</button> 239 + <button id="btn-keep-wifi" disabled>🔗 Keep WiFi (Get TopicID)</button> 240 + <button id="btn-set-time" disabled>🕐 Set Timezone</button> 241 + </div> 242 + 243 + <!-- Topic ID Result --> 244 + <div class="section hidden" id="topic-section"> 245 + <h2>✅ Pairing Complete</h2> 246 + <div class="device-info"> 247 + <div><span class="label">Topic ID</span><span class="value" id="topic-id">—</span></div> 248 + </div> 249 + <p style="margin-top: 1rem; color: #888; font-size: 0.85rem;"> 250 + Use this Topic ID with the HTTP casting API: 251 + <code style="color: #fc8;">POST /api/cast?topicID={id}</code> 252 + </p> 253 + </div> 254 + 255 + <!-- Log Section --> 256 + <div class="section"> 257 + <h2>Debug Log</h2> 258 + <div id="log"></div> 259 + <button id="btn-clear-log" style="margin-top: 0.75rem;">Clear Log</button> 260 + </div> 261 + 262 + <script> 263 + // ═══════════════════════════════════════════════════════════════ 264 + // FF1 Bluetooth Protocol Constants (from feralfile-app source) 265 + // ═══════════════════════════════════════════════════════════════ 266 + 267 + const FF1_SERVICE_UUID = 'f7826da6-4fa2-4e98-8024-bc5b71e0893e'; 268 + const FF1_CHAR_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; 269 + 270 + // Commands (from BluetoothCommand enum) 271 + const COMMANDS = { 272 + CONNECT_WIFI: 'connect_wifi', 273 + SCAN_WIFI: 'scan_wifi', 274 + KEEP_WIFI: 'keep_wifi', 275 + FACTORY_RESET: 'factory_reset', 276 + SEND_LOG: 'send_log', 277 + SET_TIME: 'set_time', 278 + GET_INFO: 'get_info' 279 + }; 280 + 281 + // ═══════════════════════════════════════════════════════════════ 282 + // State 283 + // ═══════════════════════════════════════════════════════════════ 284 + 285 + let device = null; 286 + let server = null; 287 + let service = null; 288 + let characteristic = null; 289 + let pendingCallbacks = new Map(); // replyId -> callback 290 + 291 + // ═══════════════════════════════════════════════════════════════ 292 + // UI Elements 293 + // ═══════════════════════════════════════════════════════════════ 294 + 295 + const $ = id => document.getElementById(id); 296 + const btnScan = $('btn-scan'); 297 + const btnDisconnect = $('btn-disconnect'); 298 + const btnScanWifi = $('btn-scan-wifi'); 299 + const btnSendWifi = $('btn-send-wifi'); 300 + const btnGetInfo = $('btn-get-info'); 301 + const btnKeepWifi = $('btn-keep-wifi'); 302 + const btnSetTime = $('btn-set-time'); 303 + const btnClearLog = $('btn-clear-log'); 304 + const statusDot = $('status-dot'); 305 + const statusText = $('status-text'); 306 + const logDiv = $('log'); 307 + 308 + // ═══════════════════════════════════════════════════════════════ 309 + // Logging 310 + // ═══════════════════════════════════════════════════════════════ 311 + 312 + function log(msg, type = 'info') { 313 + const time = new Date().toLocaleTimeString(); 314 + const entry = document.createElement('div'); 315 + entry.className = `log-entry log-${type}`; 316 + entry.textContent = `[${time}] ${msg}`; 317 + logDiv.appendChild(entry); 318 + logDiv.scrollTop = logDiv.scrollHeight; 319 + console.log(`[${type}] ${msg}`); 320 + } 321 + 322 + btnClearLog.onclick = () => logDiv.innerHTML = ''; 323 + 324 + // ═══════════════════════════════════════════════════════════════ 325 + // Protocol Encoder (matches Flutter app's _buildCommandMessage) 326 + // ═══════════════════════════════════════════════════════════════ 327 + 328 + // Write a varint (protobuf-style variable-length integer) 329 + function writeVarint(value) { 330 + const bytes = []; 331 + while (value > 0x7f) { 332 + bytes.push((value & 0x7f) | 0x80); 333 + value >>>= 7; 334 + } 335 + bytes.push(value & 0x7f); 336 + return bytes; 337 + } 338 + 339 + // Generate random 4-char reply ID (a-z) 340 + function generateReplyId() { 341 + const chars = 'abcdefghijklmnopqrstuvwxyz'; 342 + return Array.from({ length: 4 }, () => 343 + chars[Math.floor(Math.random() * chars.length)] 344 + ).join(''); 345 + } 346 + 347 + // Build command message matching Flutter protocol 348 + function buildCommand(command, params = {}) { 349 + const encoder = new TextEncoder(); 350 + const bytes = []; 351 + 352 + // 1. Command name (length-prefixed) 353 + const cmdBytes = encoder.encode(command); 354 + bytes.push(...writeVarint(cmdBytes.length), ...cmdBytes); 355 + 356 + // 2. Reply ID (length-prefixed) 357 + const replyId = generateReplyId(); 358 + const replyBytes = encoder.encode(replyId); 359 + bytes.push(...writeVarint(replyBytes.length), ...replyBytes); 360 + 361 + // 3. Parameters (each length-prefixed) 362 + for (const [key, value] of Object.entries(params)) { 363 + const valBytes = encoder.encode(String(value)); 364 + bytes.push(...writeVarint(valBytes.length), ...valBytes); 365 + } 366 + 367 + log(`Built command: ${command} [replyId: ${replyId}]`, 'data'); 368 + return { bytes: new Uint8Array(bytes), replyId }; 369 + } 370 + 371 + // ═══════════════════════════════════════════════════════════════ 372 + // Protocol Decoder (parse notification responses) 373 + // ═══════════════════════════════════════════════════════════════ 374 + 375 + function readVarint(data, offset) { 376 + let value = 0; 377 + let shift = 0; 378 + let pos = offset; 379 + while (pos < data.length) { 380 + const byte = data[pos++]; 381 + value |= (byte & 0x7f) << shift; 382 + if ((byte & 0x80) === 0) break; 383 + shift += 7; 384 + } 385 + return { value, nextOffset: pos }; 386 + } 387 + 388 + function parseResponse(data) { 389 + const decoder = new TextDecoder(); 390 + const fields = []; 391 + let offset = 0; 392 + 393 + while (offset < data.length) { 394 + const { value: len, nextOffset } = readVarint(data, offset); 395 + offset = nextOffset; 396 + if (offset + len > data.length) break; 397 + fields.push(decoder.decode(data.slice(offset, offset + len))); 398 + offset += len; 399 + } 400 + 401 + // Fields: [replyId, errorCode, ...data] 402 + return { 403 + replyId: fields[0], 404 + errorCode: parseInt(fields[1]) || 0, 405 + data: fields.slice(2) 406 + }; 407 + } 408 + 409 + // ═══════════════════════════════════════════════════════════════ 410 + // Connection Management 411 + // ═══════════════════════════════════════════════════════════════ 412 + 413 + function updateStatus(state, text) { 414 + statusDot.className = 'status-dot ' + state; 415 + statusText.textContent = text; 416 + 417 + const connected = state === 'connected'; 418 + btnDisconnect.disabled = !connected; 419 + btnScanWifi.disabled = !connected; 420 + btnSendWifi.disabled = !connected; 421 + btnGetInfo.disabled = !connected; 422 + btnKeepWifi.disabled = !connected; 423 + btnSetTime.disabled = !connected; 424 + 425 + if (connected) { 426 + $('device-info').classList.remove('hidden'); 427 + } 428 + } 429 + 430 + async function scanAndConnect() { 431 + try { 432 + log('Requesting Bluetooth device...', 'info'); 433 + updateStatus('connecting', 'Scanning...'); 434 + 435 + // Request device with FF1 service filter 436 + device = await navigator.bluetooth.requestDevice({ 437 + filters: [{ services: [FF1_SERVICE_UUID] }], 438 + optionalServices: [FF1_SERVICE_UUID] 439 + }); 440 + 441 + log(`Found device: ${device.name || 'Unknown'} (${device.id})`, 'success'); 442 + $('info-name').textContent = device.name || 'Unknown'; 443 + $('info-id').textContent = device.id.substring(0, 20) + '...'; 444 + 445 + // Handle disconnection 446 + device.addEventListener('gattserverdisconnected', () => { 447 + log('Device disconnected', 'error'); 448 + updateStatus('', 'Disconnected'); 449 + device = null; 450 + server = null; 451 + service = null; 452 + characteristic = null; 453 + }); 454 + 455 + // Connect to GATT server 456 + log('Connecting to GATT server...', 'info'); 457 + updateStatus('connecting', 'Connecting...'); 458 + server = await device.gatt.connect(); 459 + 460 + // Get service 461 + log(`Getting service ${FF1_SERVICE_UUID}...`, 'info'); 462 + service = await server.getPrimaryService(FF1_SERVICE_UUID); 463 + $('info-service').textContent = FF1_SERVICE_UUID.substring(0, 8) + '...'; 464 + 465 + // Get characteristic 466 + log(`Getting characteristic ${FF1_CHAR_UUID}...`, 'info'); 467 + characteristic = await service.getCharacteristic(FF1_CHAR_UUID); 468 + 469 + // Start notifications for responses 470 + log('Starting notifications...', 'info'); 471 + await characteristic.startNotifications(); 472 + characteristic.addEventListener('characteristicvaluechanged', handleNotification); 473 + 474 + log('✅ Connected and ready!', 'success'); 475 + updateStatus('connected', `Connected: ${device.name || device.id}`); 476 + 477 + } catch (err) { 478 + log(`Error: ${err.message}`, 'error'); 479 + updateStatus('error', 'Connection failed'); 480 + console.error(err); 481 + } 482 + } 483 + 484 + async function disconnect() { 485 + if (device?.gatt?.connected) { 486 + device.gatt.disconnect(); 487 + log('Disconnected', 'info'); 488 + } 489 + updateStatus('', 'Not connected'); 490 + } 491 + 492 + // ═══════════════════════════════════════════════════════════════ 493 + // Notification Handler 494 + // ═══════════════════════════════════════════════════════════════ 495 + 496 + function handleNotification(event) { 497 + const data = new Uint8Array(event.target.value.buffer); 498 + log(`📨 Received ${data.length} bytes`, 'data'); 499 + 500 + try { 501 + const response = parseResponse(data); 502 + log(`Response: replyId=${response.replyId}, error=${response.errorCode}, data=[${response.data.join(', ')}]`, 'data'); 503 + 504 + // Call pending callback if exists 505 + const callback = pendingCallbacks.get(response.replyId); 506 + if (callback) { 507 + pendingCallbacks.delete(response.replyId); 508 + callback(response); 509 + } 510 + } catch (err) { 511 + log(`Parse error: ${err.message}`, 'error'); 512 + log(`Raw data: ${Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' ')}`, 'data'); 513 + } 514 + } 515 + 516 + // ═══════════════════════════════════════════════════════════════ 517 + // Command Senders 518 + // ═══════════════════════════════════════════════════════════════ 519 + 520 + async function sendCommand(command, params = {}, timeout = 10000) { 521 + if (!characteristic) { 522 + log('Not connected!', 'error'); 523 + return null; 524 + } 525 + 526 + const { bytes, replyId } = buildCommand(command, params); 527 + 528 + return new Promise(async (resolve, reject) => { 529 + // Set up response callback 530 + const timer = setTimeout(() => { 531 + pendingCallbacks.delete(replyId); 532 + log(`Timeout waiting for response (${command})`, 'error'); 533 + resolve(null); 534 + }, timeout); 535 + 536 + pendingCallbacks.set(replyId, (response) => { 537 + clearTimeout(timer); 538 + if (response.errorCode !== 0) { 539 + log(`Command error: ${response.errorCode}`, 'error'); 540 + } 541 + resolve(response); 542 + }); 543 + 544 + // Send command 545 + try { 546 + log(`📤 Sending ${command} (${bytes.length} bytes)...`, 'info'); 547 + await characteristic.writeValue(bytes); 548 + log(`Sent ${command}`, 'success'); 549 + } catch (err) { 550 + clearTimeout(timer); 551 + pendingCallbacks.delete(replyId); 552 + log(`Write error: ${err.message}`, 'error'); 553 + resolve(null); 554 + } 555 + }); 556 + } 557 + 558 + // ═══════════════════════════════════════════════════════════════ 559 + // Button Handlers 560 + // ═══════════════════════════════════════════════════════════════ 561 + 562 + btnScan.onclick = scanAndConnect; 563 + btnDisconnect.onclick = disconnect; 564 + 565 + btnScanWifi.onclick = async () => { 566 + log('Scanning for WiFi networks...', 'info'); 567 + const response = await sendCommand(COMMANDS.SCAN_WIFI); 568 + if (response?.data) { 569 + log(`Found networks: ${response.data.join(', ')}`, 'success'); 570 + // Could populate a dropdown here 571 + } 572 + }; 573 + 574 + btnSendWifi.onclick = async () => { 575 + const ssid = $('wifi-ssid').value.trim(); 576 + const password = $('wifi-password').value; 577 + 578 + if (!ssid) { 579 + log('Please enter WiFi SSID', 'error'); 580 + return; 581 + } 582 + 583 + log(`Sending WiFi credentials for "${ssid}"...`, 'info'); 584 + const response = await sendCommand(COMMANDS.CONNECT_WIFI, { 585 + ssid, 586 + password 587 + }, 30000); // 30s timeout for WiFi connection 588 + 589 + if (response?.data?.[0]) { 590 + const topicId = response.data[0]; 591 + log(`✅ WiFi connected! TopicID: ${topicId}`, 'success'); 592 + $('topic-id').textContent = topicId; 593 + $('topic-section').classList.remove('hidden'); 594 + } 595 + }; 596 + 597 + btnGetInfo.onclick = async () => { 598 + log('Getting device info...', 'info'); 599 + const response = await sendCommand(COMMANDS.GET_INFO); 600 + if (response?.data) { 601 + log(`Device info: ${JSON.stringify(response.data)}`, 'success'); 602 + } 603 + }; 604 + 605 + btnKeepWifi.onclick = async () => { 606 + log('Requesting TopicID (keep_wifi)...', 'info'); 607 + const response = await sendCommand(COMMANDS.KEEP_WIFI); 608 + if (response?.data?.[0]) { 609 + const topicId = response.data[0]; 610 + log(`✅ TopicID: ${topicId}`, 'success'); 611 + $('topic-id').textContent = topicId; 612 + $('topic-section').classList.remove('hidden'); 613 + } 614 + }; 615 + 616 + btnSetTime.onclick = async () => { 617 + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 618 + const time = new Date().toISOString().replace('T', ' ').substring(0, 19); 619 + log(`Setting timezone: ${timezone}, time: ${time}`, 'info'); 620 + await sendCommand(COMMANDS.SET_TIME, { timezone, time }, 5000); 621 + }; 622 + 623 + // ═══════════════════════════════════════════════════════════════ 624 + // Check Web Bluetooth Support 625 + // ═══════════════════════════════════════════════════════════════ 626 + 627 + if (!navigator.bluetooth) { 628 + log('❌ Web Bluetooth is NOT supported in this browser!', 'error'); 629 + btnScan.disabled = true; 630 + btnScan.textContent = '❌ Not Supported'; 631 + } else { 632 + log('✅ Web Bluetooth is supported', 'success'); 633 + } 634 + </script> 635 + </body> 636 + </html>