diff --git a/src/protocols/bootloader.ts b/src/protocols/bootloader.ts index 1ed22098..c454e7d9 100644 --- a/src/protocols/bootloader.ts +++ b/src/protocols/bootloader.ts @@ -1,5 +1,7 @@ // Ref: https://lego.github.io/lego-ble-wireless-protocol-docs/index.html#lego-hub-boot-loader-service +import { assert } from '../utils'; + /** * LEGO Powered Up Bootloader Service UUID. */ @@ -186,13 +188,11 @@ export function getMessageType(msg: DataView): Command | ErrorMessage { * @param msg The raw message data. */ export function parseErrorResponse(msg: DataView): Command { + assert(msg.getUint8(0) === 5, 'unexpected length'); // Error responses are ordered differently compared to command responses. - if (msg.getUint8(2) === ErrorBytecode) { + if (msg.getUint8(2) !== ErrorBytecode) { throw Error('expecting error'); } - if (msg.getUint8(0) !== 5) { - throw Error('unexpected length'); - } if (msg.getUint8(4) !== ErrorCode.UnknownCommand) { // "command not recognized" is only possible error code throw Error('unexpected error code'); @@ -207,9 +207,7 @@ export function parseErrorResponse(msg: DataView): Command { * @returns The result of the erase operation. */ export function parseEraseFlashResponse(msg: DataView): Result { - if (msg.getUint8(0) !== Command.EraseFlash) { - throw Error('expecting erase flash command'); - } + assert(msg.getUint8(0) === Command.EraseFlash, 'expecting erase flash command'); const result = msg.getUint8(1); return result; } @@ -220,9 +218,7 @@ export function parseEraseFlashResponse(msg: DataView): Result { * @returns The final checksum and the number of bytes written. */ export function parseProgramFlashResponse(msg: DataView): [number, number] { - if (msg.getUint8(0) !== Command.ProgramFlash) { - throw Error('expecting program flash command'); - } + assert(msg.getUint8(0) === Command.ProgramFlash, 'expecting program flash command'); const checksum = msg.getUint8(1); const count = msg.getUint32(2, true); return [checksum, count]; @@ -234,9 +230,7 @@ export function parseProgramFlashResponse(msg: DataView): [number, number] { * @returns The result of the initialization. */ export function parseInitLoaderResponse(msg: DataView): Result { - if (msg.getUint8(0) !== Command.InitLoader) { - throw Error('expecting init loader command'); - } + assert(msg.getUint8(0) === Command.InitLoader, 'expecting init loader command'); const result = msg.getUint8(1); return result; } @@ -248,9 +242,7 @@ export function parseInitLoaderResponse(msg: DataView): Result { * of where firmware can be flashed, and the hub type identifier. */ export function parseGetInfoResponse(msg: DataView): [number, number, number, HubType] { - if (msg.getUint8(0) !== Command.GetInfo) { - throw Error('expecting get info command'); - } + assert(msg.getUint8(0) === Command.GetInfo, 'expecting get info command'); const version = msg.getUint32(1, true); const startAddress = msg.getUint32(5, true); const endAddress = msg.getUint32(9, true); @@ -264,9 +256,7 @@ export function parseGetInfoResponse(msg: DataView): [number, number, number, Hu * @returns The checksum of the data that has been flashed so far. */ export function parseGetChecksumResponse(msg: DataView): number { - if (msg.getUint8(0) !== Command.GetChecksum) { - throw Error('expecting get checksum command'); - } + assert(msg.getUint8(0) === Command.GetChecksum, 'expecting get checksum command'); const checksum = msg.getUint8(1); return checksum; } @@ -277,9 +267,10 @@ export function parseGetChecksumResponse(msg: DataView): number { * @returns The protection level */ export function parseGetFlashStateResponse(msg: DataView): ProtectionLevel { - if (msg.getUint8(0) !== Command.GetFlashState) { - throw Error('expecting get flash state command'); - } + assert( + msg.getUint8(0) === Command.GetFlashState, + 'expecting get flash state command', + ); const level = msg.getUint8(1); return level; } diff --git a/src/sagas/bootloader.test.ts b/src/sagas/bootloader.test.ts new file mode 100644 index 00000000..2d9b8991 --- /dev/null +++ b/src/sagas/bootloader.test.ts @@ -0,0 +1,125 @@ +import { Action } from 'redux'; +import { runSaga, stdChannel } from 'redux-saga'; +import { BootloaderResponseActionType, didReceive } from '../actions/bootloader'; +import { Command, HubType, ProtectionLevel, Result } from '../protocols/bootloader'; +import bootloader from './bootloader'; + +describe('message decoder', () => { + test.each([ + [ + 'erase', + [ + 0x11, // erase command + 0xff, // success + ], + { + type: BootloaderResponseActionType.Erase, + result: Result.Error, + }, + ], + [ + 'flash', + [ + 0x22, // flash command + 0xaa, // checksum + 0xa0, // byte count LSB + 0x86, // . + 0x01, // . + 0x00, // byte count MSB + ], + { + type: BootloaderResponseActionType.Program, + checksum: 0xaa, + count: 100000, + }, + ], + [ + 'init', + [ + 0x44, // init command + 0xff, // success + ], + { + type: BootloaderResponseActionType.Init, + result: Result.Error, + }, + ], + [ + 'info', + [ + 0x55, // info command + 0x78, // version LSB + 0x56, // . + 0x34, // . + 0x12, // version MSB + 0x00, // start address LSB + 0x50, // . + 0x00, // . + 0x08, // start address MSB + 0xff, // end address LSB + 0xf7, // . + 0x01, // . + 0x08, // end address MSB + 0x40, // hub type ID + ], + { + type: BootloaderResponseActionType.Info, + version: 0x12345678, + startAddress: 0x08005000, + endAddress: 0x0801f7ff, + hubType: HubType.MoveHub, + }, + ], + [ + 'checksum', + [ + 0x66, // checksum command + 0xaa, // checksum + ], + { + type: BootloaderResponseActionType.Checksum, + checksum: 0xaa, + }, + ], + [ + 'state', + [ + 0x77, // flash state command + 0x02, // protection level + ], + { + type: BootloaderResponseActionType.State, + level: ProtectionLevel.Level2, + }, + ], + [ + 'error', + [ + 0x05, // length + 0x00, // unused (hub id) + 0x05, // flash loader error message + 0x77, // get flash state command + 0x05, // command not recognized + ], + { + type: BootloaderResponseActionType.Error, + command: Command.GetFlashState, + }, + ], + ])('decode %s response', async (_n, message, expected) => { + const response = new Uint8Array(message); + const channel = stdChannel(); + const dispatched = new Array(); + const task = runSaga( + { + channel, + dispatch: (action: Action) => dispatched.push(action), + }, + bootloader, + ); + channel.put(didReceive(new DataView(response.buffer))); + task.cancel(); + await task.toPromise(); + expect(dispatched[0]).toEqual(expected); + }); +}); diff --git a/src/sagas/bootloader.ts b/src/sagas/bootloader.ts index 840aa8d2..c9268eff 100644 --- a/src/sagas/bootloader.ts +++ b/src/sagas/bootloader.ts @@ -35,6 +35,7 @@ import { connect, eraseRequest, eraseResponse, + errorResponse, infoRequest, infoResponse, initRequest, @@ -62,6 +63,7 @@ import { createStartAppRequest, getMessageType, parseEraseFlashResponse, + parseErrorResponse, parseGetChecksumResponse, parseGetFlashStateResponse, parseGetInfoResponse, @@ -147,10 +149,12 @@ function* decodeResponse(action: BootloaderConnectionDidReceiveAction): Generato yield put(stateResponse(parseGetFlashStateResponse(action.data))); break; case ErrorBytecode: - yield put(stateResponse(parseGetFlashStateResponse(action.data))); + yield put(errorResponse(parseErrorResponse(action.data))); break; + /* istanbul ignore next: should not be possible to reach */ default: console.error(`Unknown bootloader response action ${action}`); + break; } } diff --git a/src/sagas/index.ts b/src/sagas/index.ts index a89013e5..eed2b2ee 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -1,6 +1,7 @@ import { all } from 'redux-saga/effects'; import bootloader from './bootloader'; +/* istanbul ignore next */ export default function* (): Generator { yield all([bootloader()]); } diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..7d4a0505 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,12 @@ +/** + * Asserts that an assumption is true. This is used to detect programmer errors + * and should never actually throw in a correctly written program. + * @param condition A condition that is assumed to be true + * @param message Informational message for debugging + */ +export function assert(condition: boolean, message: string): void { + /* istanbul ignore next */ + if (!condition) { + throw Error(message); + } +}