Add Web Bluetooth support for browser-based sensor setup

- Add webBluetooth.ts with browser detection and compatibility checks
- Add WebBLEManager implementing IBLEManager for Web Bluetooth API
- Add BrowserNotSupported component showing clear error for Safari/Firefox
- Update services/ble/index.ts to use WebBLEManager on web platform
- Add comprehensive tests for browser detection and WebBLEManager

Works in Chrome/Edge/Opera, shows user-friendly error in unsupported browsers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-02-01 10:48:01 -08:00
parent 5f40370dfa
commit c2064a76eb
7 changed files with 2180 additions and 3 deletions

View File

@ -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 (
<View style={styles.container}>
<View style={styles.content}>
{/* Browser Icon with X overlay */}
<View style={styles.iconContainer}>
<View style={styles.iconWrapper}>
<Ionicons name={browserIcon} size={48} color="#6B7280" />
<View style={styles.xOverlay}>
<Ionicons name="close-circle" size={24} color="#EF4444" />
</View>
</View>
</View>
{/* Title */}
<Text style={styles.title}>{errorInfo.title}</Text>
{/* Browser info */}
<Text style={styles.browserInfo}>
{support.browserName} {support.browserVersion}
</Text>
{/* Message */}
<Text style={styles.message}>{errorInfo.message}</Text>
{/* Suggestion */}
<View style={styles.suggestionBox}>
<Ionicons name="information-circle" size={20} color="#3B82F6" />
<Text style={styles.suggestionText}>{errorInfo.suggestion}</Text>
</View>
{/* Supported browsers list */}
<View style={styles.supportedBrowsers}>
<Text style={styles.supportedTitle}>Supported browsers:</Text>
<View style={styles.browserList}>
<View style={styles.browserItem}>
<Ionicons name="logo-chrome" size={24} color="#4285F4" />
<Text style={styles.browserName}>Chrome</Text>
</View>
<View style={styles.browserItem}>
<Ionicons name="logo-edge" size={24} color="#0078D4" />
<Text style={styles.browserName}>Edge</Text>
</View>
<View style={styles.browserItem}>
<Ionicons name="globe-outline" size={24} color="#FF1B2D" />
<Text style={styles.browserName}>Opera</Text>
</View>
</View>
</View>
{/* Action buttons */}
<View style={styles.buttons}>
<TouchableOpacity
style={styles.downloadButton}
onPress={handleOpenChrome}
activeOpacity={0.8}
>
<Ionicons name="logo-chrome" size={20} color="#fff" style={styles.buttonIcon} />
<Text style={styles.downloadButtonText}>Get Chrome</Text>
</TouchableOpacity>
{onDismiss && (
<TouchableOpacity
style={styles.dismissButton}
onPress={onDismiss}
activeOpacity={0.8}
>
<Text style={styles.dismissButtonText}>Continue anyway</Text>
</TouchableOpacity>
)}
</View>
{/* Note for secure context */}
{support.reason === 'insecure_context' && (
<View style={styles.secureNote}>
<Ionicons name="lock-closed" size={16} color="#9CA3AF" />
<Text style={styles.secureNoteText}>
This page must be served over HTTPS for Bluetooth to work.
</Text>
</View>
)}
</View>
</View>
);
}
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;

View File

@ -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';

View File

@ -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<WebBluetoothGATTServer>;
disconnect(): void;
getPrimaryService(service: string): Promise<WebBluetoothGATTService>;
};
type WebBluetoothGATTService = {
device: WebBluetoothDevice;
uuid: string;
getCharacteristic(characteristic: string): Promise<WebBluetoothGATTCharacteristic>;
};
type WebBluetoothGATTCharacteristic = {
service: WebBluetoothGATTService;
uuid: string;
value?: DataView;
startNotifications(): Promise<WebBluetoothGATTCharacteristic>;
stopNotifications(): Promise<WebBluetoothGATTCharacteristic>;
readValue(): Promise<DataView>;
writeValue(value: BufferSource): Promise<void>;
writeValueWithResponse(value: BufferSource): Promise<void>;
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<string, WebBluetoothDevice>();
private gattServers = new Map<string, WebBluetoothGATTServer>();
private characteristics = new Map<string, WebBluetoothGATTCharacteristic>();
private connectionStates = new Map<string, BLEDeviceConnection>();
private eventListeners: BLEEventListener[] = [];
private connectingDevices = new Set<string>();
// Health monitoring state
private sensorHealthMetrics = new Map<string, SensorHealthMetrics>();
private communicationStats = new Map<string, CommunicationHealth>();
// Reconnect state
private reconnectConfig: ReconnectConfig = { ...DEFAULT_RECONNECT_CONFIG };
private reconnectStates = new Map<string, ReconnectState>();
private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
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<string, BLEDeviceConnection> {
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<WPDevice[]> {
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<boolean> {
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<void> {
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<string> {
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<typeof setTimeout> | 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<WiFiNetwork[]> {
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<string, WiFiNetwork>();
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<boolean> {
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<WiFiStatus | null> {
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<void> {
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<SensorHealthMetrics | null> {
// 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<string, SensorHealthMetrics> {
return new Map(this.sensorHealthMetrics);
}
/**
* Cleanup all connections
*/
async cleanup(): Promise<void> {
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<BulkOperationResult[]> {
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<BulkOperationResult[]> {
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<BulkWiFiResult[]> {
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<ReconnectConfig>): 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<void> {
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<boolean> {
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<string, ReconnectState> {
return new Map(this.reconnectStates);
}
}

View File

@ -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
});
});
});

View File

@ -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');
});
});
});

View File

@ -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';

View File

@ -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<string, string> = {
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;
}