@rwesa/payu-ble
Version:
A flexible, smart Bluetooth Low Energy challenge system for secure device connections
969 lines (817 loc) โข 37.4 kB
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>