Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 292 additions & 27 deletions docs/testing/pro2-ble-performance.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/core/__tests__/device-lifecycle-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isProtocolV2PeerRemovedPairingError,
isRetryableBleConnectionError,
isRetryableBleProtocolV2ProbeError,
resolveBleConnectProtocol,
} from '../src/core';
import { DataManager } from '../src/data-manager';
import TransportManager from '../src/data-manager/TransportManager';
Expand Down Expand Up @@ -71,6 +72,19 @@ describe('public device lifecycle events', () => {
jest.restoreAllMocks();
});

test('prefers Protocol V2 only when the method contract is explicitly V2-only', () => {
const createMethod = (protocols: readonly ('V1' | 'V2')[], connectProtocol?: 'V1' | 'V2') =>
({
payload: { connectProtocol },
getSupportedProtocols: () => protocols,
} as never);

expect(resolveBleConnectProtocol(createMethod(['V2']))).toBe('V2');
expect(resolveBleConnectProtocol(createMethod(['V1']))).toBeUndefined();
expect(resolveBleConnectProtocol(createMethod(['V1', 'V2']))).toBeUndefined();
expect(resolveBleConnectProtocol(createMethod(['V2'], 'V1'))).toBe('V1');
});

test('registers the shared device lifecycle listeners exactly once', async () => {
jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
core = initCore();
Expand Down
108 changes: 107 additions & 1 deletion packages/core/__tests__/pro2HostAssetPackage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,82 @@
supportsPro2HostAssetPackage,
} from '../src/utils/pro2HostAssetPackage';

const decodeRawLz4Block = (compressed: Uint8Array, expectedLength: number) => {
const output = new Uint8Array(expectedLength);
let inputOffset = 0;
let outputOffset = 0;

const readLength = (initialLength: number) => {
let length = initialLength;
if (length === 15) {
let extension = 255;
while (extension === 255) {
extension = compressed[inputOffset];
inputOffset += 1;
length += extension;
}
}
return length;
};

while (inputOffset < compressed.byteLength) {
const token = compressed[inputOffset];
inputOffset += 1;
const literalLength = readLength(token >>> 4);

Check failure on line 29 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '>>>'
output.set(compressed.subarray(inputOffset, inputOffset + literalLength), outputOffset);
inputOffset += literalLength;
outputOffset += literalLength;
if (inputOffset >= compressed.byteLength) break;

const matchOffset = compressed[inputOffset] | (compressed[inputOffset + 1] << 8);

Check failure on line 35 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '<<'

Check failure on line 35 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '|'
inputOffset += 2;
const matchLength = readLength(token & 0x0f) + 4;

Check failure on line 37 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '&'
for (let index = 0; index < matchLength; index += 1) {
output[outputOffset] = output[outputOffset - matchOffset];
outputOffset += 1;
}
}

expect(outputOffset).toBe(expectedLength);
return output;
};

const decodeFirstPackageEntry = (packageData: Uint8Array, rawLength: number) => {
const containerHeaderSize = 0x5f90;
const archive = packageData.subarray(containerHeaderSize);
const archiveView = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
const compressedOffset = archiveView.getUint32(42 + 0x100, true);
const compressed = archive.subarray(compressedOffset);
const compressedView = new DataView(
compressed.buffer,
compressed.byteOffset,
compressed.byteLength
);
const blockCount = compressedView.getUint16(0, true);
const blockSize = 1 << compressedView.getUint16(2, true);

Check failure on line 60 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '<<'
let blockOffset = 8 + blockCount * 4;
const decodedBlocks: Uint8Array[] = [];

for (let index = 0; index < blockCount; index += 1) {
const compressedLength = compressedView.getUint32(8 + index * 4, true);
const expectedLength = Math.min(blockSize, rawLength - index * blockSize);
decodedBlocks.push(
decodeRawLz4Block(
compressed.subarray(blockOffset, blockOffset + compressedLength),
expectedLength
)
);
blockOffset += compressedLength;
}

const decoded = new Uint8Array(rawLength);
decodedBlocks.reduce((offset, block) => {
decoded.set(block, offset);
return offset + block.byteLength;
}, 0);
return decoded;
};

describe('Pro2 host asset package', () => {
test('builds the unsigned RESOURCE container and LZ4-blocked archive expected by firmware', () => {
const raw = new TextEncoder().encode('123456789');
Expand Down Expand Up @@ -40,12 +116,42 @@

const compressedOffset = archiveView.getUint32(42 + 0x100, true);
expect(archiveView.getUint16(compressedOffset, true)).toBe(1);
expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(12);
expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(14);
expect(archiveView.getUint32(compressedOffset + 4, true)).toBe(0);
expect(archiveView.getUint32(compressedOffset + 8, true)).toBe(10);
expect(payload.subarray(compressedOffset + 12)).toEqual(new Uint8Array([0x90, ...raw]));
});

test('round-trips multi-block data byte-for-byte with best-match compression', () => {
const raw = Uint8Array.from({ length: 16_384 * 3 + 137 }, (_, index) => {
const column = index % 604;
const row = Math.floor(index / 604);
return (column * 31 + row * 17) & 0xff;

Check failure on line 129 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '&'
});

const packageData = buildPro2HostAssetPackage([{ name: 'wallpaper.bin', data: raw }]);

expect(decodeFirstPackageEntry(packageData, raw.byteLength)).toEqual(raw);
});

test('falls back to 8 KiB blocks when a compressed 16 KiB block exceeds firmware capacity', () => {
let state = 0x12345678;
const raw = Uint8Array.from({ length: 16_384 }, () => {
state ^= state << 13;

Check failure on line 140 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '<<'

Check failure on line 140 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '^='
state ^= state >>> 17;

Check failure on line 141 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '>>>'

Check failure on line 141 in packages/core/__tests__/pro2HostAssetPackage.test.ts

View workflow job for this annotation

GitHub Actions / lint (22)

Unexpected use of '^='
state ^= state << 5;
return state & 0xff;
});

const packageData = buildPro2HostAssetPackage([{ name: 'wallpaper.bin', data: raw }]);
const archive = packageData.subarray(0x5f90);
const archiveView = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
const compressedOffset = archiveView.getUint32(42 + 0x100, true);

expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(13);
expect(decodeFirstPackageEntry(packageData, raw.byteLength)).toEqual(raw);
});

test.each([
['1.0.0', false],
['1.0.1-beta.1', false],
Expand Down
76 changes: 58 additions & 18 deletions packages/core/__tests__/protocol-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@ describe('DeviceUploadWallpaper', () => {
});

test('uploads and applies the fixed wallpaper package on firmware 1.0.1', async () => {
const getSettingsSpy = jest
.spyOn(DataManager, 'getSettings')
.mockReturnValue('react-native' as any);
const typedCall = jest.fn().mockImplementation((request, _response, params) => {
if (request === 'FilesystemDirMake') return { message: {} };
if (request === 'FilesystemFileWrite') {
Expand Down Expand Up @@ -282,8 +285,13 @@ describe('DeviceUploadWallpaper', () => {
(method as any).device = device;
method.postMessage = jest.fn();

method.init();
const result = await method.run();
let result;
try {
method.init();
result = await method.run();
} finally {
getSettingsSpy.mockRestore();
}

const fileWrites = typedCall.mock.calls.filter(call => call[0] === 'FilesystemFileWrite');
expect(new Set(fileWrites.map(call => call[2].file.path))).toEqual(
Expand All @@ -292,6 +300,7 @@ describe('DeviceUploadWallpaper', () => {
expect(fileWrites[0][2].file.data.subarray(0, 4)).toEqual(
new Uint8Array([0x4f, 0x4b, 0x50, 0x50])
);
expect(fileWrites[0][2].file.data).toHaveLength(1960);
expect(typedCall).toHaveBeenLastCalledWith('DeviceSettingsSet', 'Success', {
settings: { wallpaper_path: 'vol1:/wallpapers/wallpaper.okpkg' },
});
Expand Down Expand Up @@ -7184,7 +7193,14 @@ describe('Protocol V2 firmware update targets', () => {
true
);
expect((method as any).exitProtocolV2BootloaderToNormal).not.toHaveBeenCalled();
expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
expect(method.postProgressMessage).toHaveBeenCalledWith(
100,
'transferData',
expect.objectContaining({
transferredBytes: 5,
totalBytes: 5,
})
);
expect((method as any).completeProtocolV2FinalVerification).toHaveBeenCalledTimes(1);
});

Expand Down Expand Up @@ -7258,7 +7274,14 @@ describe('Protocol V2 firmware update targets', () => {
expect.objectContaining({ processedSize: 2, totalSize: 3 })
);
expect(method.postProgressMessage).toHaveBeenCalledTimes(1);
expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
expect(method.postProgressMessage).toHaveBeenCalledWith(
100,
'transferData',
expect.objectContaining({
transferredBytes: 3,
totalBytes: 3,
})
);
expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
Expand Down Expand Up @@ -8578,26 +8601,43 @@ describe('Protocol V2 firmware update targets', () => {
(method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
(method as any).protocolV2StartFirmwareUpdate = jest.fn();
(method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(5_000);

await (method as any).executeProtocolV2SourceUpdate({
installSources: [],
resourceSources: [
{
name: 'images.okpkg',
source: {
size: 3,
readAt: jest.fn(),
close: jest.fn(),
try {
await (method as any).executeProtocolV2SourceUpdate({
installSources: [],
resourceSources: [
{
name: 'images.okpkg',
source: {
size: 3,
readAt: jest.fn(),
close: jest.fn(),
},
devicePath: 'vol0:/bundles/images/images.okpkg',
},
devicePath: 'vol0:/bundles/images/images.okpkg',
},
],
});
],
});
} finally {
dateNowSpy.mockRestore();
}

expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
expect.objectContaining({ filePath: 'vol0:/bundles/images/images.okpkg' })
expect.objectContaining({
filePath: 'vol0:/bundles/images/images.okpkg',
transferStartedAt: 1_000,
})
);
expect(method.postProgressMessage).toHaveBeenLastCalledWith(100, 'transferData', {
transferredBytes: 3,
totalBytes: 3,
rateBytesPerSecond: 1,
elapsedMs: 4_000,
});
expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(
'vol0:/bundles/images/images.okpkg',
3
Expand Down
40 changes: 40 additions & 0 deletions packages/core/__tests__/protocolV2FileWrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,46 @@ jest.mock('../src/data/config', () => ({
}));

describe('writeProtocolV2File', () => {
test('allows a verified caller-specific BLE chunk limit', async () => {
const getSettingsSpy = jest
.spyOn(DataManager, 'getSettings')
.mockReturnValue('react-native' as any);
const isBleConnectSpy = jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(true);
const data = new Uint8Array(1961);
const typedCall = jest.fn().mockResolvedValue({ message: {} });

try {
await writeProtocolV2File({
commands: { typedCall } as any,
path: 'vol1:/wallpapers/wallpaper.okpkg',
data,
bleChunkSizeLimit: 1960,
});
} finally {
getSettingsSpy.mockRestore();
isBleConnectSpy.mockRestore();
}

expect(typedCall).toHaveBeenCalledTimes(2);
expect(typedCall.mock.calls[0][2].file.data).toEqual(data.slice(0, 1960));
expect(typedCall.mock.calls[1][2].file.data).toEqual(data.slice(1960));
});

test('does not apply the BLE-only limit to WebUSB', async () => {
const data = new Uint8Array(1961);
const typedCall = jest.fn().mockResolvedValue({ message: {} });

await writeProtocolV2File({
commands: { typedCall } as any,
path: 'vol1:/wallpapers/wallpaper.okpkg',
data,
bleChunkSizeLimit: 1960,
});

expect(typedCall).toHaveBeenCalledTimes(1);
expect(typedCall.mock.calls[0][2].file.data).toEqual(data);
});

test('按分片写入并只在首片设置 overwrite', async () => {
const data = new Uint8Array(4097);
const typedCall = jest.fn().mockResolvedValue({ message: {} });
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/api/FirmwareUpdateV4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2114,6 +2114,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
this.postTipMessage(FirmwareUpdateTipMessage.StartTransferData);
this.protocolV2LastTransferProgress = undefined;
this.protocolV2LastTransferProgressAt = 0;
const transferStartedAt = Date.now();
let processedSize = 0;
for (const resource of resourcesToSync) {
// The bootloader keeps its live resource package mounted. FatFs rejects
Expand All @@ -2124,6 +2125,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
filePath: writePath,
processedSize,
totalSize,
transferStartedAt,
});
await this.verifyProtocolV2StagedFile(writePath, resource.source.size);
if (isProtocolV2BootResourcePackagePath(resource.devicePath)) {
Expand All @@ -2139,6 +2141,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
filePath,
processedSize,
totalSize,
transferStartedAt,
});
await this.verifyProtocolV2StagedFile(filePath, item.source.size);
stagedInstallTargets.push({
Expand All @@ -2148,7 +2151,13 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
}

if (totalSize > 0) {
this.postProgressMessage(100, 'transferData');
const elapsedMs = Math.max(Date.now() - transferStartedAt, 0);
this.postProgressMessage(100, 'transferData', {
transferredBytes: totalSize,
totalBytes: totalSize,
rateBytesPerSecond: elapsedMs > 0 ? Math.round((totalSize / elapsedMs) * 1000) : undefined,
elapsedMs,
});
}
if (stagedInstallTargets.length === 0) {
return;
Expand All @@ -2173,16 +2182,17 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
filePath,
processedSize,
totalSize,
transferStartedAt = Date.now(),
}: {
source: FirmwareByteSource;
filePath: string;
processedSize: number;
totalSize: number;
transferStartedAt?: number;
}) {
let lastError: unknown;
for (let attempt = 1; attempt <= PROTOCOL_V2_FILE_TRANSFER_RETRY_COUNT; attempt += 1) {
try {
const transferStartedAt = Date.now();
await writeFirmwareByteSource({
source,
chunkSize: this.getProtocolV2FirmwareChunkSize('write', filePath),
Expand Down Expand Up @@ -2225,7 +2235,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
transferredBytes,
totalBytes: totalSize,
rateBytesPerSecond:
elapsedMs > 0 ? Math.round((chunkEnd / elapsedMs) * 1000) : undefined,
elapsedMs > 0 ? Math.round((transferredBytes / elapsedMs) * 1000) : undefined,
elapsedMs,
});
}
Expand Down
Loading
Loading