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 { ErrorToast } from './ErrorToast';
|
||||||
export { FieldError, FieldErrorSummary } from './FieldError';
|
export { FieldError, FieldErrorSummary } from './FieldError';
|
||||||
export { FullScreenError, EmptyState, OfflineState } from './FullScreenError';
|
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
|
// BLE Service entry point
|
||||||
|
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import * as Device from 'expo-device';
|
import * as Device from 'expo-device';
|
||||||
import { IBLEManager } from './types';
|
import { IBLEManager } from './types';
|
||||||
|
|
||||||
|
// Check if running on web platform
|
||||||
|
const isWeb = Platform.OS === 'web';
|
||||||
|
|
||||||
// Determine if BLE is available (real device vs simulator)
|
// 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
|
// Lazy singleton - only create BLEManager when first accessed
|
||||||
let _bleManager: IBLEManager | null = null;
|
let _bleManager: IBLEManager | null = null;
|
||||||
|
|
||||||
function getBLEManager(): IBLEManager {
|
function getBLEManager(): IBLEManager {
|
||||||
if (!_bleManager) {
|
if (!_bleManager) {
|
||||||
// Dynamic import to prevent crash on Android startup
|
if (isWeb) {
|
||||||
if (isBLEAvailable) {
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { RealBLEManager } = require('./BLEManager');
|
const { RealBLEManager } = require('./BLEManager');
|
||||||
_bleManager = new RealBLEManager();
|
_bleManager = new RealBLEManager();
|
||||||
} else {
|
} else {
|
||||||
|
// Native platform without real BLE (simulator/emulator)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { MockBLEManager } = require('./MockBLEManager');
|
const { MockBLEManager } = require('./MockBLEManager');
|
||||||
_bleManager = new MockBLEManager();
|
_bleManager = new MockBLEManager();
|
||||||
@ -66,3 +77,6 @@ export * from './permissions';
|
|||||||
|
|
||||||
// Re-export error types and utilities
|
// Re-export error types and utilities
|
||||||
export * from './errors';
|
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