diff --git a/package.json b/package.json index 7e40e43f..2d07d243 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", "@types/jest": "^24.0.0", + "@types/jszip": "^3.1.7", "@types/node": "^12.0.0", "@types/react": "^16.9.0", "@types/react-dom": "^16.9.0", @@ -18,6 +19,7 @@ "ace-builds": "^1.4.9", "bootstrap": "^4.4.1", "bootswatch": "^4.4.1", + "jszip": "^3.4.0", "node-sass": "^4.13.1", "react": "^16.13.1", "react-ace": "^8.1.0", diff --git a/src/actions/mpy.ts b/src/actions/mpy.ts index 721941c8..d39b8d56 100644 --- a/src/actions/mpy.ts +++ b/src/actions/mpy.ts @@ -4,11 +4,11 @@ import { Action } from 'redux'; // this starts the mpy-cross wasm runtime and leaves it running in the background const mpy = MpyCross({ arguments: ['-mno-unicode'] }); -enum MpyActionType { +export enum MpyActionType { Compiled = 'mpy.action.compile', } -interface MpyCompiledAction extends Action { +export interface MpyCompiledAction extends Action { /** * The compiled .mpy data. */ diff --git a/src/components/FlashButton.tsx b/src/components/FlashButton.tsx index 105d3eb1..01ff63a0 100644 --- a/src/components/FlashButton.tsx +++ b/src/components/FlashButton.tsx @@ -33,7 +33,7 @@ const mergeProps = ( dispatchProps: DispatchProps, ownProps: OwnProps, ): OpenFileButtonProps => ({ - fileExtension: '.bin', + fileExtension: '.zip', tooltip: 'Flash hub firmware', icon: 'firmware.svg', ...ownProps, diff --git a/src/sagas/bootloader.ts b/src/sagas/bootloader.ts index ca461e7d..0692c5b9 100644 --- a/src/sagas/bootloader.ts +++ b/src/sagas/bootloader.ts @@ -1,8 +1,10 @@ +import JSZip from 'jszip'; import { Action } from 'redux'; import { Channel, buffers } from 'redux-saga'; import { Effect, actionChannel, + call, delay, fork, put, @@ -42,9 +44,11 @@ import { send, stateResponse, } from '../actions/bootloader'; +import { MpyCompiledAction, compile } from '../actions/mpy'; import { Command, ErrorBytecode, + HubType, MaxProgramFlashSize, createDisconnectRequest, createEraseFlashRequest, @@ -62,6 +66,7 @@ import { parseInitLoaderResponse, parseProgramFlashResponse, } from '../protocols/bootloader'; +import { fmod, sumComplement32 } from '../utils/math'; /** * Converts a request action into bytecodes and creates a new action to send @@ -166,11 +171,76 @@ function wait(type: BootloaderResponseActionType, timeout = 500): Effect { return race([take(type), take(BootloaderResponseActionType.Error), delay(timeout)]); } +interface FirmwareMetadata { + 'metadata-version': string; + 'firmware-version': string; + 'device-id': HubType; + 'checksum-type': 'sum' | 'crc32'; + 'mpy-abi-version': number; + 'mpy-cross-options': Array; + 'user-mpy-offset': number; + 'max-firmware-size': number; +} + +function* firmwareIterator(data: DataView, maxSize: number): Generator { + // read each 32-bit word of the firmware + for (let i = 0; i < data.byteLength; i += 4) { + yield data.getUint32(i, true); + } + // remaining free space in flash will be 0xff after erase + for (let i = data.byteLength; i < maxSize; i += 4) { + yield ~0; + } +} + /** * Flashes firmware to a Powered Up device. * @param action The action that triggered this saga. */ function* flashFirmware(action: BootloaderFlashFirmwareAction): Generator { + const zip = (yield call(() => JSZip().loadAsync(action.data))) as JSZip; + const firmwareBase = (yield call(() => + zip.file('firmware-base.bin').async('uint8array'), + )) as Uint8Array; + const metadata = JSON.parse( + (yield call(() => zip.file('firmware.metadata.json').async('text'))) as string, + ) as FirmwareMetadata; + const main = (yield call(() => zip.file('main.py').async('text'))) as string; + + if (metadata['mpy-abi-version'] !== 4) { + throw Error( + `Firmware requires mpy-cross ABI version ${metadata['mpy-abi-version']} we have v4`, + ); + } + + // TODO: pass metadata["mpy-cross-options"] to compiler + const mpy = (yield put(compile(main))) as MpyCompiledAction; + + // 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']) { + throw Error('firmware + main.mpy is too large'); + } + + firmware.set(firmwareBase); + firmwareView.setUint32(metadata['user-mpy-offset'], mpy.data.length, true); + firmware.set(mpy.data, metadata['user-mpy-offset'] + 4); + + if (metadata['checksum-type'] !== 'sum') { + throw Error(`Unknown checksum type "${metadata['checksum-type']}"`); + } + + firmwareView.setUint32( + checksumOffset, + sumComplement32(firmwareIterator(firmwareView, metadata['max-firmware-size'])), + true, + ); + yield put(connect()); const didConnect = (yield take([ BootloaderConnectionActionType.DidConnect, @@ -198,7 +268,11 @@ function* flashFirmware(action: BootloaderFlashFirmwareAction): Generator { throw Error(`failed to get info: ${info}`); } - // TODO: verify hubType === info.response.hubType + if (info[0].hubType !== metadata['device-id']) { + throw Error( + `Connected to ${info[0].hubType} but firmware is for ${metadata['device-id']}`, + ); + } yield put(eraseRequest()); const erase = (yield wait( @@ -210,7 +284,7 @@ function* flashFirmware(action: BootloaderFlashFirmwareAction): Generator { throw Error(`Failed to erase: ${erase}`); } - yield put(initRequest(action.data.byteLength)); + yield put(initRequest(firmware.length)); const init = (yield wait(BootloaderResponseActionType.Init)) as WaitResponse< BootloaderInitResponseAction >; @@ -221,13 +295,9 @@ function* flashFirmware(action: BootloaderFlashFirmwareAction): Generator { let count = 0; - for ( - let offset = 0; - offset < action.data.byteLength; - offset += MaxProgramFlashSize - ) { - const payload = action.data.slice(offset, offset + MaxProgramFlashSize); - yield put(programRequest(info[0].startAddress + offset, payload)); + for (let offset = 0; offset < firmware.length; offset += MaxProgramFlashSize) { + const payload = firmware.slice(offset, offset + MaxProgramFlashSize); + yield put(programRequest(info[0].startAddress + offset, payload.buffer)); // TODO: dispatch progress action @@ -253,7 +323,7 @@ function* flashFirmware(action: BootloaderFlashFirmwareAction): Generator { if (!flash[0]) { throw Error(`failed to get final response: ${flash}`); } - if (flash[0].count !== action.data.byteLength) { + if (flash[0].count !== firmware.length) { // TODO: proper error handling throw Error("Didn't flash all bytes"); } diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 00000000..85d1b13a --- /dev/null +++ b/src/utils/math.ts @@ -0,0 +1,27 @@ +/** + * Compute modulo using floored division + * @param a first operand + * @param b second operand + */ +export function fmod(a: number, b: number): number { + let c = a % b; + if (c / b < 0) { + c += b; + } + return c; +} + +/** + * Calculates the 32-bit "sum complement" checksum + * @data an iterable of 32-bit integers + * @returns the value that needs to be added to get 0 + */ +export function sumComplement32(data: Iterable): number { + let total = 0; + for (const n of data) { + total += n; + total &= ~0; + } + // checksum is two's complement of total + return ~total + 1; +} diff --git a/tsconfig.json b/tsconfig.json index 4455388b..eed8da05 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react" + "jsx": "react", + "downlevelIteration": true }, "include": ["src"] } diff --git a/yarn.lock b/yarn.lock index 979d344c..9dc1b3a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1570,6 +1570,13 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== +"@types/jszip@^3.1.7": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@types/jszip/-/jszip-3.1.7.tgz#c45bd72b448b3fb002125282c57c36190247cb34" + integrity sha512-+XQKNI5zpxutK05hO67huUTw/2imXCuJWjnFdU63tRES/xXSX1yVR9cv/QAdO6Rii2y2tTHbzjQ4i2apLfuK0Q== + dependencies: + "@types/node" "*" + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -5525,6 +5532,11 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= + immer@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" @@ -6668,6 +6680,16 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: array-includes "^3.0.3" object.assign "^4.1.0" +jszip@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.4.0.tgz#1a69421fa5f0bb9bc222a46bca88182fba075350" + integrity sha512-gZAOYuPl4EhPTXT0GjhI3o+ZAz3su6EhLrKUoAivcKqyqC7laS5JEv4XWZND9BgcDcF83vI85yGbDmDR6UhrIg== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + set-immediate-shim "~1.0.1" + killable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" @@ -6766,6 +6788,13 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -7862,7 +7891,7 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -pako@~1.0.5: +pako@~1.0.2, pako@~1.0.5: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== @@ -9979,6 +10008,11 @@ set-blocking@^2.0.0, set-blocking@~2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= +set-immediate-shim@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" + integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E= + set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"