diff --git a/components/errors/BrowserNotSupported.tsx b/components/errors/BrowserNotSupported.tsx new file mode 100644 index 0000000..a257bd4 --- /dev/null +++ b/components/errors/BrowserNotSupported.tsx @@ -0,0 +1,297 @@ +/** + * BrowserNotSupported - Error screen for unsupported browsers + * + * Displayed when Web Bluetooth is not available: + * - Safari (no Web Bluetooth support) + * - Firefox (no Web Bluetooth support) + * - Insecure context (HTTP instead of HTTPS) + */ + +import React from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Linking, + Platform, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { + WebBluetoothSupport, + getUnsupportedBrowserMessage, + BROWSER_HELP_URLS, +} from '@/services/ble/webBluetooth'; + +interface BrowserNotSupportedProps { + support: WebBluetoothSupport; + onDismiss?: () => void; +} + +/** + * Get browser-specific icon + */ +function getBrowserIcon(browserName: string): keyof typeof Ionicons.glyphMap { + switch (browserName.toLowerCase()) { + case 'safari': + return 'logo-apple'; + case 'firefox': + return 'logo-firefox'; + case 'chrome': + return 'logo-chrome'; + case 'edge': + return 'logo-edge'; + default: + return 'globe-outline'; + } +} + +/** + * Get suggested browser based on current browser + */ +function getSuggestedBrowser(currentBrowser: string): { name: string; url: string } { + // On desktop, suggest Chrome + // On mobile, suggest appropriate browser + if (Platform.OS === 'web') { + return { + name: 'Chrome', + url: BROWSER_HELP_URLS.Chrome, + }; + } + return { + name: 'Chrome', + url: BROWSER_HELP_URLS.Chrome, + }; +} + +export function BrowserNotSupported({ support, onDismiss }: BrowserNotSupportedProps) { + const errorInfo = getUnsupportedBrowserMessage(support); + const browserIcon = getBrowserIcon(support.browserName); + const suggestedBrowser = getSuggestedBrowser(support.browserName); + + const handleOpenChrome = async () => { + try { + await Linking.openURL(suggestedBrowser.url); + } catch { + // Ignore errors opening URL + } + }; + + return ( + + + {/* Browser Icon with X overlay */} + + + + + + + + + + {/* Title */} + {errorInfo.title} + + {/* Browser info */} + + {support.browserName} {support.browserVersion} + + + {/* Message */} + {errorInfo.message} + + {/* Suggestion */} + + + {errorInfo.suggestion} + + + {/* Supported browsers list */} + + Supported browsers: + + + + Chrome + + + + Edge + + + + Opera + + + + + {/* Action buttons */} + + + + Get Chrome + + + {onDismiss && ( + + Continue anyway + + )} + + + {/* Note for secure context */} + {support.reason === 'insecure_context' && ( + + + + This page must be served over HTTPS for Bluetooth to work. + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#fff', + padding: 24, + }, + content: { + alignItems: 'center', + maxWidth: 360, + }, + iconContainer: { + marginBottom: 24, + }, + iconWrapper: { + width: 96, + height: 96, + borderRadius: 48, + backgroundColor: '#F3F4F6', + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + }, + xOverlay: { + position: 'absolute', + bottom: -4, + right: -4, + backgroundColor: '#fff', + borderRadius: 12, + }, + title: { + fontSize: 22, + fontWeight: '700', + color: '#1F2937', + textAlign: 'center', + marginBottom: 4, + }, + browserInfo: { + fontSize: 14, + color: '#9CA3AF', + marginBottom: 12, + }, + message: { + fontSize: 15, + color: '#6B7280', + textAlign: 'center', + lineHeight: 22, + marginBottom: 16, + }, + suggestionBox: { + flexDirection: 'row', + alignItems: 'flex-start', + backgroundColor: '#EFF6FF', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 12, + marginBottom: 24, + gap: 10, + }, + suggestionText: { + flex: 1, + fontSize: 14, + color: '#1E40AF', + lineHeight: 20, + }, + supportedBrowsers: { + width: '100%', + marginBottom: 24, + }, + supportedTitle: { + fontSize: 13, + fontWeight: '600', + color: '#6B7280', + marginBottom: 12, + textAlign: 'center', + }, + browserList: { + flexDirection: 'row', + justifyContent: 'center', + gap: 24, + }, + browserItem: { + alignItems: 'center', + gap: 4, + }, + browserName: { + fontSize: 12, + color: '#6B7280', + }, + buttons: { + width: '100%', + gap: 12, + }, + downloadButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#3B82F6', + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 12, + }, + buttonIcon: { + marginRight: 8, + }, + downloadButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + dismissButton: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + }, + dismissButtonText: { + color: '#6B7280', + fontSize: 15, + }, + secureNote: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 24, + gap: 6, + }, + secureNoteText: { + fontSize: 12, + color: '#9CA3AF', + }, +}); + +export default BrowserNotSupported; diff --git a/components/errors/index.ts b/components/errors/index.ts index fd6957e..2d491b9 100644 --- a/components/errors/index.ts +++ b/components/errors/index.ts @@ -6,3 +6,4 @@ export { ErrorBoundary, withErrorBoundary } from './ErrorBoundary'; export { ErrorToast } from './ErrorToast'; export { FieldError, FieldErrorSummary } from './FieldError'; export { FullScreenError, EmptyState, OfflineState } from './FullScreenError'; +export { BrowserNotSupported } from './BrowserNotSupported'; diff --git a/services/ble/WebBLEManager.ts b/services/ble/WebBLEManager.ts new file mode 100644 index 0000000..5938e1c --- /dev/null +++ b/services/ble/WebBLEManager.ts @@ -0,0 +1,998 @@ +// Web Bluetooth BLE Manager for browser-based sensor configuration +// Supports Chrome, Edge, Opera (NOT Safari/Firefox) + +import { + IBLEManager, + WPDevice, + WiFiNetwork, + WiFiStatus, + BLE_CONFIG, + BLE_COMMANDS, + BLEConnectionState, + BLEDeviceConnection, + BLEEventListener, + BLEConnectionEvent, + SensorHealthMetrics, + SensorHealthStatus, + WiFiSignalQuality, + CommunicationHealth, + BulkOperationResult, + BulkWiFiResult, + ReconnectConfig, + ReconnectState, + DEFAULT_RECONNECT_CONFIG, +} from './types'; +import { + BLEError, + BLEErrorCode, + BLELogger, + isBLEError, + parseBLEError, +} from './errors'; +import { + checkWebBluetoothSupport, + getUnsupportedBrowserMessage, +} from './webBluetooth'; + +// Web Bluetooth API types +// These types are available when running in a browser that supports Web Bluetooth +// We use `any` here to avoid conflicts with different type definitions +type WebBluetoothDevice = { + id: string; + name?: string; + gatt?: WebBluetoothGATTServer; + addEventListener(type: string, listener: EventListener): void; + removeEventListener(type: string, listener: EventListener): void; +}; + +type WebBluetoothGATTServer = { + device: WebBluetoothDevice; + connected: boolean; + connect(): Promise; + disconnect(): void; + getPrimaryService(service: string): Promise; +}; + +type WebBluetoothGATTService = { + device: WebBluetoothDevice; + uuid: string; + getCharacteristic(characteristic: string): Promise; +}; + +type WebBluetoothGATTCharacteristic = { + service: WebBluetoothGATTService; + uuid: string; + value?: DataView; + startNotifications(): Promise; + stopNotifications(): Promise; + readValue(): Promise; + writeValue(value: BufferSource): Promise; + writeValueWithResponse(value: BufferSource): Promise; + addEventListener(type: string, listener: EventListener): void; + removeEventListener(type: string, listener: EventListener): void; +}; + +/** + * Web Bluetooth implementation of BLE Manager + * Works in Chrome, Edge, Opera on desktop and Android + */ +export class WebBLEManager implements IBLEManager { + private connectedDevices = new Map(); + private gattServers = new Map(); + private characteristics = new Map(); + private connectionStates = new Map(); + private eventListeners: BLEEventListener[] = []; + private connectingDevices = new Set(); + + // Health monitoring state + private sensorHealthMetrics = new Map(); + private communicationStats = new Map(); + + // Reconnect state + private reconnectConfig: ReconnectConfig = { ...DEFAULT_RECONNECT_CONFIG }; + private reconnectStates = new Map(); + private reconnectTimers = new Map>(); + + constructor() { + // Check browser support on initialization + const support = checkWebBluetoothSupport(); + if (!support.supported) { + const msg = getUnsupportedBrowserMessage(support); + BLELogger.warn(`Web Bluetooth not supported: ${msg.message}`); + } + } + + /** + * Check if Web Bluetooth is available before any operation + */ + private checkBluetoothAvailable(): void { + const support = checkWebBluetoothSupport(); + if (!support.supported) { + const msg = getUnsupportedBrowserMessage(support); + throw new BLEError(BLEErrorCode.BLUETOOTH_DISABLED, { + message: `${msg.title}: ${msg.message} ${msg.suggestion}`, + }); + } + } + + /** + * 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?: any): 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 WellNuo sensor devices + * In Web Bluetooth, this opens a browser picker dialog + */ + async scanDevices(): Promise { + this.checkBluetoothAvailable(); + BLELogger.log('[Web] Starting device scan (browser picker)...'); + + try { + // Request device with name prefix filter + const device = await navigator.bluetooth!.requestDevice({ + filters: [{ namePrefix: BLE_CONFIG.DEVICE_NAME_PREFIX }], + optionalServices: [BLE_CONFIG.SERVICE_UUID], + }); + + if (!device || !device.name) { + BLELogger.log('[Web] No device selected'); + return []; + } + + // Parse device info from name (WP_497_81a14c) + const wellIdMatch = device.name.match(/WP_(\d+)_/); + const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined; + + // Extract partial MAC from name + 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: -60, // Web Bluetooth doesn't provide RSSI during pairing + wellId, + }; + + BLELogger.log(`[Web] Device selected: ${device.name}`); + + // Store device reference for later connection + this.connectedDevices.set(device.id, device); + + return [wpDevice]; + } catch (error: any) { + // User cancelled the picker + if (error.name === 'NotFoundError' || error.message?.includes('cancelled')) { + BLELogger.log('[Web] Device selection cancelled by user'); + return []; + } + + // Permission denied + if (error.name === 'SecurityError' || error.name === 'NotAllowedError') { + throw new BLEError(BLEErrorCode.PERMISSION_DENIED, { + message: 'Bluetooth permission denied. Please allow access in your browser settings.', + originalError: error, + }); + } + + throw parseBLEError(error, { operation: 'scan' }); + } + } + + /** + * Stop scan - no-op in Web Bluetooth (picker handles this) + */ + stopScan(): void { + // Web Bluetooth doesn't have a continuous scan to stop + } + + /** + * Connect to a device by ID + */ + async connectDevice(deviceId: string): Promise { + this.checkBluetoothAvailable(); + const startTime = Date.now(); + BLELogger.log(`[Web] Connecting to device: ${deviceId}`); + + try { + // Check if connection is already in progress + if (this.connectingDevices.has(deviceId)) { + throw new BLEError(BLEErrorCode.CONNECTION_IN_PROGRESS, { deviceId }); + } + + // Check if already connected + const existingServer = this.gattServers.get(deviceId); + if (existingServer?.connected) { + BLELogger.log(`[Web] Device already connected: ${deviceId}`); + this.updateConnectionState(deviceId, BLEConnectionState.READY); + this.emitEvent(deviceId, 'ready'); + return true; + } + + // Get device reference + const device = this.connectedDevices.get(deviceId); + if (!device) { + throw new BLEError(BLEErrorCode.DEVICE_NOT_FOUND, { + deviceId, + message: 'Device not found. Please scan for devices first.', + }); + } + + // Mark as connecting + this.connectingDevices.add(deviceId); + this.updateConnectionState(deviceId, BLEConnectionState.CONNECTING, device.name || undefined); + + // Set up disconnection handler + device.addEventListener('gattserverdisconnected', () => { + this.handleDisconnection(deviceId, device.name); + }); + + // Connect to GATT server + if (!device.gatt) { + throw new BLEError(BLEErrorCode.CONNECTION_FAILED, { + deviceId, + message: 'Device does not support GATT', + }); + } + + const server = await device.gatt.connect(); + this.gattServers.set(deviceId, server); + this.updateConnectionState(deviceId, BLEConnectionState.CONNECTED, device.name || undefined); + + // Discover services + this.updateConnectionState(deviceId, BLEConnectionState.DISCOVERING, device.name || undefined); + const service = await server.getPrimaryService(BLE_CONFIG.SERVICE_UUID); + const characteristic = await service.getCharacteristic(BLE_CONFIG.CHAR_UUID); + this.characteristics.set(deviceId, characteristic); + + // Enable notifications + await characteristic.startNotifications(); + + // Ready + this.updateConnectionState(deviceId, BLEConnectionState.READY, device.name || undefined); + this.emitEvent(deviceId, 'ready'); + + const duration = Date.now() - startTime; + BLELogger.log(`[Web] Device ready: ${device.name || deviceId} (${(duration / 1000).toFixed(1)}s)`); + + return true; + } catch (error: any) { + const bleError = isBLEError(error) ? error : parseBLEError(error, { deviceId }); + const errorMessage = bleError.userMessage.message; + + this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, errorMessage); + this.emitEvent(deviceId, 'connection_failed', { error: errorMessage, code: bleError.code }); + BLELogger.error(`[Web] Connection failed for ${deviceId}`, bleError); + + return false; + } finally { + this.connectingDevices.delete(deviceId); + } + } + + /** + * Handle device disconnection + */ + private handleDisconnection(deviceId: string, deviceName?: string): void { + BLELogger.log(`[Web] Device disconnected: ${deviceName || deviceId}`); + + // Clean up + this.gattServers.delete(deviceId); + this.characteristics.delete(deviceId); + + this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, deviceName); + this.emitEvent(deviceId, 'disconnected', { unexpected: true }); + + // Handle auto-reconnect if enabled + if (this.reconnectConfig.enabled) { + const state = this.reconnectStates.get(deviceId); + if (state && state.attempts < this.reconnectConfig.maxAttempts) { + this.scheduleReconnect(deviceId, deviceName || deviceId); + } + } + } + + /** + * Disconnect from a device + */ + async disconnectDevice(deviceId: string): Promise { + BLELogger.log(`[Web] Disconnecting device: ${deviceId}`); + + this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTING); + + // Stop notifications + const characteristic = this.characteristics.get(deviceId); + if (characteristic) { + try { + await characteristic.stopNotifications(); + } catch { + // Ignore errors during cleanup + } + } + + // Disconnect GATT + const server = this.gattServers.get(deviceId); + if (server?.connected) { + server.disconnect(); + } + + // Clean up + this.gattServers.delete(deviceId); + this.characteristics.delete(deviceId); + + this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED); + this.emitEvent(deviceId, 'disconnected'); + } + + /** + * Check if device is connected + */ + isDeviceConnected(deviceId: string): boolean { + const server = this.gattServers.get(deviceId); + return server?.connected || false; + } + + /** + * Update communication stats for a device + */ + private updateCommunicationStats(deviceKey: string, success: boolean, responseTime: number): void { + const existing = this.communicationStats.get(deviceKey); + const now = Date.now(); + + if (!existing) { + this.communicationStats.set(deviceKey, { + successfulCommands: success ? 1 : 0, + failedCommands: success ? 0 : 1, + averageResponseTime: responseTime, + lastSuccessfulCommand: success ? now : 0, + lastFailedCommand: success ? undefined : now, + }); + } else { + const totalCommands = existing.successfulCommands + existing.failedCommands; + const newAverage = + (existing.averageResponseTime * totalCommands + responseTime) / (totalCommands + 1); + + this.communicationStats.set(deviceKey, { + successfulCommands: existing.successfulCommands + (success ? 1 : 0), + failedCommands: existing.failedCommands + (success ? 0 : 1), + averageResponseTime: newAverage, + lastSuccessfulCommand: success ? now : existing.lastSuccessfulCommand, + lastFailedCommand: success ? existing.lastFailedCommand : now, + }); + } + } + + /** + * Send a command to a device and wait for response + */ + async sendCommand(deviceId: string, command: string): Promise { + const startTime = Date.now(); + const safeCommand = command.length > 20 ? command.substring(0, 20) + '...' : command; + BLELogger.log(`[Web] Sending command to ${deviceId}: ${safeCommand}`); + + const characteristic = this.characteristics.get(deviceId); + if (!characteristic) { + throw new BLEError(BLEErrorCode.DEVICE_DISCONNECTED, { deviceId }); + } + + return new Promise(async (resolve, reject) => { + let responseReceived = false; + let timeoutId: ReturnType | null = null; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + characteristic.removeEventListener('characteristicvaluechanged', handleNotification as EventListener); + }; + + const handleNotification = (event: Event) => { + const target = event.target as unknown as WebBluetoothGATTCharacteristic; + if (!target.value || responseReceived) return; + + responseReceived = true; + cleanup(); + + // Decode response + const decoder = new TextDecoder('utf-8'); + const response = decoder.decode(target.value); + + // Track successful command + const responseTime = Date.now() - startTime; + this.updateCommunicationStats(deviceId, true, responseTime); + + resolve(response); + }; + + try { + // Set up notification handler + characteristic.addEventListener('characteristicvaluechanged', handleNotification as EventListener); + + // Send command + const encoder = new TextEncoder(); + const data = encoder.encode(command); + await characteristic.writeValueWithResponse(data); + + // Set timeout + timeoutId = setTimeout(() => { + if (!responseReceived) { + responseReceived = true; + cleanup(); + + const responseTime = Date.now() - startTime; + this.updateCommunicationStats(deviceId, false, responseTime); + + reject(new BLEError(BLEErrorCode.COMMAND_TIMEOUT, { + deviceId, + message: `Command timed out after ${BLE_CONFIG.COMMAND_TIMEOUT}ms`, + })); + } + }, BLE_CONFIG.COMMAND_TIMEOUT); + } catch (error: any) { + cleanup(); + + const responseTime = Date.now() - startTime; + this.updateCommunicationStats(deviceId, false, responseTime); + + reject(parseBLEError(error, { deviceId, operation: 'command' })); + } + }); + } + + /** + * Get WiFi networks list from sensor + */ + async getWiFiList(deviceId: string): Promise { + BLELogger.log(`[Web] Getting WiFi list from device: ${deviceId}`); + + // Step 1: Unlock device + const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + if (!unlockResponse.includes('ok')) { + throw new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { deviceId }); + } + + // 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 BLEError(BLEErrorCode.INVALID_RESPONSE, { + deviceId, + message: 'Invalid WiFi list response format', + }); + } + + const count = parseInt(parts[2], 10); + if (count < 0) { + if (count === -1) { + throw new BLEError(BLEErrorCode.WIFI_SCAN_IN_PROGRESS, { deviceId }); + } + if (count === -2) { + return []; // No networks found + } + } + + // Use Map to deduplicate by SSID + 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); + + if (!trimmedSsid) continue; + + const existing = networksMap.get(trimmedSsid); + if (!existing || rssi > existing.rssi) { + networksMap.set(trimmedSsid, { ssid: trimmedSsid, rssi }); + } + } + } + + return Array.from(networksMap.values()).sort((a, b) => b.rssi - a.rssi); + } + + /** + * Configure WiFi on sensor + */ + async setWiFi(deviceId: string, ssid: string, password: string): Promise { + BLELogger.log(`[Web] Setting WiFi on device: ${deviceId}, SSID: ${ssid}`); + + // Validate credentials + if (ssid.includes('|') || ssid.includes(',')) { + throw new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, { + deviceId, + message: 'Network name contains invalid characters', + }); + } + if (password.includes('|')) { + throw new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, { + deviceId, + message: 'Password contains an invalid character (|)', + }); + } + + // Step 1: Unlock device + const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + if (!unlockResponse.includes('ok')) { + throw new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { deviceId }); + } + + // Step 2: Set WiFi credentials + const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`; + const setResponse = await this.sendCommand(deviceId, command); + + if (setResponse.includes('|W|ok')) { + BLELogger.log(`[Web] WiFi configured successfully for ${ssid}`); + return true; + } + + if (setResponse.includes('|W|fail')) { + throw new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT, { deviceId }); + } + + throw new BLEError(BLEErrorCode.WIFI_CONFIG_FAILED, { + deviceId, + message: `Unexpected response: ${setResponse}`, + }); + } + + /** + * Get current WiFi status from sensor + */ + async getCurrentWiFi(deviceId: string): Promise { + BLELogger.log(`[Web] Getting current WiFi status from device: ${deviceId}`); + + // Step 1: Unlock device + const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + if (!unlockResponse.includes('ok')) { + throw new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { deviceId }); + } + + // Step 2: Get current WiFi status + const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS); + + // Parse response: "mac,XXXXXX|a|SSID,RSSI" + const parts = statusResponse.split('|'); + if (parts.length < 3) { + return null; + } + + const [ssid, rssiStr] = parts[2].split(','); + if (!ssid || ssid.trim() === '') { + return null; + } + + return { + ssid: ssid.trim(), + rssi: parseInt(rssiStr, 10), + connected: true, + }; + } + + /** + * Reboot sensor + */ + async rebootDevice(deviceId: string): Promise { + BLELogger.log(`[Web] Rebooting device: ${deviceId}`); + + try { + await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + await this.sendCommand(deviceId, BLE_COMMANDS.REBOOT); + } catch (error: any) { + if (isBLEError(error)) throw error; + throw new BLEError(BLEErrorCode.SENSOR_REBOOT_FAILED, { + deviceId, + originalError: error, + }); + } + + // Clean up after reboot + this.gattServers.delete(deviceId); + this.characteristics.delete(deviceId); + } + + /** + * Get sensor health metrics + */ + async getSensorHealth(wellId: number, mac: string): Promise { + // Web Bluetooth requires user interaction for each device scan + // Return cached metrics or null + const deviceKey = `WP_${wellId}_${mac.slice(-6).toLowerCase()}`; + return this.sensorHealthMetrics.get(deviceKey) || null; + } + + /** + * Get all cached sensor health metrics + */ + getAllSensorHealth(): Map { + return new Map(this.sensorHealthMetrics); + } + + /** + * Cleanup all connections + */ + async cleanup(): Promise { + BLELogger.log('[Web] Cleaning up BLE connections'); + + // Cancel all reconnect timers + this.reconnectTimers.forEach((timer) => { + clearTimeout(timer); + }); + this.reconnectTimers.clear(); + this.reconnectStates.clear(); + + // Disconnect all devices + const deviceIds = Array.from(this.gattServers.keys()); + for (const deviceId of deviceIds) { + try { + await this.disconnectDevice(deviceId); + } catch { + // Continue cleanup + } + } + + // Clear all state + this.connectedDevices.clear(); + this.gattServers.clear(); + this.characteristics.clear(); + this.connectionStates.clear(); + this.connectingDevices.clear(); + this.sensorHealthMetrics.clear(); + this.communicationStats.clear(); + this.eventListeners = []; + } + + /** + * Bulk disconnect multiple devices + */ + async bulkDisconnect(deviceIds: string[]): Promise { + const results: BulkOperationResult[] = []; + + for (const deviceId of deviceIds) { + const connection = this.connectionStates.get(deviceId); + const deviceName = connection?.deviceName || deviceId; + + try { + await this.disconnectDevice(deviceId); + results.push({ deviceId, deviceName, success: true }); + } catch (error: any) { + results.push({ + deviceId, + deviceName, + success: false, + error: error?.message || 'Disconnect failed', + }); + } + } + + return results; + } + + /** + * Bulk reboot multiple devices + */ + async bulkReboot(deviceIds: string[]): Promise { + const results: BulkOperationResult[] = []; + + for (const deviceId of deviceIds) { + const connection = this.connectionStates.get(deviceId); + const deviceName = connection?.deviceName || deviceId; + + try { + await this.rebootDevice(deviceId); + results.push({ deviceId, deviceName, success: true }); + } catch (error: any) { + results.push({ + deviceId, + deviceName, + success: false, + error: error?.message || 'Reboot failed', + }); + } + } + + return results; + } + + /** + * Bulk WiFi configuration + */ + async bulkSetWiFi( + devices: { id: string; name: string }[], + ssid: string, + password: string, + onProgress?: (deviceId: string, status: 'connecting' | 'configuring' | 'rebooting' | 'success' | 'error', error?: string) => void + ): Promise { + const results: BulkWiFiResult[] = []; + const total = devices.length; + const batchStartTime = Date.now(); + + BLELogger.log(`[Web] Starting bulk WiFi setup for ${total} devices, SSID: ${ssid}`); + + for (let i = 0; i < devices.length; i++) { + const { id: deviceId, name: deviceName } = devices[i]; + const index = i + 1; + + try { + // Step 1: Connect + BLELogger.logBatchProgress(index, total, deviceName, 'connecting...'); + onProgress?.(deviceId, 'connecting'); + const connected = await this.connectDevice(deviceId); + if (!connected) { + throw new BLEError(BLEErrorCode.CONNECTION_FAILED, { deviceId, deviceName }); + } + + // Step 2: Set WiFi + BLELogger.logBatchProgress(index, total, deviceName, 'setting WiFi...'); + onProgress?.(deviceId, 'configuring'); + await this.setWiFi(deviceId, ssid, password); + + // Step 3: Reboot + BLELogger.logBatchProgress(index, total, deviceName, 'rebooting...'); + onProgress?.(deviceId, 'rebooting'); + await this.rebootDevice(deviceId); + + // Success + BLELogger.logBatchProgress(index, total, deviceName, 'SUCCESS', true); + onProgress?.(deviceId, 'success'); + results.push({ deviceId, deviceName, success: true }); + } catch (error: any) { + const bleError = isBLEError(error) ? error : parseBLEError(error, { deviceId, deviceName }); + const errorMessage = bleError.userMessage.message; + + BLELogger.logBatchProgress(index, total, deviceName, `ERROR: ${errorMessage}`, false); + onProgress?.(deviceId, 'error', errorMessage); + results.push({ deviceId, deviceName, success: false, error: errorMessage }); + } + } + + // Log summary + const succeeded = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + const batchDuration = Date.now() - batchStartTime; + BLELogger.logBatchSummary(total, succeeded, failed, batchDuration); + + return results; + } + + // ==================== RECONNECT FUNCTIONALITY ==================== + + setReconnectConfig(config: Partial): void { + this.reconnectConfig = { ...this.reconnectConfig, ...config }; + } + + getReconnectConfig(): ReconnectConfig { + return { ...this.reconnectConfig }; + } + + enableAutoReconnect(deviceId: string, deviceName?: string): void { + const device = this.connectedDevices.get(deviceId); + this.reconnectStates.set(deviceId, { + deviceId, + deviceName: deviceName || device?.name || deviceId, + attempts: 0, + lastAttemptTime: 0, + isReconnecting: false, + }); + } + + disableAutoReconnect(deviceId: string): void { + this.cancelReconnect(deviceId); + this.reconnectStates.delete(deviceId); + } + + cancelReconnect(deviceId: string): void { + const timer = this.reconnectTimers.get(deviceId); + if (timer) { + clearTimeout(timer); + this.reconnectTimers.delete(deviceId); + } + + const state = this.reconnectStates.get(deviceId); + if (state?.isReconnecting) { + this.reconnectStates.set(deviceId, { + ...state, + isReconnecting: false, + nextAttemptTime: undefined, + }); + } + } + + private scheduleReconnect(deviceId: string, deviceName: string): void { + const state = this.reconnectStates.get(deviceId); + if (!state) return; + + const delay = Math.min( + this.reconnectConfig.delayMs * Math.pow(this.reconnectConfig.backoffMultiplier, state.attempts), + this.reconnectConfig.maxDelayMs + ); + + const nextAttemptTime = Date.now() + delay; + + this.reconnectStates.set(deviceId, { + ...state, + nextAttemptTime, + isReconnecting: true, + }); + + this.emitEvent(deviceId, 'state_changed', { + state: BLEConnectionState.CONNECTING, + reconnecting: true, + nextAttemptIn: delay, + }); + + const timer = setTimeout(() => { + this.attemptReconnect(deviceId, deviceName); + }, delay); + + this.reconnectTimers.set(deviceId, timer); + } + + private async attemptReconnect(deviceId: string, deviceName: string): Promise { + const state = this.reconnectStates.get(deviceId); + if (!state) return; + + const newAttempts = state.attempts + 1; + this.reconnectStates.set(deviceId, { + ...state, + attempts: newAttempts, + lastAttemptTime: Date.now(), + isReconnecting: true, + }); + + try { + const success = await this.connectDevice(deviceId); + + if (success) { + this.reconnectStates.set(deviceId, { + deviceId, + deviceName, + attempts: 0, + lastAttemptTime: Date.now(), + isReconnecting: false, + }); + this.emitEvent(deviceId, 'ready', { reconnected: true }); + } else { + throw new Error('Connection failed'); + } + } catch (error: any) { + this.reconnectStates.set(deviceId, { + ...state, + attempts: newAttempts, + lastAttemptTime: Date.now(), + isReconnecting: newAttempts < this.reconnectConfig.maxAttempts, + lastError: error?.message || 'Reconnection failed', + }); + + if (newAttempts < this.reconnectConfig.maxAttempts) { + this.scheduleReconnect(deviceId, deviceName); + } else { + this.updateConnectionState(deviceId, BLEConnectionState.ERROR, deviceName, 'Max reconnection attempts reached'); + this.emitEvent(deviceId, 'connection_failed', { + error: 'Max reconnection attempts reached', + reconnectFailed: true, + }); + } + } + } + + async manualReconnect(deviceId: string): Promise { + this.cancelReconnect(deviceId); + + const state = this.reconnectStates.get(deviceId); + const connection = this.connectionStates.get(deviceId); + const deviceName = state?.deviceName || connection?.deviceName || deviceId; + + this.reconnectStates.set(deviceId, { + deviceId, + deviceName, + attempts: 0, + lastAttemptTime: Date.now(), + isReconnecting: true, + }); + + try { + const success = await this.connectDevice(deviceId); + + this.reconnectStates.set(deviceId, { + deviceId, + deviceName, + attempts: 0, + lastAttemptTime: Date.now(), + isReconnecting: false, + }); + + return success; + } catch (error: any) { + this.reconnectStates.set(deviceId, { + deviceId, + deviceName, + attempts: 1, + lastAttemptTime: Date.now(), + isReconnecting: false, + lastError: error?.message || 'Reconnection failed', + }); + return false; + } + } + + getReconnectState(deviceId: string): ReconnectState | undefined { + return this.reconnectStates.get(deviceId); + } + + getAllReconnectStates(): Map { + return new Map(this.reconnectStates); + } +} diff --git a/services/ble/__tests__/WebBLEManager.test.ts b/services/ble/__tests__/WebBLEManager.test.ts new file mode 100644 index 0000000..2df311f --- /dev/null +++ b/services/ble/__tests__/WebBLEManager.test.ts @@ -0,0 +1,357 @@ +// Tests for WebBLEManager + +import { BLEErrorCode } from '../errors'; +import { BLEConnectionState } from '../types'; + +// Mock the webBluetooth module before importing WebBLEManager +jest.mock('../webBluetooth', () => ({ + checkWebBluetoothSupport: jest.fn(() => ({ + supported: true, + browserName: 'Chrome', + browserVersion: '120', + })), + getUnsupportedBrowserMessage: jest.fn(() => ({ + title: 'Browser Not Supported', + message: 'Safari does not support Web Bluetooth.', + suggestion: 'Please use Chrome.', + })), +})); + +import { WebBLEManager } from '../WebBLEManager'; +import { checkWebBluetoothSupport } from '../webBluetooth'; + +// Mock Web Bluetooth API +const mockDevice: any = { + id: 'device-123', + name: 'WP_497_81a14c', + gatt: { + connected: false, + connect: jest.fn(), + disconnect: jest.fn(), + getPrimaryService: jest.fn(), + }, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +}; + +const mockCharacteristic: any = { + startNotifications: jest.fn(), + stopNotifications: jest.fn(), + writeValueWithResponse: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +}; + +const mockService: any = { + getCharacteristic: jest.fn(() => Promise.resolve(mockCharacteristic)), +}; + +const mockServer: any = { + device: mockDevice, + connected: true, + connect: jest.fn(() => Promise.resolve(mockServer)), + disconnect: jest.fn(), + getPrimaryService: jest.fn(() => Promise.resolve(mockService)), +}; + +describe('WebBLEManager', () => { + let manager: WebBLEManager; + const originalNavigator = global.navigator; + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset mock implementation + (checkWebBluetoothSupport as jest.Mock).mockReturnValue({ + supported: true, + browserName: 'Chrome', + browserVersion: '120', + }); + + // Mock navigator.bluetooth + Object.defineProperty(global, 'navigator', { + value: { + ...originalNavigator, + bluetooth: { + requestDevice: jest.fn(() => Promise.resolve(mockDevice)), + }, + }, + writable: true, + configurable: true, + }); + + // Reset device mocks + mockDevice.gatt = { + connected: false, + connect: jest.fn(() => { + mockDevice.gatt.connected = true; + return Promise.resolve(mockServer); + }), + disconnect: jest.fn(() => { + mockDevice.gatt.connected = false; + }), + getPrimaryService: jest.fn(() => Promise.resolve(mockService)), + }; + + mockServer.connected = true; + mockServer.getPrimaryService.mockResolvedValue(mockService); + mockCharacteristic.startNotifications.mockResolvedValue(mockCharacteristic); + + manager = new WebBLEManager(); + }); + + afterEach(() => { + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }); + }); + + describe('constructor', () => { + it('should create manager when Web Bluetooth is supported', () => { + expect(manager).toBeDefined(); + }); + + it('should warn when Web Bluetooth is not supported', () => { + (checkWebBluetoothSupport as jest.Mock).mockReturnValue({ + supported: false, + browserName: 'Safari', + browserVersion: '17', + reason: 'unsupported_browser', + }); + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + new WebBLEManager(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('scanDevices', () => { + it('should return device when user selects one', async () => { + const devices = await manager.scanDevices(); + + expect(devices).toHaveLength(1); + expect(devices[0].id).toBe('device-123'); + expect(devices[0].name).toBe('WP_497_81a14c'); + expect(devices[0].wellId).toBe(497); + expect(devices[0].mac).toBe('81A14C'); + }); + + it('should return empty array when user cancels', async () => { + (navigator.bluetooth!.requestDevice as jest.Mock).mockRejectedValue( + new Error('User cancelled') + ); + + const devices = await manager.scanDevices(); + expect(devices).toHaveLength(0); + }); + + it('should throw BLEError for permission denied', async () => { + const permissionError = new Error('Permission denied'); + (permissionError as any).name = 'NotAllowedError'; + (navigator.bluetooth!.requestDevice as jest.Mock).mockRejectedValue(permissionError); + + await expect(manager.scanDevices()).rejects.toMatchObject({ + code: BLEErrorCode.PERMISSION_DENIED, + }); + }); + + it('should throw BLEError when Web Bluetooth not supported', async () => { + (checkWebBluetoothSupport as jest.Mock).mockReturnValue({ + supported: false, + browserName: 'Safari', + browserVersion: '17', + reason: 'unsupported_browser', + }); + + const newManager = new WebBLEManager(); + await expect(newManager.scanDevices()).rejects.toMatchObject({ + code: BLEErrorCode.BLUETOOTH_DISABLED, + }); + }); + }); + + describe('connectDevice', () => { + beforeEach(async () => { + // First scan to get device reference + await manager.scanDevices(); + }); + + it('should connect to device successfully', async () => { + const result = await manager.connectDevice('device-123'); + + expect(result).toBe(true); + expect(manager.getConnectionState('device-123')).toBe(BLEConnectionState.READY); + expect(mockDevice.gatt.connect).toHaveBeenCalled(); + expect(mockCharacteristic.startNotifications).toHaveBeenCalled(); + }); + + it('should throw error for unknown device', async () => { + const result = await manager.connectDevice('unknown-device'); + expect(result).toBe(false); + }); + + it('should return true if already connected', async () => { + // First connection + await manager.connectDevice('device-123'); + + // Second connection attempt + const result = await manager.connectDevice('device-123'); + expect(result).toBe(true); + }); + + it('should emit state_changed event with ready state on successful connection', async () => { + const listener = jest.fn(); + manager.addEventListener(listener); + + await manager.connectDevice('device-123'); + + expect(listener).toHaveBeenCalledWith( + 'device-123', + 'state_changed', + expect.objectContaining({ state: 'ready' }) + ); + }); + }); + + describe('disconnectDevice', () => { + beforeEach(async () => { + await manager.scanDevices(); + await manager.connectDevice('device-123'); + }); + + it('should disconnect device', async () => { + await manager.disconnectDevice('device-123'); + + expect(manager.getConnectionState('device-123')).toBe(BLEConnectionState.DISCONNECTED); + expect(manager.isDeviceConnected('device-123')).toBe(false); + }); + + it('should emit disconnected event', async () => { + const listener = jest.fn(); + manager.addEventListener(listener); + + await manager.disconnectDevice('device-123'); + + expect(listener).toHaveBeenCalledWith( + 'device-123', + 'disconnected', + undefined + ); + }); + }); + + describe('isDeviceConnected', () => { + it('should return false for unknown device', () => { + expect(manager.isDeviceConnected('unknown')).toBe(false); + }); + + it('should return true for connected device', async () => { + await manager.scanDevices(); + await manager.connectDevice('device-123'); + + expect(manager.isDeviceConnected('device-123')).toBe(true); + }); + }); + + describe('event listeners', () => { + it('should add and remove event listeners', () => { + const listener = jest.fn(); + + manager.addEventListener(listener); + // Trigger an event by changing state + (manager as any).emitEvent('device-123', 'ready', {}); + expect(listener).toHaveBeenCalledTimes(1); + + manager.removeEventListener(listener); + (manager as any).emitEvent('device-123', 'ready', {}); + expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called again + }); + + it('should not add duplicate listeners', () => { + const listener = jest.fn(); + + manager.addEventListener(listener); + manager.addEventListener(listener); + + (manager as any).emitEvent('device-123', 'ready', {}); + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + describe('cleanup', () => { + it('should disconnect all devices and clear state', async () => { + await manager.scanDevices(); + await manager.connectDevice('device-123'); + + await manager.cleanup(); + + expect(manager.getAllConnections().size).toBe(0); + expect(manager.isDeviceConnected('device-123')).toBe(false); + }); + }); + + describe('reconnect functionality', () => { + it('should set and get reconnect config', () => { + manager.setReconnectConfig({ maxAttempts: 5 }); + const config = manager.getReconnectConfig(); + expect(config.maxAttempts).toBe(5); + }); + + it('should enable and disable auto reconnect', async () => { + await manager.scanDevices(); + await manager.connectDevice('device-123'); + + manager.enableAutoReconnect('device-123', 'Test Device'); + let state = manager.getReconnectState('device-123'); + expect(state).toBeDefined(); + expect(state?.deviceName).toBe('Test Device'); + + manager.disableAutoReconnect('device-123'); + state = manager.getReconnectState('device-123'); + expect(state).toBeUndefined(); + }); + + it('should cancel reconnect', async () => { + await manager.scanDevices(); + manager.enableAutoReconnect('device-123', 'Test Device'); + + // Set as reconnecting + (manager as any).reconnectStates.set('device-123', { + deviceId: 'device-123', + deviceName: 'Test Device', + attempts: 1, + lastAttemptTime: Date.now(), + isReconnecting: true, + }); + + manager.cancelReconnect('device-123'); + const state = manager.getReconnectState('device-123'); + expect(state?.isReconnecting).toBe(false); + }); + }); + + describe('bulk operations', () => { + beforeEach(async () => { + await manager.scanDevices(); + await manager.connectDevice('device-123'); + }); + + it('should bulk disconnect devices', async () => { + const results = await manager.bulkDisconnect(['device-123']); + + expect(results).toHaveLength(1); + expect(results[0].success).toBe(true); + expect(results[0].deviceId).toBe('device-123'); + }); + + it('should handle errors in bulk disconnect', async () => { + const results = await manager.bulkDisconnect(['unknown-device']); + + expect(results).toHaveLength(1); + expect(results[0].success).toBe(true); // disconnect on unknown is no-op + }); + }); +}); diff --git a/services/ble/__tests__/webBluetooth.test.ts b/services/ble/__tests__/webBluetooth.test.ts new file mode 100644 index 0000000..e19c707 --- /dev/null +++ b/services/ble/__tests__/webBluetooth.test.ts @@ -0,0 +1,337 @@ +// Tests for Web Bluetooth browser compatibility utilities + +import { + detectBrowser, + checkWebBluetoothSupport, + getUnsupportedBrowserMessage, + isWebPlatform, + hasWebBluetooth, + SUPPORTED_BROWSERS, + BROWSER_HELP_URLS, +} from '../webBluetooth'; + +describe('webBluetooth utilities', () => { + // Store original navigator and window + const originalNavigator = global.navigator; + const originalWindow = global.window; + + afterEach(() => { + // Restore originals + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }); + Object.defineProperty(global, 'window', { + value: originalWindow, + writable: true, + configurable: true, + }); + }); + + describe('detectBrowser', () => { + it('should detect Chrome', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + writable: true, + configurable: true, + }); + + const browser = detectBrowser(); + expect(browser.name).toBe('Chrome'); + expect(browser.version).toBe('120'); + }); + + it('should detect Safari', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', + }, + writable: true, + configurable: true, + }); + + const browser = detectBrowser(); + expect(browser.name).toBe('Safari'); + expect(browser.version).toBe('17'); + }); + + it('should detect Firefox', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0', + }, + writable: true, + configurable: true, + }); + + const browser = detectBrowser(); + expect(browser.name).toBe('Firefox'); + expect(browser.version).toBe('122'); + }); + + it('should detect Edge', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + }, + writable: true, + configurable: true, + }); + + const browser = detectBrowser(); + expect(browser.name).toBe('Edge'); + expect(browser.version).toBe('120'); + }); + + it('should detect Opera', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0', + }, + writable: true, + configurable: true, + }); + + const browser = detectBrowser(); + expect(browser.name).toBe('Opera'); + expect(browser.version).toBe('106'); + }); + + it('should detect Samsung Internet', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36', + }, + writable: true, + configurable: true, + }); + + const browser = detectBrowser(); + expect(browser.name).toBe('Samsung Internet'); + expect(browser.version).toBe('23'); + }); + + it('should return Unknown for undefined navigator', () => { + Object.defineProperty(global, 'navigator', { + value: undefined, + writable: true, + configurable: true, + }); + + const browser = detectBrowser(); + expect(browser.name).toBe('Unknown'); + expect(browser.version).toBe('0'); + }); + }); + + describe('checkWebBluetoothSupport', () => { + it('should return supported for Chrome with Web Bluetooth', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 Chrome/120.0.0.0', + bluetooth: {}, + }, + writable: true, + configurable: true, + }); + Object.defineProperty(global, 'window', { + value: { isSecureContext: true }, + writable: true, + configurable: true, + }); + + const support = checkWebBluetoothSupport(); + expect(support.supported).toBe(true); + expect(support.browserName).toBe('Chrome'); + }); + + it('should return unsupported for Safari', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X) Safari/605.1.15 Version/17.0', + }, + writable: true, + configurable: true, + }); + Object.defineProperty(global, 'window', { + value: { isSecureContext: true }, + writable: true, + configurable: true, + }); + + const support = checkWebBluetoothSupport(); + expect(support.supported).toBe(false); + expect(support.browserName).toBe('Safari'); + expect(support.reason).toBe('unsupported_browser'); + }); + + it('should return unsupported for Firefox', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 Firefox/122.0', + }, + writable: true, + configurable: true, + }); + Object.defineProperty(global, 'window', { + value: { isSecureContext: true }, + writable: true, + configurable: true, + }); + + const support = checkWebBluetoothSupport(); + expect(support.supported).toBe(false); + expect(support.browserName).toBe('Firefox'); + expect(support.reason).toBe('unsupported_browser'); + }); + + it('should return unsupported for insecure context', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 Chrome/120.0.0.0', + bluetooth: {}, + }, + writable: true, + configurable: true, + }); + Object.defineProperty(global, 'window', { + value: { isSecureContext: false }, + writable: true, + configurable: true, + }); + + const support = checkWebBluetoothSupport(); + expect(support.supported).toBe(false); + expect(support.reason).toBe('insecure_context'); + }); + + it('should return api_unavailable for Chrome without bluetooth API', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 Chrome/120.0.0.0', + // No bluetooth property + }, + writable: true, + configurable: true, + }); + Object.defineProperty(global, 'window', { + value: { isSecureContext: true }, + writable: true, + configurable: true, + }); + + const support = checkWebBluetoothSupport(); + expect(support.supported).toBe(false); + expect(support.reason).toBe('api_unavailable'); + }); + }); + + describe('getUnsupportedBrowserMessage', () => { + it('should return correct message for unsupported browser', () => { + const support = { + supported: false, + browserName: 'Safari', + browserVersion: '17', + reason: 'unsupported_browser' as const, + }; + + const message = getUnsupportedBrowserMessage(support); + expect(message.title).toBe('Browser Not Supported'); + expect(message.message).toContain('Safari'); + expect(message.suggestion).toContain('Chrome'); + }); + + it('should return correct message for insecure context', () => { + const support = { + supported: false, + browserName: 'Chrome', + browserVersion: '120', + reason: 'insecure_context' as const, + }; + + const message = getUnsupportedBrowserMessage(support); + expect(message.title).toBe('Secure Connection Required'); + expect(message.message).toContain('HTTPS'); + }); + + it('should return correct message for api unavailable', () => { + const support = { + supported: false, + browserName: 'Chrome', + browserVersion: '120', + reason: 'api_unavailable' as const, + }; + + const message = getUnsupportedBrowserMessage(support); + expect(message.title).toBe('Bluetooth Not Available'); + }); + }); + + describe('isWebPlatform', () => { + it('should return true when window and document exist', () => { + Object.defineProperty(global, 'window', { + value: {}, + writable: true, + configurable: true, + }); + Object.defineProperty(global, 'document', { + value: {}, + writable: true, + configurable: true, + }); + + expect(isWebPlatform()).toBe(true); + }); + + it('should return false when window is undefined', () => { + Object.defineProperty(global, 'window', { + value: undefined, + writable: true, + configurable: true, + }); + + expect(isWebPlatform()).toBe(false); + }); + }); + + describe('hasWebBluetooth', () => { + it('should return true when navigator.bluetooth exists', () => { + Object.defineProperty(global, 'navigator', { + value: { bluetooth: {} }, + writable: true, + configurable: true, + }); + + expect(hasWebBluetooth()).toBe(true); + }); + + it('should return false when navigator.bluetooth does not exist', () => { + Object.defineProperty(global, 'navigator', { + value: {}, + writable: true, + configurable: true, + }); + + expect(hasWebBluetooth()).toBe(false); + }); + }); + + describe('constants', () => { + it('should have supported browsers list', () => { + expect(SUPPORTED_BROWSERS).toContain('Chrome'); + expect(SUPPORTED_BROWSERS).toContain('Edge'); + expect(SUPPORTED_BROWSERS).toContain('Opera'); + expect(SUPPORTED_BROWSERS).not.toContain('Safari'); + expect(SUPPORTED_BROWSERS).not.toContain('Firefox'); + }); + + it('should have browser help URLs', () => { + expect(BROWSER_HELP_URLS.Chrome).toContain('google.com/chrome'); + expect(BROWSER_HELP_URLS.Edge).toContain('microsoft.com'); + expect(BROWSER_HELP_URLS.Opera).toContain('opera.com'); + }); + }); +}); diff --git a/services/ble/index.ts b/services/ble/index.ts index 8ed057f..1e035e6 100644 --- a/services/ble/index.ts +++ b/services/ble/index.ts @@ -1,22 +1,33 @@ // BLE Service entry point +import { Platform } from 'react-native'; import * as Device from 'expo-device'; import { IBLEManager } from './types'; +// Check if running on web platform +const isWeb = Platform.OS === 'web'; + // Determine if BLE is available (real device vs simulator) -export const isBLEAvailable = Device.isDevice; +// On web, we use Web Bluetooth API which is always "available" (browser will show error if not) +export const isBLEAvailable = isWeb ? true : Device.isDevice; // Lazy singleton - only create BLEManager when first accessed let _bleManager: IBLEManager | null = null; function getBLEManager(): IBLEManager { if (!_bleManager) { - // Dynamic import to prevent crash on Android startup - if (isBLEAvailable) { + if (isWeb) { + // Web platform - use Web Bluetooth API + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { WebBLEManager } = require('./WebBLEManager'); + _bleManager = new WebBLEManager(); + } else if (isBLEAvailable) { + // Native platform with real BLE (physical device) // eslint-disable-next-line @typescript-eslint/no-require-imports const { RealBLEManager } = require('./BLEManager'); _bleManager = new RealBLEManager(); } else { + // Native platform without real BLE (simulator/emulator) // eslint-disable-next-line @typescript-eslint/no-require-imports const { MockBLEManager } = require('./MockBLEManager'); _bleManager = new MockBLEManager(); @@ -66,3 +77,6 @@ export * from './permissions'; // Re-export error types and utilities export * from './errors'; + +// Re-export Web Bluetooth utilities +export * from './webBluetooth'; diff --git a/services/ble/webBluetooth.ts b/services/ble/webBluetooth.ts new file mode 100644 index 0000000..85005fa --- /dev/null +++ b/services/ble/webBluetooth.ts @@ -0,0 +1,173 @@ +// Web Bluetooth browser compatibility utilities + +/** + * Web Bluetooth support status + */ +export interface WebBluetoothSupport { + supported: boolean; + browserName: string; + browserVersion: string; + reason?: 'unsupported_browser' | 'insecure_context' | 'api_unavailable'; + helpUrl?: string; +} + +/** + * Supported browsers for Web Bluetooth + * Chrome/Edge/Opera on desktop and Android support Web Bluetooth + * Safari and Firefox do NOT support Web Bluetooth + */ +export const SUPPORTED_BROWSERS = ['Chrome', 'Edge', 'Opera', 'Samsung Internet']; + +/** + * URLs for browser help pages + */ +export const BROWSER_HELP_URLS: Record = { + Chrome: 'https://www.google.com/chrome/', + Edge: 'https://www.microsoft.com/edge', + Opera: 'https://www.opera.com/', + 'Samsung Internet': 'https://www.samsung.com/us/support/owners/app/samsung-internet', +}; + +/** + * Detect the current browser name and version + */ +export function detectBrowser(): { name: string; version: string } { + if (typeof navigator === 'undefined') { + return { name: 'Unknown', version: '0' }; + } + + const userAgent = navigator.userAgent; + let browserName = 'Unknown'; + let browserVersion = '0'; + + // Edge (must be checked before Chrome as Edge contains "Chrome" in UA) + if (userAgent.includes('Edg/')) { + browserName = 'Edge'; + const match = userAgent.match(/Edg\/(\d+)/); + browserVersion = match?.[1] || '0'; + } + // Opera (must be checked before Chrome as Opera contains "Chrome" in UA) + else if (userAgent.includes('OPR/') || userAgent.includes('Opera')) { + browserName = 'Opera'; + const match = userAgent.match(/(?:OPR|Opera)\/(\d+)/); + browserVersion = match?.[1] || '0'; + } + // Samsung Internet (must be checked before Chrome) + else if (userAgent.includes('SamsungBrowser')) { + browserName = 'Samsung Internet'; + const match = userAgent.match(/SamsungBrowser\/(\d+)/); + browserVersion = match?.[1] || '0'; + } + // Chrome + else if (userAgent.includes('Chrome/')) { + browserName = 'Chrome'; + const match = userAgent.match(/Chrome\/(\d+)/); + browserVersion = match?.[1] || '0'; + } + // Safari (must be checked after Chrome-based browsers) + else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) { + browserName = 'Safari'; + const match = userAgent.match(/Version\/(\d+)/); + browserVersion = match?.[1] || '0'; + } + // Firefox + else if (userAgent.includes('Firefox/')) { + browserName = 'Firefox'; + const match = userAgent.match(/Firefox\/(\d+)/); + browserVersion = match?.[1] || '0'; + } + + return { name: browserName, version: browserVersion }; +} + +/** + * Check if the current browser supports Web Bluetooth API + */ +export function checkWebBluetoothSupport(): WebBluetoothSupport { + const browser = detectBrowser(); + + // Check if we're in a secure context (HTTPS or localhost) + if (typeof window !== 'undefined' && !window.isSecureContext) { + return { + supported: false, + browserName: browser.name, + browserVersion: browser.version, + reason: 'insecure_context', + }; + } + + // Check if the Web Bluetooth API is available + if (typeof navigator === 'undefined' || !('bluetooth' in navigator)) { + // Determine reason based on browser + const isKnownUnsupportedBrowser = + browser.name === 'Safari' || + browser.name === 'Firefox'; + + return { + supported: false, + browserName: browser.name, + browserVersion: browser.version, + reason: isKnownUnsupportedBrowser ? 'unsupported_browser' : 'api_unavailable', + helpUrl: BROWSER_HELP_URLS.Chrome, // Suggest Chrome as alternative + }; + } + + return { + supported: true, + browserName: browser.name, + browserVersion: browser.version, + }; +} + +/** + * Get a user-friendly error message for unsupported browsers + */ +export function getUnsupportedBrowserMessage(support: WebBluetoothSupport): { + title: string; + message: string; + suggestion: string; +} { + switch (support.reason) { + case 'unsupported_browser': + return { + title: 'Browser Not Supported', + message: `${support.browserName} does not support Web Bluetooth, which is required to connect to WellNuo sensors.`, + suggestion: 'Please use Chrome, Edge, or Opera browser to set up your sensors.', + }; + + case 'insecure_context': + return { + title: 'Secure Connection Required', + message: 'Web Bluetooth requires a secure connection (HTTPS).', + suggestion: 'Please access this page using HTTPS or localhost.', + }; + + case 'api_unavailable': + return { + title: 'Bluetooth Not Available', + message: 'Web Bluetooth API is not available in this browser.', + suggestion: 'Please use Chrome, Edge, or Opera browser to set up your sensors.', + }; + + default: + return { + title: 'Bluetooth Not Available', + message: 'Could not access Bluetooth functionality.', + suggestion: 'Please use Chrome, Edge, or Opera browser.', + }; + } +} + +/** + * Type guard to check if we're running in a web browser + */ +export function isWebPlatform(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined'; +} + +/** + * Type guard to check if Web Bluetooth is available + */ +export function hasWebBluetooth(): boolean { + return typeof navigator !== 'undefined' && 'bluetooth' in navigator; +}