/** * Web Bluetooth Service for WellNuo WP Sensors * * This service provides Web Bluetooth API integration for scanning, * connecting, and communicating with WP (WellNuo Presence) sensors * in the web browser. * * Supported browsers: Chrome 70+, Edge 79+, Opera 57+ * Not supported: Safari, Firefox, iOS browsers */ // BLE Configuration (matching mobile app) export const BLE_CONFIG = { SERVICE_UUID: '4fafc201-1fb5-459e-8fcc-c5c9c331914b', CHAR_UUID: 'beb5483e-36e1-4688-b7f5-ea07361b26a8', SCAN_TIMEOUT: 10000, // 10 seconds COMMAND_TIMEOUT: 5000, // 5 seconds DEVICE_NAME_PREFIX: 'WP_', }; // BLE Commands (matching mobile app protocol) export const BLE_COMMANDS = { PIN_UNLOCK: 'pin|7856', GET_WIFI_LIST: 'w', SET_WIFI: 'W', // Format: W|SSID,PASSWORD GET_WIFI_STATUS: 'a', REBOOT: 's', DISCONNECT: 'D', }; // Types export interface WPDevice { id: string; name: string; mac: string; rssi: number; wellId?: number; device: BluetoothDevice; } export interface WiFiNetwork { ssid: string; rssi: number; } export interface WiFiStatus { ssid: string; rssi: number; connected: boolean; } export enum BLEConnectionState { DISCONNECTED = 'disconnected', CONNECTING = 'connecting', CONNECTED = 'connected', DISCOVERING = 'discovering', READY = 'ready', DISCONNECTING = 'disconnecting', ERROR = 'error', } export type BLEConnectionEvent = | 'state_changed' | 'connection_failed' | 'disconnected' | 'ready'; export interface BLEDeviceConnection { deviceId: string; deviceName: string; state: BLEConnectionState; error?: string; connectedAt?: number; lastActivity?: number; } export type BLEEventListener = ( deviceId: string, event: BLEConnectionEvent, data?: unknown ) => void; // TextEncoder/TextDecoder for string conversion (lazily initialized for Node.js compatibility) let _encoder: TextEncoder | null = null; let _decoder: TextDecoder | null = null; function getEncoder(): TextEncoder { if (!_encoder) { _encoder = new TextEncoder(); } return _encoder; } function getDecoder(): TextDecoder { if (!_decoder) { _decoder = new TextDecoder(); } return _decoder; } /** * Check if Web Bluetooth is supported in the current browser */ export function isWebBluetoothSupported(): boolean { return typeof navigator !== 'undefined' && 'bluetooth' in navigator; } /** * Check if Bluetooth is available and enabled */ export async function isBluetoothAvailable(): Promise { if (!isWebBluetoothSupported()) { return false; } try { const available = await navigator.bluetooth.getAvailability(); return available; } catch { // Some browsers don't support getAvailability return true; // Assume available, will fail on actual scan if not } } /** * Web Bluetooth Manager for WP Sensors */ export class WebBluetoothManager { private connectedDevices = new Map(); private gattServers = new Map(); private characteristics = new Map(); private connectionStates = new Map(); private eventListeners: BLEEventListener[] = []; private connectingDevices = new Set(); /** * Update connection state and notify listeners */ private updateConnectionState( deviceId: string, state: BLEConnectionState, deviceName?: string, error?: string ): void { const existing = this.connectionStates.get(deviceId); const now = Date.now(); const connection: BLEDeviceConnection = { deviceId, deviceName: deviceName || existing?.deviceName || deviceId, state, error, connectedAt: state === BLEConnectionState.CONNECTED ? now : existing?.connectedAt, lastActivity: now, }; this.connectionStates.set(deviceId, connection); this.emitEvent(deviceId, 'state_changed', { state, error }); } /** * Emit event to all registered listeners */ private emitEvent(deviceId: string, event: BLEConnectionEvent, data?: unknown): void { this.eventListeners.forEach((listener) => { try { listener(deviceId, event, data); } catch { // Listener error should not crash the app } }); } /** * Get current connection state for a device */ getConnectionState(deviceId: string): BLEConnectionState { const connection = this.connectionStates.get(deviceId); return connection?.state || BLEConnectionState.DISCONNECTED; } /** * Get all active connections */ getAllConnections(): Map { return new Map(this.connectionStates); } /** * Add event listener */ addEventListener(listener: BLEEventListener): void { if (!this.eventListeners.includes(listener)) { this.eventListeners.push(listener); } } /** * Remove event listener */ removeEventListener(listener: BLEEventListener): void { const index = this.eventListeners.indexOf(listener); if (index > -1) { this.eventListeners.splice(index, 1); } } /** * Scan for WP devices using Web Bluetooth API * Note: Web Bluetooth requires user gesture (click) to initiate scan */ async scanForDevices(): Promise { if (!isWebBluetoothSupported()) { throw new Error('Web Bluetooth is not supported in this browser'); } const available = await isBluetoothAvailable(); if (!available) { throw new Error('Bluetooth is not available. Please enable Bluetooth and try again.'); } try { // Request device with filters for WP sensors // Web Bluetooth API shows a picker dialog instead of returning multiple devices const device = await navigator.bluetooth.requestDevice({ filters: [ { namePrefix: BLE_CONFIG.DEVICE_NAME_PREFIX }, ], optionalServices: [BLE_CONFIG.SERVICE_UUID], }); if (!device || !device.name) { throw new Error('No device selected'); } // Parse well_id from name (WP_497_81a14c -> 497) const wellIdMatch = device.name.match(/WP_(\d+)_/); const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined; // Extract MAC from device name (last part after underscore) const macMatch = device.name.match(/_([a-fA-F0-9]{6})$/); const mac = macMatch ? macMatch[1].toUpperCase() : ''; const wpDevice: WPDevice = { id: device.id, name: device.name, mac, rssi: -50, // Web Bluetooth doesn't provide RSSI during scan wellId, device, }; return [wpDevice]; } catch (error: unknown) { if (error instanceof Error) { if (error.name === 'NotFoundError') { throw new Error('No WP devices found. Make sure your sensor is powered on and nearby.'); } if (error.name === 'SecurityError') { throw new Error('Bluetooth permission denied. Please allow Bluetooth access in your browser settings.'); } if (error.name === 'NotAllowedError') { throw new Error('User cancelled the device selection.'); } throw error; } throw new Error('Failed to scan for devices'); } } /** * Connect to a WP device */ async connectDevice(device: WPDevice): Promise { const deviceId = device.id; try { // Check if connection is already in progress if (this.connectingDevices.has(deviceId)) { throw new Error('Connection already in progress for this device'); } // Check if already connected const existingServer = this.gattServers.get(deviceId); if (existingServer?.connected) { this.updateConnectionState(deviceId, BLEConnectionState.READY, device.name); this.emitEvent(deviceId, 'ready'); return true; } // Mark device as connecting this.connectingDevices.add(deviceId); // Update state to CONNECTING this.updateConnectionState(deviceId, BLEConnectionState.CONNECTING, device.name); // Connect to GATT server const server = await device.device.gatt?.connect(); if (!server) { throw new Error('Failed to connect to GATT server'); } // Update state to CONNECTED this.updateConnectionState(deviceId, BLEConnectionState.CONNECTED, device.name); // Update state to DISCOVERING this.updateConnectionState(deviceId, BLEConnectionState.DISCOVERING, device.name); // Get service and characteristic const service = await server.getPrimaryService(BLE_CONFIG.SERVICE_UUID); const characteristic = await service.getCharacteristic(BLE_CONFIG.CHAR_UUID); // Store references this.connectedDevices.set(deviceId, device.device); this.gattServers.set(deviceId, server); this.characteristics.set(deviceId, characteristic); // Set up disconnection handler device.device.addEventListener('gattserverdisconnected', () => { this.handleDisconnection(deviceId, device.name); }); // Update state to READY this.updateConnectionState(deviceId, BLEConnectionState.READY, device.name); this.emitEvent(deviceId, 'ready'); return true; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Connection failed'; this.updateConnectionState(deviceId, BLEConnectionState.ERROR, device.name, errorMessage); this.emitEvent(deviceId, 'connection_failed', { error: errorMessage }); return false; } finally { // Always remove from connecting set when done (success or failure) this.connectingDevices.delete(deviceId); } } /** * Handle device disconnection */ private handleDisconnection(deviceId: string, deviceName: string): void { this.connectedDevices.delete(deviceId); this.gattServers.delete(deviceId); this.characteristics.delete(deviceId); this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, deviceName); this.emitEvent(deviceId, 'disconnected', { unexpected: true }); } /** * Disconnect from a device */ async disconnectDevice(deviceId: string): Promise { const device = this.connectedDevices.get(deviceId); const connection = this.connectionStates.get(deviceId); if (device) { try { // Update state to DISCONNECTING this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTING, connection?.deviceName); // Disconnect GATT if (device.gatt?.connected) { device.gatt.disconnect(); } // Cleanup references this.connectedDevices.delete(deviceId); this.gattServers.delete(deviceId); this.characteristics.delete(deviceId); // Update state to DISCONNECTED this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, connection?.deviceName); this.emitEvent(deviceId, 'disconnected'); } catch { // Log but don't throw - device may already be disconnected this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, connection?.deviceName); this.emitEvent(deviceId, 'disconnected'); } } else { // Not in connected devices map, just update state this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, connection?.deviceName); this.emitEvent(deviceId, 'disconnected'); } } /** * Check if a device is connected */ isDeviceConnected(deviceId: string): boolean { const server = this.gattServers.get(deviceId); return server?.connected ?? false; } /** * Send a command to a device and wait for response */ async sendCommand(deviceId: string, command: string): Promise { const characteristic = this.characteristics.get(deviceId); if (!characteristic) { throw new Error('Device not connected'); } // Verify device is still connected const server = this.gattServers.get(deviceId); if (!server?.connected) { this.connectedDevices.delete(deviceId); this.gattServers.delete(deviceId); this.characteristics.delete(deviceId); throw new Error('Device disconnected'); } return new Promise(async (resolve, reject) => { let responseReceived = false; let timeoutId: ReturnType | null = null; const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } try { characteristic.removeEventListener('characteristicvaluechanged', handleNotification); characteristic.stopNotifications().catch(() => {}); } catch { // Ignore cleanup errors } }; const handleNotification = (event: Event) => { const target = event.target as BluetoothRemoteGATTCharacteristic; if (target.value && !responseReceived) { responseReceived = true; cleanup(); const decoded = getDecoder().decode(target.value); resolve(decoded); } }; try { // Subscribe to notifications await characteristic.startNotifications(); characteristic.addEventListener('characteristicvaluechanged', handleNotification); // Send command const encoded = getEncoder().encode(command); await characteristic.writeValueWithResponse(encoded); // Timeout timeoutId = setTimeout(() => { if (!responseReceived) { responseReceived = true; cleanup(); reject(new Error('Command timeout')); } }, BLE_CONFIG.COMMAND_TIMEOUT); } catch (error: unknown) { cleanup(); reject(error instanceof Error ? error : new Error('BLE operation failed')); } }); } /** * Get list of available WiFi networks from device */ async getWiFiList(deviceId: string): Promise { // Step 1: Unlock device const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); if (!unlockResponse.includes('ok')) { throw new Error('Failed to unlock device'); } // Step 2: Get WiFi list const listResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_LIST); // Parse response: "mac,XXXXXX|w|COUNT|SSID1,RSSI1|SSID2,RSSI2|..." const parts = listResponse.split('|'); if (parts.length < 3) { throw new Error('Invalid WiFi list response'); } const count = parseInt(parts[2], 10); if (count < 0) { if (count === -1) { throw new Error('WiFi scan in progress, please wait'); } if (count === -2) { return []; // No networks found } } // Use Map to deduplicate by SSID, keeping the strongest signal const networksMap = new Map(); for (let i = 3; i < parts.length; i++) { const [ssid, rssiStr] = parts[i].split(','); if (ssid && rssiStr) { const trimmedSsid = ssid.trim(); const rssi = parseInt(rssiStr, 10); // Skip empty SSIDs if (!trimmedSsid) continue; // Keep the one with strongest signal if duplicate const existing = networksMap.get(trimmedSsid); if (!existing || rssi > existing.rssi) { networksMap.set(trimmedSsid, { ssid: trimmedSsid, rssi: rssi, }); } } } // Convert to array and sort by signal strength (strongest first) return Array.from(networksMap.values()).sort((a, b) => b.rssi - a.rssi); } /** * Configure WiFi on a device */ async setWiFi(deviceId: string, ssid: string, password: string): Promise { // Pre-validate credentials before BLE transmission if (ssid.includes('|') || ssid.includes(',')) { throw new Error('Network name contains invalid characters. Please select a different network.'); } if (password.includes('|')) { throw new Error('Password contains an invalid character (|). Please use a different password.'); } // Step 1: Unlock device let unlockResponse: string; try { unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; if (message.includes('timeout')) { throw new Error('Sensor not responding. Please move closer and try again.'); } throw new Error(`Cannot communicate with sensor: ${message}`); } if (!unlockResponse.includes('ok')) { throw new Error('Sensor authentication failed. Please try reconnecting.'); } // Step 1.5: Check if already connected to the target WiFi try { const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS); const parts = statusResponse.split('|'); if (parts.length >= 3) { const [currentSsid] = parts[2].split(','); if (currentSsid && currentSsid.trim().toLowerCase() === ssid.toLowerCase()) { return true; } } } catch { // Ignore status check errors - continue with WiFi config } // Step 2: Set WiFi credentials const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`; let setResponse: string; try { setResponse = await this.sendCommand(deviceId, command); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; if (message.includes('timeout')) { throw new Error('Sensor did not respond to WiFi config. Please try again.'); } throw new Error(`WiFi configuration failed: ${message}`); } // Parse response if (setResponse.includes('|W|ok')) { return true; } // WiFi config failed - check if sensor is still connected (using old credentials) if (setResponse.includes('|W|fail')) { try { const recheckResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS); const parts = recheckResponse.split('|'); if (parts.length >= 3) { const [currentSsid, rssiStr] = parts[2].split(','); const rssi = parseInt(rssiStr, 10); if (currentSsid && currentSsid.trim().toLowerCase() === ssid.toLowerCase() && rssi < 0) { return true; } } } catch { // Ignore recheck errors } throw new Error('WiFi password is incorrect. Please check and try again.'); } if (setResponse.includes('timeout') || setResponse.includes('Timeout')) { throw new Error('Sensor did not respond to WiFi config. Please try again.'); } if (setResponse.includes('not found') || setResponse.includes('no network')) { throw new Error('WiFi network not found. Make sure the sensor is within range of your router.'); } throw new Error('WiFi configuration failed. Please try again or contact support.'); } /** * Get current WiFi status from device */ async getCurrentWiFi(deviceId: string): Promise { // Step 1: Unlock device const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); if (!unlockResponse.includes('ok')) { throw new Error('Failed to unlock device'); } // Step 2: Get current WiFi status const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS); // Parse response: "mac,XXXXXX|a|SSID,RSSI" or "mac,XXXXXX|a|,0" (not connected) const parts = statusResponse.split('|'); if (parts.length < 3) { return null; } const [ssid, rssiStr] = parts[2].split(','); if (!ssid || ssid.trim() === '') { return null; // Not connected } return { ssid: ssid.trim(), rssi: parseInt(rssiStr, 10), connected: true, }; } /** * Reboot the device */ async rebootDevice(deviceId: string): Promise { // Step 1: Unlock device await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); // Step 2: Reboot (device will disconnect) try { await this.sendCommand(deviceId, BLE_COMMANDS.REBOOT); } catch { // Ignore timeout errors - device may reboot before responding } // Cleanup this.connectedDevices.delete(deviceId); this.gattServers.delete(deviceId); this.characteristics.delete(deviceId); this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED); this.emitEvent(deviceId, 'disconnected'); } /** * Cleanup all connections and state */ async cleanup(): Promise { // Disconnect all connected devices const deviceIds = Array.from(this.connectedDevices.keys()); for (const deviceId of deviceIds) { try { await this.disconnectDevice(deviceId); } catch { // Continue cleanup even if one device fails } } // Clear the maps and sets this.connectedDevices.clear(); this.gattServers.clear(); this.characteristics.clear(); this.connectionStates.clear(); this.connectingDevices.clear(); // Clear event listeners this.eventListeners = []; } } // Singleton instance let _webBluetoothManager: WebBluetoothManager | null = null; /** * Get the WebBluetoothManager singleton instance */ export function getWebBluetoothManager(): WebBluetoothManager { if (!_webBluetoothManager) { _webBluetoothManager = new WebBluetoothManager(); } return _webBluetoothManager; } // Default export export default getWebBluetoothManager;