237 lines
8.5 KiB
Plaintext
237 lines
8.5 KiB
Plaintext
// 设备管理相关:扫描、连接、断开、重连
|
||
import { BleDevice, BLE_CONNECTION_STATE } from '../interface.uts';
|
||
import type { BleConnectOptionsExt } from '../interface.uts';
|
||
|
||
export class DeviceManager {
|
||
private devices = {};
|
||
private servers = {};
|
||
private connectionStates = {};
|
||
private allowedServices = {};
|
||
private reconnectAttempts: number = 0;
|
||
private maxReconnectAttempts: number = 5;
|
||
private reconnectDelay: number = 2000;
|
||
private reconnectTimeoutId: number = 0;
|
||
private autoReconnect: boolean = false;
|
||
private connectionStateChangeListeners: Function[] = [];
|
||
|
||
private deviceFoundListeners: ((device: BleDevice) => void)[] = [];
|
||
private scanFinishedListeners: (() => void)[] = [];
|
||
|
||
onDeviceFound(listener: (device: BleDevice) => void) {
|
||
this.deviceFoundListeners.push(listener);
|
||
}
|
||
onScanFinished(listener: () => void) {
|
||
this.scanFinishedListeners.push(listener);
|
||
}
|
||
private emitDeviceFound(device: BleDevice) {
|
||
for (const listener of this.deviceFoundListeners) {
|
||
try { listener(device); } catch (e) {}
|
||
}
|
||
}
|
||
private emitScanFinished() {
|
||
for (const listener of this.scanFinishedListeners) {
|
||
try { listener(); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
async startScan(options?: { optionalServices?: string[] } ): Promise<void> {
|
||
if (!navigator.bluetooth) throw new Error('Web Bluetooth API not supported');
|
||
try {
|
||
const scanOptions: any = { acceptAllDevices: true };
|
||
// allow callers to request optionalServices (required by Web Bluetooth to access custom services)
|
||
if (options && Array.isArray(options.optionalServices) && options.optionalServices.length > 0) {
|
||
scanOptions.optionalServices = options.optionalServices;
|
||
}
|
||
// Log the exact options passed to requestDevice for debugging optionalServices propagation
|
||
try {
|
||
console.log('[DeviceManager] requestDevice options:', JSON.stringify(scanOptions));
|
||
} catch (e) {
|
||
console.log('[DeviceManager] requestDevice options (raw):', scanOptions);
|
||
}
|
||
const device = await navigator.bluetooth.requestDevice(scanOptions);
|
||
try {
|
||
console.log('[DeviceManager] requestDevice result:', device);
|
||
} catch (e) {
|
||
console.log('[DeviceManager] requestDevice result (raw):', device);
|
||
}
|
||
if (device) {
|
||
console.log(device)
|
||
// 格式化 deviceId 为 MAC 地址格式
|
||
const formatDeviceId = (id: string): string => {
|
||
// 如果是12位16进制字符串(如 'AABBCCDDEEFF'),转为 'AA:BB:CC:DD:EE:FF'
|
||
if (/^[0-9A-Fa-f]{12}$/.test(id)) {
|
||
return id.match(/.{1,2}/g)!.join(":").toUpperCase();
|
||
}
|
||
// 如果是base64,无法直接转MAC,保留原样
|
||
// 你可以根据实际情况扩展此处
|
||
return id;
|
||
};
|
||
const isConnected = !!this.servers[device.id];
|
||
const formattedId = formatDeviceId(device.id);
|
||
const bleDevice = { deviceId: formattedId, name: device.name, connected: isConnected };
|
||
this.devices[formattedId] = device;
|
||
this.emitDeviceFound(bleDevice);
|
||
}
|
||
this.emitScanFinished();
|
||
} catch (e) {
|
||
this.emitScanFinished();
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
onConnectionStateChange(listener: (deviceId: string, state: string) => void) {
|
||
this.connectionStateChangeListeners.push(listener);
|
||
}
|
||
private emitConnectionStateChange(deviceId: string, state: string) {
|
||
for (const listener of this.connectionStateChangeListeners) {
|
||
try {
|
||
listener(deviceId, state);
|
||
} catch (e) {
|
||
// 忽略单个回调异常
|
||
}
|
||
}
|
||
}
|
||
|
||
async connectDevice(deviceId: string, options?: BleConnectOptionsExt): Promise<boolean> {
|
||
this.autoReconnect = options?.autoReconnect ?? false;
|
||
try {
|
||
const key = this.resolveDeviceKey(deviceId)
|
||
console.log(key,deviceId)
|
||
if (!key) {
|
||
// better debugging: include a short sample of known device keys
|
||
const known = Object.keys(this.devices || {}).slice(0, 20)
|
||
throw new Error(`设备未找到: ${deviceId}; 已知设备: ${known.join(',')}`)
|
||
}
|
||
const device = this.devices[key]
|
||
const server = await device.gatt.connect();
|
||
this.servers[key] = server;
|
||
this.connectionStates[key] = BLE_CONNECTION_STATE.CONNECTED;
|
||
this.reconnectAttempts = 0;
|
||
this.emitConnectionStateChange(key, 'connected');
|
||
// 监听物理断开
|
||
if (device.gatt) {
|
||
device.gatt.onconnectionstatechanged = null;
|
||
device.gatt.onconnectionstatechanged = () => {
|
||
if (!device.gatt.connected) {
|
||
this.emitConnectionStateChange(key, 'disconnected');
|
||
}
|
||
};
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||
return this.scheduleReconnect(deviceId);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async disconnectDevice(deviceId: string): Promise<void> {
|
||
const key = this.resolveDeviceKey(deviceId)
|
||
if (!key) throw new Error('设备未找到')
|
||
const device = this.devices[key]
|
||
try {
|
||
if (device.gatt && device.gatt.connected) {
|
||
device.gatt.disconnect();
|
||
}
|
||
delete this.servers[key];
|
||
this.connectionStates[key] = BLE_CONNECTION_STATE.DISCONNECTED;
|
||
this.emitConnectionStateChange(key, 'disconnected');
|
||
} catch (e) {
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
getConnectedDevices(): BleDevice[] {
|
||
const connectedDevices: BleDevice[] = [];
|
||
for (const deviceId in this.servers) {
|
||
const device = this.devices[deviceId];
|
||
if (device) {
|
||
connectedDevices.push({ deviceId: device.id, name: device.name || '未知设备', connected: true });
|
||
}
|
||
}
|
||
return connectedDevices;
|
||
}
|
||
|
||
handleDisconnect(deviceId: string) {
|
||
const key = this.resolveDeviceKey(deviceId)
|
||
const idKey = key ?? deviceId
|
||
this.connectionStates[idKey] = BLE_CONNECTION_STATE.DISCONNECTED;
|
||
this.emitConnectionStateChange(idKey, 'disconnected');
|
||
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||
this.scheduleReconnect(idKey);
|
||
}
|
||
}
|
||
|
||
private scheduleReconnect(deviceId: string): Promise<boolean> {
|
||
this.reconnectAttempts++;
|
||
return new Promise((resolve, reject) => {
|
||
this.reconnectTimeoutId = setTimeout(() => {
|
||
this.connectDevice(deviceId, { autoReconnect: true })
|
||
.then(resolve)
|
||
.catch(reject);
|
||
}, this.reconnectDelay);
|
||
});
|
||
}
|
||
|
||
// Resolve a device key used in this.devices map from a given deviceId.
|
||
// Accepts formatted IDs (with ':'), raw ids or case-insensitive hex strings.
|
||
private resolveDeviceKey(deviceId: string): string | null {
|
||
// Accept either a string id or an object that contains the id (UTSJSONObject or plain object)
|
||
if (deviceId == null) return null
|
||
let idCandidate: any = deviceId
|
||
if (typeof deviceId !== 'string') {
|
||
try {
|
||
// UTSJSONObject has getString
|
||
if (typeof (deviceId as any).getString === 'function') {
|
||
const got = (deviceId as any).getString('deviceId') || (deviceId as any).getString('device_id') || (deviceId as any).getString('id')
|
||
if (got) idCandidate = got
|
||
} else if (typeof deviceId === 'object') {
|
||
const got = (deviceId as any).deviceId || (deviceId as any).device_id || (deviceId as any).id
|
||
if (got) idCandidate = got
|
||
}
|
||
} catch (e) { /* ignore extraction errors */ }
|
||
}
|
||
if (!idCandidate) return null
|
||
if (this.devices[idCandidate]) return idCandidate
|
||
const normalize = (s: string) => (s || '').toString().replace(/:/g, '').toUpperCase()
|
||
const target = normalize(idCandidate)
|
||
for (const k in this.devices) {
|
||
if (k === deviceId) return k
|
||
const dev = this.devices[k]
|
||
try {
|
||
if (dev && dev.id && normalize(dev.id) === target) return k
|
||
} catch (e) { }
|
||
if (normalize(k) === target) return k
|
||
}
|
||
return null
|
||
}
|
||
|
||
cancelReconnect() {
|
||
if (this.reconnectTimeoutId) {
|
||
clearTimeout(this.reconnectTimeoutId);
|
||
this.reconnectTimeoutId = 0;
|
||
}
|
||
this.autoReconnect = false;
|
||
this.reconnectAttempts = 0;
|
||
}
|
||
|
||
setMaxReconnectAttempts(attempts: number) {
|
||
this.maxReconnectAttempts = attempts;
|
||
}
|
||
|
||
setReconnectDelay(delay: number) {
|
||
this.reconnectDelay = delay;
|
||
}
|
||
|
||
isDeviceConnected(deviceId: string): boolean {
|
||
return !!this.servers[deviceId];
|
||
}
|
||
|
||
// Public helper to obtain the GATT server for a device id using flexible matching
|
||
getServer(deviceId: string): any | null {
|
||
const key = this.resolveDeviceKey(deviceId)
|
||
if (!key) return null
|
||
return this.servers[key] ?? null
|
||
}
|
||
} |