diff --git a/package.json b/package.json index 0be9dc5c..7fefd7fb 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@blueprintjs/popover2": "^1.6.4", "@blueprintjs/select": "^4.6.4", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", - "@pybricks/firmware": "5.0.0", + "@pybricks/firmware": "6.0.1", "@pybricks/ide-docs": "2.2.0", "@pybricks/jedi": "^1.0.1", "@pybricks/mpy-cross-v5": "^2.0.0", diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index b18baa84..9d3ef72a 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2022 The Pybricks Authors -import { FirmwareMetadata, FirmwareReaderError } from '@pybricks/firmware'; +import { FirmwareReaderError } from '@pybricks/firmware'; import { createAction } from '../actions'; export enum MetadataProblem { @@ -86,7 +86,7 @@ export type FailToFinishReasonZipError = Reason export type FailToFinishReasonBadMetadata = Reason & { - property: keyof FirmwareMetadata; + property: string; problem: MetadataProblem; }; @@ -228,7 +228,7 @@ function didFailToFinishCreator( function didFailToFinishCreator( reason: FailToFinishReasonType.BadMetadata, - property: keyof FirmwareMetadata, + property: string, problem: MetadataProblem, ): { type: typeof didFailToFinishType; @@ -324,7 +324,9 @@ function didFailToFinishCreator( arg1 !== 'mpy-abi-version' && arg1 !== 'mpy-cross-options' && arg1 !== 'user-mpy-offset' && - arg1 !== 'max-firmware-size' + arg1 !== 'max-firmware-size' && + arg1 !== 'checksum-size' && + arg1 !== 'hub-name-size' ) { throw new Error('missing or invalid property'); } diff --git a/src/firmware/sagas.test.ts b/src/firmware/sagas.test.ts index 8b7ef4c1..e1a63f22 100644 --- a/src/firmware/sagas.test.ts +++ b/src/firmware/sagas.test.ts @@ -4,6 +4,8 @@ import { ToasterInstance } from '@blueprintjs/core'; import { FirmwareMetadata, + FirmwareMetadataV110, + FirmwareMetadataV200, FirmwareReaderError, FirmwareReaderErrorCode, } from '@pybricks/firmware'; @@ -55,9 +57,9 @@ afterEach(() => { describe('flashFirmware', () => { describe('normal flow using app supplied firmware', () => { - test('success', async () => { - const metadata: FirmwareMetadata = { - 'metadata-version': '1.0.0', + test('metadata v1.x works', async () => { + const metadata: FirmwareMetadataV110 = { + 'metadata-version': '1.1.0', 'device-id': HubType.MoveHub, 'checksum-type': 'sum', 'firmware-version': '1.2.3', @@ -65,7 +67,7 @@ describe('flashFirmware', () => { 'mpy-abi-version': 5, 'mpy-cross-options': ['-mno-unicode'], 'user-mpy-offset': 100, - 'hub-name-offset': 90, + 'hub-name-offset': 54, 'max-hub-name-size': 10, }; @@ -207,6 +209,134 @@ describe('flashFirmware', () => { await saga.end(); }); + test('metadata v2.x works', async () => { + const metadata: FirmwareMetadataV200 = { + 'metadata-version': '2.0.0', + 'device-id': HubType.MoveHub, + 'firmware-version': '1.2.3', + 'checksum-type': 'sum', + 'checksum-size': 1024, + 'hub-name-offset': 54, + 'hub-name-size': 10, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga(flashFirmware, { + nextMessageId: createCountFunc(), + toaster: mock(), + }); + + // saga is triggered by this action + + saga.put(flashFirmwareAction(null, undefined, 'test name')); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.MoveHub)); + + // then start flashing the firmware + + // should get didStart action just before starting to erase + action = await saga.take(); + expect(action).toEqual(didStart()); + + // erase first + + action = await saga.take(); + expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton')); + + action = await saga.take(); + expect(action).toEqual(eraseRequest(1, /* isCityHub */ false)); + + saga.put(didRequest(1)); + saga.put(eraseResponse(Result.OK)); + + // then write the new firmware + + const totalFirmwareSize = 68; + action = await saga.take(); + expect(action).toEqual(initRequest(2, totalFirmwareSize)); + + saga.put(didRequest(2)); + saga.put(initResponse(Result.OK)); + + const dummyPayload = new ArrayBuffer(0); + let id = 2; + for (let count = 1, offset = 0; ; count++, offset += 14) { + action = await saga.take(); + expect(action).toEqual( + programRequest(++id, 0x08005000 + offset, dummyPayload), + ); + expect( + (action as ReturnType).payload.byteLength, + ).toBe(Math.min(14, totalFirmwareSize - offset)); + + saga.put(didRequest(id)); + + action = await saga.take(); + expect(action).toEqual(didProgress(offset / totalFirmwareSize)); + + // Have to be careful that a checksum request is not sent after + // last payload is sent, otherwise the hub gets confused. + + if (offset + 14 >= totalFirmwareSize) { + expect(count).toBe(5); + break; + } + + if (count % 10 === 0) { + action = await saga.take(); + expect(action).toEqual(checksumRequest(++id)); + + saga.put(didRequest(id)); + saga.put(checksumResponse(0)); + } + } + + // hub indicates success + + saga.put(programResponse(0xe0, totalFirmwareSize)); + + action = await saga.take(); + expect(action).toEqual(didProgress(1)); + + // and finally reboot the hub + + action = await saga.take(); + expect(action).toEqual(rebootRequest(++id)); + + saga.put(didRequest(id)); + + // then we are done + + action = await saga.take(); + expect(action).toEqual(didFinish()); + + await saga.end(); + }); + test('fail to connect', async () => { const metadata: FirmwareMetadata = { 'metadata-version': '1.0.0', diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 9c9bdb80..e3daeb98 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -7,6 +7,8 @@ import { FirmwareReaderError, HubType, encodeHubName, + metadataIsV100, + metadataIsV110, } from '@pybricks/firmware'; import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; import moveHubZip from '@pybricks/firmware/build/movehub.zip'; @@ -219,90 +221,145 @@ function* loadFirmware( const firmwareBase = yield* call(() => reader.readFirmwareBase()); const metadata = yield* call(() => reader.readMetadata()); - // if a user program was not given, then use main.py from the firmware.zip - if (program === undefined) { - program = yield* call(() => reader.readMainPy()); - } + // v1.x allows appending main.py to firmware, later versions do not + if (metadataIsV100(metadata) || metadataIsV110(metadata)) { + // if a user program was not given, then use main.py from the firmware.zip + if (program === undefined) { + program = yield* call(() => reader.readMainPy()); + } - // REVISIT: the firmware may eventually be changed to allow no main.py - // for now, ensure there is a program even if it does nothing - if (!program) { - program = ''; - } + // REVISIT: the firmware may eventually be changed to allow no main.py + // for now, ensure there is a program even if it does nothing + if (!program) { + program = ''; + } + + if (![5, 6].includes(metadata['mpy-abi-version'])) { + yield* put( + didFailToFinish( + FailToFinishReasonType.BadMetadata, + 'mpy-abi-version', + MetadataProblem.NotSupported, + ), + ); + + // FIXME: we should return error/throw instead + yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); + } - if (![5, 6].includes(metadata['mpy-abi-version'])) { yield* put( - didFailToFinish( - FailToFinishReasonType.BadMetadata, - 'mpy-abi-version', - MetadataProblem.NotSupported, + compile( + program, + metadata['mpy-abi-version'], + metadata['mpy-cross-options'], ), ); + const { mpy, mpyFail } = yield* race({ + mpy: take(didCompile), + mpyFail: take(didFailToCompile), + }); - // FIXME: we should return error/throw instead - yield* disconnectAndCancel(); + if (mpyFail) { + // FIXME: we should return error/throw instead + yield* put(didFailToFinish(FailToFinishReasonType.FailedToCompile)); + yield* disconnectAndCancel(); - // istanbul ignore next: needed for typescript flow - throw new Error('unreachable'); + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); + } + + defined(mpy); + + // compute offset for checksum - must be aligned to 4-byte boundary + const checksumOffset = + metadata['user-mpy-offset'] + + 4 + + mpy.data.length + + fmod(-mpy.data.length, 4); + + const firmware = new Uint8Array(checksumOffset + 4); + const firmwareView = new DataView(firmware.buffer); + + if (firmware.length > metadata['max-firmware-size']) { + // FIXME: we should return error/throw instead + yield* put(didFailToFinish(FailToFinishReasonType.FirmwareSize)); + yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); + } + + firmware.set(firmwareBase); + firmwareView.setUint32(metadata['user-mpy-offset'], mpy.data.length, true); + firmware.set(mpy.data, metadata['user-mpy-offset'] + 4); + + // if the firmware supports it, we can set a custom hub name + if (!metadataIsV100(metadata)) { + // empty string means use default name (don't write over firmware) + if (hubName) { + firmware.set( + encodeHubName(hubName, metadata), + metadata['hub-name-offset'], + ); + } + } + + const checksum = (function () { + switch (metadata['checksum-type']) { + case 'sum': + return sumComplement32( + firmwareIterator(firmwareView, metadata['max-firmware-size']), + ); + case 'crc32': + return crc32( + firmwareIterator(firmwareView, metadata['max-firmware-size']), + ); + default: + return undefined; + } + })(); + + if (!checksum) { + // FIXME: we should return error/throw instead + yield* put( + didFailToFinish( + FailToFinishReasonType.BadMetadata, + 'checksum-type', + MetadataProblem.NotSupported, + ), + ); + yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); + } + + firmwareView.setUint32(checksumOffset, checksum, true); + + return { firmware, deviceId: metadata['device-id'] }; } - yield* put( - compile(program, metadata['mpy-abi-version'], metadata['mpy-cross-options']), - ); - const { mpy, mpyFail } = yield* race({ - mpy: take(didCompile), - mpyFail: take(didFailToCompile), - }); - - if (mpyFail) { - // FIXME: we should return error/throw instead - yield* put(didFailToFinish(FailToFinishReasonType.FailedToCompile)); - yield* disconnectAndCancel(); - - // istanbul ignore next: needed for typescript flow - throw new Error('unreachable'); - } - - defined(mpy); - - // compute offset for checksum - must be aligned to 4-byte boundary - const checksumOffset = - metadata['user-mpy-offset'] + 4 + mpy.data.length + fmod(-mpy.data.length, 4); - - const firmware = new Uint8Array(checksumOffset + 4); + const firmware = new Uint8Array(firmwareBase.length + 4); const firmwareView = new DataView(firmware.buffer); - if (firmware.length > metadata['max-firmware-size']) { - // FIXME: we should return error/throw instead - yield* put(didFailToFinish(FailToFinishReasonType.FirmwareSize)); - yield* disconnectAndCancel(); - - // istanbul ignore next: needed for typescript flow - throw new Error('unreachable'); - } - firmware.set(firmwareBase); - firmwareView.setUint32(metadata['user-mpy-offset'], mpy.data.length, true); - firmware.set(mpy.data, metadata['user-mpy-offset'] + 4); - // if the firmware supports it, we can set a custom hub name - if (metadata['max-hub-name-size']) { - // empty string means use default name (don't write over firmware) - if (hubName) { - firmware.set(encodeHubName(hubName, metadata), metadata['hub-name-offset']); - } + // empty string means use default name (don't write over firmware) + if (hubName) { + firmware.set(encodeHubName(hubName, metadata), metadata['hub-name-offset']); } const checksum = (function () { switch (metadata['checksum-type']) { case 'sum': return sumComplement32( - firmwareIterator(firmwareView, metadata['max-firmware-size']), + firmwareIterator(firmwareView, metadata['checksum-size']), ); case 'crc32': - return crc32( - firmwareIterator(firmwareView, metadata['max-firmware-size']), - ); + return crc32(firmwareIterator(firmwareView, metadata['checksum-size'])); default: return undefined; } @@ -323,7 +380,7 @@ function* loadFirmware( throw new Error('unreachable'); } - firmwareView.setUint32(checksumOffset, checksum, true); + firmwareView.setUint32(firmwareBase.length, checksum, true); return { firmware, deviceId: metadata['device-id'] }; } diff --git a/yarn.lock b/yarn.lock index b4ac63c4..c9a92ef2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2369,12 +2369,12 @@ __metadata: languageName: node linkType: hard -"@pybricks/firmware@npm:5.0.0": - version: 5.0.0 - resolution: "@pybricks/firmware@npm:5.0.0" +"@pybricks/firmware@npm:6.0.1": + version: 6.0.1 + resolution: "@pybricks/firmware@npm:6.0.1" dependencies: jszip: ^3.7.1 - checksum: 049dd90e988aa574cfa0ead1e62bcb74e6fdfc9b709bc1c40874ddf3abb63cd35555d22806c91184bc2e982912a001e0ef94ef72ef66217eb0319bcaf45a7cb3 + checksum: c0d6e9bef7ac8b1009f90f64cedb8872bae02c6ef6c02fcaf73238407b5164c9c27312dc7afaf051663f7f086bca7b8ccbeec641ef3105ec03308e3d9221175a languageName: node linkType: hard @@ -2415,7 +2415,7 @@ __metadata: "@blueprintjs/popover2": ^1.6.4 "@blueprintjs/select": ^4.6.4 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.7 - "@pybricks/firmware": 5.0.0 + "@pybricks/firmware": 6.0.1 "@pybricks/ide-docs": 2.2.0 "@pybricks/jedi": ^1.0.1 "@pybricks/mpy-cross-v5": ^2.0.0