diff --git a/package.json b/package.json index 1b26d161..1809af67 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "react": "^16.13.1", "react-ace": "^8.1.0", "react-dom": "^16.13.1", + "react-dropzone": "^10.2.2", "react-scripts": "3.4.1", "typescript": "~3.7.2", "xterm": "^4.4.0" diff --git a/src/App.tsx b/src/App.tsx index bc893ed5..f528557b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import './App.css'; import { Connection } from './Connection'; import { Terminal } from './Terminal'; import { Run } from './Run'; +import { Flash } from './Flash'; import 'ace-builds/src-noconflict/mode-python'; import 'ace-builds/src-noconflict/theme-github'; @@ -20,6 +21,7 @@ function App(): JSX.Element { ref={connection} /> + { diff --git a/src/Flash.tsx b/src/Flash.tsx new file mode 100644 index 00000000..5c19ae91 --- /dev/null +++ b/src/Flash.tsx @@ -0,0 +1,328 @@ +import React from 'react'; +import Dropzone from 'react-dropzone'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const legoFirmwareServiceUUID = '00001625-1212-efde-1623-785feabcd123'; +const legoFirmwareCharUUID = '00001626-1212-efde-1623-785feabcd123'; + +enum legoFirmwareCmd { + eraseFlash = 0x11, + programFlash = 0x22, + startApp = 0x33, + initLoader = 0x44, + getInfo = 0x55, + getChecksum = 0x66, + getFlashState = 0x77, + disconnect = 0x88, +} + +enum legoHubType { + moveHub = 0x40, + cityHub = 0x41, + cplusHub = 0x80, +} + +const legoFirmwareError = 0x05; + +function createEraseFlashRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, legoFirmwareCmd.eraseFlash); + return msg; +} + +function createProgramFlashRequest(address: number, payload: ArrayBuffer): Uint8Array { + const size = payload.byteLength; + if (size > 14) { + throw Error('size too big'); + } + const msg = new Uint8Array(size + 6); + const view = new DataView(msg.buffer); + view.setUint8(0, legoFirmwareCmd.programFlash); + view.setUint8(1, size + 4); + view.setUint32(2, address, true); + const payloadView = new DataView(payload); + for (let i = 0; i < size; i++) { + view.setUint8(6 + i, payloadView.getUint8(i)); + } + return msg; +} + +function createStartAppRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, legoFirmwareCmd.startApp); + return msg; +} + +function createInitLoaderRequest(fwSize: number): Uint8Array { + const msg = new Uint8Array(5); + const view = new DataView(msg.buffer); + view.setUint8(0, legoFirmwareCmd.initLoader); + view.setUint32(1, fwSize, true); + return msg; +} + +function createGetInfoRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, legoFirmwareCmd.getInfo); + return msg; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function createGetChecksumRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, legoFirmwareCmd.getChecksum); + return msg; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function createGetFlashStateRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, legoFirmwareCmd.getFlashState); + return msg; +} + +function assertError(msg: DataView): void { + if (msg.getUint8(0) === legoFirmwareError && msg.getUint8(1) === 5) { + throw Error( + `msg type: 0x${msg.getUint8(3).toString(16)} error: 0x${msg + .getUint8(4) + .toString(16)}`, + ); + } +} + +function parseEraseFlashReply(msg: DataView): { result: number } { + assertError(msg); + if (msg.getUint8(0) !== legoFirmwareCmd.eraseFlash) { + throw Error('expecting erase flash command'); + } + return { result: msg.getUint8(1) }; +} + +function parseProgramFlashReply(msg: DataView): { checksum: number; count: number } { + assertError(msg); + if (msg.getUint8(0) !== legoFirmwareCmd.programFlash) { + throw Error('expecting program flash command'); + } + return { checksum: msg.getUint8(1), count: msg.getUint32(2, true) }; +} + +function parseInitLoaderReply(msg: DataView): { result: number } { + assertError(msg); + if (msg.getUint8(0) !== legoFirmwareCmd.initLoader) { + throw Error('expecting init loader command'); + } + return { result: msg.getUint8(1) }; +} + +function parseGetInfoReply( + msg: DataView, +): { version: number; startAddress: number; endAddress: number; typeId: legoHubType } { + assertError(msg); + if (msg.getUint8(0) !== legoFirmwareCmd.getInfo) { + throw Error('expecting get info command'); + } + return { + version: msg.getUint32(1, true), + startAddress: msg.getUint32(5, true), + endAddress: msg.getUint32(9, true), + typeId: msg.getUint8(13), + }; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function parseGetChecksumReply(msg: DataView): { checksum: number } { + assertError(msg); + if (msg.getUint8(0) !== legoFirmwareCmd.getChecksum) { + throw Error('expecting get checksum command'); + } + return { checksum: msg.getUint8(1) }; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function parseGetFlashStateReply(msg: DataView): { level: number } { + assertError(msg); + if (msg.getUint8(0) !== legoFirmwareCmd.getFlashState) { + throw Error('expecting get flash state command'); + } + return { level: msg.getUint8(1) }; +} + +function getResponse( + char: BluetoothRemoteGATTCharacteristic, + decode: (msg: DataView) => T, + timeout = 100, +): Promise { + let handler: EventListener; + return new Promise((resolve, reject) => { + handler = (): void => { + if (char.value === undefined) { + reject('unexpected undefined value'); + } else { + resolve(decode(char.value)); + } + }; + char.addEventListener('characteristicvaluechanged', handler); + setTimeout(() => reject('timed out'), timeout); + }).finally(() => { + char.removeEventListener('characteristicvaluechanged', handler); + }); +} + +async function sendRequest( + char: BluetoothRemoteGATTCharacteristic, + msg: BufferSource, +): Promise { + return char.writeValue(msg); +} + +class Flash extends React.Component { + constructor(props: {}) { + super(props); + this.onDropAccepted = this.onDropAccepted.bind(this); + this.onDropRejected = this.onDropRejected.bind(this); + } + + private async flash(data: ArrayBuffer): Promise { + if (navigator.bluetooth === undefined) { + throw Error('No web bluetooth'); + } + const device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [legoFirmwareServiceUUID] }], + }); + if (device.gatt === undefined) { + throw Error('Device does not support GATT'); + } + device.addEventListener('gattserverdisconnected', () => { + // this.setState({ connection: connectionState.disconnected }); + }); + const server = await device.gatt.connect(); + const service = await server.getPrimaryService(legoFirmwareServiceUUID); + const char = await service.getCharacteristic(legoFirmwareCharUUID); + char.addEventListener('characteristicvaluechanged', () => { + if (!char.value) { + return; + } + // this.props.onData(new Uint8Array(char.value.buffer)); + }); + await char.startNotifications(); + + console.log('Getting info'); + await sendRequest(char, createGetInfoRequest()); + const info = await getResponse(char, parseGetInfoReply); + console.log( + `version: ${info.version.toString( + 16, + )} startAddress: ${info.startAddress.toString( + 16, + )} endAddress: ${info.endAddress.toString( + 16, + )} typeId: ${info.typeId.toString(16)}`, + ); + // TODO: verify typeId === firmware.typeId + + console.log('Erasing flash'); + await sendRequest(char, createEraseFlashRequest()); + const eraseResult = await getResponse(char, parseEraseFlashReply, 5000); + if (eraseResult.result) { + throw Error('Failed to erase'); + } + + console.log('Initializing'); + await sendRequest(char, createInitLoaderRequest(data.byteLength)); + const initResult = await getResponse(char, parseInitLoaderReply); + if (initResult.result) { + throw Error('Failed to init'); + } + + // TODO: we can receive an async error message during this loop + // in which case it would be better to abort early rather than waiting + // for all messages to be sent + for (let offset = 0; offset < data.byteLength; offset += 14) { + console.log(`sending ${offset / 14 + 1} of ${data.byteLength / 14}`); + const payload = data.slice(offset, offset + 14); + await sendRequest( + char, + createProgramFlashRequest(info.startAddress + offset, payload), + ); + + // unfortunately there is not a way to get backpressure from BLE + // so we just have to add a delay to avoid buffer overrun on the + // remote device and it has to be slow enough to work in the worst + // conditions + await sleep(10); + } + + console.log('waiting for confirmation'); + const flashResult = await getResponse(char, parseProgramFlashReply, 5000); + if (flashResult.count !== data.byteLength) { + throw Error("Didn't flash all bytes"); + } + + // this will cause the remote device to disconnect and reboot + console.log('restarting'); + await sendRequest(char, createStartAppRequest()); + } + + private onDropAccepted(acceptedFiles: File[]): void { + // should only be one file since multiple={false} + acceptedFiles.forEach((f) => { + const reader = new FileReader(); + + reader.onabort = (): void => console.log('file reading was aborted'); + reader.onerror = (): void => console.log('file reading has failed'); + reader.onload = (): void => { + // Do whatever you want with the file contents + const binaryStr = reader.result; + if (binaryStr === null) { + throw Error('Unexpected null binaryStr'); + } + if (typeof binaryStr === 'string') { + throw Error('Unexpected string binaryStr'); + } + this.flash(binaryStr); + }; + reader.readAsArrayBuffer(f); + }); + } + + private onDropRejected(rejectedFiles: File[]): void { + // should only be one file since multiple={false} + rejectedFiles.forEach((f) => { + alert(`bad file ${f.name}`); + }); + } + + render(): JSX.Element { + return ( + + {({ getRootProps, getInputProps }): JSX.Element => ( + + + + + Drag and drop a firmware file here, or click to select a + file + + + + )} + + ); + } +} + +export { Flash }; diff --git a/yarn.lock b/yarn.lock index cb40ece6..4f48e41f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2800,6 +2800,11 @@ atob@^2.1.1: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +attr-accept@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.1.0.tgz#a231a854385d36ff7a99647bb77b33c8a5175aee" + integrity sha512-sLzVM3zCCmmDtDNhI0i96k6PUztkotSOXqE4kDGQt/6iDi5M+H0srjeF+QC6jN581l4X/Zq3Zu/tgcErEssavg== + autoprefixer@^9.6.1: version "9.7.3" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.7.3.tgz#fd42ed03f53de9beb4ca0d61fb4f7268a9bb50b4" @@ -5226,6 +5231,13 @@ file-loader@4.3.0: loader-utils "^1.2.3" schema-utils "^2.5.0" +file-selector@^0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.1.12.tgz#fe726547be219a787a9dcc640575a04a032b1fd0" + integrity sha512-Kx7RTzxyQipHuiqyZGf+Nz4vY9R1XGxuQl/hLoJwq+J4avk/9wxxgZyHKtbyIPJmbD4A66DWGYfyykWNpcYutQ== + dependencies: + tslib "^1.9.0" + filesize@6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.0.1.tgz#f850b509909c7c86f7e450ea19006c31c2ed3d2f" @@ -9617,6 +9629,15 @@ react-dom@^16.13.1: prop-types "^15.6.2" scheduler "^0.19.1" +react-dropzone@^10.2.2: + version "10.2.2" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-10.2.2.tgz#67b4db7459589a42c3b891a82eaf9ade7650b815" + integrity sha512-U5EKckXVt6IrEyhMMsgmHQiWTGLudhajPPG77KFSvgsMqNEHSyGpqWvOMc5+DhEah/vH4E1n+J5weBNLd5VtyA== + dependencies: + attr-accept "^2.0.0" + file-selector "^0.1.12" + prop-types "^15.7.2" + react-error-overlay@^6.0.7: version "6.0.7" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108"
+ Drag and drop a firmware file here, or click to select a + file +