@onekeyfe/hd-transport-react-native 1.2.0-alpha.6 → 1.2.0-alpha.61
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/BleTransport.d.ts +1 -3
- package/dist/BleTransport.d.ts.map +1 -1
- package/dist/bleStrategy.d.ts +0 -2
- package/dist/bleStrategy.d.ts.map +1 -1
- package/dist/constants.d.ts +0 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/index.d.ts +21 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +428 -368
- package/dist/subscribeBleOn.d.ts.map +1 -1
- package/dist/transportLog.d.ts +2 -0
- package/dist/transportLog.d.ts.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/jest.config.js +5 -0
- package/package.json +7 -6
- package/src/BleTransport.ts +6 -41
- package/src/__tests__/BleTransport.test.ts +93 -0
- package/src/__tests__/bleStrategy.test.ts +1 -6
- package/src/__tests__/constants.test.ts +20 -0
- package/src/__tests__/enumerate.test.ts +132 -0
- package/src/__tests__/protocolV2Link.test.ts +760 -0
- package/src/bleStrategy.ts +0 -25
- package/src/constants.ts +7 -13
- package/src/index.ts +500 -387
- package/src/subscribeBleOn.ts +0 -2
- package/src/transportLog.ts +1 -0
- package/src/types.ts +1 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import transportPackage, {
|
|
3
|
+
PROTOCOL_V2_CHANNEL_BLE_UART,
|
|
4
|
+
ProtocolV2,
|
|
5
|
+
TRANSPORT_EVENT,
|
|
6
|
+
} from '@onekeyfe/hd-transport';
|
|
7
|
+
import { HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
|
|
8
|
+
|
|
9
|
+
import ReactNativeBleTransport, {
|
|
10
|
+
configureProtocolV2BleTuning,
|
|
11
|
+
getFirmwareUploadWriteRetryType,
|
|
12
|
+
resetProtocolV2BleTuning,
|
|
13
|
+
} from '../index';
|
|
14
|
+
|
|
15
|
+
jest.mock(
|
|
16
|
+
'react-native',
|
|
17
|
+
() => ({
|
|
18
|
+
PermissionsAndroid: {},
|
|
19
|
+
Platform: { OS: 'ios' },
|
|
20
|
+
}),
|
|
21
|
+
{ virtual: true }
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
jest.mock('react-native-ble-plx', () => ({
|
|
25
|
+
BleATTErrorCode: { UnlikelyError: 14 },
|
|
26
|
+
BleError: class BleError extends Error {},
|
|
27
|
+
BleErrorCode: {
|
|
28
|
+
DeviceAlreadyConnected: 203,
|
|
29
|
+
DeviceDisconnected: 205,
|
|
30
|
+
DeviceMTUChangeFailed: 206,
|
|
31
|
+
OperationCancelled: 2,
|
|
32
|
+
CharacteristicNotFound: 404,
|
|
33
|
+
},
|
|
34
|
+
BleManager: jest.fn(),
|
|
35
|
+
ScanMode: { LowLatency: 2 },
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
jest.mock('../BleManager', () => ({
|
|
39
|
+
getConnectedDeviceIds: jest.fn(() => Promise.resolve([])),
|
|
40
|
+
onDeviceBondState: jest.fn(() => Promise.resolve()),
|
|
41
|
+
pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
jest.mock('../subscribeBleOn', () => ({
|
|
45
|
+
subscribeBleOn: jest.fn(() => Promise.resolve()),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const setPlatformOS = (os: 'ios' | 'android') => {
|
|
49
|
+
const reactNative: { Platform: { OS: string } } = jest.requireMock('react-native');
|
|
50
|
+
reactNative.Platform.OS = os;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const { parseConfigure } = transportPackage;
|
|
54
|
+
|
|
55
|
+
const protocolV1Schema = {
|
|
56
|
+
nested: {
|
|
57
|
+
Initialize: { fields: {} },
|
|
58
|
+
GetFeatures: { fields: {} },
|
|
59
|
+
Success: {
|
|
60
|
+
fields: {
|
|
61
|
+
message: { type: 'string', id: 1 },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
MessageType: {
|
|
65
|
+
values: {
|
|
66
|
+
MessageType_Initialize: 1,
|
|
67
|
+
MessageType_Success: 2,
|
|
68
|
+
MessageType_GetFeatures: 55,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const protocolV2Schema = {
|
|
75
|
+
nested: {
|
|
76
|
+
ProtocolInfoRequest: { fields: {} },
|
|
77
|
+
Ping: {
|
|
78
|
+
fields: {
|
|
79
|
+
message: { type: 'string', id: 1 },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
DeviceInfoGet: { fields: {} },
|
|
83
|
+
FileWrite: { fields: {} },
|
|
84
|
+
Success: {
|
|
85
|
+
fields: {
|
|
86
|
+
message: { type: 'string', id: 1 },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
MessageType: {
|
|
90
|
+
values: {
|
|
91
|
+
MessageType_ProtocolInfoRequest: 60200,
|
|
92
|
+
MessageType_Ping: 60206,
|
|
93
|
+
MessageType_Success: 60207,
|
|
94
|
+
MessageType_DeviceInfoGet: 60600,
|
|
95
|
+
MessageType_FileWrite: 60805,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const schemas = {
|
|
102
|
+
protocolV1: parseConfigure(protocolV1Schema),
|
|
103
|
+
protocolV2: parseConfigure(protocolV2Schema),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const createHarness = ({
|
|
107
|
+
deviceName = 'OneKey Pro 2',
|
|
108
|
+
isWritableWithResponse = true,
|
|
109
|
+
}: {
|
|
110
|
+
deviceName?: string;
|
|
111
|
+
isWritableWithResponse?: boolean;
|
|
112
|
+
} = {}) => {
|
|
113
|
+
const uuid = 'rn-pro2-id';
|
|
114
|
+
const sentSeqs: number[] = [];
|
|
115
|
+
let responseSeq = 0;
|
|
116
|
+
let shouldRespond = true;
|
|
117
|
+
let notifyCallback:
|
|
118
|
+
| ((
|
|
119
|
+
error: (Error & { reason?: string }) | null,
|
|
120
|
+
characteristic: { value: string } | null
|
|
121
|
+
) => void)
|
|
122
|
+
| undefined;
|
|
123
|
+
let disconnectCallback: (() => void) | undefined;
|
|
124
|
+
const notifyCharacteristic = {
|
|
125
|
+
uuid: '0003',
|
|
126
|
+
deviceID: uuid,
|
|
127
|
+
isNotifiable: true,
|
|
128
|
+
monitor: jest.fn(callback => {
|
|
129
|
+
notifyCallback = callback;
|
|
130
|
+
return { remove: jest.fn() };
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
const handleWrite = (base64: string) => {
|
|
134
|
+
const frame = Buffer.from(base64, 'base64');
|
|
135
|
+
sentSeqs.push(frame[6]);
|
|
136
|
+
if (shouldRespond) {
|
|
137
|
+
responseSeq += 1;
|
|
138
|
+
const response = ProtocolV2.encodeFrame(
|
|
139
|
+
schemas,
|
|
140
|
+
'Success',
|
|
141
|
+
{ message: 'ok' },
|
|
142
|
+
{ router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq }
|
|
143
|
+
);
|
|
144
|
+
notifyCallback?.(null, { value: Buffer.from(response).toString('base64') });
|
|
145
|
+
}
|
|
146
|
+
return Promise.resolve();
|
|
147
|
+
};
|
|
148
|
+
const writeCharacteristic = {
|
|
149
|
+
uuid: '0002',
|
|
150
|
+
deviceID: uuid,
|
|
151
|
+
isWritableWithResponse,
|
|
152
|
+
isWritableWithoutResponse: true,
|
|
153
|
+
writeWithResponse: jest.fn(handleWrite),
|
|
154
|
+
writeWithoutResponse: jest.fn(handleWrite),
|
|
155
|
+
};
|
|
156
|
+
const device = {
|
|
157
|
+
id: uuid,
|
|
158
|
+
name: deviceName,
|
|
159
|
+
localName: deviceName,
|
|
160
|
+
serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
|
|
161
|
+
isConnected: jest.fn(() => Promise.resolve(true)),
|
|
162
|
+
cancelConnection: jest.fn(() => Promise.resolve()),
|
|
163
|
+
onDisconnected: jest.fn(callback => {
|
|
164
|
+
disconnectCallback = callback;
|
|
165
|
+
return { remove: jest.fn() };
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
const bleManager = {
|
|
169
|
+
devices: jest.fn(() => Promise.resolve([device])),
|
|
170
|
+
connectedDevices: jest.fn(() => Promise.resolve([])),
|
|
171
|
+
cancelTransaction: jest.fn(() => Promise.resolve()),
|
|
172
|
+
};
|
|
173
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
|
|
174
|
+
const emitter = new EventEmitter();
|
|
175
|
+
transport.blePlxManager = bleManager;
|
|
176
|
+
transport.resolveCharacteristics = jest.fn(() =>
|
|
177
|
+
Promise.resolve({ writeCharacteristic, notifyCharacteristic })
|
|
178
|
+
);
|
|
179
|
+
transport.init({ debug: jest.fn(), error: jest.fn() }, emitter);
|
|
180
|
+
transport.configure(protocolV1Schema);
|
|
181
|
+
transport.configureProtocolV2(protocolV2Schema);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
transport,
|
|
185
|
+
emitter,
|
|
186
|
+
uuid,
|
|
187
|
+
sentSeqs,
|
|
188
|
+
writeCharacteristic,
|
|
189
|
+
setShouldRespond(value: boolean) {
|
|
190
|
+
shouldRespond = value;
|
|
191
|
+
},
|
|
192
|
+
emitMonitorError(error: Error & { reason?: string }) {
|
|
193
|
+
notifyCallback?.(error, null);
|
|
194
|
+
},
|
|
195
|
+
emitDisconnect() {
|
|
196
|
+
disconnectCallback?.();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const createV1Harness = ({
|
|
202
|
+
respondOnWriteCount = 1,
|
|
203
|
+
isWritableWithResponse = true,
|
|
204
|
+
}: {
|
|
205
|
+
respondOnWriteCount?: number | number[];
|
|
206
|
+
isWritableWithResponse?: boolean;
|
|
207
|
+
} = {}) => {
|
|
208
|
+
const uuid = 'rn-classic-id';
|
|
209
|
+
const notifySubscriptionRemovers: jest.Mock[] = [];
|
|
210
|
+
const disconnectSubscriptionRemovers: jest.Mock[] = [];
|
|
211
|
+
let notifyCallback:
|
|
212
|
+
| ((error: Error | null, characteristic: { value: string } | null) => void)
|
|
213
|
+
| undefined;
|
|
214
|
+
const notifyCharacteristic = {
|
|
215
|
+
uuid: '0003',
|
|
216
|
+
deviceID: uuid,
|
|
217
|
+
isNotifiable: true,
|
|
218
|
+
monitor: jest.fn(callback => {
|
|
219
|
+
notifyCallback = callback;
|
|
220
|
+
const remove = jest.fn();
|
|
221
|
+
notifySubscriptionRemovers.push(remove);
|
|
222
|
+
return { remove };
|
|
223
|
+
}),
|
|
224
|
+
};
|
|
225
|
+
let writeCount = 0;
|
|
226
|
+
const responseWriteCounts = new Set(
|
|
227
|
+
Array.isArray(respondOnWriteCount) ? respondOnWriteCount : [respondOnWriteCount]
|
|
228
|
+
);
|
|
229
|
+
const handleWrite = () => {
|
|
230
|
+
writeCount += 1;
|
|
231
|
+
if (responseWriteCounts.has(writeCount)) {
|
|
232
|
+
notifyCallback?.(null, {
|
|
233
|
+
value: Buffer.from('3f23230002000000040a026f6b', 'hex').toString('base64'),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return Promise.resolve();
|
|
237
|
+
};
|
|
238
|
+
const writeCharacteristic = {
|
|
239
|
+
uuid: '0002',
|
|
240
|
+
deviceID: uuid,
|
|
241
|
+
isWritableWithResponse,
|
|
242
|
+
isWritableWithoutResponse: true,
|
|
243
|
+
writeWithResponse: jest.fn(handleWrite),
|
|
244
|
+
writeWithoutResponse: jest.fn(handleWrite),
|
|
245
|
+
};
|
|
246
|
+
const device = {
|
|
247
|
+
id: uuid,
|
|
248
|
+
name: 'OneKey Classic',
|
|
249
|
+
localName: 'OneKey Classic',
|
|
250
|
+
serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
|
|
251
|
+
isConnected: jest.fn(() => Promise.resolve(true)),
|
|
252
|
+
cancelConnection: jest.fn(() => Promise.resolve()),
|
|
253
|
+
onDisconnected: jest.fn(() => {
|
|
254
|
+
const remove = jest.fn();
|
|
255
|
+
disconnectSubscriptionRemovers.push(remove);
|
|
256
|
+
return { remove };
|
|
257
|
+
}),
|
|
258
|
+
};
|
|
259
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
|
|
260
|
+
const bleManager = {
|
|
261
|
+
devices: jest.fn(() => Promise.resolve([device])),
|
|
262
|
+
connectedDevices: jest.fn(() => Promise.resolve([])),
|
|
263
|
+
cancelTransaction: jest.fn(() => Promise.resolve()),
|
|
264
|
+
};
|
|
265
|
+
transport.blePlxManager = bleManager as any;
|
|
266
|
+
transport.resolveCharacteristics = jest.fn(() =>
|
|
267
|
+
Promise.resolve({ writeCharacteristic, notifyCharacteristic })
|
|
268
|
+
);
|
|
269
|
+
transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter());
|
|
270
|
+
transport.configure(protocolV1Schema);
|
|
271
|
+
transport.configureProtocolV2(protocolV2Schema);
|
|
272
|
+
return {
|
|
273
|
+
transport,
|
|
274
|
+
uuid,
|
|
275
|
+
device,
|
|
276
|
+
bleManager,
|
|
277
|
+
writeCharacteristic,
|
|
278
|
+
notifySubscriptionRemovers,
|
|
279
|
+
disconnectSubscriptionRemovers,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
|
|
284
|
+
test('does not classify disconnects as retryable firmware writes', () => {
|
|
285
|
+
expect(
|
|
286
|
+
getFirmwareUploadWriteRetryType({
|
|
287
|
+
errorCode: 205,
|
|
288
|
+
message: 'Device disconnected after write',
|
|
289
|
+
})
|
|
290
|
+
).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test.each(['status 143', 'status:143', 'status = 143', 'GATT_CONGESTED'])(
|
|
294
|
+
'classifies %s as transient GATT congestion',
|
|
295
|
+
message => {
|
|
296
|
+
expect(getFirmwareUploadWriteRetryType({ message })).toBe('congested');
|
|
297
|
+
}
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
test('handles long uncontrolled status messages without a backtracking regular expression', () => {
|
|
301
|
+
const message = `status${' '.repeat(100_000)}142`;
|
|
302
|
+
|
|
303
|
+
expect(getFirmwareUploadWriteRetryType({ message })).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('keeps another device reader when releasing a device with an active V1 call', async () => {
|
|
307
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
|
|
308
|
+
const activeV1Call = createDeferred<string>();
|
|
309
|
+
const otherDeviceReader = createDeferred<Uint8Array>();
|
|
310
|
+
activeV1Call.promise.catch(() => undefined);
|
|
311
|
+
otherDeviceReader.promise.catch(() => undefined);
|
|
312
|
+
transport.runPromise = activeV1Call;
|
|
313
|
+
transport.runPromiseDeviceId = 'device-a';
|
|
314
|
+
transport.protocolV2FramePromises.set('device-b', otherDeviceReader);
|
|
315
|
+
|
|
316
|
+
await transport.releaseNative('device-a', true);
|
|
317
|
+
|
|
318
|
+
expect(transport.protocolV2FramePromises.get('device-b')).toBe(otherDeviceReader);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('rejects a pending reader when its device frame state resets', async () => {
|
|
322
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
|
|
323
|
+
const reader = createDeferred<Uint8Array>();
|
|
324
|
+
transport.protocolV2FramePromises.set('device-a', reader);
|
|
325
|
+
const result = Promise.race([
|
|
326
|
+
reader.promise.then(
|
|
327
|
+
() => 'resolved',
|
|
328
|
+
() => 'rejected'
|
|
329
|
+
),
|
|
330
|
+
new Promise(resolve => {
|
|
331
|
+
setTimeout(() => resolve('pending'), 20);
|
|
332
|
+
}),
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
transport.resetProtocolV2Frames('device-a');
|
|
336
|
+
|
|
337
|
+
await expect(result).resolves.toBe('rejected');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('keeps the legacy default BLE scan timeout', () => {
|
|
341
|
+
expect(new ReactNativeBleTransport({}).scanTimeout).toBe(3000);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('uses withResponse for consecutive iOS Protocol V1 control commands without releasing', async () => {
|
|
345
|
+
const { transport, uuid, writeCharacteristic } = createV1Harness({
|
|
346
|
+
respondOnWriteCount: [1, 2],
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
await expect(transport.acquire({ uuid, expectedProtocol: 'V1' })).resolves.toEqual({
|
|
350
|
+
uuid,
|
|
351
|
+
protocolType: 'V1',
|
|
352
|
+
});
|
|
353
|
+
const releaseNative = jest.spyOn(transport as any, 'releaseNative');
|
|
354
|
+
expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
|
|
355
|
+
expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
|
|
356
|
+
|
|
357
|
+
await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).resolves.toBeDefined();
|
|
358
|
+
await expect(transport.call(uuid, 'GetFeatures', {}, { timeoutMs: 50 })).resolves.toBeDefined();
|
|
359
|
+
|
|
360
|
+
expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2);
|
|
361
|
+
expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
|
|
362
|
+
expect(releaseNative).not.toHaveBeenCalled();
|
|
363
|
+
await transport.release(uuid, true);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('falls back to withoutResponse for an iOS Protocol V1 control command when required', async () => {
|
|
367
|
+
const { transport, uuid, writeCharacteristic } = createV1Harness({
|
|
368
|
+
isWritableWithResponse: false,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await transport.acquire({ uuid, expectedProtocol: 'V1' });
|
|
372
|
+
await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).resolves.toBeDefined();
|
|
373
|
+
|
|
374
|
+
expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
|
|
375
|
+
expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
|
|
376
|
+
await transport.release(uuid, true);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('does not resend a failed iOS Protocol V1 control write without response', async () => {
|
|
380
|
+
const { transport, uuid, writeCharacteristic } = createV1Harness();
|
|
381
|
+
const writeError = new Error('write with response failed');
|
|
382
|
+
|
|
383
|
+
await transport.acquire({ uuid, expectedProtocol: 'V1' });
|
|
384
|
+
writeCharacteristic.writeWithResponse.mockRejectedValueOnce(writeError);
|
|
385
|
+
|
|
386
|
+
await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).rejects.toMatchObject({
|
|
387
|
+
errorCode: HardwareErrorCode.BleWriteCharacteristicError,
|
|
388
|
+
});
|
|
389
|
+
expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
|
|
390
|
+
expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
|
|
391
|
+
await transport.release(uuid, true);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('actively probes Protocol V2 on iOS when only a name-derived hint is available', async () => {
|
|
395
|
+
const { transport, uuid, sentSeqs, writeCharacteristic } = createHarness({
|
|
396
|
+
deviceName: 'Pro2 6E9E',
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await expect(transport.acquire({ uuid })).resolves.toEqual({
|
|
400
|
+
uuid,
|
|
401
|
+
protocolType: 'V2',
|
|
402
|
+
});
|
|
403
|
+
expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
|
|
404
|
+
|
|
405
|
+
await expect(
|
|
406
|
+
transport.call(uuid, 'Ping', { message: 'first-core-command' })
|
|
407
|
+
).resolves.toBeDefined();
|
|
408
|
+
expect(sentSeqs).toEqual([1, 2]);
|
|
409
|
+
await transport.release(uuid, true);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('falls back to the other active probe on iOS when protocol metadata is absent', async () => {
|
|
413
|
+
const { transport, uuid } = createHarness({ deviceName: 'OneKey' });
|
|
414
|
+
const probeProtocolV1 = jest
|
|
415
|
+
.spyOn(transport as any, 'probeProtocolV1')
|
|
416
|
+
.mockResolvedValue(false);
|
|
417
|
+
const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(true);
|
|
418
|
+
|
|
419
|
+
await expect(transport.acquire({ uuid })).resolves.toEqual({
|
|
420
|
+
uuid,
|
|
421
|
+
protocolType: 'V2',
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
expect(probeProtocolV1).toHaveBeenCalledTimes(1);
|
|
425
|
+
expect(probeProtocolV2).toHaveBeenCalledTimes(1);
|
|
426
|
+
expect(probeProtocolV1.mock.invocationCallOrder[0]).toBeLessThan(
|
|
427
|
+
probeProtocolV2.mock.invocationCallOrder[0]
|
|
428
|
+
);
|
|
429
|
+
await transport.release(uuid, true);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('reconnects before falling back to Protocol V1 after a fatal V2 probe failure', async () => {
|
|
433
|
+
setPlatformOS('android');
|
|
434
|
+
const { transport, uuid, device, notifySubscriptionRemovers, disconnectSubscriptionRemovers } =
|
|
435
|
+
createV1Harness();
|
|
436
|
+
const probeProtocolV2 = jest
|
|
437
|
+
.spyOn(transport as any, 'probeProtocolV2')
|
|
438
|
+
.mockImplementation(async () => {
|
|
439
|
+
await (transport as any).releaseNative(uuid, true);
|
|
440
|
+
return false;
|
|
441
|
+
});
|
|
442
|
+
const resolveCharacteristics = jest.spyOn(transport as any, 'resolveCharacteristics');
|
|
443
|
+
|
|
444
|
+
await expect(transport.acquire({ uuid, protocolHint: 'V2' })).resolves.toEqual({
|
|
445
|
+
uuid,
|
|
446
|
+
protocolType: 'V1',
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(probeProtocolV2).toHaveBeenCalledTimes(1);
|
|
450
|
+
expect(resolveCharacteristics).toHaveBeenCalledTimes(2);
|
|
451
|
+
expect(transport.getProtocolType(uuid)).toBe('V1');
|
|
452
|
+
expect(device.onDisconnected).toHaveBeenCalledTimes(1);
|
|
453
|
+
expect(notifySubscriptionRemovers).toHaveLength(2);
|
|
454
|
+
expect(notifySubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
|
|
455
|
+
|
|
456
|
+
await transport.release(uuid, true);
|
|
457
|
+
|
|
458
|
+
expect(notifySubscriptionRemovers[1]).toHaveBeenCalledTimes(1);
|
|
459
|
+
expect(disconnectSubscriptionRemovers).toHaveLength(1);
|
|
460
|
+
expect(disconnectSubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('cleans the rebuilt transport when Protocol V1 fallback also fails', async () => {
|
|
464
|
+
setPlatformOS('android');
|
|
465
|
+
const { transport, uuid, device, bleManager, notifySubscriptionRemovers } = createV1Harness();
|
|
466
|
+
jest.spyOn(transport as any, 'probeProtocolV2').mockImplementation(async () => {
|
|
467
|
+
await (transport as any).releaseNative(uuid, true);
|
|
468
|
+
return false;
|
|
469
|
+
});
|
|
470
|
+
jest.spyOn(transport as any, 'probeProtocolV1').mockResolvedValue(false);
|
|
471
|
+
|
|
472
|
+
await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toMatchObject({
|
|
473
|
+
errorCode: HardwareErrorCode.BleTimeoutError,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(device.onDisconnected).not.toHaveBeenCalled();
|
|
477
|
+
expect(notifySubscriptionRemovers).toHaveLength(2);
|
|
478
|
+
expect(notifySubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
|
|
479
|
+
expect(notifySubscriptionRemovers[1]).toHaveBeenCalledTimes(1);
|
|
480
|
+
expect(bleManager.cancelTransaction).toHaveBeenCalled();
|
|
481
|
+
expect(transport.getProtocolType(uuid)).toBeUndefined();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('disconnects and invalidates a Protocol V1 link after a response timeout', async () => {
|
|
485
|
+
const { transport, uuid, device } = createV1Harness({
|
|
486
|
+
respondOnWriteCount: Number.POSITIVE_INFINITY,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await transport.acquire({ uuid, expectedProtocol: 'V1' });
|
|
490
|
+
await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 5 })).rejects.toMatchObject({
|
|
491
|
+
errorCode: HardwareErrorCode.BleTimeoutError,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
expect(device.cancelConnection).toHaveBeenCalled();
|
|
495
|
+
expect(transport.getProtocolType(uuid)).toBeUndefined();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
afterEach(() => {
|
|
499
|
+
setPlatformOS('ios');
|
|
500
|
+
resetProtocolV2BleTuning();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('uses the first Protocol V2 sequence for the first Core call when protocol is known', async () => {
|
|
504
|
+
const { transport, uuid, sentSeqs } = createHarness();
|
|
505
|
+
|
|
506
|
+
await transport.acquire({ uuid, expectedProtocol: 'V2' });
|
|
507
|
+
await transport.call(uuid, 'Ping', { message: 'first-core-command' });
|
|
508
|
+
|
|
509
|
+
expect(sentSeqs).toEqual([1]);
|
|
510
|
+
await transport.release(uuid, true);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('rejects the active Protocol V2 reader when the current monitor errors', async () => {
|
|
514
|
+
const harness = createHarness();
|
|
515
|
+
const { transport, uuid, sentSeqs } = harness;
|
|
516
|
+
await transport.acquire({ uuid, expectedProtocol: 'V2' });
|
|
517
|
+
harness.setShouldRespond(false);
|
|
518
|
+
|
|
519
|
+
const call = transport.call(uuid, 'Ping', { message: 'wait-for-monitor' }, { timeoutMs: 50 });
|
|
520
|
+
while (sentSeqs.length < 1) {
|
|
521
|
+
await new Promise(resolve => {
|
|
522
|
+
setTimeout(resolve, 0);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
await new Promise(resolve => {
|
|
526
|
+
setTimeout(resolve, 0);
|
|
527
|
+
});
|
|
528
|
+
harness.emitMonitorError(Object.assign(new Error('monitor failed'), { reason: 'link lost' }));
|
|
529
|
+
|
|
530
|
+
await expect(call).rejects.toMatchObject({
|
|
531
|
+
errorCode: HardwareErrorCode.BleCharacteristicNotifyError,
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('retains the sequence cursor when a new monitor generation is acquired', async () => {
|
|
536
|
+
const { transport, uuid, sentSeqs } = createHarness();
|
|
537
|
+
|
|
538
|
+
await transport.acquire({ uuid, expectedProtocol: 'V2' });
|
|
539
|
+
await transport.call(uuid, 'Ping', { message: 'first-generation' });
|
|
540
|
+
await transport.release(uuid, true);
|
|
541
|
+
await transport.acquire({ uuid, expectedProtocol: 'V2' });
|
|
542
|
+
await transport.call(uuid, 'Ping', { message: 'second-generation' });
|
|
543
|
+
|
|
544
|
+
expect(sentSeqs).toEqual([1, 2]);
|
|
545
|
+
await transport.release(uuid, true);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('uses withResponse for consecutive iOS Protocol V2 control calls without releasing', async () => {
|
|
549
|
+
const { transport, uuid, writeCharacteristic } = createHarness();
|
|
550
|
+
|
|
551
|
+
await transport.acquire({ uuid, expectedProtocol: 'V2' });
|
|
552
|
+
const releaseNative = jest.spyOn(transport as any, 'releaseNative');
|
|
553
|
+
expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
|
|
554
|
+
expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
|
|
555
|
+
|
|
556
|
+
await transport.call(uuid, 'DeviceInfoGet', {});
|
|
557
|
+
await transport.call(uuid, 'ProtocolInfoRequest', {});
|
|
558
|
+
|
|
559
|
+
expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2);
|
|
560
|
+
expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
|
|
561
|
+
expect(releaseNative).not.toHaveBeenCalled();
|
|
562
|
+
|
|
563
|
+
await transport.release(uuid, true);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test('keeps iOS Protocol V2 high-volume calls on withoutResponse', async () => {
|
|
567
|
+
const { transport, uuid, writeCharacteristic } = createHarness();
|
|
568
|
+
|
|
569
|
+
await transport.acquire({ uuid, expectedProtocol: 'V2' });
|
|
570
|
+
|
|
571
|
+
await transport.call(uuid, 'FileWrite', {});
|
|
572
|
+
expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
|
|
573
|
+
expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
|
|
574
|
+
await transport.release(uuid, true);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('uses withResponse for an iOS Protocol V2 firmware file write when requested', async () => {
|
|
578
|
+
const { transport, uuid, writeCharacteristic } = createHarness();
|
|
579
|
+
|
|
580
|
+
await transport.acquire({ uuid, expectedProtocol: 'V2' });
|
|
581
|
+
|
|
582
|
+
await transport.call(uuid, 'FileWrite', {}, { writeWithResponse: true });
|
|
583
|
+
expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
|
|
584
|
+
expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
|
|
585
|
+
await transport.release(uuid, true);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('falls back to withoutResponse for an iOS Protocol V2 control call when required', async () => {
|
|
589
|
+
const { transport, uuid, writeCharacteristic } = createHarness({
|
|
590
|
+
isWritableWithResponse: false,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
await transport.acquire({ uuid, expectedProtocol: 'V2' });
|
|
594
|
+
await transport.call(uuid, 'ProtocolInfoRequest', {});
|
|
595
|
+
|
|
596
|
+
expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
|
|
597
|
+
expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
|
|
598
|
+
await transport.release(uuid, true);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('does not resend a failed iOS Protocol V2 control write without response', async () => {
|
|
602
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
|
|
603
|
+
const writeError = new Error('write with response failed');
|
|
604
|
+
const writeWithResponse = jest.fn().mockRejectedValue(writeError);
|
|
605
|
+
const writeWithoutResponse = jest.fn().mockResolvedValue(undefined);
|
|
606
|
+
const context = {
|
|
607
|
+
messageName: 'ProtocolInfoRequest',
|
|
608
|
+
timeoutMs: 1000,
|
|
609
|
+
highVolume: false,
|
|
610
|
+
generation: 1,
|
|
611
|
+
signal: new AbortController().signal,
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
await expect(
|
|
615
|
+
transport.writeProtocolV2Packet(
|
|
616
|
+
{
|
|
617
|
+
writeCharacteristic: {
|
|
618
|
+
isWritableWithResponse: true,
|
|
619
|
+
writeWithResponse,
|
|
620
|
+
writeWithoutResponse,
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
Buffer.from('control').toString('base64'),
|
|
624
|
+
context,
|
|
625
|
+
jest.fn()
|
|
626
|
+
)
|
|
627
|
+
).rejects.toBe(writeError);
|
|
628
|
+
expect(writeWithResponse).toHaveBeenCalledTimes(1);
|
|
629
|
+
expect(writeWithoutResponse).not.toHaveBeenCalled();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('paces a one-packet Protocol V2 control write on iOS', async () => {
|
|
633
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
|
|
634
|
+
const writeWithoutResponse = jest.fn().mockResolvedValue(undefined);
|
|
635
|
+
const bleTransport = {
|
|
636
|
+
mtuSize: 23,
|
|
637
|
+
writeCharacteristic: { writeWithoutResponse },
|
|
638
|
+
};
|
|
639
|
+
const context = {
|
|
640
|
+
messageName: 'ProtocolInfoRequest',
|
|
641
|
+
timeoutMs: 1000,
|
|
642
|
+
highVolume: false,
|
|
643
|
+
generation: 1,
|
|
644
|
+
signal: new AbortController().signal,
|
|
645
|
+
};
|
|
646
|
+
configureProtocolV2BleTuning({ iosPacketLength: 20 });
|
|
647
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
const call = transport.writeProtocolV2Frame(
|
|
651
|
+
bleTransport,
|
|
652
|
+
new Uint8Array(10),
|
|
653
|
+
context,
|
|
654
|
+
jest.fn()
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
await Promise.resolve();
|
|
658
|
+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5);
|
|
659
|
+
expect(writeWithoutResponse).not.toHaveBeenCalled();
|
|
660
|
+
|
|
661
|
+
await call;
|
|
662
|
+
expect(writeWithoutResponse).toHaveBeenCalledTimes(1);
|
|
663
|
+
} finally {
|
|
664
|
+
setTimeoutSpy.mockRestore();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test('rejects an active Protocol V2 reader when disconnect resets the link', async () => {
|
|
669
|
+
const harness = createHarness();
|
|
670
|
+
const { transport, uuid, sentSeqs } = harness;
|
|
671
|
+
await transport.acquire({ uuid, expectedProtocol: 'V2' });
|
|
672
|
+
harness.setShouldRespond(false);
|
|
673
|
+
|
|
674
|
+
const call = transport.call(uuid, 'Ping', { message: 'disconnect' }, { timeoutMs: 50 });
|
|
675
|
+
while (sentSeqs.length < 1) {
|
|
676
|
+
await new Promise(resolve => {
|
|
677
|
+
setTimeout(resolve, 0);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const rejection = expect(call).rejects.toThrow('React Native BLE transport disconnected');
|
|
682
|
+
await transport.disconnect(uuid);
|
|
683
|
+
await rejection;
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test('emits one disconnect event when the physical callback races manual cleanup', async () => {
|
|
687
|
+
const harness = createHarness();
|
|
688
|
+
const disconnectListener = jest.fn();
|
|
689
|
+
harness.emitter.on(TRANSPORT_EVENT.DEVICE_DISCONNECT, disconnectListener);
|
|
690
|
+
|
|
691
|
+
await harness.transport.acquire({ uuid: harness.uuid, expectedProtocol: 'V2' });
|
|
692
|
+
harness.emitDisconnect();
|
|
693
|
+
await harness.transport.disconnect(harness.uuid);
|
|
694
|
+
|
|
695
|
+
expect(disconnectListener).toHaveBeenCalledTimes(1);
|
|
696
|
+
expect(disconnectListener).toHaveBeenCalledWith({
|
|
697
|
+
name: 'OneKey Pro 2',
|
|
698
|
+
id: harness.uuid,
|
|
699
|
+
connectId: harness.uuid,
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test('chunks large frames and retries only transient GATT congestion', async () => {
|
|
704
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
|
|
705
|
+
const congested = { status: 143, message: 'GATT_CONGESTED' };
|
|
706
|
+
const writeWithoutResponse = jest
|
|
707
|
+
.fn()
|
|
708
|
+
.mockRejectedValueOnce(congested)
|
|
709
|
+
.mockResolvedValue(undefined);
|
|
710
|
+
const bleTransport = {
|
|
711
|
+
mtuSize: 23,
|
|
712
|
+
writeCharacteristic: { writeWithoutResponse },
|
|
713
|
+
};
|
|
714
|
+
const context = {
|
|
715
|
+
messageName: 'Ping',
|
|
716
|
+
timeoutMs: 1000,
|
|
717
|
+
highVolume: false,
|
|
718
|
+
generation: 1,
|
|
719
|
+
signal: new AbortController().signal,
|
|
720
|
+
};
|
|
721
|
+
const assertCurrentGeneration = jest.fn();
|
|
722
|
+
configureProtocolV2BleTuning({ iosPacketLength: 20 });
|
|
723
|
+
|
|
724
|
+
await transport.writeProtocolV2Frame(
|
|
725
|
+
bleTransport,
|
|
726
|
+
new Uint8Array(30),
|
|
727
|
+
context,
|
|
728
|
+
assertCurrentGeneration
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
expect(writeWithoutResponse).toHaveBeenCalledTimes(3);
|
|
732
|
+
expect(writeWithoutResponse.mock.calls[0][0]).toBe(writeWithoutResponse.mock.calls[1][0]);
|
|
733
|
+
expect(Buffer.from(writeWithoutResponse.mock.calls[2][0], 'base64')).toHaveLength(10);
|
|
734
|
+
expect(assertCurrentGeneration).toHaveBeenCalled();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('does not retry a disconnected Protocol V2 write inside a partial frame', async () => {
|
|
738
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
|
|
739
|
+
const writeWithoutResponse = jest
|
|
740
|
+
.fn()
|
|
741
|
+
.mockRejectedValue({ errorCode: 205, message: 'Device disconnected' });
|
|
742
|
+
const bleTransport = {
|
|
743
|
+
mtuSize: 23,
|
|
744
|
+
writeCharacteristic: { writeWithoutResponse },
|
|
745
|
+
};
|
|
746
|
+
const context = {
|
|
747
|
+
messageName: 'FileWrite',
|
|
748
|
+
timeoutMs: 1000,
|
|
749
|
+
highVolume: true,
|
|
750
|
+
generation: 1,
|
|
751
|
+
signal: new AbortController().signal,
|
|
752
|
+
};
|
|
753
|
+
configureProtocolV2BleTuning({ iosPacketLength: 20 });
|
|
754
|
+
|
|
755
|
+
await expect(
|
|
756
|
+
transport.writeProtocolV2Frame(bleTransport, new Uint8Array(30), context, jest.fn())
|
|
757
|
+
).rejects.toMatchObject({ errorCode: 205 });
|
|
758
|
+
expect(writeWithoutResponse).toHaveBeenCalledTimes(1);
|
|
759
|
+
});
|
|
760
|
+
});
|