@onekeyfe/hardware-cli 1.2.0-alpha.2 → 1.2.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +17 -1
- package/dist/cli.js +480 -54
- package/dist/deviceSelection.d.ts +3 -0
- package/dist/deviceSelection.js +11 -0
- package/dist/deviceStateCommands.d.ts +19 -0
- package/dist/deviceStateCommands.js +62 -0
- package/dist/sdk.d.ts +2 -0
- package/dist/sdk.js +8 -6
- package/dist/transports/nobleBlePlugin.d.ts +2 -0
- package/dist/transports/nobleBlePlugin.js +384 -0
- package/package.json +8 -6
- package/src/__tests__/cli-version.test.ts +8 -0
- package/src/__tests__/device-selection.test.ts +29 -0
- package/src/__tests__/device-state-commands.test.ts +118 -0
- package/src/__tests__/firmware-update-legacy-command.test.ts +20 -0
- package/src/__tests__/firmware-update-v4-command.test.ts +19 -0
- package/src/__tests__/noble-ble-plugin.test.ts +225 -0
- package/src/__tests__/wallpaper-upload-command.test.ts +46 -0
- package/src/cli.ts +691 -66
- package/src/deviceSelection.ts +13 -0
- package/src/deviceStateCommands.ts +71 -0
- package/src/sdk.ts +15 -7
- package/src/transports/nobleBlePlugin.ts +498 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function selectSearchDevice<T extends { connectId?: string }>(
|
|
2
|
+
devices: T[],
|
|
3
|
+
preferredConnectId?: string
|
|
4
|
+
): T | undefined {
|
|
5
|
+
if (preferredConnectId) {
|
|
6
|
+
return (
|
|
7
|
+
devices.find(device => device.connectId === preferredConnectId) ??
|
|
8
|
+
({ connectId: preferredConnectId } as T)
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return devices[0];
|
|
13
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CoreApi, DeviceStateScope, KnownDevice, SearchDevice } from '@onekeyfe/hd-core';
|
|
2
|
+
|
|
3
|
+
type DeviceStateSdk = Pick<CoreApi, 'searchDevices' | 'getDeviceState' | 'getFeatures'>;
|
|
4
|
+
|
|
5
|
+
type DiscoveredDevice = SearchDevice & Partial<Pick<KnownDevice, 'features' | 'state'>>;
|
|
6
|
+
|
|
7
|
+
const createDeviceNotFoundResult = (connectId?: string) => ({
|
|
8
|
+
success: false as const,
|
|
9
|
+
payload: {
|
|
10
|
+
code: 'DEVICE_NOT_FOUND',
|
|
11
|
+
error: connectId ? `Device not found: ${connectId}` : 'No device found',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const resolveSearchDevice = async (sdk: DeviceStateSdk, connectId?: string) => {
|
|
16
|
+
const searchResult = await sdk.searchDevices();
|
|
17
|
+
if (!searchResult.success) return searchResult;
|
|
18
|
+
|
|
19
|
+
const devices = searchResult.payload as DiscoveredDevice[];
|
|
20
|
+
const device = connectId ? devices.find(item => item.connectId === connectId) : devices[0];
|
|
21
|
+
|
|
22
|
+
if (!device?.connectId) return createDeviceNotFoundResult(connectId);
|
|
23
|
+
return { success: true as const, payload: device };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Unified state entry for the new CLI. Resolve the user-facing connectId through
|
|
28
|
+
* discovery so V1 serial IDs map to the process-local USB path.
|
|
29
|
+
*/
|
|
30
|
+
export const getCanonicalDeviceState = async (
|
|
31
|
+
sdk: DeviceStateSdk,
|
|
32
|
+
connectId: string | undefined,
|
|
33
|
+
scope: DeviceStateScope
|
|
34
|
+
) => {
|
|
35
|
+
const deviceResult = await resolveSearchDevice(sdk, connectId);
|
|
36
|
+
if (!deviceResult.success) return deviceResult;
|
|
37
|
+
|
|
38
|
+
if (scope === 'runtime' && deviceResult.payload.state) {
|
|
39
|
+
return { success: true as const, payload: deviceResult.payload.state };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const resolvedConnectId = deviceResult.payload.connectId ?? undefined;
|
|
43
|
+
if (!resolvedConnectId) return createDeviceNotFoundResult();
|
|
44
|
+
return sdk.getDeviceState(resolvedConnectId, { scope });
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Legacy CLI only: retain getFeatures for V1 and reuse the discovery projection for V2.
|
|
49
|
+
* Public SDK.getFeatures remains V1-only.
|
|
50
|
+
*/
|
|
51
|
+
export const getCompatibleFeatures = async (sdk: DeviceStateSdk, connectId?: string) => {
|
|
52
|
+
const deviceResult = await resolveSearchDevice(sdk, connectId);
|
|
53
|
+
if (!deviceResult.success) return deviceResult;
|
|
54
|
+
|
|
55
|
+
const device = deviceResult.payload;
|
|
56
|
+
const protocol = device.state?.protocol ?? device.features?.protocol;
|
|
57
|
+
if (protocol === 'V2') {
|
|
58
|
+
if (!device.features) {
|
|
59
|
+
return {
|
|
60
|
+
success: false as const,
|
|
61
|
+
payload: {
|
|
62
|
+
code: 'DEVICE_FEATURES_UNAVAILABLE',
|
|
63
|
+
error: 'Protocol V2 compatibility features are unavailable',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { success: true as const, payload: device.features };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return sdk.getFeatures(device.connectId ?? '');
|
|
71
|
+
};
|
package/src/sdk.ts
CHANGED
|
@@ -14,14 +14,17 @@ import HardwareSDK from '@onekeyfe/hd-common-connect-sdk';
|
|
|
14
14
|
import { DEVICE, UI_EVENT, UI_REQUEST, UI_RESPONSE } from '@onekeyfe/hd-core';
|
|
15
15
|
|
|
16
16
|
import { promptPassphraseViaPinentry } from './pinentry';
|
|
17
|
+
import { createNobleBlePlugin } from './transports/nobleBlePlugin';
|
|
17
18
|
|
|
18
|
-
import type { ConnectSettings } from '@onekeyfe/hd-core';
|
|
19
|
+
import type { ConnectSettings, KnownDevice } from '@onekeyfe/hd-core';
|
|
19
20
|
import type { PinentryResult } from './pinentry';
|
|
20
21
|
|
|
21
22
|
export interface SDKOptions {
|
|
22
23
|
connectId?: string;
|
|
23
24
|
passphraseState?: string;
|
|
24
25
|
useEmptyPassphrase?: boolean;
|
|
26
|
+
debug?: boolean;
|
|
27
|
+
transport?: 'usb' | 'ble';
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -166,12 +169,12 @@ function registerEventHandlers(sdk: typeof HardwareSDK): void {
|
|
|
166
169
|
}
|
|
167
170
|
});
|
|
168
171
|
|
|
169
|
-
sdk.on(DEVICE.CONNECT, (device:
|
|
172
|
+
sdk.on(DEVICE.CONNECT, ({ device }: { device: KnownDevice }) => {
|
|
170
173
|
const name = device?.label || device?.name;
|
|
171
174
|
if (name) process.stderr.write(`[onekey-hw] Device connected: ${name}\n`);
|
|
172
175
|
});
|
|
173
176
|
|
|
174
|
-
sdk.on(DEVICE.DISCONNECT, (device:
|
|
177
|
+
sdk.on(DEVICE.DISCONNECT, ({ device }: { device: KnownDevice }) => {
|
|
175
178
|
const name = device?.label || device?.name;
|
|
176
179
|
if (name) process.stderr.write(`[onekey-hw] Device disconnected: ${name}\n`);
|
|
177
180
|
});
|
|
@@ -182,12 +185,17 @@ function registerEventHandlers(sdk: typeof HardwareSDK): void {
|
|
|
182
185
|
// ---------------------------------------------------------------------------
|
|
183
186
|
|
|
184
187
|
async function initSDK(): Promise<typeof HardwareSDK> {
|
|
188
|
+
const transport = currentOpts.transport ?? 'usb';
|
|
185
189
|
const settings: Partial<ConnectSettings> = {
|
|
186
|
-
debug: false,
|
|
190
|
+
debug: currentOpts.debug ?? false,
|
|
187
191
|
fetchConfig: true,
|
|
188
|
-
env: 'node-usb',
|
|
192
|
+
env: transport === 'ble' ? 'lowlevel' : 'node-usb',
|
|
189
193
|
};
|
|
190
|
-
await HardwareSDK.init(
|
|
194
|
+
await HardwareSDK.init(
|
|
195
|
+
settings,
|
|
196
|
+
undefined,
|
|
197
|
+
transport === 'ble' ? createNobleBlePlugin() : undefined
|
|
198
|
+
);
|
|
191
199
|
|
|
192
200
|
// Defensive: strip any stale listeners (e.g. left over from a previous
|
|
193
201
|
// dispose/init cycle in a long-running process) before wiring ours.
|
|
@@ -223,7 +231,7 @@ export async function disposeSDK(): Promise<void> {
|
|
|
223
231
|
if (!sdkReadyPromise) return;
|
|
224
232
|
try {
|
|
225
233
|
const sdk = await sdkReadyPromise;
|
|
226
|
-
sdk.dispose();
|
|
234
|
+
await Promise.resolve(sdk.dispose());
|
|
227
235
|
} catch {
|
|
228
236
|
// ignore errors during cleanup
|
|
229
237
|
} finally {
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ERRORS,
|
|
3
|
+
HardwareErrorCode,
|
|
4
|
+
ONEKEY_SERVICE_UUID,
|
|
5
|
+
isOnekeyDevice,
|
|
6
|
+
} from '@onekeyfe/hd-shared';
|
|
7
|
+
|
|
8
|
+
import type { LowLevelDevice, LowlevelTransportSharedPlugin } from '@onekeyfe/hd-transport';
|
|
9
|
+
import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
|
|
10
|
+
|
|
11
|
+
type NobleModule = {
|
|
12
|
+
state: string;
|
|
13
|
+
startScanning(
|
|
14
|
+
serviceUUIDs: string[],
|
|
15
|
+
allowDuplicates: boolean,
|
|
16
|
+
callback?: (error?: Error) => void
|
|
17
|
+
): void;
|
|
18
|
+
stopScanning(callback?: () => void): void;
|
|
19
|
+
on(event: 'stateChange', listener: (state: string) => void): void;
|
|
20
|
+
on(event: 'discover', listener: (peripheral: Peripheral) => void): void;
|
|
21
|
+
removeListener(event: 'stateChange', listener: (state: string) => void): void;
|
|
22
|
+
removeListener(event: 'discover', listener: (peripheral: Peripheral) => void): void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type CharacteristicPair = {
|
|
26
|
+
write: Characteristic;
|
|
27
|
+
notify: Characteristic;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type NoblePendingReceiver = {
|
|
31
|
+
resolve: (data: string) => void;
|
|
32
|
+
reject: (error: Error) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type NobleNotificationState = {
|
|
36
|
+
generation: number;
|
|
37
|
+
queue: string[];
|
|
38
|
+
pendingReceivers: Set<NoblePendingReceiver>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const ONEKEY_SERVICE_UUIDS = [ONEKEY_SERVICE_UUID];
|
|
42
|
+
const PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS = new Set(['fffd']);
|
|
43
|
+
const NORMALIZED_WRITE_UUID = '0002';
|
|
44
|
+
const NORMALIZED_NOTIFY_UUID = '0003';
|
|
45
|
+
const NORMALIZED_ONEKEY_SERVICE_UUIDS = new Set([
|
|
46
|
+
...ONEKEY_SERVICE_UUIDS.map(uuid => getBleUuidKey(uuid)),
|
|
47
|
+
'0001',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const BLUETOOTH_INIT_TIMEOUT = 10_000;
|
|
51
|
+
const DEVICE_SCAN_TIMEOUT = 8_000;
|
|
52
|
+
const CONNECTION_TIMEOUT = 8_000;
|
|
53
|
+
const SERVICE_DISCOVERY_TIMEOUT = 10_000;
|
|
54
|
+
const BLE_CLEANUP_TIMEOUT = 100;
|
|
55
|
+
const BLE_PACKET_SIZE = 192;
|
|
56
|
+
const BLE_ENCRYPTION_ERROR_PATTERNS = [/encryption is insufficient/i, /insufficient encryption/i];
|
|
57
|
+
|
|
58
|
+
let noble: NobleModule | null = null;
|
|
59
|
+
let nobleReadyPromise: Promise<void> | null = null;
|
|
60
|
+
const discoveredDevices = new Map<string, Peripheral>();
|
|
61
|
+
const connectedDevices = new Map<string, Peripheral>();
|
|
62
|
+
const deviceCharacteristics = new Map<string, CharacteristicPair>();
|
|
63
|
+
const notificationStates = new Map<string, NobleNotificationState>();
|
|
64
|
+
const notificationGenerations = new Map<string, number>();
|
|
65
|
+
|
|
66
|
+
function getBleUuidKey(uuid?: string | null) {
|
|
67
|
+
const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
|
|
68
|
+
return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isGenericBleService(uuid?: string | null) {
|
|
72
|
+
return ['1800', '1801', '180a', '180f'].includes(getBleUuidKey(uuid));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasOneKeyAdvertisementService(peripheral: Peripheral) {
|
|
76
|
+
const serviceUuids = peripheral.advertisement?.serviceUuids ?? [];
|
|
77
|
+
return serviceUuids.some(uuid => {
|
|
78
|
+
const uuidKey = getBleUuidKey(uuid);
|
|
79
|
+
return (
|
|
80
|
+
NORMALIZED_ONEKEY_SERVICE_UUIDS.has(uuidKey) ||
|
|
81
|
+
PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(uuidKey)
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isOneKeyPeripheral(peripheral: Peripheral) {
|
|
87
|
+
const deviceName = peripheral.advertisement?.localName || null;
|
|
88
|
+
return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function enqueueNotification(deviceId: string, generation: number, data: Buffer) {
|
|
92
|
+
const state = notificationStates.get(deviceId);
|
|
93
|
+
if (!state || state.generation !== generation) return;
|
|
94
|
+
|
|
95
|
+
const hex = data.toString('hex');
|
|
96
|
+
const [receiver] = state.pendingReceivers;
|
|
97
|
+
if (receiver) {
|
|
98
|
+
state.pendingReceivers.delete(receiver);
|
|
99
|
+
receiver.resolve(hex);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
state.queue.push(hex);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createNotificationState(deviceId: string) {
|
|
106
|
+
const existing = notificationStates.get(deviceId);
|
|
107
|
+
if (existing) {
|
|
108
|
+
const error = new Error(`BLE notification state replaced for ${deviceId}`);
|
|
109
|
+
existing.pendingReceivers.forEach(receiver => receiver.reject(error));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const generation = (notificationGenerations.get(deviceId) ?? 0) + 1;
|
|
113
|
+
notificationGenerations.set(deviceId, generation);
|
|
114
|
+
const state: NobleNotificationState = {
|
|
115
|
+
generation,
|
|
116
|
+
queue: [],
|
|
117
|
+
pendingReceivers: new Set(),
|
|
118
|
+
};
|
|
119
|
+
notificationStates.set(deviceId, state);
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function clearNotificationState(deviceId: string, reason: string) {
|
|
124
|
+
const state = notificationStates.get(deviceId);
|
|
125
|
+
if (!state) return;
|
|
126
|
+
|
|
127
|
+
notificationStates.delete(deviceId);
|
|
128
|
+
const error = new Error(reason);
|
|
129
|
+
state.pendingReceivers.forEach(receiver => receiver.reject(error));
|
|
130
|
+
state.pendingReceivers.clear();
|
|
131
|
+
state.queue.length = 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function waitForNobleCleanup(registerCallback: (callback: () => void) => void) {
|
|
135
|
+
return new Promise<void>(resolve => {
|
|
136
|
+
let completed = false;
|
|
137
|
+
const complete = () => {
|
|
138
|
+
if (completed) return;
|
|
139
|
+
completed = true;
|
|
140
|
+
clearTimeout(timeout);
|
|
141
|
+
resolve();
|
|
142
|
+
};
|
|
143
|
+
const timeout = setTimeout(complete, BLE_CLEANUP_TIMEOUT);
|
|
144
|
+
try {
|
|
145
|
+
registerCallback(complete);
|
|
146
|
+
} catch {
|
|
147
|
+
complete();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function initializeNoble() {
|
|
153
|
+
if (!noble) {
|
|
154
|
+
try {
|
|
155
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
156
|
+
noble = require('@stoprocent/noble') as NobleModule;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw ERRORS.TypedError(
|
|
159
|
+
HardwareErrorCode.BleUnsupported,
|
|
160
|
+
error instanceof Error ? error.message : String(error)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (noble.state === 'poweredOn') return;
|
|
166
|
+
|
|
167
|
+
if (nobleReadyPromise) {
|
|
168
|
+
await nobleReadyPromise;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
nobleReadyPromise = new Promise<void>((resolve, reject) => {
|
|
173
|
+
const timeout = setTimeout(() => {
|
|
174
|
+
noble?.removeListener('stateChange', onStateChange);
|
|
175
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BlePoweredOff, 'Bluetooth is not powered on'));
|
|
176
|
+
}, BLUETOOTH_INIT_TIMEOUT);
|
|
177
|
+
|
|
178
|
+
const onStateChange = (state: string) => {
|
|
179
|
+
if (state === 'poweredOn') {
|
|
180
|
+
clearTimeout(timeout);
|
|
181
|
+
noble?.removeListener('stateChange', onStateChange);
|
|
182
|
+
resolve();
|
|
183
|
+
} else if (state === 'unsupported') {
|
|
184
|
+
clearTimeout(timeout);
|
|
185
|
+
noble?.removeListener('stateChange', onStateChange);
|
|
186
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BleUnsupported));
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
noble?.on('stateChange', onStateChange);
|
|
191
|
+
}).finally(() => {
|
|
192
|
+
nobleReadyPromise = null;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await nobleReadyPromise;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function stopScanning() {
|
|
199
|
+
try {
|
|
200
|
+
noble?.stopScanning();
|
|
201
|
+
} catch {
|
|
202
|
+
// ignore best-effort scan cleanup
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function scanDevices(targetDeviceId?: string) {
|
|
207
|
+
await initializeNoble();
|
|
208
|
+
if (!noble) {
|
|
209
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not initialized');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!targetDeviceId) {
|
|
213
|
+
discoveredDevices.clear();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const nobleInstance = noble;
|
|
217
|
+
return new Promise<Peripheral[]>((resolve, reject) => {
|
|
218
|
+
const found = new Map<string, Peripheral>();
|
|
219
|
+
|
|
220
|
+
const cleanup = () => {
|
|
221
|
+
clearTimeout(timeout);
|
|
222
|
+
nobleInstance.removeListener('discover', onDiscover);
|
|
223
|
+
stopScanning();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const finish = () => {
|
|
227
|
+
cleanup();
|
|
228
|
+
resolve([...found.values()]);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const onDiscover = (peripheral: Peripheral) => {
|
|
232
|
+
if (targetDeviceId && peripheral.id !== targetDeviceId) return;
|
|
233
|
+
if (!targetDeviceId && !isOneKeyPeripheral(peripheral)) return;
|
|
234
|
+
|
|
235
|
+
discoveredDevices.set(peripheral.id, peripheral);
|
|
236
|
+
found.set(peripheral.id, peripheral);
|
|
237
|
+
if (targetDeviceId) {
|
|
238
|
+
finish();
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const timeout = setTimeout(finish, DEVICE_SCAN_TIMEOUT);
|
|
243
|
+
nobleInstance.on('discover', onDiscover);
|
|
244
|
+
nobleInstance.startScanning([], false, (error?: Error) => {
|
|
245
|
+
if (error) {
|
|
246
|
+
cleanup();
|
|
247
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function connectPeripheral(peripheral: Peripheral) {
|
|
254
|
+
if (peripheral.state === 'connected') return Promise.resolve();
|
|
255
|
+
|
|
256
|
+
return new Promise<void>((resolve, reject) => {
|
|
257
|
+
const timeout = setTimeout(() => {
|
|
258
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
|
|
259
|
+
}, CONNECTION_TIMEOUT);
|
|
260
|
+
|
|
261
|
+
peripheral.connect((error?: Error) => {
|
|
262
|
+
clearTimeout(timeout);
|
|
263
|
+
if (error) {
|
|
264
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
resolve();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function discoverCharacteristics(peripheral: Peripheral): Promise<CharacteristicPair> {
|
|
273
|
+
const services = await new Promise<Service[]>((resolve, reject) => {
|
|
274
|
+
const timeout = setTimeout(() => {
|
|
275
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'Service discovery timeout'));
|
|
276
|
+
}, SERVICE_DISCOVERY_TIMEOUT);
|
|
277
|
+
|
|
278
|
+
peripheral.discoverServices([], (error, discoveredServices) => {
|
|
279
|
+
clearTimeout(timeout);
|
|
280
|
+
if (error) {
|
|
281
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
resolve(discoveredServices);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
let service = services.find(s => NORMALIZED_ONEKEY_SERVICE_UUIDS.has(getBleUuidKey(s.uuid)));
|
|
289
|
+
if (!service) {
|
|
290
|
+
service =
|
|
291
|
+
services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
|
|
292
|
+
services.find(s => !isGenericBleService(s.uuid)) ||
|
|
293
|
+
services[0];
|
|
294
|
+
}
|
|
295
|
+
if (!service) {
|
|
296
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No BLE service found');
|
|
297
|
+
}
|
|
298
|
+
const selectedService = service;
|
|
299
|
+
|
|
300
|
+
const characteristics = await new Promise<Characteristic[]>((resolve, reject) => {
|
|
301
|
+
selectedService.discoverCharacteristics([], (error, discoveredCharacteristics) => {
|
|
302
|
+
if (error) {
|
|
303
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotFound, error.message));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
resolve(discoveredCharacteristics);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
let writeCharacteristic: Characteristic | undefined;
|
|
311
|
+
let notifyCharacteristic: Characteristic | undefined;
|
|
312
|
+
for (const characteristic of characteristics) {
|
|
313
|
+
const uuidKey = getBleUuidKey(characteristic.uuid);
|
|
314
|
+
if (uuidKey === NORMALIZED_WRITE_UUID) {
|
|
315
|
+
writeCharacteristic = characteristic;
|
|
316
|
+
} else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
|
|
317
|
+
notifyCharacteristic = characteristic;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!writeCharacteristic || !notifyCharacteristic) {
|
|
322
|
+
throw ERRORS.TypedError(
|
|
323
|
+
HardwareErrorCode.BleCharacteristicNotFound,
|
|
324
|
+
'Required OneKey BLE characteristics not found'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
write: writeCharacteristic,
|
|
330
|
+
notify: notifyCharacteristic,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function subscribeNotifications(
|
|
335
|
+
deviceId: string,
|
|
336
|
+
generation: number,
|
|
337
|
+
notifyCharacteristic: Characteristic
|
|
338
|
+
) {
|
|
339
|
+
return waitForNobleCleanup(callback => notifyCharacteristic.unsubscribe(callback))
|
|
340
|
+
.then(
|
|
341
|
+
() =>
|
|
342
|
+
new Promise<void>((resolve, reject) => {
|
|
343
|
+
notifyCharacteristic.subscribe((error?: Error) => {
|
|
344
|
+
if (error) {
|
|
345
|
+
const errorMessage = error.message || String(error);
|
|
346
|
+
if (BLE_ENCRYPTION_ERROR_PATTERNS.some(pattern => pattern.test(errorMessage))) {
|
|
347
|
+
reject(
|
|
348
|
+
ERRORS.TypedError(
|
|
349
|
+
HardwareErrorCode.BleDeviceNotBonded,
|
|
350
|
+
`BLE device ${deviceId} is not paired or the encrypted link is not ready: ${errorMessage}`
|
|
351
|
+
)
|
|
352
|
+
);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
reject(
|
|
356
|
+
ERRORS.TypedError(
|
|
357
|
+
HardwareErrorCode.BleCharacteristicNotifyChangeFailure,
|
|
358
|
+
`Failed to subscribe notifications for ${deviceId}: ${errorMessage}`
|
|
359
|
+
)
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
resolve();
|
|
364
|
+
});
|
|
365
|
+
})
|
|
366
|
+
)
|
|
367
|
+
.then(() => {
|
|
368
|
+
notifyCharacteristic.removeAllListeners('data');
|
|
369
|
+
notifyCharacteristic.on('data', data => enqueueNotification(deviceId, generation, data));
|
|
370
|
+
})
|
|
371
|
+
.catch(error => {
|
|
372
|
+
notifyCharacteristic.removeAllListeners('data');
|
|
373
|
+
if (error) {
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotifyChangeFailure);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function writeCharacteristic(
|
|
381
|
+
characteristic: Characteristic,
|
|
382
|
+
buffer: Buffer,
|
|
383
|
+
withoutResponse: boolean
|
|
384
|
+
) {
|
|
385
|
+
return new Promise<void>((resolve, reject) => {
|
|
386
|
+
characteristic.write(buffer, withoutResponse, (error?: Error) => {
|
|
387
|
+
if (error) {
|
|
388
|
+
reject(error);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
resolve();
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function disconnectDevice(uuid: string) {
|
|
397
|
+
const peripheral = connectedDevices.get(uuid);
|
|
398
|
+
const characteristics = deviceCharacteristics.get(uuid);
|
|
399
|
+
clearNotificationState(uuid, `BLE device disconnected: ${uuid}`);
|
|
400
|
+
if (characteristics) {
|
|
401
|
+
characteristics.notify.removeAllListeners('data');
|
|
402
|
+
await waitForNobleCleanup(callback => characteristics.notify.unsubscribe(callback));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
connectedDevices.delete(uuid);
|
|
406
|
+
deviceCharacteristics.delete(uuid);
|
|
407
|
+
|
|
408
|
+
if (!peripheral || peripheral.state === 'disconnected') return;
|
|
409
|
+
|
|
410
|
+
await waitForNobleCleanup(callback => peripheral.disconnect(callback));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
|
|
414
|
+
return {
|
|
415
|
+
version: 'OneKey-CLI-Noble-1.0',
|
|
416
|
+
|
|
417
|
+
async init() {
|
|
418
|
+
await initializeNoble();
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
async enumerate(): Promise<LowLevelDevice[]> {
|
|
422
|
+
const devices = await scanDevices();
|
|
423
|
+
return devices.map(device => ({
|
|
424
|
+
commType: 'ble',
|
|
425
|
+
id: device.id,
|
|
426
|
+
name: device.advertisement?.localName || 'Unknown BLE Device',
|
|
427
|
+
}));
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
async connect(uuid: string) {
|
|
431
|
+
let peripheral = discoveredDevices.get(uuid);
|
|
432
|
+
if (!peripheral) {
|
|
433
|
+
[peripheral] = await scanDevices(uuid);
|
|
434
|
+
}
|
|
435
|
+
if (!peripheral) {
|
|
436
|
+
throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `BLE device not found: ${uuid}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
await connectPeripheral(peripheral);
|
|
440
|
+
const characteristics = await discoverCharacteristics(peripheral);
|
|
441
|
+
const notificationState = createNotificationState(uuid);
|
|
442
|
+
try {
|
|
443
|
+
await subscribeNotifications(uuid, notificationState.generation, characteristics.notify);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
clearNotificationState(uuid, `BLE notification subscription failed: ${uuid}`);
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
connectedDevices.set(uuid, peripheral);
|
|
449
|
+
deviceCharacteristics.set(uuid, characteristics);
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
async disconnect(uuid: string) {
|
|
453
|
+
await disconnectDevice(uuid);
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
async send(uuid: string, data: string, options?: { withoutResponse?: boolean }) {
|
|
457
|
+
const characteristics = deviceCharacteristics.get(uuid);
|
|
458
|
+
if (!characteristics) {
|
|
459
|
+
throw ERRORS.TypedError(
|
|
460
|
+
HardwareErrorCode.BleCharacteristicNotFound,
|
|
461
|
+
`BLE device is not connected: ${uuid}`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const buffer = Buffer.from(data, 'hex');
|
|
466
|
+
const withoutResponse = options?.withoutResponse ?? true;
|
|
467
|
+
for (let offset = 0; offset < buffer.length; offset += BLE_PACKET_SIZE) {
|
|
468
|
+
const chunk = buffer.subarray(offset, Math.min(offset + BLE_PACKET_SIZE, buffer.length));
|
|
469
|
+
await writeCharacteristic(characteristics.write, chunk, withoutResponse);
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
async receive(uuid?: string) {
|
|
474
|
+
const resolvedUuid =
|
|
475
|
+
uuid ??
|
|
476
|
+
(notificationStates.size === 1 ? notificationStates.keys().next().value : undefined);
|
|
477
|
+
if (!resolvedUuid) {
|
|
478
|
+
throw ERRORS.TypedError(
|
|
479
|
+
HardwareErrorCode.RuntimeError,
|
|
480
|
+
'BLE receive requires a device UUID when multiple devices are connected'
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const state = notificationStates.get(resolvedUuid);
|
|
485
|
+
if (!state) {
|
|
486
|
+
throw ERRORS.TypedError(
|
|
487
|
+
HardwareErrorCode.TransportNotFound,
|
|
488
|
+
`BLE notification state not found: ${resolvedUuid}`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
const queued = state.queue.shift();
|
|
492
|
+
if (queued !== undefined) return queued;
|
|
493
|
+
return new Promise<string>((resolve, reject) => {
|
|
494
|
+
state.pendingReceivers.add({ resolve, reject });
|
|
495
|
+
});
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|