WellNuo/admin/services/webBluetooth.ts
Sergei ba4c31399a Add Web Bluetooth Service for WP sensor connectivity
Implement comprehensive Web Bluetooth API integration for the web admin:
- Device scanning with requestDevice and filter by WP_ prefix
- GATT connection with service/characteristic discovery
- Command protocol matching mobile app (PIN unlock, WiFi config)
- WiFi network scanning and configuration via BLE
- Connection state management with event listeners
- TypeScript type definitions for Web Bluetooth API
- Unit tests with 26 test cases covering core functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-01 08:40:05 -08:00

704 lines
21 KiB
TypeScript

/**
* Web Bluetooth Service for WellNuo WP Sensors
*
* This service provides Web Bluetooth API integration for scanning,
* connecting, and communicating with WP (WellNuo Presence) sensors
* in the web browser.
*
* Supported browsers: Chrome 70+, Edge 79+, Opera 57+
* Not supported: Safari, Firefox, iOS browsers
*/
// BLE Configuration (matching mobile app)
export const BLE_CONFIG = {
SERVICE_UUID: '4fafc201-1fb5-459e-8fcc-c5c9c331914b',
CHAR_UUID: 'beb5483e-36e1-4688-b7f5-ea07361b26a8',
SCAN_TIMEOUT: 10000, // 10 seconds
COMMAND_TIMEOUT: 5000, // 5 seconds
DEVICE_NAME_PREFIX: 'WP_',
};
// BLE Commands (matching mobile app protocol)
export const BLE_COMMANDS = {
PIN_UNLOCK: 'pin|7856',
GET_WIFI_LIST: 'w',
SET_WIFI: 'W', // Format: W|SSID,PASSWORD
GET_WIFI_STATUS: 'a',
REBOOT: 's',
DISCONNECT: 'D',
};
// Types
export interface WPDevice {
id: string;
name: string;
mac: string;
rssi: number;
wellId?: number;
device: BluetoothDevice;
}
export interface WiFiNetwork {
ssid: string;
rssi: number;
}
export interface WiFiStatus {
ssid: string;
rssi: number;
connected: boolean;
}
export enum BLEConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
DISCOVERING = 'discovering',
READY = 'ready',
DISCONNECTING = 'disconnecting',
ERROR = 'error',
}
export type BLEConnectionEvent =
| 'state_changed'
| 'connection_failed'
| 'disconnected'
| 'ready';
export interface BLEDeviceConnection {
deviceId: string;
deviceName: string;
state: BLEConnectionState;
error?: string;
connectedAt?: number;
lastActivity?: number;
}
export type BLEEventListener = (
deviceId: string,
event: BLEConnectionEvent,
data?: unknown
) => void;
// TextEncoder/TextDecoder for string conversion (lazily initialized for Node.js compatibility)
let _encoder: TextEncoder | null = null;
let _decoder: TextDecoder | null = null;
function getEncoder(): TextEncoder {
if (!_encoder) {
_encoder = new TextEncoder();
}
return _encoder;
}
function getDecoder(): TextDecoder {
if (!_decoder) {
_decoder = new TextDecoder();
}
return _decoder;
}
/**
* Check if Web Bluetooth is supported in the current browser
*/
export function isWebBluetoothSupported(): boolean {
return typeof navigator !== 'undefined' && 'bluetooth' in navigator;
}
/**
* Check if Bluetooth is available and enabled
*/
export async function isBluetoothAvailable(): Promise<boolean> {
if (!isWebBluetoothSupported()) {
return false;
}
try {
const available = await navigator.bluetooth.getAvailability();
return available;
} catch {
// Some browsers don't support getAvailability
return true; // Assume available, will fail on actual scan if not
}
}
/**
* Web Bluetooth Manager for WP Sensors
*/
export class WebBluetoothManager {
private connectedDevices = new Map<string, BluetoothDevice>();
private gattServers = new Map<string, BluetoothRemoteGATTServer>();
private characteristics = new Map<string, BluetoothRemoteGATTCharacteristic>();
private connectionStates = new Map<string, BLEDeviceConnection>();
private eventListeners: BLEEventListener[] = [];
private connectingDevices = new Set<string>();
/**
* 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?: unknown): 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 WP devices using Web Bluetooth API
* Note: Web Bluetooth requires user gesture (click) to initiate scan
*/
async scanForDevices(): Promise<WPDevice[]> {
if (!isWebBluetoothSupported()) {
throw new Error('Web Bluetooth is not supported in this browser');
}
const available = await isBluetoothAvailable();
if (!available) {
throw new Error('Bluetooth is not available. Please enable Bluetooth and try again.');
}
try {
// Request device with filters for WP sensors
// Web Bluetooth API shows a picker dialog instead of returning multiple devices
const device = await navigator.bluetooth.requestDevice({
filters: [
{ namePrefix: BLE_CONFIG.DEVICE_NAME_PREFIX },
],
optionalServices: [BLE_CONFIG.SERVICE_UUID],
});
if (!device || !device.name) {
throw new Error('No device selected');
}
// Parse well_id from name (WP_497_81a14c -> 497)
const wellIdMatch = device.name.match(/WP_(\d+)_/);
const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined;
// Extract MAC from device name (last part after underscore)
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: -50, // Web Bluetooth doesn't provide RSSI during scan
wellId,
device,
};
return [wpDevice];
} catch (error: unknown) {
if (error instanceof Error) {
if (error.name === 'NotFoundError') {
throw new Error('No WP devices found. Make sure your sensor is powered on and nearby.');
}
if (error.name === 'SecurityError') {
throw new Error('Bluetooth permission denied. Please allow Bluetooth access in your browser settings.');
}
if (error.name === 'NotAllowedError') {
throw new Error('User cancelled the device selection.');
}
throw error;
}
throw new Error('Failed to scan for devices');
}
}
/**
* Connect to a WP device
*/
async connectDevice(device: WPDevice): Promise<boolean> {
const deviceId = device.id;
try {
// Check if connection is already in progress
if (this.connectingDevices.has(deviceId)) {
throw new Error('Connection already in progress for this device');
}
// Check if already connected
const existingServer = this.gattServers.get(deviceId);
if (existingServer?.connected) {
this.updateConnectionState(deviceId, BLEConnectionState.READY, device.name);
this.emitEvent(deviceId, 'ready');
return true;
}
// Mark device as connecting
this.connectingDevices.add(deviceId);
// Update state to CONNECTING
this.updateConnectionState(deviceId, BLEConnectionState.CONNECTING, device.name);
// Connect to GATT server
const server = await device.device.gatt?.connect();
if (!server) {
throw new Error('Failed to connect to GATT server');
}
// Update state to CONNECTED
this.updateConnectionState(deviceId, BLEConnectionState.CONNECTED, device.name);
// Update state to DISCOVERING
this.updateConnectionState(deviceId, BLEConnectionState.DISCOVERING, device.name);
// Get service and characteristic
const service = await server.getPrimaryService(BLE_CONFIG.SERVICE_UUID);
const characteristic = await service.getCharacteristic(BLE_CONFIG.CHAR_UUID);
// Store references
this.connectedDevices.set(deviceId, device.device);
this.gattServers.set(deviceId, server);
this.characteristics.set(deviceId, characteristic);
// Set up disconnection handler
device.device.addEventListener('gattserverdisconnected', () => {
this.handleDisconnection(deviceId, device.name);
});
// Update state to READY
this.updateConnectionState(deviceId, BLEConnectionState.READY, device.name);
this.emitEvent(deviceId, 'ready');
return true;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Connection failed';
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, device.name, errorMessage);
this.emitEvent(deviceId, 'connection_failed', { error: errorMessage });
return false;
} finally {
// Always remove from connecting set when done (success or failure)
this.connectingDevices.delete(deviceId);
}
}
/**
* Handle device disconnection
*/
private handleDisconnection(deviceId: string, deviceName: string): void {
this.connectedDevices.delete(deviceId);
this.gattServers.delete(deviceId);
this.characteristics.delete(deviceId);
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, deviceName);
this.emitEvent(deviceId, 'disconnected', { unexpected: true });
}
/**
* Disconnect from a device
*/
async disconnectDevice(deviceId: string): Promise<void> {
const device = this.connectedDevices.get(deviceId);
const connection = this.connectionStates.get(deviceId);
if (device) {
try {
// Update state to DISCONNECTING
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTING, connection?.deviceName);
// Disconnect GATT
if (device.gatt?.connected) {
device.gatt.disconnect();
}
// Cleanup references
this.connectedDevices.delete(deviceId);
this.gattServers.delete(deviceId);
this.characteristics.delete(deviceId);
// Update state to DISCONNECTED
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, connection?.deviceName);
this.emitEvent(deviceId, 'disconnected');
} catch {
// Log but don't throw - device may already be disconnected
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, connection?.deviceName);
this.emitEvent(deviceId, 'disconnected');
}
} else {
// Not in connected devices map, just update state
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, connection?.deviceName);
this.emitEvent(deviceId, 'disconnected');
}
}
/**
* Check if a device is connected
*/
isDeviceConnected(deviceId: string): boolean {
const server = this.gattServers.get(deviceId);
return server?.connected ?? false;
}
/**
* Send a command to a device and wait for response
*/
async sendCommand(deviceId: string, command: string): Promise<string> {
const characteristic = this.characteristics.get(deviceId);
if (!characteristic) {
throw new Error('Device not connected');
}
// Verify device is still connected
const server = this.gattServers.get(deviceId);
if (!server?.connected) {
this.connectedDevices.delete(deviceId);
this.gattServers.delete(deviceId);
this.characteristics.delete(deviceId);
throw new Error('Device disconnected');
}
return new Promise(async (resolve, reject) => {
let responseReceived = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
try {
characteristic.removeEventListener('characteristicvaluechanged', handleNotification);
characteristic.stopNotifications().catch(() => {});
} catch {
// Ignore cleanup errors
}
};
const handleNotification = (event: Event) => {
const target = event.target as BluetoothRemoteGATTCharacteristic;
if (target.value && !responseReceived) {
responseReceived = true;
cleanup();
const decoded = getDecoder().decode(target.value);
resolve(decoded);
}
};
try {
// Subscribe to notifications
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', handleNotification);
// Send command
const encoded = getEncoder().encode(command);
await characteristic.writeValueWithResponse(encoded);
// Timeout
timeoutId = setTimeout(() => {
if (!responseReceived) {
responseReceived = true;
cleanup();
reject(new Error('Command timeout'));
}
}, BLE_CONFIG.COMMAND_TIMEOUT);
} catch (error: unknown) {
cleanup();
reject(error instanceof Error ? error : new Error('BLE operation failed'));
}
});
}
/**
* Get list of available WiFi networks from device
*/
async getWiFiList(deviceId: string): Promise<WiFiNetwork[]> {
// Step 1: Unlock device
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
if (!unlockResponse.includes('ok')) {
throw new Error('Failed to unlock device');
}
// 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 Error('Invalid WiFi list response');
}
const count = parseInt(parts[2], 10);
if (count < 0) {
if (count === -1) {
throw new Error('WiFi scan in progress, please wait');
}
if (count === -2) {
return []; // No networks found
}
}
// Use Map to deduplicate by SSID, keeping the strongest signal
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);
// Skip empty SSIDs
if (!trimmedSsid) continue;
// Keep the one with strongest signal if duplicate
const existing = networksMap.get(trimmedSsid);
if (!existing || rssi > existing.rssi) {
networksMap.set(trimmedSsid, {
ssid: trimmedSsid,
rssi: rssi,
});
}
}
}
// Convert to array and sort by signal strength (strongest first)
return Array.from(networksMap.values()).sort((a, b) => b.rssi - a.rssi);
}
/**
* Configure WiFi on a device
*/
async setWiFi(deviceId: string, ssid: string, password: string): Promise<boolean> {
// Pre-validate credentials before BLE transmission
if (ssid.includes('|') || ssid.includes(',')) {
throw new Error('Network name contains invalid characters. Please select a different network.');
}
if (password.includes('|')) {
throw new Error('Password contains an invalid character (|). Please use a different password.');
}
// Step 1: Unlock device
let unlockResponse: string;
try {
unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
if (message.includes('timeout')) {
throw new Error('Sensor not responding. Please move closer and try again.');
}
throw new Error(`Cannot communicate with sensor: ${message}`);
}
if (!unlockResponse.includes('ok')) {
throw new Error('Sensor authentication failed. Please try reconnecting.');
}
// Step 1.5: Check if already connected to the target WiFi
try {
const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS);
const parts = statusResponse.split('|');
if (parts.length >= 3) {
const [currentSsid] = parts[2].split(',');
if (currentSsid && currentSsid.trim().toLowerCase() === ssid.toLowerCase()) {
return true;
}
}
} catch {
// Ignore status check errors - continue with WiFi config
}
// Step 2: Set WiFi credentials
const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`;
let setResponse: string;
try {
setResponse = await this.sendCommand(deviceId, command);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
if (message.includes('timeout')) {
throw new Error('Sensor did not respond to WiFi config. Please try again.');
}
throw new Error(`WiFi configuration failed: ${message}`);
}
// Parse response
if (setResponse.includes('|W|ok')) {
return true;
}
// WiFi config failed - check if sensor is still connected (using old credentials)
if (setResponse.includes('|W|fail')) {
try {
const recheckResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS);
const parts = recheckResponse.split('|');
if (parts.length >= 3) {
const [currentSsid, rssiStr] = parts[2].split(',');
const rssi = parseInt(rssiStr, 10);
if (currentSsid && currentSsid.trim().toLowerCase() === ssid.toLowerCase() && rssi < 0) {
return true;
}
}
} catch {
// Ignore recheck errors
}
throw new Error('WiFi password is incorrect. Please check and try again.');
}
if (setResponse.includes('timeout') || setResponse.includes('Timeout')) {
throw new Error('Sensor did not respond to WiFi config. Please try again.');
}
if (setResponse.includes('not found') || setResponse.includes('no network')) {
throw new Error('WiFi network not found. Make sure the sensor is within range of your router.');
}
throw new Error('WiFi configuration failed. Please try again or contact support.');
}
/**
* Get current WiFi status from device
*/
async getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null> {
// Step 1: Unlock device
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
if (!unlockResponse.includes('ok')) {
throw new Error('Failed to unlock device');
}
// Step 2: Get current WiFi status
const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS);
// Parse response: "mac,XXXXXX|a|SSID,RSSI" or "mac,XXXXXX|a|,0" (not connected)
const parts = statusResponse.split('|');
if (parts.length < 3) {
return null;
}
const [ssid, rssiStr] = parts[2].split(',');
if (!ssid || ssid.trim() === '') {
return null; // Not connected
}
return {
ssid: ssid.trim(),
rssi: parseInt(rssiStr, 10),
connected: true,
};
}
/**
* Reboot the device
*/
async rebootDevice(deviceId: string): Promise<void> {
// Step 1: Unlock device
await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
// Step 2: Reboot (device will disconnect)
try {
await this.sendCommand(deviceId, BLE_COMMANDS.REBOOT);
} catch {
// Ignore timeout errors - device may reboot before responding
}
// Cleanup
this.connectedDevices.delete(deviceId);
this.gattServers.delete(deviceId);
this.characteristics.delete(deviceId);
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED);
this.emitEvent(deviceId, 'disconnected');
}
/**
* Cleanup all connections and state
*/
async cleanup(): Promise<void> {
// Disconnect all connected devices
const deviceIds = Array.from(this.connectedDevices.keys());
for (const deviceId of deviceIds) {
try {
await this.disconnectDevice(deviceId);
} catch {
// Continue cleanup even if one device fails
}
}
// Clear the maps and sets
this.connectedDevices.clear();
this.gattServers.clear();
this.characteristics.clear();
this.connectionStates.clear();
this.connectingDevices.clear();
// Clear event listeners
this.eventListeners = [];
}
}
// Singleton instance
let _webBluetoothManager: WebBluetoothManager | null = null;
/**
* Get the WebBluetoothManager singleton instance
*/
export function getWebBluetoothManager(): WebBluetoothManager {
if (!_webBluetoothManager) {
_webBluetoothManager = new WebBluetoothManager();
}
return _webBluetoothManager;
}
// Default export
export default getWebBluetoothManager;