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;
+}