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>
This commit is contained in:
parent
d530695b8b
commit
ba4c31399a
478
admin/services/__tests__/webBluetooth.test.ts
Normal file
478
admin/services/__tests__/webBluetooth.test.ts
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Web Bluetooth Service
|
||||||
|
*
|
||||||
|
* Note: These tests mock the Web Bluetooth API since it's not available in Node.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Setup TextEncoder/TextDecoder for Node.js environment BEFORE importing the module
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const util = require('util');
|
||||||
|
if (typeof global.TextEncoder === 'undefined') {
|
||||||
|
global.TextEncoder = util.TextEncoder;
|
||||||
|
}
|
||||||
|
if (typeof global.TextDecoder === 'undefined') {
|
||||||
|
global.TextDecoder = util.TextDecoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
WebBluetoothManager,
|
||||||
|
isWebBluetoothSupported,
|
||||||
|
isBluetoothAvailable,
|
||||||
|
BLE_CONFIG,
|
||||||
|
BLE_COMMANDS,
|
||||||
|
BLEConnectionState,
|
||||||
|
WPDevice,
|
||||||
|
} from '../webBluetooth';
|
||||||
|
|
||||||
|
// Mock BluetoothRemoteGATTCharacteristic
|
||||||
|
class MockCharacteristic {
|
||||||
|
private listeners: Map<string, ((event: Event) => void)[]> = new Map();
|
||||||
|
private notifying = false;
|
||||||
|
value: DataView | null = null;
|
||||||
|
|
||||||
|
async startNotifications(): Promise<void> {
|
||||||
|
this.notifying = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopNotifications(): Promise<void> {
|
||||||
|
this.notifying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: (event: Event) => void): void {
|
||||||
|
const existing = this.listeners.get(type) || [];
|
||||||
|
this.listeners.set(type, [...existing, listener]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: (event: Event) => void): void {
|
||||||
|
const existing = this.listeners.get(type) || [];
|
||||||
|
this.listeners.set(type, existing.filter((l) => l !== listener));
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeValueWithResponse(_value: ArrayBuffer): Promise<void> {
|
||||||
|
// Simulate device response after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const response = encoder.encode('pin|ok');
|
||||||
|
this.value = new DataView(response.buffer);
|
||||||
|
|
||||||
|
const listeners = this.listeners.get('characteristicvaluechanged') || [];
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener({ target: this } as unknown as Event);
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for testing - simulate specific response
|
||||||
|
simulateResponse(response: string): void {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const encoded = encoder.encode(response);
|
||||||
|
this.value = new DataView(encoded.buffer);
|
||||||
|
|
||||||
|
const listeners = this.listeners.get('characteristicvaluechanged') || [];
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener({ target: this } as unknown as Event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock BluetoothRemoteGATTService
|
||||||
|
class MockService {
|
||||||
|
constructor(private characteristic: MockCharacteristic) {}
|
||||||
|
|
||||||
|
async getCharacteristic(_uuid: string): Promise<MockCharacteristic> {
|
||||||
|
return this.characteristic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock BluetoothRemoteGATTServer
|
||||||
|
class MockGATTServer {
|
||||||
|
connected = true;
|
||||||
|
|
||||||
|
constructor(private service: MockService) {}
|
||||||
|
|
||||||
|
async connect(): Promise<MockGATTServer> {
|
||||||
|
this.connected = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrimaryService(_uuid: string): Promise<MockService> {
|
||||||
|
return this.service;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock BluetoothDevice
|
||||||
|
class MockBluetoothDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
gatt: MockGATTServer;
|
||||||
|
private listeners: Map<string, (() => void)[]> = new Map();
|
||||||
|
|
||||||
|
constructor(id: string, name: string, gattServer: MockGATTServer) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.gatt = gattServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: () => void): void {
|
||||||
|
const existing = this.listeners.get(type) || [];
|
||||||
|
this.listeners.set(type, [...existing, listener]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: () => void): void {
|
||||||
|
const existing = this.listeners.get(type) || [];
|
||||||
|
this.listeners.set(type, existing.filter((l) => l !== listener));
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerDisconnection(): void {
|
||||||
|
const listeners = this.listeners.get('gattserverdisconnected') || [];
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mock device helper
|
||||||
|
function createMockDevice(id: string, name: string): { device: MockBluetoothDevice; characteristic: MockCharacteristic } {
|
||||||
|
const characteristic = new MockCharacteristic();
|
||||||
|
const service = new MockService(characteristic);
|
||||||
|
const gattServer = new MockGATTServer(service);
|
||||||
|
const device = new MockBluetoothDevice(id, name, gattServer);
|
||||||
|
return { device, characteristic };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WebBluetoothManager', () => {
|
||||||
|
let manager: WebBluetoothManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new WebBluetoothManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await manager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isWebBluetoothSupported', () => {
|
||||||
|
it('returns false when navigator.bluetooth is not available', () => {
|
||||||
|
const originalNavigator = global.navigator;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(global as any).navigator = undefined;
|
||||||
|
|
||||||
|
expect(isWebBluetoothSupported()).toBe(false);
|
||||||
|
|
||||||
|
global.navigator = originalNavigator;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BLE_CONFIG', () => {
|
||||||
|
it('has correct service UUID', () => {
|
||||||
|
expect(BLE_CONFIG.SERVICE_UUID).toBe('4fafc201-1fb5-459e-8fcc-c5c9c331914b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct characteristic UUID', () => {
|
||||||
|
expect(BLE_CONFIG.CHAR_UUID).toBe('beb5483e-36e1-4688-b7f5-ea07361b26a8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct device name prefix', () => {
|
||||||
|
expect(BLE_CONFIG.DEVICE_NAME_PREFIX).toBe('WP_');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BLE_COMMANDS', () => {
|
||||||
|
it('has correct PIN unlock command', () => {
|
||||||
|
expect(BLE_COMMANDS.PIN_UNLOCK).toBe('pin|7856');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct WiFi list command', () => {
|
||||||
|
expect(BLE_COMMANDS.GET_WIFI_LIST).toBe('w');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct set WiFi command', () => {
|
||||||
|
expect(BLE_COMMANDS.SET_WIFI).toBe('W');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct WiFi status command', () => {
|
||||||
|
expect(BLE_COMMANDS.GET_WIFI_STATUS).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct reboot command', () => {
|
||||||
|
expect(BLE_COMMANDS.REBOOT).toBe('s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('connection state management', () => {
|
||||||
|
it('returns DISCONNECTED for unknown device', () => {
|
||||||
|
expect(manager.getConnectionState('unknown-device')).toBe(BLEConnectionState.DISCONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks all connections', () => {
|
||||||
|
const connections = manager.getAllConnections();
|
||||||
|
expect(connections).toBeInstanceOf(Map);
|
||||||
|
expect(connections.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event listeners', () => {
|
||||||
|
it('adds event listener', () => {
|
||||||
|
const listener = jest.fn();
|
||||||
|
manager.addEventListener(listener);
|
||||||
|
|
||||||
|
// Trigger an event by updating state
|
||||||
|
const { device } = createMockDevice('test-id', 'WP_497_81a14c');
|
||||||
|
const wpDevice: WPDevice = {
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'WP_497_81a14c',
|
||||||
|
mac: '81A14C',
|
||||||
|
rssi: -50,
|
||||||
|
wellId: 497,
|
||||||
|
device: device as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect triggers state change
|
||||||
|
manager.connectDevice(wpDevice);
|
||||||
|
|
||||||
|
// Give time for async operations
|
||||||
|
expect(listener).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes event listener', () => {
|
||||||
|
const listener = jest.fn();
|
||||||
|
manager.addEventListener(listener);
|
||||||
|
manager.removeEventListener(listener);
|
||||||
|
|
||||||
|
// Listener should not be called after removal
|
||||||
|
// (internal implementation detail - testing the interface)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add duplicate listeners', () => {
|
||||||
|
const listener = jest.fn();
|
||||||
|
manager.addEventListener(listener);
|
||||||
|
manager.addEventListener(listener);
|
||||||
|
manager.removeEventListener(listener);
|
||||||
|
|
||||||
|
// After one removal, listener should be gone
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('connectDevice', () => {
|
||||||
|
it('successfully connects to a device', async () => {
|
||||||
|
const { device } = createMockDevice('test-id', 'WP_497_81a14c');
|
||||||
|
const wpDevice: WPDevice = {
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'WP_497_81a14c',
|
||||||
|
mac: '81A14C',
|
||||||
|
rssi: -50,
|
||||||
|
wellId: 497,
|
||||||
|
device: device as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await manager.connectDevice(wpDevice);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(manager.isDeviceConnected('test-id')).toBe(true);
|
||||||
|
expect(manager.getConnectionState('test-id')).toBe(BLEConnectionState.READY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents concurrent connections to the same device', async () => {
|
||||||
|
const { device } = createMockDevice('test-id', 'WP_497_81a14c');
|
||||||
|
const wpDevice: WPDevice = {
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'WP_497_81a14c',
|
||||||
|
mac: '81A14C',
|
||||||
|
rssi: -50,
|
||||||
|
wellId: 497,
|
||||||
|
device: device as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start first connection
|
||||||
|
const firstConnection = manager.connectDevice(wpDevice);
|
||||||
|
|
||||||
|
// Try second connection immediately
|
||||||
|
const secondConnection = manager.connectDevice(wpDevice);
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.all([firstConnection, secondConnection]);
|
||||||
|
|
||||||
|
// First should succeed, second should fail (or both succeed if first finished fast)
|
||||||
|
expect(result1).toBe(true);
|
||||||
|
// Second might succeed if first finished before it started
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true if already connected', async () => {
|
||||||
|
const { device } = createMockDevice('test-id', 'WP_497_81a14c');
|
||||||
|
const wpDevice: WPDevice = {
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'WP_497_81a14c',
|
||||||
|
mac: '81A14C',
|
||||||
|
rssi: -50,
|
||||||
|
wellId: 497,
|
||||||
|
device: device as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
// First connection
|
||||||
|
await manager.connectDevice(wpDevice);
|
||||||
|
|
||||||
|
// Second connection should return true immediately
|
||||||
|
const result = await manager.connectDevice(wpDevice);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disconnectDevice', () => {
|
||||||
|
it('disconnects a connected device', async () => {
|
||||||
|
const { device } = createMockDevice('test-id', 'WP_497_81a14c');
|
||||||
|
const wpDevice: WPDevice = {
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'WP_497_81a14c',
|
||||||
|
mac: '81A14C',
|
||||||
|
rssi: -50,
|
||||||
|
wellId: 497,
|
||||||
|
device: device as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.connectDevice(wpDevice);
|
||||||
|
expect(manager.isDeviceConnected('test-id')).toBe(true);
|
||||||
|
|
||||||
|
await manager.disconnectDevice('test-id');
|
||||||
|
expect(manager.isDeviceConnected('test-id')).toBe(false);
|
||||||
|
expect(manager.getConnectionState('test-id')).toBe(BLEConnectionState.DISCONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles disconnect of non-existent device', async () => {
|
||||||
|
await expect(manager.disconnectDevice('non-existent')).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('cleans up all connections', async () => {
|
||||||
|
const { device: device1 } = createMockDevice('test-id-1', 'WP_497_81a14c');
|
||||||
|
const { device: device2 } = createMockDevice('test-id-2', 'WP_523_81aad4');
|
||||||
|
|
||||||
|
const wpDevice1: WPDevice = {
|
||||||
|
id: 'test-id-1',
|
||||||
|
name: 'WP_497_81a14c',
|
||||||
|
mac: '81A14C',
|
||||||
|
rssi: -50,
|
||||||
|
wellId: 497,
|
||||||
|
device: device1 as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wpDevice2: WPDevice = {
|
||||||
|
id: 'test-id-2',
|
||||||
|
name: 'WP_523_81aad4',
|
||||||
|
mac: '81AAD4',
|
||||||
|
rssi: -67,
|
||||||
|
wellId: 523,
|
||||||
|
device: device2 as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.connectDevice(wpDevice1);
|
||||||
|
await manager.connectDevice(wpDevice2);
|
||||||
|
|
||||||
|
expect(manager.isDeviceConnected('test-id-1')).toBe(true);
|
||||||
|
expect(manager.isDeviceConnected('test-id-2')).toBe(true);
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
|
||||||
|
expect(manager.isDeviceConnected('test-id-1')).toBe(false);
|
||||||
|
expect(manager.isDeviceConnected('test-id-2')).toBe(false);
|
||||||
|
expect(manager.getAllConnections().size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WiFi validation', () => {
|
||||||
|
it('rejects SSID with pipe character', async () => {
|
||||||
|
const { device } = createMockDevice('test-id', 'WP_497_81a14c');
|
||||||
|
const wpDevice: WPDevice = {
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'WP_497_81a14c',
|
||||||
|
mac: '81A14C',
|
||||||
|
rssi: -50,
|
||||||
|
wellId: 497,
|
||||||
|
device: device as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.connectDevice(wpDevice);
|
||||||
|
|
||||||
|
await expect(manager.setWiFi('test-id', 'Test|Network', 'password123')).rejects.toThrow(
|
||||||
|
'Network name contains invalid characters'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects SSID with comma character', async () => {
|
||||||
|
const { device } = createMockDevice('test-id', 'WP_497_81a14c');
|
||||||
|
const wpDevice: WPDevice = {
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'WP_497_81a14c',
|
||||||
|
mac: '81A14C',
|
||||||
|
rssi: -50,
|
||||||
|
wellId: 497,
|
||||||
|
device: device as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.connectDevice(wpDevice);
|
||||||
|
|
||||||
|
await expect(manager.setWiFi('test-id', 'Test,Network', 'password123')).rejects.toThrow(
|
||||||
|
'Network name contains invalid characters'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects password with pipe character', async () => {
|
||||||
|
const { device } = createMockDevice('test-id', 'WP_497_81a14c');
|
||||||
|
const wpDevice: WPDevice = {
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'WP_497_81a14c',
|
||||||
|
mac: '81A14C',
|
||||||
|
rssi: -50,
|
||||||
|
wellId: 497,
|
||||||
|
device: device as unknown as BluetoothDevice,
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.connectDevice(wpDevice);
|
||||||
|
|
||||||
|
await expect(manager.setWiFi('test-id', 'TestNetwork', 'pass|word123')).rejects.toThrow(
|
||||||
|
'Password contains an invalid character'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('device parsing', () => {
|
||||||
|
it('correctly parses device name format', () => {
|
||||||
|
// Test the parsing logic implicitly through device creation
|
||||||
|
const deviceName = 'WP_497_81a14c';
|
||||||
|
|
||||||
|
// Parse well_id from name (WP_497_81a14c -> 497)
|
||||||
|
const wellIdMatch = deviceName.match(/WP_(\d+)_/);
|
||||||
|
const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined;
|
||||||
|
|
||||||
|
// Extract MAC from device name (last part after underscore)
|
||||||
|
const macMatch = deviceName.match(/_([a-fA-F0-9]{6})$/);
|
||||||
|
const mac = macMatch ? macMatch[1].toUpperCase() : '';
|
||||||
|
|
||||||
|
expect(wellId).toBe(497);
|
||||||
|
expect(mac).toBe('81A14C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles device name without well_id', () => {
|
||||||
|
const deviceName = 'WP_81a14c';
|
||||||
|
|
||||||
|
const wellIdMatch = deviceName.match(/WP_(\d+)_/);
|
||||||
|
const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined;
|
||||||
|
|
||||||
|
expect(wellId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBluetoothAvailable', () => {
|
||||||
|
it('returns false when navigator.bluetooth is not available', async () => {
|
||||||
|
const originalNavigator = global.navigator;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(global as any).navigator = undefined;
|
||||||
|
|
||||||
|
const result = await isBluetoothAvailable();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
global.navigator = originalNavigator;
|
||||||
|
});
|
||||||
|
});
|
||||||
703
admin/services/webBluetooth.ts
Normal file
703
admin/services/webBluetooth.ts
Normal file
@ -0,0 +1,703 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
112
admin/types/web-bluetooth.d.ts
vendored
Normal file
112
admin/types/web-bluetooth.d.ts
vendored
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Web Bluetooth API Type Definitions
|
||||||
|
*
|
||||||
|
* These type definitions provide TypeScript support for the Web Bluetooth API.
|
||||||
|
* The Web Bluetooth API is currently supported in Chrome, Edge, and Opera.
|
||||||
|
*
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface BluetoothDevice extends EventTarget {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name?: string;
|
||||||
|
readonly gatt?: BluetoothRemoteGATTServer;
|
||||||
|
watchAdvertisements(): Promise<void>;
|
||||||
|
unwatchAdvertisements(): void;
|
||||||
|
readonly watchingAdvertisements: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothRemoteGATTServer {
|
||||||
|
readonly device: BluetoothDevice;
|
||||||
|
readonly connected: boolean;
|
||||||
|
connect(): Promise<BluetoothRemoteGATTServer>;
|
||||||
|
disconnect(): void;
|
||||||
|
getPrimaryService(service: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService>;
|
||||||
|
getPrimaryServices(service?: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothRemoteGATTService extends EventTarget {
|
||||||
|
readonly device: BluetoothDevice;
|
||||||
|
readonly uuid: string;
|
||||||
|
readonly isPrimary: boolean;
|
||||||
|
getCharacteristic(characteristic: BluetoothCharacteristicUUID): Promise<BluetoothRemoteGATTCharacteristic>;
|
||||||
|
getCharacteristics(characteristic?: BluetoothCharacteristicUUID): Promise<BluetoothRemoteGATTCharacteristic[]>;
|
||||||
|
getIncludedService(service: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService>;
|
||||||
|
getIncludedServices(service?: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothRemoteGATTCharacteristic extends EventTarget {
|
||||||
|
readonly service: BluetoothRemoteGATTService;
|
||||||
|
readonly uuid: string;
|
||||||
|
readonly properties: BluetoothCharacteristicProperties;
|
||||||
|
readonly value?: DataView;
|
||||||
|
getDescriptor(descriptor: BluetoothDescriptorUUID): Promise<BluetoothRemoteGATTDescriptor>;
|
||||||
|
getDescriptors(descriptor?: BluetoothDescriptorUUID): Promise<BluetoothRemoteGATTDescriptor[]>;
|
||||||
|
readValue(): Promise<DataView>;
|
||||||
|
writeValue(value: BufferSource): Promise<void>;
|
||||||
|
writeValueWithResponse(value: BufferSource): Promise<void>;
|
||||||
|
writeValueWithoutResponse(value: BufferSource): Promise<void>;
|
||||||
|
startNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
|
||||||
|
stopNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothCharacteristicProperties {
|
||||||
|
readonly broadcast: boolean;
|
||||||
|
readonly read: boolean;
|
||||||
|
readonly writeWithoutResponse: boolean;
|
||||||
|
readonly write: boolean;
|
||||||
|
readonly notify: boolean;
|
||||||
|
readonly indicate: boolean;
|
||||||
|
readonly authenticatedSignedWrites: boolean;
|
||||||
|
readonly reliableWrite: boolean;
|
||||||
|
readonly writableAuxiliaries: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothRemoteGATTDescriptor {
|
||||||
|
readonly characteristic: BluetoothRemoteGATTCharacteristic;
|
||||||
|
readonly uuid: string;
|
||||||
|
readonly value?: DataView;
|
||||||
|
readValue(): Promise<DataView>;
|
||||||
|
writeValue(value: BufferSource): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothRequestDeviceFilter {
|
||||||
|
services?: BluetoothServiceUUID[];
|
||||||
|
name?: string;
|
||||||
|
namePrefix?: string;
|
||||||
|
manufacturerData?: BluetoothManufacturerDataFilter[];
|
||||||
|
serviceData?: BluetoothServiceDataFilter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothManufacturerDataFilter {
|
||||||
|
companyIdentifier: number;
|
||||||
|
dataPrefix?: BufferSource;
|
||||||
|
mask?: BufferSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothServiceDataFilter {
|
||||||
|
service: BluetoothServiceUUID;
|
||||||
|
dataPrefix?: BufferSource;
|
||||||
|
mask?: BufferSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestDeviceOptions {
|
||||||
|
filters?: BluetoothRequestDeviceFilter[];
|
||||||
|
optionalServices?: BluetoothServiceUUID[];
|
||||||
|
acceptAllDevices?: boolean;
|
||||||
|
optionalManufacturerData?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type BluetoothServiceUUID = string | number;
|
||||||
|
type BluetoothCharacteristicUUID = string | number;
|
||||||
|
type BluetoothDescriptorUUID = string | number;
|
||||||
|
|
||||||
|
interface Bluetooth extends EventTarget {
|
||||||
|
getAvailability(): Promise<boolean>;
|
||||||
|
requestDevice(options?: RequestDeviceOptions): Promise<BluetoothDevice>;
|
||||||
|
getDevices(): Promise<BluetoothDevice[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Navigator {
|
||||||
|
bluetooth: Bluetooth;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user