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:
parent
5f40370dfa
commit
c2064a76eb
297
components/errors/BrowserNotSupported.tsx
Normal file
297
components/errors/BrowserNotSupported.tsx
Normal 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;
|
||||
@ -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';
|
||||
|
||||
998
services/ble/WebBLEManager.ts
Normal file
998
services/ble/WebBLEManager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
357
services/ble/__tests__/WebBLEManager.test.ts
Normal file
357
services/ble/__tests__/WebBLEManager.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
337
services/ble/__tests__/webBluetooth.test.ts
Normal file
337
services/ble/__tests__/webBluetooth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
173
services/ble/webBluetooth.ts
Normal file
173
services/ble/webBluetooth.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user