UNPKG

@rwesa/payu-ble

Version:

A flexible, smart Bluetooth Low Energy challenge system for secure device connections

969 lines (817 loc) โ€ข 37.4 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>PayuBLE Browser Demo</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: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-radius: 20px; padding: 40px; max-width: 800px; width: 100%; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); } h1 { text-align: center; color: #333; margin-bottom: 30px; font-size: 2.5em; background: linear-gradient(45deg, #667eea, #764ba2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .status-section { margin-bottom: 30px; padding: 20px; background: rgba(255, 255, 255, 0.7); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.3); } .section-title { font-size: 1.3em; font-weight: 600; color: #333; margin-bottom: 15px; display: flex; align-items: center; gap: 10px; } .status-indicator { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: 8px; } .status-disconnected { background: #dc3545; } .status-scanning { background: #ffc107; animation: pulse 1s infinite; } .status-connected { background: #28a745; } .status-challenge { background: #007bff; animation: pulse 1s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } button { background: linear-gradient(45deg, #667eea, #764ba2); color: white; border: none; padding: 12px 24px; border-radius: 8px; cursor: pointer; font-weight: 500; font-size: 1em; transition: all 0.3s ease; margin: 5px; } button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); } button:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; } .device-list { background: #f8f9fa; border: 2px solid #e9ecef; border-radius: 8px; max-height: 300px; overflow-y: auto; margin: 15px 0; padding: 10px; } .device-item { padding: 12px; border-bottom: 1px solid #dee2e6; cursor: pointer; transition: background-color 0.2s; border-radius: 6px; margin-bottom: 5px; } .device-item:hover { background: rgba(102, 126, 234, 0.1); } .device-item.selected { background: rgba(102, 126, 234, 0.2); border: 2px solid #667eea; } .device-item:last-child { border-bottom: none; margin-bottom: 0; } .device-name { font-weight: 600; color: #333; } .device-id { font-family: 'Courier New', monospace; font-size: 0.9em; color: #666; margin-top: 4px; } .device-rssi { font-size: 0.8em; color: #28a745; margin-top: 2px; } .scanning-indicator { display: none; text-align: center; padding: 20px; color: #666; } .scanning-indicator.active { display: block; } .challenge-section { background: #fff3cd; border: 2px solid #ffeaa7; border-radius: 12px; padding: 20px; margin: 20px 0; display: none; } .challenge-section.active { display: block; animation: slideIn 0.3s ease-out; } @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } .challenge-prompt { font-size: 1.3em; color: #333; margin-bottom: 20px; text-align: center; font-weight: 500; } .answer-input { width: 100%; padding: 15px; border: 2px solid #dee2e6; border-radius: 8px; font-size: 1.1em; margin-bottom: 15px; transition: border-color 0.3s ease; } .answer-input:focus { outline: none; border-color: #667eea; } .result-message { padding: 15px; border-radius: 8px; margin-top: 15px; font-weight: 500; text-align: center; transition: all 0.3s ease; } .result-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .result-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .log-section { margin-top: 30px; background: #000; color: #00ff00; padding: 15px; border-radius: 8px; font-family: 'Courier New', monospace; font-size: 0.9em; max-height: 300px; overflow-y: auto; } .warning { background: #fff3cd; color: #856404; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #ffeaa7; } .info-box { background: #d1ecf1; color: #0c5460; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #bee5eb; } .controls { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } .demo-controls { background: #e2e3e5; border: 1px solid #d6d8db; border-radius: 8px; padding: 15px; margin-top: 20px; } .demo-title { font-weight: 600; color: #495057; margin-bottom: 10px; } .scan-progress { display: none; margin: 15px 0; padding: 10px; background: #e3f2fd; border-radius: 8px; border-left: 4px solid #2196f3; } .scan-progress.active { display: block; } @media (max-width: 600px) { .container { padding: 20px; } .controls { flex-direction: column; } button { width: 100%; } } </style> </head> <body> <div class="container"> <h1>๐Ÿ” PayuBLE Browser Demo</h1> <div class="warning"> โš ๏ธ <strong>Web Bluetooth Required:</strong> This demo requires a browser with Web Bluetooth support (Chrome, Edge, Opera) and HTTPS or localhost. </div> <div class="info-box"> ๐Ÿ“ฑ <strong>How to use:</strong> <ol style="margin-top: 10px; margin-left: 20px;"> <li>Click "Start Bluetooth Scan" to discover nearby devices</li> <li>PayuBLE devices and other Bluetooth devices will appear in the list</li> <li>Select a PayuBLE device and click Connect</li> <li>Solve the challenge presented by the device</li> </ol> </div> <div class="status-section"> <div class="section-title"> <span>๐Ÿ”</span> Bluetooth Device Scanner </div> <div id="connectionStatus"> <span id="statusIndicator" class="status-indicator status-disconnected"></span> <span id="statusText">Ready to scan</span> </div> <div class="controls"> <button id="scanBtn" onclick="startBluetoothScan()">Start Bluetooth Scan</button> <button id="stopScanBtn" onclick="stopBluetoothScan()" disabled>Stop Scan</button> <button id="connectBtn" onclick="connectToSelected()" disabled>Connect to Device</button> <button id="disconnectBtn" onclick="disconnect()" disabled>Disconnect</button> </div> <div id="scanProgress" class="scan-progress"> <div style="display: flex; align-items: center; gap: 10px;"> <div class="status-indicator status-scanning"></div> <span>Scanning for Bluetooth devices... <span id="deviceCount">0</span> found</span> </div> </div> <div id="deviceList" class="device-list" style="display: none;"> <!-- Discovered devices will appear here --> </div> <div id="scanningIndicator" class="scanning-indicator"> <div>๐Ÿ”„ Scanning for Bluetooth devices...</div> <div style="font-size: 0.9em; margin-top: 5px;">Make sure your PayuBLE device is advertising</div> </div> </div> <div class="demo-controls"> <div class="demo-title">๐Ÿงช Demo Mode</div> <button onclick="addDemoDevice()" style="background: #6c757d;">Add Demo Device</button> <small style="display: block; margin-top: 5px; color: #6c757d;"> Use this if you don't have a real PayuBLE device for testing </small> </div> <div id="challengeSection" class="challenge-section"> <div class="section-title"> <span>๐Ÿงฉ</span> Device Challenge </div> <div id="challengePrompt" class="challenge-prompt"> <!-- Challenge text will appear here --> </div> <input type="text" id="answerInput" class="answer-input" placeholder="Enter your answer..." onkeypress="handleAnswerKeypress(event)"> <div class="controls"> <button onclick="submitAnswer()">Submit Answer</button> <button onclick="getNewChallenge()">Get New Challenge</button> </div> <div id="resultMessage" class="result-message" style="display: none;"> <!-- Result will appear here --> </div> </div> <div class="log-section"> <div style="color: #fff; margin-bottom: 10px; font-weight: bold;">Activity Log:</div> <div id="logOutput"> > PayuBLE Browser Demo initialized<br> > Ready to scan for Bluetooth devices...<br> </div> </div> </div> <script> // Global variables let currentDevice = null; let currentService = null; let challengeCharacteristic = null; let answerCharacteristic = null; let discoveredDevices = []; let selectedDeviceIndex = -1; let isScanning = false; // PayuBLE Service UUIDs - Updated to use real UUIDs const PAYU_BLE_SERVICE_UUID = '12345678-1234-1234-1234-123456789abc'; const CHALLENGE_CHARACTERISTIC_UUID = '87654321-4321-4321-4321-cba987654321'; const ANSWER_CHARACTERISTIC_UUID = '11111111-2222-3333-4444-555555555555'; // Operation timeout settings const OPERATION_TIMEOUT = 10000; // UI Helper Functions function updateStatus(status, text) { const indicator = document.getElementById('statusIndicator'); const statusText = document.getElementById('statusText'); indicator.className = `status-indicator status-${status}`; statusText.textContent = text; } function log(message) { const logOutput = document.getElementById('logOutput'); const timestamp = new Date().toLocaleTimeString(); logOutput.innerHTML += `> [${timestamp}] ${message}<br>`; logOutput.scrollTop = logOutput.scrollHeight; } function showResult(message, isSuccess) { const resultDiv = document.getElementById('resultMessage'); resultDiv.textContent = message; resultDiv.className = `result-message ${isSuccess ? 'result-success' : 'result-error'}`; resultDiv.style.display = 'block'; setTimeout(() => { resultDiv.style.display = 'none'; }, 5000); } function updateButtons() { const scanBtn = document.getElementById('scanBtn'); const stopScanBtn = document.getElementById('stopScanBtn'); const connectBtn = document.getElementById('connectBtn'); const disconnectBtn = document.getElementById('disconnectBtn'); const isConnected = currentDevice && currentDevice.gatt && currentDevice.gatt.connected; const hasSelection = selectedDeviceIndex >= 0 && selectedDeviceIndex < discoveredDevices.length; scanBtn.disabled = isConnected || isScanning; stopScanBtn.disabled = !isScanning; connectBtn.disabled = !hasSelection || isConnected || isScanning; disconnectBtn.disabled = !isConnected; } function updateDeviceList() { const deviceList = document.getElementById('deviceList'); const deviceCount = document.getElementById('deviceCount'); if (deviceCount) { deviceCount.textContent = discoveredDevices.length; } if (discoveredDevices.length === 0) { deviceList.style.display = 'none'; return; } deviceList.style.display = 'block'; deviceList.innerHTML = ''; discoveredDevices.forEach((device, index) => { const deviceItem = document.createElement('div'); deviceItem.className = 'device-item'; if (index === selectedDeviceIndex) { deviceItem.classList.add('selected'); } const isPayuBLE = device.name && device.name.toLowerCase().includes('payuble'); const deviceNameDisplay = device.name || 'Unknown Device'; const nameColor = isPayuBLE ? '#28a745' : '#333'; deviceItem.innerHTML = ` <div class="device-name" style="color: ${nameColor}"> ${isPayuBLE ? '๐Ÿ” ' : '๐Ÿ“ฑ '}${deviceNameDisplay} ${isPayuBLE ? ' (PayuBLE)' : ''} </div> <div class="device-id">${device.id}</div> `; deviceItem.onclick = () => selectDevice(index); deviceList.appendChild(deviceItem); }); } function selectDevice(index) { if (index >= 0 && index < discoveredDevices.length) { selectedDeviceIndex = index; updateDeviceList(); updateButtons(); const device = discoveredDevices[index]; const isPayuBLE = device.name && device.name.toLowerCase().includes('payuble'); log(`Selected device: ${device.name || 'Unknown'} ${isPayuBLE ? '(PayuBLE device)' : ''}`); if (!isPayuBLE) { showResult('โš ๏ธ Selected device may not be a PayuBLE device', false); } } } function resetChallengeSection() { const challengeSection = document.getElementById('challengeSection'); const answerInput = document.getElementById('answerInput'); const resultMessage = document.getElementById('resultMessage'); challengeSection.classList.remove('active'); answerInput.value = ''; resultMessage.style.display = 'none'; } function withTimeout(promise, timeoutMs = OPERATION_TIMEOUT) { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Operation timed out')), timeoutMs); }); return Promise.race([promise, timeoutPromise]); } // Bluetooth Scanning Functions async function startBluetoothScan() { if (isScanning) return; try { // Check if Web Bluetooth is supported if (!navigator.bluetooth) { throw new Error('Web Bluetooth not supported in this browser'); } isScanning = true; discoveredDevices = []; selectedDeviceIndex = -1; log('Starting Bluetooth device scan...'); updateStatus('scanning', 'Opening device picker...'); updateButtons(); // Show scanning UI document.getElementById('scanProgress').classList.add('active'); document.getElementById('scanningIndicator').classList.add('active'); // Request device using the standard device picker // This works reliably across all Web Bluetooth implementations const device = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [ PAYU_BLE_SERVICE_UUID, 'battery_service', 'device_information' ] }); log(`Device selected: ${device.name || 'Unknown'} (${device.id})`); // Add device to discovered devices list discoveredDevices.push(device); updateDeviceList(); selectDevice(0); // Hide scanning UI document.getElementById('scanProgress').classList.remove('active'); document.getElementById('scanningIndicator').classList.remove('active'); updateStatus('disconnected', 'Device selected - click Connect'); isScanning = false; updateButtons(); } catch (error) { log(`Scan failed: ${error.message}`); // Hide scanning UI document.getElementById('scanProgress').classList.remove('active'); document.getElementById('scanningIndicator').classList.remove('active'); if (error.name === 'NotFoundError') { updateStatus('disconnected', 'No device selected'); showResult('No device selected. Please try again.', false); } else if (error.name === 'SecurityError') { updateStatus('disconnected', 'Access denied'); showResult('Bluetooth access denied. Please allow access and try again.', false); } else { updateStatus('disconnected', 'Scan failed'); showResult(`Scan error: ${error.message}`, false); } isScanning = false; updateButtons(); } } function stopBluetoothScan() { if (!isScanning) return; isScanning = false; // Hide scanning UI document.getElementById('scanProgress').classList.remove('active'); document.getElementById('scanningIndicator').classList.remove('active'); log('Scan stopped by user'); updateStatus('disconnected', 'Scan stopped'); updateButtons(); } // Connection Functions async function connectToSelected() { if (selectedDeviceIndex < 0 || selectedDeviceIndex >= discoveredDevices.length) { showResult('Please select a device first', false); return; } const device = discoveredDevices[selectedDeviceIndex]; try { log(`Connecting to ${device.name || 'Unknown'}...`); updateStatus('scanning', 'Connecting...'); updateButtons(); // Set up disconnect handler device.addEventListener('gattserverdisconnected', onDisconnected); // Connect to GATT server log('Establishing GATT connection...'); const server = await withTimeout(device.gatt.connect()); log('โœ“ Connected to GATT server'); // Try to get PayuBLE service try { log(`Looking for PayuBLE service: ${PAYU_BLE_SERVICE_UUID}`); const service = await withTimeout(server.getPrimaryService(PAYU_BLE_SERVICE_UUID)); log('โœ“ Found PayuBLE service'); // Get challenge characteristic log(`Getting challenge characteristic: ${CHALLENGE_CHARACTERISTIC_UUID}`); challengeCharacteristic = await withTimeout(service.getCharacteristic(CHALLENGE_CHARACTERISTIC_UUID)); log('โœ“ Found challenge characteristic'); // Get answer characteristic log(`Getting answer characteristic: ${ANSWER_CHARACTERISTIC_UUID}`); answerCharacteristic = await withTimeout(service.getCharacteristic(ANSWER_CHARACTERISTIC_UUID)); log('โœ“ Found answer characteristic'); // Store references currentDevice = device; currentService = service; updateStatus('connected', `Connected to ${device.name || 'Unknown'}`); log('๐ŸŽ‰ PayuBLE connection established successfully'); updateButtons(); // Automatically get first challenge setTimeout(() => getNewChallenge(), 500); } catch (serviceError) { log(`โŒ PayuBLE service not found: ${serviceError.message}`); // Still connected but no PayuBLE service currentDevice = device; updateStatus('connected', `Connected to ${device.name || 'Unknown'} (No PayuBLE service)`); updateButtons(); showResult('Connected but device does not have PayuBLE service. Try a different device.', false); } } catch (error) { log(`โŒ Connection failed: ${error.message}`); updateStatus('disconnected', 'Connection failed'); if (error.name === 'NetworkError') { showResult('Failed to connect: Device not reachable. Make sure it\'s nearby and advertising.', false); } else if (error.name === 'SecurityError') { showResult('Failed to connect: Access denied. Please allow Bluetooth access.', false); } else if (error.message.includes('timed out')) { showResult('Failed to connect: Connection timeout. Device may be busy or out of range.', false); } else { showResult(`Failed to connect: ${error.message}`, false); } // Clean up on connection failure if (device && device.removeEventListener) { device.removeEventListener('gattserverdisconnected', onDisconnected); } currentDevice = null; currentService = null; challengeCharacteristic = null; answerCharacteristic = null; updateButtons(); } } function onDisconnected(event) { log('๐Ÿ“ก Device disconnected'); updateStatus('disconnected', 'Device disconnected'); currentDevice = null; currentService = null; challengeCharacteristic = null; answerCharacteristic = null; resetChallengeSection(); updateButtons(); } async function disconnect() { if (currentDevice && currentDevice.gatt && currentDevice.gatt.connected) { try { log('Disconnecting from device...'); await currentDevice.gatt.disconnect(); log('โœ“ Disconnected from device'); } catch (error) { log(`โŒ Disconnect error: ${error.message}`); } } } // Challenge Functions async function getNewChallenge() { if (!challengeCharacteristic) { showResult('Not connected to PayuBLE service', false); return; } try { log('๐Ÿ“– Requesting new challenge...'); updateStatus('challenge', 'Getting challenge...'); const value = await withTimeout(challengeCharacteristic.readValue()); const challengeText = new TextDecoder().decode(value); log(`๐Ÿ“ Received challenge: "${challengeText}"`); document.getElementById('challengePrompt').textContent = challengeText; document.getElementById('challengeSection').classList.add('active'); const answerInput = document.getElementById('answerInput'); answerInput.value = ''; answerInput.focus(); document.getElementById('resultMessage').style.display = 'none'; updateStatus('challenge', 'Challenge received - solve it!'); } catch (error) { log(`โŒ Failed to get challenge: ${error.message}`); if (error.message.includes('timed out')) { showResult('Error getting challenge: Request timeout. Try again.', false); } else { showResult(`Error getting challenge: ${error.message}`, false); } updateStatus('connected', 'Challenge failed'); } } async function submitAnswer() { const answerInput = document.getElementById('answerInput'); const answer = answerInput.value.trim(); if (!answer) { showResult('Please enter an answer', false); answerInput.focus(); return; } if (!answerCharacteristic) { showResult('Not connected to PayuBLE service', false); return; } try { log(`๐Ÿ“ค Submitting answer: "${answer}"`); // Write answer to the answer characteristic const encoder = new TextEncoder(); await withTimeout(answerCharacteristic.writeValue(encoder.encode(answer))); log('โœ“ Answer submitted to device'); // Wait a moment for the device to process await new Promise(resolve => setTimeout(resolve, 100)); // Read the result from the answer characteristic log('๐Ÿ“ฅ Reading verification result...'); const resultValue = await withTimeout(answerCharacteristic.readValue()); const resultText = new TextDecoder().decode(resultValue); log(`๐Ÿ“‹ Device response: "${resultText}"`); // Check if the answer was correct const isCorrect = resultText.toLowerCase().includes('correct') || resultText.toLowerCase().includes('success') || resultText === '1' || resultText.toLowerCase() === 'true'; if (isCorrect) { showResult('๐ŸŽ‰ Correct! Access granted!', true); updateStatus('connected', 'Challenge solved - access granted'); log('๐ŸŽ‰ Challenge solved successfully!'); } else { showResult('โŒ Incorrect answer. Try again!', false); answerInput.select(); log('โŒ Answer was incorrect'); } } catch (error) { log(`โŒ Failed to submit answer: ${error.message}`); if (error.message.includes('timed out')) { showResult('Error submitting answer: Request timeout. Try again.', false); } else { showResult(`Error submitting answer: ${error.message}`, false); } } } function handleAnswerKeypress(event) { if (event.key === 'Enter') { submitAnswer(); } } // Demo Device Functions function addDemoDevice() { log('๐Ÿงช Adding demo PayuBLE device for testing...'); const mockDevice = { name: 'PayuBLE Demo Device', id: `demo-device-${Date.now()}`, _challenges: [ 'What is 15 + 7?', 'What has keys but cannot open locks?', 'Solve: 3 ร— 8', 'What gets wet while drying?', 'What is 2 + 2?', 'What programming language is this demo written in?' ], _answers: ['22', 'piano', 'keyboard', '24', 'towel', '4', 'javascript', 'js'], _currentChallenge: '', _lastResult: 'Incorrect', gatt: { connected: false, connect: function() { return new Promise((resolve) => { setTimeout(() => { mockDevice.gatt.connected = true; resolve({ getPrimaryService: function() { return Promise.resolve({ getCharacteristic: function(uuid) { if (uuid === CHALLENGE_CHARACTERISTIC_UUID) { return Promise.resolve({ readValue: function() { const challenges = mockDevice._challenges; const challenge = challenges[Math.floor(Math.random() * challenges.length)]; mockDevice._currentChallenge = challenge; return Promise.resolve(new TextEncoder().encode(challenge)); } }); } else if (uuid === ANSWER_CHARACTERISTIC_UUID) { return Promise.resolve({ writeValue: function(value) { const answer = new TextDecoder().decode(value).toLowerCase().trim(); const isCorrect = mockDevice._answers.includes(answer); mockDevice._lastResult = isCorrect ? 'Correct! Well done!' : 'Incorrect, try again'; return Promise.resolve(); }, readValue: function() { return Promise.resolve(new TextEncoder().encode(mockDevice._lastResult)); } }); } return Promise.reject(new Error('Characteristic not found')); } }); } }); }, 500); }); }, disconnect: function() { return new Promise((resolve) => { mockDevice.gatt.connected = false; setTimeout(() => { if (mockDevice._onDisconnect) { mockDevice._onDisconnect(); } resolve(); }, 100); }); } }, addEventListener: function(event, handler) { if (event === 'gattserverdisconnected') { mockDevice._onDisconnect = handler; } }, removeEventListener: function(event, handler) { if (event === 'gattserverdisconnected') { mockDevice._onDisconnect = null; } } }; // Add to device list const existingIndex = discoveredDevices.findIndex(d => d.name === mockDevice.name); if (existingIndex === -1) { discoveredDevices.push(mockDevice); updateDeviceList(); selectDevice(discoveredDevices.length - 1); showResult('Demo device added! Click Connect to test.', true); log('โœ“ Demo device ready for connection'); } else { selectDevice(existingIndex); showResult('Demo device already exists and selected.', true); } } // Initialize demo function initDemo() { log('๐Ÿš€ PayuBLE Browser Demo initialized'); updateButtons(); // Check Web Bluetooth support if (!navigator.bluetooth) { log('โŒ Web Bluetooth not supported in this browser'); showResult('Web Bluetooth not supported. Please use Chrome, Edge, or Opera on HTTPS/localhost.', false); // Auto-add demo device for testing setTimeout(() => { log('๐Ÿงช Auto-adding demo device for testing...'); addDemoDevice(); }, 1000); } else { log('โœ… Web Bluetooth API available'); log('โœ… Browser supports Bluetooth device discovery'); log('๐Ÿ“ฑ Click "Start Bluetooth Scan" to discover nearby devices'); } } // Cleanup on page unload window.addEventListener('beforeunload', () => { if (isScanning) { isScanning = false; } if (currentDevice && currentDevice.gatt && currentDevice.gatt.connected) { currentDevice.gatt.disconnect().catch(() => {}); } }); // Start demo when page loads window.addEventListener('DOMContentLoaded', initDemo); </script> </body> </html>