- 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>
999 lines
30 KiB
TypeScript
999 lines
30 KiB
TypeScript
// 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);
|
|
}
|
|
}
|