// SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors import { FirmwareMetadata, FirmwareReaderError, FirmwareReaderErrorCode, } from '@pybricks/firmware'; import JSZip from 'jszip'; import { AsyncSaga } from '../../test'; import { BootloaderConnectionFailureReason, BootloaderProgramRequestAction, checksumRequest, checksumResponse, connect, didConnect, didDisconnect, didFailToConnect, didFailToRequest, didRequest, disconnect, eraseRequest, eraseResponse, errorResponse, infoRequest, infoResponse, initRequest, initResponse, programRequest, programResponse, rebootRequest, } from '../lwp3-bootloader/actions'; import { Command, HubType, Result } from '../lwp3-bootloader/protocol'; import { BootloaderConnectionState } from '../lwp3-bootloader/reducers'; import { didCompile, didFailToCompile } from '../mpy/actions'; import { createCountFunc } from '../utils/iter'; import { FailToFinishReasonType, HubError, MetadataProblem, didFailToFinish, didFinish, didProgress, didStart, flashFirmware as flashFirmwareAction, } from './actions'; import flashFirmware from './sagas'; afterEach(() => { jest.restoreAllMocks(); }); describe('flashFirmware', () => { describe('normal flow using app supplied firmware', () => { 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, 'hub-name-offset': 90, 'max-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('main.py', 'print("test")'); zip.file('ReadMe_OSS.txt', 'test'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false, hubName: 'test name' }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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 compile main.py to .mpy action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); // this makes total firmware size 140 bytes to check for // https://github.com/pybricks/support/issues/178 const mpySize = 40 - 8; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // 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, /* isCityHub */ false)); 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) { expect(count).toBe(10); 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(0x33, 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', '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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // first step is to connect to the hub bootloader let action = await saga.take(); expect(action).toEqual(connect()); saga.put( didFailToConnect(BootloaderConnectionFailureReason.GattServiceNotFound), ); // it should fail here because of failure to connect action = await saga.take(); expect(action).toEqual( didFailToFinish(FailToFinishReasonType.FailedToConnect), ); await saga.end(); }); test('untimely disconnect before start cancels saga', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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)); // hub disconnects before replying saga.updateState({ bootloader: { connection: BootloaderConnectionState.Disconnected }, }); saga.put(didDisconnect()); // should get a failure to start action = await saga.take(); expect(action).toEqual( didFailToFinish(FailToFinishReasonType.Disconnected), ); // On city hub, we can end up in this situation. BLE writeValueWithResponse() // doesn't return until erasing is done, so there is a long window for // this to happen. saga.put(didFailToRequest(0, new Error('failed due to disconnect'))); await saga.end(); }); test('fail to send info request', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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)); const testError = new Error('test'); saga.put(didFailToRequest(0, testError)); // should get a failure to start action = await saga.take(); expect(action).toEqual( didFailToFinish(FailToFinishReasonType.BleError, testError), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); test('info request is unknown command', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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(errorResponse(Command.GetInfo)); // should get an unknown command failure action = await saga.take(); expect(action).toEqual( didFailToFinish( FailToFinishReasonType.HubError, HubError.UnknownCommand, ), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); test('timeout waiting for info response', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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)); // should get a timed-out failure action = await saga.take(); expect(action).toEqual(didFailToFinish(FailToFinishReasonType.TimedOut)); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); test('failed to fetch firmware', 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 response = new Response(undefined, { status: 404 }); jest.spyOn(window, 'fetch').mockResolvedValueOnce(response); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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)); // received an unknown hub type ID saga.put(didRequest(0)); saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.MoveHub)); // should raise an error that we don't have any firmware for this hub action = await saga.take(); expect(action).toStrictEqual( didFailToFinish(FailToFinishReasonType.FailedToFetch, response), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); test('connected device does not match firmware device', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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)); // connected hub type does not match firmware hub type saga.put(didRequest(0)); saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.CityHub)); // then compile main.py to .mpy action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // should raise an error that we don't have any firmware for this hub action = await saga.take(); expect(action).toStrictEqual( didFailToFinish(FailToFinishReasonType.DeviceMismatch), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); test('unsupported device', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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)); // received an unknown hub type ID saga.put(didRequest(0)); saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, 0 as HubType)); // should raise an error that we don't have any firmware for this hub action = await saga.take(); expect(action).toStrictEqual( didFailToFinish(FailToFinishReasonType.NoFirmware), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); test('erase response is failed', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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 compile main.py to .mpy action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // 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, /* isCityHub */ false)); saga.put(didRequest(1)); saga.put(eraseResponse(Result.Error)); // should get a hub error action = await saga.take(); expect(action).toEqual( didFailToFinish(FailToFinishReasonType.HubError, HubError.EraseFailed), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); test('init response is failed', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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 compile main.py to .mpy action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // 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, /* isCityHub */ false)); 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.Error)); // should get a hub error action = await saga.take(); expect(action).toEqual( didFailToFinish(FailToFinishReasonType.HubError, HubError.InitFailed), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); test('size mismatch after flashing', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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 compile main.py to .mpy action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // 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, /* isCityHub */ false)); 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) { expect(count).toBe(10); break; } if (count % 10 === 0) { action = await saga.take(); expect(action).toEqual(checksumRequest(++id)); saga.put(didRequest(id)); saga.put(checksumResponse(0)); } } // hub indicates incorrect size saga.put(programResponse(0x62, totalFirmwareSize - 1)); // should get a hub error action = await saga.take(); expect(action).toEqual( didFailToFinish( FailToFinishReasonType.HubError, HubError.CountMismatch, ), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); test('checksum mismatch after flashing', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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 compile main.py to .mpy action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // 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, /* isCityHub */ false)); 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) { expect(count).toBe(10); break; } if (count % 10 === 0) { action = await saga.take(); expect(action).toEqual(checksumRequest(++id)); saga.put(didRequest(id)); saga.put(checksumResponse(0)); } } // hub indicates incorrect checksum saga.put(programResponse(0x100, totalFirmwareSize)); // should get a hub error action = await saga.take(); expect(action).toEqual( didFailToFinish( FailToFinishReasonType.HubError, HubError.ChecksumMismatch, ), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); }); 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 saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put( flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), ); // the first step is to compile main.py to .mpy let action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); // make sure that total firmware size is big enough that checksum // is called at least once const mpySize = 100; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // then connect to the hub bootloader 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(eraseRequest(1, /* isCityHub */ false)); 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) { expect(count).toBeGreaterThan(10); break; } if (count % 10 === 0) { action = await saga.take(); expect(action).toEqual(checksumRequest(++id)); saga.put(didRequest(id)); saga.put(checksumResponse(0x9b)); } } // hub indicates success saga.put(programResponse(0xf3, 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('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, }; 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'); const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put( flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), ); // should get failure due to missing file const action = await saga.take(); expect(action).toStrictEqual( didFailToFinish( FailToFinishReasonType.ZipError, new FirmwareReaderError( FirmwareReaderErrorCode.MissingFirmwareBaseBin, ), ), ); await saga.end(); }); test('unsupported mpy-cross version', 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': 4, // unsupported version '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 saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put( flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), ); // should get failure due to unsupported mpy-cross version const action = await saga.take(); expect(action).toStrictEqual( didFailToFinish( FailToFinishReasonType.BadMetadata, 'mpy-abi-version', MetadataProblem.NotSupported, ), ); await saga.end(); }); test('compile 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, }; 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, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put( flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), ); // the first step is to compile main.py to .mpy let action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); // this triggers a failure saga.put(didFailToCompile(['test'])); // compiler error should trigger firmware flash failure action = await saga.take(); expect(action).toEqual( didFailToFinish(FailToFinishReasonType.FailedToCompile), ); await saga.end(); }); test('firmware too big', async () => { const metadata: FirmwareMetadata = { 'metadata-version': '1.0.0', 'device-id': HubType.MoveHub, 'checksum-type': 'sum', 'firmware-version': '1.2.3', 'max-firmware-size': 1, // low limit to trigger error '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 saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put( flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), ); // the first step is to compile main.py to .mpy let action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // should fail due to firmware being too big action = await saga.take(); expect(action).toEqual( didFailToFinish(FailToFinishReasonType.FirmwareSize), ); await saga.end(); }); test('bad checksum algorithm', async () => { const metadata: FirmwareMetadata = { 'metadata-version': '1.0.0', 'device-id': HubType.MoveHub, // @ts-expect-error: testing bad value 'checksum-type': 'bad', '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 saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put( flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), ); // the first step is to compile main.py to .mpy let action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // should fail due to bad checksum algorithm action = await saga.take(); expect(action).toEqual( didFailToFinish( FailToFinishReasonType.BadMetadata, 'checksum-type', MetadataProblem.NotSupported, ), ); await saga.end(); }); test('connected device type does not match firmware device type', 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 saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, settings: { flashCurrentProgram: false }, }, { nextMessageId: createCountFunc(), }, ); // saga is triggered by this action saga.put( flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), ); // the first step is to compile main.py to .mpy let action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // then connect to the hub bootloader 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)); // connected hub type does not match firmware hub type saga.put(didRequest(0)); saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.CityHub)); // should raise an error that we don't have any firmware for this hub action = await saga.take(); expect(action).toStrictEqual( didFailToFinish(FailToFinishReasonType.DeviceMismatch), ); // should request to disconnect after failure action = await saga.take(); expect(action).toEqual(disconnect()); await saga.end(); }); }); test('user supplied main.py', 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'); jest.spyOn(window, 'fetch').mockResolvedValueOnce( new Response(await zip.generateAsync({ type: 'blob' })), ); const editor = { getValue: () => 'print("test")', }; const saga = new AsyncSaga( flashFirmware, { bootloader: { connection: BootloaderConnectionState.Disconnected }, editor: { current: editor }, settings: { flashCurrentProgram: true }, }, { nextMessageId: createCountFunc() }, ); // saga is triggered by this action saga.put(flashFirmwareAction(null)); // 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 compile main.py to .mpy action = await saga.take(); expect(action).toMatchInlineSnapshot(` Object { "options": Array [ "-mno-unicode", ], "script": "print(\\"test\\")", "type": "mpy.action.compile", } `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); // 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, /* isCityHub */ false)); 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(0x27, 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(); }); });