@onekeyfe/hd-core 1.2.0-alpha.47 → 1.2.0-alpha.49

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.
Files changed (62) hide show
  1. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +110 -15
  2. package/__tests__/device-lifecycle-events.test.ts +105 -3
  3. package/__tests__/method-protocol-support.test.ts +19 -0
  4. package/__tests__/protocol-binding.test.ts +89 -0
  5. package/__tests__/protocol-v2-resources.test.ts +87 -42
  6. package/__tests__/protocol-v2.test.ts +347 -40
  7. package/__tests__/search-devices.test.ts +12 -3
  8. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  9. package/dist/api/DetectDeviceConnectProtocol.d.ts +7 -0
  10. package/dist/api/DetectDeviceConnectProtocol.d.ts.map +1 -0
  11. package/dist/api/FirmwareUpdate.d.ts.map +1 -1
  12. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  13. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  14. package/dist/api/FirmwareUpdateV4.d.ts +1 -0
  15. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  16. package/dist/api/SearchDevices.d.ts.map +1 -1
  17. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  18. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  19. package/dist/api/index.d.ts +1 -0
  20. package/dist/api/index.d.ts.map +1 -1
  21. package/dist/core/index.d.ts.map +1 -1
  22. package/dist/device/Device.d.ts +5 -1
  23. package/dist/device/Device.d.ts.map +1 -1
  24. package/dist/device/DevicePool.d.ts.map +1 -1
  25. package/dist/index.d.ts +20 -3
  26. package/dist/index.js +361 -203
  27. package/dist/inject.d.ts +6 -1
  28. package/dist/inject.d.ts.map +1 -1
  29. package/dist/lowLevelInject.d.ts.map +1 -1
  30. package/dist/protocols/protocol-v2/resources.d.ts +4 -4
  31. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  32. package/dist/topLevelInject.d.ts.map +1 -1
  33. package/dist/types/api/detectDeviceConnectProtocol.d.ts +4 -0
  34. package/dist/types/api/detectDeviceConnectProtocol.d.ts.map +1 -0
  35. package/dist/types/api/index.d.ts +4 -0
  36. package/dist/types/api/index.d.ts.map +1 -1
  37. package/dist/types/params.d.ts +1 -0
  38. package/dist/types/params.d.ts.map +1 -1
  39. package/dist/utils/patch.d.ts +1 -1
  40. package/dist/utils/patch.d.ts.map +1 -1
  41. package/package.json +4 -4
  42. package/src/api/CheckAllFirmwareRelease.ts +10 -9
  43. package/src/api/DetectDeviceConnectProtocol.ts +18 -0
  44. package/src/api/FirmwareUpdate.ts +6 -2
  45. package/src/api/FirmwareUpdateV2.ts +5 -2
  46. package/src/api/FirmwareUpdateV3.ts +6 -2
  47. package/src/api/FirmwareUpdateV4.ts +73 -43
  48. package/src/api/SearchDevices.ts +3 -1
  49. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +15 -4
  50. package/src/api/firmware/uploadFirmware.ts +17 -4
  51. package/src/api/index.ts +1 -0
  52. package/src/core/index.ts +11 -2
  53. package/src/data/messages/messages-protocol-v2.json +0 -43
  54. package/src/device/Device.ts +53 -15
  55. package/src/device/DevicePool.ts +7 -2
  56. package/src/inject.ts +57 -2
  57. package/src/lowLevelInject.ts +6 -3
  58. package/src/protocols/protocol-v2/resources.ts +150 -56
  59. package/src/topLevelInject.ts +6 -3
  60. package/src/types/api/detectDeviceConnectProtocol.ts +7 -0
  61. package/src/types/api/index.ts +11 -0
  62. package/src/types/params.ts +7 -1
@@ -5,6 +5,7 @@ import CheckAllFirmwareRelease, {
5
5
  } from '../src/api/CheckAllFirmwareRelease';
6
6
  import { DataManager } from '../src/data-manager';
7
7
  import { createCoreApi } from '../src/inject';
8
+ import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
8
9
 
9
10
  import type { CoreApi } from '../src/types/api';
10
11
  import type { DeviceStateVersions, IFirmwareReleaseInfo } from '../src/types';
@@ -72,7 +73,74 @@ const release: IFirmwareReleaseInfo = {
72
73
  ],
73
74
  };
74
75
 
76
+ const stableResources = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
77
+ (type, index) => ({
78
+ type,
79
+ url: `https://example.com/${type}.okpkg`,
80
+ size: 0x52a0 + index + 1,
81
+ fileHash: 'a'.repeat(64),
82
+ headerHash: index.toString(16).padStart(128, '0'),
83
+ })
84
+ );
85
+
86
+ const createFilesystemTypedCall = (missingAll = false) => {
87
+ const resourceByPath = new Map(
88
+ stableResources.map(resource => [
89
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
90
+ resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
91
+ ],
92
+ resource,
93
+ ])
94
+ );
95
+ return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
96
+ if (requestType === 'ResourceInventoryGet') {
97
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
98
+ }
99
+ if (requestType === 'FilesystemPathInfoQuery') {
100
+ const resource = resourceByPath.get(payload.path);
101
+ return {
102
+ message: {
103
+ exist: Boolean(resource) && !missingAll,
104
+ directory: false,
105
+ size: missingAll ? 0 : resource?.size,
106
+ },
107
+ };
108
+ }
109
+ if (requestType === 'FilesystemFileRead') {
110
+ const resource = resourceByPath.get(payload.file.path);
111
+ if (!resource || missingAll) throw new Error('missing resource');
112
+ const header = new Uint8Array(0x52a0);
113
+ const view = new DataView(header.buffer);
114
+ 'OKPP'.split('').forEach((char, index) => {
115
+ header[index] = char.charCodeAt(0);
116
+ });
117
+ 'RESC'.split('').forEach((char, index) => {
118
+ header[0x08 + index] = char.charCodeAt(0);
119
+ });
120
+ view.setUint32(0x0c, header.byteLength, true);
121
+ for (let index = 0; index < resource.headerHash.length / 2; index++) {
122
+ header[0x240 + index] = Number.parseInt(
123
+ resource.headerHash.slice(index * 2, index * 2 + 2),
124
+ 16
125
+ );
126
+ }
127
+ const offset = Number(payload.file.offset);
128
+ const chunkLength = Number(payload.chunk_len);
129
+ return {
130
+ message: {
131
+ data: header.slice(offset, offset + chunkLength),
132
+ },
133
+ };
134
+ }
135
+ throw new Error(`Unexpected request: ${requestType}`);
136
+ });
137
+ };
138
+
75
139
  describe('checkAllFirmwareRelease Protocol V2 support', () => {
140
+ afterEach(() => {
141
+ jest.restoreAllMocks();
142
+ });
143
+
76
144
  test('builds ordered firmwareUpdateV4 targets from recommended component versions', () => {
77
145
  const result = buildProtocolV2FirmwareRelease({
78
146
  currentVersions,
@@ -223,7 +291,7 @@ describe('checkAllFirmwareRelease Protocol V2 support', () => {
223
291
  expect(result.targetsToUpdate).toEqual(['app_v1', 'app_v2']);
224
292
  });
225
293
 
226
- test('uses the existing method on Protocol V2 and keeps its public API name', async () => {
294
+ test('does not read vol0 resource inventory while Protocol V2 is in Application mode', async () => {
227
295
  const method = new CheckAllFirmwareRelease({
228
296
  id: 1,
229
297
  payload: {
@@ -240,6 +308,7 @@ describe('checkAllFirmwareRelease Protocol V2 support', () => {
240
308
  status: { mode: 'normal' },
241
309
  versions: currentVersions,
242
310
  });
311
+ const typedCall = createFilesystemTypedCall(true);
243
312
  method.device = {
244
313
  isProtocolV2: () => true,
245
314
  features: {
@@ -247,34 +316,60 @@ describe('checkAllFirmwareRelease Protocol V2 support', () => {
247
316
  firmwareVersion: '1.0.0',
248
317
  },
249
318
  getDeviceState,
250
- getCommands: () => ({ typedCall: jest.fn().mockResolvedValue({ message: { items: [] } }) }),
319
+ getCommands: () => ({ typedCall }),
251
320
  } as unknown as CheckAllFirmwareRelease['device'];
252
321
  jest.spyOn(DataManager, 'getFirmwareLatestRelease').mockReturnValue(release);
253
- jest.spyOn(DataManager, 'getProtocolV2Resources').mockReturnValue(
254
- ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
255
- (type, index) => ({
256
- type,
257
- url: `https://example.com/${type}.okpkg`,
258
- size: index + 1,
259
- fileHash: 'a'.repeat(64),
260
- headerHash: index.toString(16).padStart(128, '0'),
261
- })
262
- ) as any
263
- );
322
+ jest.spyOn(DataManager, 'getProtocolV2Resources').mockReturnValue(stableResources as any);
264
323
 
265
324
  await expect(method.run()).resolves.toMatchObject({
266
325
  protocol: 'V2',
267
326
  deviceType: 'pro2',
268
327
  status: 'required',
269
- resourceStatus: 'outdated',
270
- targetsToUpdate: ['boot', 'app_v1', 'resource'],
328
+ resourceStatus: 'unknown',
329
+ targetsToUpdate: ['boot', 'app_v1'],
271
330
  firmware: {
272
331
  status: 'required',
273
332
  },
274
333
  });
334
+ expect(typedCall).not.toHaveBeenCalled();
275
335
  expect(method.getSupportedProtocols()).toEqual(['V1', 'V2']);
276
336
  });
277
337
 
338
+ test.each(['bootloader', 'romloader'] as const)(
339
+ 'uses filesystem inventory in %s mode instead of forcing all resources',
340
+ async mode => {
341
+ const method = new CheckAllFirmwareRelease({
342
+ id: 1,
343
+ payload: {
344
+ method: 'checkAllFirmwareRelease',
345
+ firmwareType: EFirmwareType.Universal,
346
+ },
347
+ });
348
+ method.init();
349
+ const typedCall = createFilesystemTypedCall();
350
+ method.device = {
351
+ isProtocolV2: () => true,
352
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0' },
353
+ getDeviceState: jest.fn().mockResolvedValue({
354
+ identity: { deviceType: 'pro2', firmwareType: EFirmwareType.Universal },
355
+ status: { mode },
356
+ versions: currentVersions,
357
+ }),
358
+ getCommands: () => ({ typedCall }),
359
+ } as unknown as CheckAllFirmwareRelease['device'];
360
+ jest.spyOn(DataManager, 'getFirmwareLatestRelease').mockReturnValue(release);
361
+ jest.spyOn(DataManager, 'getProtocolV2Resources').mockReturnValue(stableResources as any);
362
+
363
+ await expect(method.run()).resolves.toMatchObject({
364
+ protocol: 'V2',
365
+ resourceStatus: 'valid',
366
+ targetsToUpdate: ['boot', 'app_v1'],
367
+ });
368
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
369
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
370
+ }
371
+ );
372
+
278
373
  test('continues forwarding the existing public method', async () => {
279
374
  const call = jest.fn().mockResolvedValue({ success: true, payload: {} });
280
375
  const api = createCoreApi(call as CoreApi['call']) as CoreApi;
@@ -1,3 +1,4 @@
1
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
1
2
  import { TRANSPORT_EVENT } from '@onekeyfe/hd-transport';
2
3
 
3
4
  import { initConnector, initCore } from '../src/core';
@@ -95,7 +96,7 @@ describe('public device lifecycle events', () => {
95
96
  expect(messages.filter(message => message.type === DEVICE.DISCONNECT)).toHaveLength(1);
96
97
  });
97
98
 
98
- test('uses a cached protocol as a fallback-capable acquire hint', async () => {
99
+ test('uses a verified cached protocol as a strict expected protocol', async () => {
99
100
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
100
101
  const device = Device.fromDescriptor({
101
102
  id: 'ble-id',
@@ -103,16 +104,117 @@ describe('public device lifecycle events', () => {
103
104
  commType: 'ble',
104
105
  protocolType: 'V1',
105
106
  } as never);
106
- const acquire = jest.fn().mockResolvedValue({ uuid: 'ble-id', protocolType: 'V2' });
107
+ const acquire = jest.fn().mockResolvedValue({ uuid: 'ble-id', protocolType: 'V1' });
107
108
  device.deviceConnector = { acquire } as never;
108
109
 
109
110
  await device.acquire();
110
111
 
111
- expect(acquire).toHaveBeenCalledWith('ble-id', undefined, true, undefined, 'V1');
112
+ expect(acquire).toHaveBeenCalledWith('ble-id', undefined, true, 'V1', undefined);
113
+ expect(device.getProtocol()).toBe('V1');
114
+ expect(device.originalDescriptor.protocolType).toBe('V1');
115
+ });
116
+
117
+ test('bypasses a cached protocol only for explicit active detection', async () => {
118
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
119
+ const device = Device.fromDescriptor({
120
+ id: 'ble-id',
121
+ path: 'ble-id',
122
+ commType: 'ble',
123
+ protocolType: 'V1',
124
+ } as never);
125
+ const acquire = jest.fn().mockResolvedValue({ uuid: 'ble-id', protocolType: 'V2' });
126
+ device.deviceConnector = { acquire } as never;
127
+
128
+ await device.acquire(undefined, { forceProtocolDetection: true });
129
+
130
+ expect(acquire).toHaveBeenCalledWith('ble-id', undefined, true, undefined, undefined);
112
131
  expect(device.getProtocol()).toBe('V2');
113
132
  expect(device.originalDescriptor.protocolType).toBe('V2');
114
133
  });
115
134
 
135
+ test('does not reuse a stale protocol when active detection returns no protocol', async () => {
136
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
137
+ const device = Device.fromDescriptor({
138
+ id: 'ble-id',
139
+ path: 'ble-id',
140
+ commType: 'ble',
141
+ protocolType: 'V1',
142
+ } as never);
143
+ const acquire = jest.fn().mockResolvedValue({ uuid: 'ble-id' });
144
+ const release = jest.fn().mockResolvedValue(undefined);
145
+ device.deviceConnector = { acquire, release } as never;
146
+ await expect(
147
+ device.acquire(undefined, {
148
+ forceProtocolDetection: true,
149
+ throwOnRunPromiseError: true,
150
+ })
151
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.RuntimeError });
152
+ expect(device.originalDescriptor.protocolType).toBe('V1');
153
+ expect(device.hasDeviceAcquire()).toBe(false);
154
+ expect(release).toHaveBeenCalledWith('ble-id', false);
155
+ });
156
+
157
+ test('actively reacquires an already connected device without initializing it', async () => {
158
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('webusb' as never);
159
+ const device = Device.fromDescriptor({
160
+ id: 'usb-device',
161
+ path: 'usb-device',
162
+ session: 'old-session',
163
+ protocolType: 'V1',
164
+ } as never);
165
+ device.mainId = 'old-session';
166
+ device.commands = { disposed: false } as never;
167
+ const release = jest.spyOn(device, 'release').mockImplementation(() => {
168
+ device.mainId = null;
169
+ device.originalDescriptor.session = null;
170
+ device.commands.disposed = true;
171
+ return Promise.resolve();
172
+ });
173
+ const acquire = jest.spyOn(device, 'acquire').mockImplementation(() => {
174
+ device.mainId = 'new-session';
175
+ device.originalDescriptor.session = 'new-session';
176
+ device.originalDescriptor.protocolType = 'V2';
177
+ device.commands.disposed = false;
178
+ return Promise.resolve();
179
+ });
180
+ const initialize = jest.spyOn(device, 'initialize').mockResolvedValue(undefined);
181
+
182
+ await device.run(() => Promise.resolve(undefined), {
183
+ forceProtocolDetection: true,
184
+ skipInitialize: true,
185
+ });
186
+
187
+ expect(release).toHaveBeenCalled();
188
+ expect(acquire).toHaveBeenCalledWith(undefined, {
189
+ forceProtocolDetection: true,
190
+ });
191
+ expect(initialize).not.toHaveBeenCalled();
192
+ expect(device.getProtocol()).toBe('V2');
193
+ });
194
+
195
+ test('keeps the freshly probed React Native BLE session until the call completes', async () => {
196
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
197
+ const device = Device.fromDescriptor({
198
+ id: 'ble-device',
199
+ path: 'ble-device',
200
+ commType: 'ble',
201
+ protocolType: 'V1',
202
+ } as never);
203
+ device.mainId = 'ble-device';
204
+ device.commands = { disposed: false } as never;
205
+ (device as unknown as { deviceAcquired: boolean }).deviceAcquired = true;
206
+ const release = jest.spyOn(device, 'release').mockResolvedValue(undefined);
207
+ const run = jest.fn().mockResolvedValue(undefined);
208
+
209
+ await device.run(run, {
210
+ forceProtocolDetection: true,
211
+ skipInitialize: true,
212
+ });
213
+
214
+ expect(run).toHaveBeenCalledTimes(1);
215
+ expect(release).toHaveBeenCalledTimes(1);
216
+ });
217
+
116
218
  test('keeps an explicit connectProtocol as a strict expected protocol', async () => {
117
219
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
118
220
  const device = Device.fromDescriptor({
@@ -1,6 +1,7 @@
1
1
  import { EFirmwareType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
2
 
3
3
  import { BaseMethod } from '../src/api/BaseMethod';
4
+ import { findMethod } from '../src/api/utils';
4
5
  import BTCGetAddress from '../src/api/btc/BTCGetAddress';
5
6
  import DeviceReboot from '../src/api/protocol-v2/DeviceReboot';
6
7
  import SolGetAddress from '../src/api/solana/SolGetAddress';
@@ -20,6 +21,24 @@ class DefaultMethod extends BaseMethod {
20
21
  }
21
22
 
22
23
  describe('method protocol support', () => {
24
+ test('creates a protocol detection method that returns only the current protocol', async () => {
25
+ const method = findMethod({
26
+ id: 6,
27
+ payload: {
28
+ method: 'detectDeviceConnectProtocol',
29
+ connectId: 'ble-device',
30
+ },
31
+ } as never);
32
+
33
+ method.init();
34
+ (method as unknown as { device: unknown }).device = {
35
+ getProtocol: () => 'V2',
36
+ };
37
+
38
+ expect(method.getSupportedProtocols()).toEqual(['V1', 'V2']);
39
+ await expect(method.run()).resolves.toBe('V2');
40
+ });
41
+
23
42
  test('defaults existing methods to Protocol V1 only', () => {
24
43
  const method = new DefaultMethod({
25
44
  id: 1,
@@ -0,0 +1,89 @@
1
+ import { createCoreApi, createProtocolAwareCall } from '../src/inject';
2
+
3
+ import type { CoreApi } from '../src/types/api';
4
+
5
+ describe('SDK protocol binding', () => {
6
+ test('exposes a minimal API that returns the actively detected protocol', async () => {
7
+ const response = { success: true as const, payload: 'V2' as const };
8
+ const rawCall = jest.fn().mockResolvedValue(response);
9
+ const api = createCoreApi(rawCall as CoreApi['call']);
10
+
11
+ await expect(api.detectDeviceConnectProtocol('USB_DEVICE_A')).resolves.toBe(response);
12
+ expect(rawCall).toHaveBeenCalledWith({
13
+ connectId: 'USB_DEVICE_A',
14
+ forceProtocolDetection: true,
15
+ method: 'detectDeviceConnectProtocol',
16
+ });
17
+ });
18
+
19
+ test('injects a strict protocol into every later call and isolates devices', async () => {
20
+ const rawCall = jest.fn(params => Promise.resolve({ success: true, payload: params }));
21
+ const protocolAwareCall = createProtocolAwareCall(rawCall as CoreApi['call']);
22
+ const api = createCoreApi(protocolAwareCall.call);
23
+
24
+ protocolAwareCall.setDeviceConnectProtocol('USB_DEVICE_A', 'V2');
25
+ protocolAwareCall.setDeviceConnectProtocol('ble-device-b', 'V1');
26
+
27
+ await api.deviceWipe('usb_device_a');
28
+ await api.deviceSupportFeatures('BLE-DEVICE-B');
29
+ await api.getFeatures('USB_DEVICE_A', { connectProtocol: 'V1' });
30
+ await api.getDeviceState('USB_DEVICE_A', { forceProtocolDetection: true });
31
+ await protocolAwareCall.call({
32
+ connectId: 'USB_DEVICE_A',
33
+ method: 'deviceUpdateReboot',
34
+ });
35
+
36
+ expect(rawCall).toHaveBeenNthCalledWith(1, {
37
+ connectId: 'usb_device_a',
38
+ method: 'deviceWipe',
39
+ connectProtocol: 'V2',
40
+ });
41
+ expect(rawCall).toHaveBeenNthCalledWith(2, {
42
+ connectId: 'BLE-DEVICE-B',
43
+ method: 'deviceSupportFeatures',
44
+ connectProtocol: 'V1',
45
+ });
46
+ expect(rawCall).toHaveBeenNthCalledWith(3, {
47
+ connectId: 'USB_DEVICE_A',
48
+ method: 'getFeatures',
49
+ connectProtocol: 'V1',
50
+ });
51
+ expect(rawCall).toHaveBeenNthCalledWith(4, {
52
+ connectId: 'USB_DEVICE_A',
53
+ method: 'getDeviceState',
54
+ forceProtocolDetection: true,
55
+ });
56
+ expect(rawCall).toHaveBeenNthCalledWith(5, {
57
+ connectId: 'USB_DEVICE_A',
58
+ method: 'deviceUpdateReboot',
59
+ connectProtocol: 'V2',
60
+ });
61
+ });
62
+
63
+ test('keeps bindings local to one SDK instance and supports clearing them', async () => {
64
+ const firstRawCall = jest.fn(() => Promise.resolve({ success: true, payload: {} }));
65
+ const secondRawCall = jest.fn(() => Promise.resolve({ success: true, payload: {} }));
66
+ const first = createProtocolAwareCall(firstRawCall as CoreApi['call']);
67
+ const second = createProtocolAwareCall(secondRawCall as CoreApi['call']);
68
+
69
+ first.setDeviceConnectProtocol('same-device', 'V2');
70
+ await first.call({ connectId: 'same-device', method: 'deviceWipe' });
71
+ await second.call({ connectId: 'same-device', method: 'deviceWipe' });
72
+ first.setDeviceConnectProtocol('same-device', undefined);
73
+ await first.call({ connectId: 'same-device', method: 'deviceWipe' });
74
+
75
+ expect(firstRawCall).toHaveBeenNthCalledWith(1, {
76
+ connectId: 'same-device',
77
+ method: 'deviceWipe',
78
+ connectProtocol: 'V2',
79
+ });
80
+ expect(secondRawCall).toHaveBeenCalledWith({
81
+ connectId: 'same-device',
82
+ method: 'deviceWipe',
83
+ });
84
+ expect(firstRawCall).toHaveBeenNthCalledWith(2, {
85
+ connectId: 'same-device',
86
+ method: 'deviceWipe',
87
+ });
88
+ });
89
+ });
@@ -7,9 +7,8 @@ import {
7
7
  PROTOCOL_V2_RESOURCE_TYPES,
8
8
  buildProtocolV2ResourceUpdatePlan,
9
9
  isProtocolV2ResourceFileValid,
10
- parseProtocolV2ResourceInventory,
11
10
  parseProtocolV2Resources,
12
- requestProtocolV2ResourceInventory,
11
+ readProtocolV2ResourceInventory,
13
12
  } from '../src/protocols/protocol-v2/resources';
14
13
 
15
14
  import type {
@@ -28,6 +27,24 @@ jest.mock('../src/data/config', () => ({
28
27
  const bytesToHex = (bytes: Uint8Array) =>
29
28
  Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
30
29
 
30
+ const PROTOCOL_V2_OKPP_HEADER_SIZE = 0x52a0;
31
+
32
+ const createResourceHeader = (headerHash: string) => {
33
+ const header = new Uint8Array(PROTOCOL_V2_OKPP_HEADER_SIZE);
34
+ const view = new DataView(header.buffer);
35
+ 'OKPP'.split('').forEach((char, index) => {
36
+ header[index] = char.charCodeAt(0);
37
+ });
38
+ 'RESC'.split('').forEach((char, index) => {
39
+ header[0x08 + index] = char.charCodeAt(0);
40
+ });
41
+ view.setUint32(0x0c, header.byteLength, true);
42
+ for (let index = 0; index < headerHash.length / 2; index++) {
43
+ header[0x240 + index] = Number.parseInt(headerHash.slice(index * 2, index * 2 + 2), 16);
44
+ }
45
+ return header;
46
+ };
47
+
31
48
  const resources: IProtocolV2Resource[] = PROTOCOL_V2_RESOURCE_TYPES.map((type, index) => ({
32
49
  type,
33
50
  url: `https://example.com/${type}.okpkg`,
@@ -124,50 +141,62 @@ describe('Pro2 resource configuration', () => {
124
141
  ).toEqual({ status: 'valid', resources: [] });
125
142
  });
126
143
 
127
- test('normalizes the success-only ResourceInventory RPC response', async () => {
128
- const items = resources.slice(0, 2).map((resource, index) => ({
129
- type: index === 0 ? 'IMAGES' : 1,
130
- size: resource.size,
131
- header_hash: resource.headerHash,
144
+ test('builds inventory from existing filesystem calls without ResourceInventoryGet', async () => {
145
+ const installedResources = resources.map((resource, index) => ({
146
+ ...resource,
147
+ size: PROTOCOL_V2_OKPP_HEADER_SIZE + index + 1,
132
148
  }));
133
- const typedCall = jest.fn().mockResolvedValue({ message: { items } });
134
-
135
- await expect(
136
- requestProtocolV2ResourceInventory({ commands: { typedCall } as any })
137
- ).resolves.toEqual([
138
- {
139
- type: 'images',
140
- size: resources[0].size,
141
- headerHash: resources[0].headerHash,
142
- },
143
- {
144
- type: 'animation',
145
- size: resources[1].size,
146
- headerHash: resources[1].headerHash,
147
- },
148
- ]);
149
- expect(typedCall).toHaveBeenCalledWith(
150
- 'ResourceInventoryGet',
151
- 'ResourceInventory',
152
- {},
153
- { timeoutMs: 5000 }
149
+ const resourceByPath = new Map(
150
+ installedResources.map(resource => [
151
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[resource.type],
152
+ resource,
153
+ ])
154
+ );
155
+ const typedCall = jest.fn(
156
+ (requestType: string, _responseType: string, payload: Record<string, any>) => {
157
+ if (requestType === 'ResourceInventoryGet') {
158
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
159
+ }
160
+ if (requestType === 'FilesystemPathInfoQuery') {
161
+ const resource = resourceByPath.get(payload.path);
162
+ return {
163
+ message: {
164
+ exist: Boolean(resource),
165
+ directory: false,
166
+ size: resource?.size ?? 0,
167
+ },
168
+ };
169
+ }
170
+ if (requestType === 'FilesystemFileRead') {
171
+ const resource = resourceByPath.get(payload.file.path);
172
+ if (!resource) throw new Error('missing resource');
173
+ const header = createResourceHeader(resource.headerHash);
174
+ const offset = Number(payload.file.offset);
175
+ const chunkLength = Number(payload.chunk_len);
176
+ return {
177
+ message: {
178
+ data: header.slice(offset, offset + chunkLength),
179
+ },
180
+ };
181
+ }
182
+ throw new Error(`Unexpected request: ${requestType}`);
183
+ }
154
184
  );
155
- });
156
185
 
157
- test('rejects malformed or duplicate inventory identities', () => {
158
- expect(() =>
159
- parseProtocolV2ResourceInventory({
160
- items: [
161
- { type: 'IMAGES', size: 1, header_hash: 'a'.repeat(128) },
162
- { type: 'IMAGES', size: 1, header_hash: 'b'.repeat(128) },
163
- ],
164
- })
165
- ).toThrow('duplicate resource type');
166
- expect(() =>
167
- parseProtocolV2ResourceInventory({
168
- items: [{ type: 'IMAGES', size: 1, header_hash: 'bad' }],
186
+ await expect(
187
+ readProtocolV2ResourceInventory({
188
+ commands: { typedCall },
189
+ resources: installedResources,
190
+ chunkSize: 4000,
169
191
  })
170
- ).toThrow('inventory headerHash');
192
+ ).resolves.toEqual(
193
+ installedResources.map(({ type, size, headerHash }) => ({ type, size, headerHash }))
194
+ );
195
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
196
+ expect(typedCall.mock.calls.filter(call => call[0] === 'FilesystemPathInfoQuery')).toHaveLength(
197
+ 6
198
+ );
199
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
171
200
  });
172
201
 
173
202
  test('selects only the changed or missing resource in application mode', () => {
@@ -199,6 +228,22 @@ describe('Pro2 resource configuration', () => {
199
228
  ).toHaveLength(6);
200
229
  });
201
230
 
231
+ test('uses a filesystem inventory for incremental recovery mode updates', () => {
232
+ const inventory = resources.slice(1).map(({ type, size, headerHash }) => ({
233
+ type,
234
+ size,
235
+ headerHash,
236
+ }));
237
+
238
+ expect(
239
+ buildProtocolV2ResourceUpdatePlan({
240
+ resources,
241
+ inventory,
242
+ mode: 'bootloader-recovery',
243
+ }).resources.map(resource => resource.type)
244
+ ).toEqual(['images']);
245
+ });
246
+
202
247
  test('verifies both full file size and SHA-256 before transfer', () => {
203
248
  const bytes = new Uint8Array([1, 2, 3]);
204
249
  const binary = bytes.buffer;