diff --git a/src/sagas/__snapshots__/flash-firmware.test.ts.snap b/src/sagas/__snapshots__/flash-firmware.test.ts.snap index f3b2d664..77e153cd 100644 --- a/src/sagas/__snapshots__/flash-firmware.test.ts.snap +++ b/src/sagas/__snapshots__/flash-firmware.test.ts.snap @@ -10,7 +10,7 @@ Object { } `; -exports[`flashFirmware user supplied firmware.zip 1`] = ` +exports[`flashFirmware user supplied firmware.zip success 1`] = ` Object { "options": Array [ "-mno-unicode", diff --git a/src/sagas/flash-firmware.test.ts b/src/sagas/flash-firmware.test.ts index 91952bcf..25ed6d58 100644 --- a/src/sagas/flash-firmware.test.ts +++ b/src/sagas/flash-firmware.test.ts @@ -1,10 +1,16 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { FirmwareMetadata } from '@pybricks/firmware'; +import { + FirmwareMetadata, + FirmwareReaderError, + FirmwareReaderErrorCode, +} from '@pybricks/firmware'; import JSZip from 'jszip'; import { AsyncSaga } from '../../test'; import { + FailToStartReasonType, + didFailToStart, didFinish, didProgress, didStart, @@ -170,131 +176,186 @@ describe('flashFirmware', () => { await saga.end(); }); - test('user supplied firmware.zip', async () => { - const metadata: FirmwareMetadata = { - 'metadata-version': '1.0.0', - 'device-id': HubType.MoveHub, - 'checksum-type': 'sum', - 'firmware-version': '1.2.3', - 'max-firmware-size': 1024, - 'mpy-abi-version': 5, - 'mpy-cross-options': ['-mno-unicode'], - 'user-mpy-offset': 100, - }; + describe('user supplied firmware.zip', () => { + test('success', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; - const zip = new JSZip(); - zip.file('firmware-base.bin', new Uint8Array(64)); - zip.file('firmware.metadata.json', JSON.stringify(metadata)); - zip.file('main.py', 'print("test")'); - zip.file('ReadMe_OSS.txt', 'test'); + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); - const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga(flashFirmware, { + nextMessageId: createCountFunc(), + }); - saga.setState({ settings: { flashCurrentProgram: false } as SettingsState }); + saga.setState({ + settings: { flashCurrentProgram: false } as SettingsState, + }); - // saga is triggered by this action + // saga is triggered by this action - saga.put(flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' }))); + saga.put( + flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), + ); - // the first step is to compile main.py to .mpy + // the first step is to compile main.py to .mpy - let action = await saga.take(); - expect(action).toMatchSnapshot(); + let action = await saga.take(); + expect(action).toMatchSnapshot(); - const mpySize = 20; - const mpyBinaryData = new Uint8Array(mpySize); - saga.put(didCompile(mpyBinaryData)); + const mpySize = 20; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); - // then connect to the hub bootloader + // then connect to the hub bootloader - action = await saga.take(); - expect(action).toEqual(connect()); - - 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(eraseRequest(1)); - - saga.put(didRequest(1)); - saga.put(eraseResponse(Result.OK)); - - // then write the new firmware - - const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8; - 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 BootloaderProgramRequestAction).payload.byteLength).toBe( - Math.min(14, totalFirmwareSize - offset), - ); + expect(action).toEqual(connect()); + + 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(eraseRequest(1)); + + saga.put(didRequest(1)); + saga.put(eraseResponse(Result.OK)); + + // then write the new firmware + + const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8; + 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 BootloaderProgramRequestAction).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) { + 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(0, 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(didProgress(offset / totalFirmwareSize)); + expect(action).toEqual(didFinish()); - // Have to be careful that a checksum request is not sent after - // last payload is sent, otherwise the hub gets confused. + await saga.end(); + }); - if (offset + 14 >= totalFirmwareSize) { - break; - } + test('zip error', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; - if (count % 10 === 0) { - action = await saga.take(); - expect(action).toEqual(checksumRequest(++id)); + const zip = new JSZip(); + // no firmware-base.bin - triggers zip error + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); - saga.put(didRequest(id)); - saga.put(checksumResponse(0)); - } - } + const saga = new AsyncSaga(flashFirmware, { + nextMessageId: createCountFunc(), + }); - // hub indicates success + saga.setState({ + settings: { flashCurrentProgram: false } as SettingsState, + }); - saga.put(programResponse(0, totalFirmwareSize)); + // saga is triggered by this action - action = await saga.take(); - expect(action).toEqual(didProgress(1)); + saga.put( + flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), + ); - // and finally reboot the hub + // should get failure due to missing file - action = await saga.take(); - expect(action).toEqual(rebootRequest(++id)); + const action = await saga.take(); + expect(action).toStrictEqual( + didFailToStart( + FailToStartReasonType.ZipError, + new FirmwareReaderError( + FirmwareReaderErrorCode.MissingFirmwareBaseBin, + ), + ), + ); - saga.put(didRequest(id)); - - // then we are done - - action = await saga.take(); - expect(action).toEqual(didFinish()); - - await saga.end(); + await saga.end(); + }); }); test('user supplied main.py', async () => { diff --git a/src/sagas/flash-firmware.ts b/src/sagas/flash-firmware.ts index 787337e5..0ba93ab6 100644 --- a/src/sagas/flash-firmware.ts +++ b/src/sagas/flash-firmware.ts @@ -1,15 +1,22 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2021 The Pybricks Authors -import { FirmwareMetadata, FirmwareReader, HubType } from '@pybricks/firmware'; +import { + FirmwareMetadata, + FirmwareReader, + FirmwareReaderError, + HubType, +} from '@pybricks/firmware'; import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; import moveHubZip from '@pybricks/firmware/build/movehub.zip'; import technicHubZip from '@pybricks/firmware/build/technichub.zip'; import { Ace } from 'ace-builds'; import { Effect, + StrictEffect, all, call, + cancel, delay, getContext, put, @@ -20,8 +27,10 @@ import { } from 'redux-saga/effects'; import { Action } from '../actions'; import { + FailToStartReasonType, FlashFirmwareActionType, FlashFirmwareFlashAction, + didFailToStart, didFinish, didProgress, didStart, @@ -65,6 +74,7 @@ import { import * as notification from '../actions/notification'; import { MaxProgramFlashSize } from '../protocols/lwp3-bootloader'; import { RootState } from '../reducers'; +import { Maybe, maybe } from '../utils'; import { fmod, sumComplement32 } from '../utils/math'; const firmwareZipMap = new Map([ @@ -123,8 +133,20 @@ function* firmwareIterator(data: DataView, maxSize: number): Generator { function* loadFirmware( data: ArrayBuffer, program: string | undefined, -): Generator { - const reader = (yield call(() => FirmwareReader.load(data))) as FirmwareReader; +): Generator { + const reader = (yield call(() => + maybe(FirmwareReader.load(data)), + )) as Maybe; + + if (reader instanceof Error) { + if (reader instanceof FirmwareReaderError) { + yield put(didFailToStart(FailToStartReasonType.ZipError, reader)); + } else { + yield put(didFailToStart(FailToStartReasonType.Unknown, reader)); + } + yield cancel(); + throw 'not reached'; + } const firmwareBase = (yield call(() => reader.readFirmwareBase())) as Uint8Array; const metadata = (yield call(() => reader.readMetadata())) as FirmwareMetadata; diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts index 203ceede..2d3fd7b4 100644 --- a/src/utils/index.test.ts +++ b/src/utils/index.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { assert, hex } from '.'; +import { assert, hex, maybe } from '.'; test('assert', () => { const assertTrue = jest.fn(() => assert(true, 'should not throw')); @@ -11,6 +11,17 @@ test('assert', () => { expect(() => assert(false, 'should throw')).toThrow(); }); +describe('maybe', () => { + test('resolved', async () => { + const result = await maybe(Promise.resolve('test')); + expect(result).toBe('test'); + }); + test('rejected', async () => { + const result = await maybe(Promise.reject(new Error('test'))); + expect(result).toBeInstanceOf(Error); + }); +}); + test('hex', () => { expect(hex(0, 2)).toBe('0x00'); expect(hex(1, 4)).toBe('0x0001'); diff --git a/src/utils/index.ts b/src/utils/index.ts index ea7af95f..ed26210f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -13,6 +13,17 @@ export function assert(condition: boolean, message: string): void { } } +export type Maybe = T | Error; + +/** Wraps a promise in try/catch and returns the promise result or error. */ +export async function maybe(promise: Promise): Promise> { + try { + return await promise; + } catch (err) { + return err; + } +} + /** * Formats a number as hex (0x00...) * @param n The number to format