diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..d9e46586 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}", + "linux": { + "runtimeExecutable": "/usr/bin/chromium-browser" + } + } + ] +} diff --git a/package.json b/package.json index eaaf7a83..69ae08f7 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "redux": "^4.0.5", "redux-logger": "^3.0.6", "redux-observable": "^1.2.0", + "redux-saga": "^1.1.3", "redux-thunk": "^2.3.0", "typescript": "~3.7.2", "xterm": "^4.5.0" diff --git a/src/actions/bootloader.ts b/src/actions/bootloader.ts new file mode 100644 index 00000000..b2f3d94d --- /dev/null +++ b/src/actions/bootloader.ts @@ -0,0 +1,307 @@ +import { Action } from 'redux'; +import { HubType, Result, ProtectionLevel, Command } from '../protocols/bootloader'; + +/** + * Bootloader BLE connection actions. + */ +export enum BootloaderConnectionActionType { + /** + * Initiate a connection. + */ + Connect = 'bootloader.action.connection.connect', + /** + * The connection has been made. + */ + DidConnect = 'bootloader.action.connection.did.connect', + /** + * The connection was cancelled. + */ + DidCancel = 'bootloader.action.connection.did.cancel', + /** + * There was a connection error. + */ + DidError = 'bootloader.action.connection.did.error', + /** + * Send a message using the connection. + */ + Send = 'bootloader.action.connection.send', + /** + * Finished sending a message. + */ + DidSend = 'bootloader.action.connection.did.send', + /** + * The connection received a message. + */ + DidReceive = 'bootloader.action.connection.did.receive', + /** + * The connection has been closed. + */ + DidDisconnect = 'bootloader.action.connection.did.disconnect', +} + +export type BootloaderConnectionConnectAction = Action< + BootloaderConnectionActionType.Connect +>; + +export function connect(): BootloaderConnectionConnectAction { + return { type: BootloaderConnectionActionType.Connect }; +} + +export type BootloaderConnectionDidConnectAction = Action< + BootloaderConnectionActionType.DidConnect +>; + +export function didConnect(): BootloaderConnectionDidConnectAction { + return { type: BootloaderConnectionActionType.DidConnect }; +} + +export type BootloaderConnectionDidCancelAction = Action< + BootloaderConnectionActionType.DidCancel +>; + +export function didCancel(): BootloaderConnectionDidCancelAction { + return { type: BootloaderConnectionActionType.DidCancel }; +} + +export interface BootloaderConnectionDidErrorAction + extends Action { + err: Error; +} + +export function didError(err: Error): BootloaderConnectionDidErrorAction { + return { type: BootloaderConnectionActionType.DidError, err }; +} + +export interface BootloaderConnectionSendAction + extends Action { + readonly data: ArrayBuffer; +} + +export function send(data: ArrayBuffer): BootloaderConnectionSendAction { + return { type: BootloaderConnectionActionType.Send, data }; +} + +export interface BootloaderConnectionDidSendAction + extends Action { + err?: Error; +} + +export function didSend(err?: Error): BootloaderConnectionDidSendAction { + return { type: BootloaderConnectionActionType.DidSend, err }; +} + +export interface BootloaderConnectionDidReceiveAction + extends Action { + data: DataView; +} + +export function didReceive(data: DataView): BootloaderConnectionDidReceiveAction { + return { type: BootloaderConnectionActionType.DidReceive, data }; +} + +export type BootloaderConnectionDidDisconnectAction = Action< + BootloaderConnectionActionType.DidDisconnect +>; + +export function didDisconnect(): BootloaderConnectionDidDisconnectAction { + return { type: BootloaderConnectionActionType.DidDisconnect }; +} + +/** + * Bootloader request actions for sending commands over the connection. + */ +export enum BootloaderRequestActionType { + Erase = 'bootloader.action.request.erase', + Program = 'bootloader.action.request.program', + Reboot = 'bootloader.action.request.reboot', + Init = 'bootloader.action.request.init', + Info = 'bootloader.action.request.info', + Checksum = 'bootloader.action.request.checksum', + State = 'bootloader.action.request.state', + Disconnect = 'bootloader.action.request.disconnect', +} + +export type BootloaderEraseRequestAction = Action; + +export function eraseRequest(): BootloaderEraseRequestAction { + return { type: BootloaderRequestActionType.Erase }; +} + +export interface BootloaderProgramRequestAction + extends Action { + address: number; + payload: ArrayBuffer; +} + +export function programRequest( + address: number, + payload: ArrayBuffer, +): BootloaderProgramRequestAction { + return { type: BootloaderRequestActionType.Program, address, payload }; +} + +export type BootloaderRebootRequestAction = Action; + +export function rebootRequest(): BootloaderRebootRequestAction { + return { type: BootloaderRequestActionType.Reboot }; +} + +export interface BootloaderInitRequestAction + extends Action { + firmwareSize: number; +} + +export function initRequest(firmwareSize: number): BootloaderInitRequestAction { + return { type: BootloaderRequestActionType.Init, firmwareSize }; +} + +export type BootloaderInfoRequestAction = Action; + +export function infoRequest(): BootloaderInfoRequestAction { + return { type: BootloaderRequestActionType.Info }; +} + +export type BootloaderChecksumRequestAction = Action< + BootloaderRequestActionType.Checksum +>; + +export function checksumRequest(): BootloaderChecksumRequestAction { + return { type: BootloaderRequestActionType.Checksum }; +} + +export type BootloaderStateRequestAction = Action; + +export function stateRequest(): BootloaderStateRequestAction { + return { type: BootloaderRequestActionType.State }; +} + +export type BootloaderDisconnectRequestAction = Action< + BootloaderRequestActionType.Disconnect +>; + +export function disconnectRequest(): BootloaderDisconnectRequestAction { + return { type: BootloaderRequestActionType.Disconnect }; +} + +export type BootloaderRequestAction = + | BootloaderEraseRequestAction + | BootloaderProgramRequestAction + | BootloaderRebootRequestAction + | BootloaderInitRequestAction + | BootloaderInfoRequestAction + | BootloaderChecksumRequestAction + | BootloaderStateRequestAction + | BootloaderDisconnectRequestAction; + +/** + * Bootloader response actions for receiving responses from the connection. + */ +export enum BootloaderResponseActionType { + Erase = 'bootloader.action.response.erase', + Program = 'bootloader.action.response.program', + Init = 'bootloader.action.response.init', + Info = 'bootloader.action.response.info', + Checksum = 'bootloader.action.response.checksum', + State = 'bootloader.action.response.state', + Error = 'bootloader.action.response.error', +} + +export interface BootloaderEraseResponseAction + extends Action { + result: Result; +} + +export function eraseResponse(result: Result): BootloaderEraseResponseAction { + return { type: BootloaderResponseActionType.Erase, result }; +} + +export interface BootloaderProgramResponseAction + extends Action { + checksum: number; + count: number; +} + +export function programResponse( + checksum: number, + count: number, +): BootloaderProgramResponseAction { + return { type: BootloaderResponseActionType.Program, checksum, count }; +} + +export interface BootloaderInitResponseAction + extends Action { + result: Result; +} + +export function initResponse(result: Result): BootloaderInitResponseAction { + return { type: BootloaderResponseActionType.Init, result }; +} + +export interface BootloaderInfoResponseAction + extends Action { + version: number; + startAddress: number; + endAddress: number; + hubType: HubType; +} + +export function infoResponse( + version: number, + startAddress: number, + endAddress: number, + hubType: HubType, +): BootloaderInfoResponseAction { + return { + type: BootloaderResponseActionType.Info, + version, + startAddress, + endAddress, + hubType, + }; +} + +export interface BootloaderChecksumResponseAction + extends Action { + checksum: number; +} + +export function checksumResponse(checksum: number): BootloaderChecksumResponseAction { + return { type: BootloaderResponseActionType.Checksum, checksum }; +} + +export interface BootloaderStateResponseAction + extends Action { + level: ProtectionLevel; +} + +export function stateResponse(level: ProtectionLevel): BootloaderStateResponseAction { + return { type: BootloaderResponseActionType.State, level }; +} + +export interface BootloaderErrorResponseAction + extends Action { + command: Command; +} + +export function errorResponse(command: Command): BootloaderErrorResponseAction { + return { type: BootloaderResponseActionType.Error, command }; +} + +/** + * High-level bootloader actions. + */ +export enum BootloaderActionType { + /** + * Flash new firmware to the device. + */ + FlashFirmware = 'bootloader.action.flash', +} + +export interface BootloaderFlashFirmwareAction + extends Action { + data: ArrayBuffer; +} + +export function flashFirmware(data: ArrayBuffer): BootloaderFlashFirmwareAction { + return { type: BootloaderActionType.FlashFirmware, data }; +} diff --git a/src/components/BluetoothButton.tsx b/src/components/BluetoothButton.tsx index 63f8a7da..68cfd8df 100644 --- a/src/components/BluetoothButton.tsx +++ b/src/components/BluetoothButton.tsx @@ -4,6 +4,7 @@ import { AnyAction } from 'redux'; import { connect as bleConnect, disconnect as bleDisconnect } from '../actions/ble'; import { RootState } from '../reducers'; import { BLEConnectionState } from '../reducers/ble'; +import { BootloaderConnectionState } from '../reducers/bootloader'; import ActionButton, { ActionButtonProps } from './ActionButton'; type Dispatch = ThunkDispatch<{}, {}, AnyAction>; @@ -13,7 +14,10 @@ type StateProps = Pick; type DispatchProps = Pick; const mapStateToProps = (state: RootState): StateProps => { - if (state.ble.connection === BLEConnectionState.Disconnected) { + if ( + state.ble.connection === BLEConnectionState.Disconnected && + state.bootloader.connection === BootloaderConnectionState.Disconnected + ) { return { tooltip: 'Connect using Bluetooth', icon: 'btdisconnected.svg', diff --git a/src/components/FlashButton.tsx b/src/components/FlashButton.tsx new file mode 100644 index 00000000..dfd8867d --- /dev/null +++ b/src/components/FlashButton.tsx @@ -0,0 +1,38 @@ +import { connect } from 'react-redux'; +import { ThunkDispatch } from 'redux-thunk'; +import { AnyAction } from 'redux'; +import { RootState } from '../reducers'; +import { flashFirmware } from '../actions/bootloader'; +import { BootloaderConnectionState } from '../reducers/bootloader'; +import OpenFileButton, { OpenFileButtonProps } from './OpenFileButton'; + +type Dispatch = ThunkDispatch<{}, {}, AnyAction>; + +type StateProps = Pick; +type DispatchProps = Pick; +type OwnProps = Pick; + +const mapStateToProps = (state: RootState): StateProps => ({ + enabled: state.bootloader.connection === BootloaderConnectionState.Disconnected, +}); + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onFile: (data): void => { + dispatch(flashFirmware(data)); + }, +}); + +const mergeProps = ( + stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: OwnProps, +): OpenFileButtonProps => ({ + fileExtension: '.bin', + tooltip: 'Flash hub firmware', + icon: 'firmware.svg', + ...ownProps, + ...stateProps, + ...dispatchProps, +}); + +export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(OpenFileButton); diff --git a/src/components/OpenFileButton.tsx b/src/components/OpenFileButton.tsx new file mode 100644 index 00000000..318872c3 --- /dev/null +++ b/src/components/OpenFileButton.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import Dropzone from 'react-dropzone'; +import Button from 'react-bootstrap/Button'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Tooltip from 'react-bootstrap/Tooltip'; +import Image from 'react-bootstrap/Image'; + +export interface OpenFileButtonProps { + /** A unique id for each instance. */ + readonly id: string; + /** The accepted file extension */ + readonly fileExtension: string; + /** Tooltip text that appears when hovering over the button. */ + readonly tooltip: string; + /** Icon shown on the button. */ + readonly icon: string; + /** When true or undefined, the button is enabled. */ + readonly enabled?: boolean; + /** Callback that is called when the button is activated (clicked). */ + readonly onFile: (data: ArrayBuffer) => void; +} + +/** + * Button that opens a file chooser dialog or accepts files dropped on it. + */ +class OpenFileButton extends React.Component { + constructor(props: OpenFileButtonProps) { + super(props); + this.onDropAccepted = this.onDropAccepted.bind(this); + this.onDropRejected = this.onDropRejected.bind(this); + } + + private onDropAccepted(acceptedFiles: File[]): void { + // should only be one file since multiple={false} + acceptedFiles.forEach((f) => { + const reader = new FileReader(); + + reader.onabort = (): void => console.error('file reading was aborted'); + reader.onerror = (): void => console.error('file reading has failed'); + reader.onload = (): void => { + const binaryStr = reader.result; + if (binaryStr === null) { + throw Error('Unexpected null binaryStr'); + } + if (typeof binaryStr === 'string') { + throw Error('Unexpected string binaryStr'); + } + this.props.onFile(binaryStr); + }; + reader.readAsArrayBuffer(f); + }); + } + + private onDropRejected(rejectedFiles: File[]): void { + // should only be one file since multiple={false} + rejectedFiles.forEach((f) => { + // TODO: proper bootstrap toast + alert(`bad file ${f.name}`); + }); + } + + render(): JSX.Element { + return ( + + {({ getRootProps, getInputProps }): JSX.Element => ( + + {this.props.tooltip}. + + } + > + + + )} + + ); + } +} + +export default OpenFileButton; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 53f7b60c..73991519 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -3,32 +3,13 @@ import Col from 'react-bootstrap/Col'; import ButtonGroup from 'react-bootstrap/ButtonGroup'; import ButtonToolbar from 'react-bootstrap/ButtonToolbar'; import React from 'react'; -import { BLEConnectionState } from '../reducers/ble'; -import ActionButton from './ActionButton'; import BluetoothButton from './BluetoothButton'; import RunButton from './RunButton'; import StopButton from './StopButton'; import ReplButton from './ReplButton'; +import FlashButton from './FlashButton'; -interface ToolbarState { - bleState: BLEConnectionState; -} - -class Toolbar extends React.Component<{}, ToolbarState> { - constructor(props: {}) { - super(props); - this.state = { bleState: BLEConnectionState.Disconnected }; - this.onAction = this.onAction.bind(this); - } - - private onAction(action?: string): void { - console.log(action); - } - - setBLEState(state: BLEConnectionState): void { - this.setState({ bleState: state }); - } - +class Toolbar extends React.Component { render(): JSX.Element { return ( @@ -41,12 +22,7 @@ class Toolbar extends React.Component<{}, ToolbarState> { - + diff --git a/src/index.tsx b/src/index.tsx index 3f8910d9..643f193c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,20 +5,31 @@ import { createStore, applyMiddleware } from 'redux'; import React from 'react'; import { Provider } from 'react-redux'; import ReactDOM from 'react-dom'; +import createSagaMiddleware from 'redux-saga'; +import rootSaga from './sagas'; import rootEpic from './epics'; import rootReducer from './reducers'; import './index.scss'; import App from './components/App'; import * as serviceWorker from './serviceWorker'; +import serviceMiddleware from './services'; +const sagaMiddleware = createSagaMiddleware(); const epicMiddleware = createEpicMiddleware(); const loggerMiddleware = createLogger(); const store = createStore( rootReducer, - applyMiddleware(thunkMiddleware, epicMiddleware, loggerMiddleware), + applyMiddleware( + thunkMiddleware, + sagaMiddleware, + epicMiddleware, + serviceMiddleware, + loggerMiddleware, + ), ); +sagaMiddleware.run(rootSaga); epicMiddleware.run(rootEpic); ReactDOM.render( diff --git a/src/protocols/bootloader.ts b/src/protocols/bootloader.ts new file mode 100644 index 00000000..1ed22098 --- /dev/null +++ b/src/protocols/bootloader.ts @@ -0,0 +1,285 @@ +// Ref: https://lego.github.io/lego-ble-wireless-protocol-docs/index.html#lego-hub-boot-loader-service + +/** + * LEGO Powered Up Bootloader Service UUID. + */ +export const ServiceUUID = '00001625-1212-efde-1623-785feabcd123'; + +/** + * LEGO Powered Up Bootloader Characteristic UUID. + */ +export const CharacteristicUUID = '00001626-1212-efde-1623-785feabcd123'; + +/** + * The maximum message size that can be sent or received. + */ +export const MaxMessageSize = 20; + +/** + * LEGO Powered Up Hub IDs + */ +export enum HubType { + MoveHub = 0x40, + CityHub = 0x41, + CPlusHub = 0x80, +} + +/** + * LEGO bootloader command bytecodes. + */ +export enum Command { + EraseFlash = 0x11, + ProgramFlash = 0x22, + StartApp = 0x33, + InitLoader = 0x44, + GetInfo = 0x55, + GetChecksum = 0x66, + GetFlashState = 0x77, + Disconnect = 0x88, +} + +/** + * Error message bytecode. + */ +export type ErrorMessage = 0x05; +export const ErrorBytecode: ErrorMessage = 0x05; + +enum ErrorCode { + UnknownCommand = 0x05, +} + +/** + * Result status. + */ +export enum Result { + OK = 0x00, + Error = 0xff, +} + +/** + * The largest allowable size for the payload of the ProgramFlash command. + */ +export const MaxProgramFlashSize = 14; + +/** + * Flash memory protection level. + * + * Refer to STM32 technical reference. + */ +export enum ProtectionLevel { + None = 0x00, + Level1 = 0x01, + Level2 = 0x02, +} + +/** + * Creates a new message to erase the flash memory. + */ +export function createEraseFlashRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, Command.EraseFlash); + return msg; +} + +/** + * Creates a new message to program the flash memory. + * @param address The starting address. + * @param payload The data (14 bytes max) + */ +export function createProgramFlashRequest( + address: number, + payload: ArrayBuffer, +): Uint8Array { + const size = payload.byteLength; + if (size > MaxProgramFlashSize) { + throw Error('payload is bigger than MaxProgramFlashSize'); + } + const msg = new Uint8Array(size + 6); + const view = new DataView(msg.buffer); + view.setUint8(0, Command.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; +} + +/** + * Creates a new message to reboot an start the new firmware. + */ +export function createStartAppRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, Command.StartApp); + return msg; +} + +/** + * Creates a new message to prepare the bootloader to receive a new firmware. + * @param fwSize The total size of the firmware to be flashed. + */ +export function createInitLoaderRequest(fwSize: number): Uint8Array { + const msg = new Uint8Array(5); + const view = new DataView(msg.buffer); + view.setUint8(0, Command.InitLoader); + view.setUint32(1, fwSize, true); + return msg; +} + +/** + * Creates a new message to get bootloader and device info. + */ +export function createGetInfoRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, Command.GetInfo); + return msg; +} + +/** + * Creates a new message to get the current checksum. + */ +export function createGetChecksumRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, Command.GetChecksum); + return msg; +} + +/** + * Creates a new message to get the flash memory protection state. + * + * This command is not implemented on some devices. + */ +export function createGetFlashStateRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, Command.GetFlashState); + return msg; +} + +/** + * Creates a new message to disconnect the connection. + */ +export function createDisconnectRequest(): Uint8Array { + const msg = new Uint8Array(1); + const view = new DataView(msg.buffer); + view.setUint8(0, Command.Disconnect); + return msg; +} + +/** + * Gets the type of message. + * @param msg The raw message data. + */ +export function getMessageType(msg: DataView): Command | ErrorMessage { + // Technically, the first byte of an error message is the length, but it + // is always 0x05 which is the same as the error message bytecode. + return msg.getUint8(0); +} + +/** + * Parses an error message. + * @param msg The raw message data. + */ +export function parseErrorResponse(msg: DataView): Command { + // Error responses are ordered differently compared to command responses. + 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'); + } + const command = msg.getUint8(3); + return command; +} + +/** + * Parses an erase flash response message. + * @param msg The raw message data. + * @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'); + } + const result = msg.getUint8(1); + return result; +} + +/** + * Parses a program flash response message. + * @param msg The raw message data. + * @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'); + } + const checksum = msg.getUint8(1); + const count = msg.getUint32(2, true); + return [checksum, count]; +} + +/** + * Parses an initialization response message. + * @param msg The raw message data. + * @returns The result of the initialization. + */ +export function parseInitLoaderResponse(msg: DataView): Result { + if (msg.getUint8(0) !== Command.InitLoader) { + throw Error('expecting init loader command'); + } + const result = msg.getUint8(1); + return result; +} + +/** + * Parses an information response message. + * @param msg The raw message data. + * @returns The bootloader software version, the starting and ending addresses + * 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'); + } + const version = msg.getUint32(1, true); + const startAddress = msg.getUint32(5, true); + const endAddress = msg.getUint32(9, true); + const hubType = msg.getUint8(13); + return [version, startAddress, endAddress, hubType]; +} + +/** + * Parses a checksum response message. + * @param msg The raw message data. + * @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'); + } + const checksum = msg.getUint8(1); + return checksum; +} + +/** + * Parses a flash protection state response message. + * @param msg The raw message data. + * @returns The protection level + */ +export function parseGetFlashStateResponse(msg: DataView): ProtectionLevel { + if (msg.getUint8(0) !== Command.GetFlashState) { + throw Error('expecting get flash state command'); + } + const level = msg.getUint8(1); + return level; +} diff --git a/src/reducers/bootloader.ts b/src/reducers/bootloader.ts new file mode 100644 index 00000000..981192b8 --- /dev/null +++ b/src/reducers/bootloader.ts @@ -0,0 +1,171 @@ +import { Reducer, combineReducers } from 'redux'; +import { + BootloaderRequestActionType, + BootloaderResponseActionType, + BootloaderConnectionActionType, +} from '../actions/bootloader'; + +/** + * Describes the state of the bootloader connection. + */ +export enum BootloaderConnectionState { + /** + * No device is connected. + */ + Disconnected = 'bootloader.connection.disconnected', + /** + * Connecting to a device. + */ + Connecting = 'bootloader.connection.connecting', + /** + * Connected to a device. + */ + Connected = 'bootloader.connection.connected', + /** + * Disconnecting from a device. + */ + Disconnecting = 'bootloader.connection.disconnecting', +} + +const connection: Reducer = ( + state = BootloaderConnectionState.Disconnected, + action, +) => { + switch (action.type) { + case BootloaderConnectionActionType.Connect: + return BootloaderConnectionState.Connecting; + case BootloaderConnectionActionType.DidConnect: + return BootloaderConnectionState.Connected; + case BootloaderRequestActionType.Reboot: + case BootloaderRequestActionType.Disconnect: + return BootloaderConnectionState.Disconnecting; + case BootloaderConnectionActionType.DidDisconnect: + case BootloaderConnectionActionType.DidCancel: + return BootloaderConnectionState.Disconnected; + case BootloaderConnectionActionType.DidError: + // Error while connecting means we didn't connect. + if (state === BootloaderConnectionState.Connecting) { + return BootloaderConnectionState.Disconnected; + } else { + return state; + } + default: + return state; + } +}; + +export enum FirmwareFlashState { + /** + * Erase command has been sent. + */ + BeginErase = 'bootloader.flash.erase.begin', + /** + * Erase result has been received. + */ + EndErase = 'bootloader.flash.erase.end', + /** + * Program command has been sent. + */ + BeginProgram = 'bootloader.flash.program.begin', + /** + * Program result has been received. + */ + EndProgram = 'bootloader.flash.program.end', + /** + * Reboot command has been sent. (no matching end state - becomes disconnected) + */ + BeginReboot = 'bootloader.flash.reboot.begin', + /** + * Init command has been sent. + */ + BeginInit = 'bootloader.flash.init.begin', + /** + * Init result has been received. + */ + EndInit = 'bootloader.flash.init.end', + /** + * Info command has been sent. + */ + BeginInfo = 'bootloader.flash.info.begin', + /** + * Info result has been received. + */ + EndInfo = 'bootloader.flash.info.end', + /** + * Checksum command has been sent. + */ + BeginChecksum = 'bootloader.flash.checksum.begin', + /** + * Checksum result has been received. + */ + EndChecksum = 'bootloader.flash.checksum.end', + /** + * State command has been sent. + */ + BeginState = 'bootloader.flash.state.begin', + /** + * State result has been received. + */ + EndState = 'bootloader.flash.state.end', + /** + * Disconnect command has been sent. (no reply is received - becomes disconnected) + */ + BeginDisconnect = 'bootloader.flash.disconnect.begin', + /** + * Bootloader is not connected. + */ + EndDisconnect = 'bootloader.flash.disconnect.end', + /** + * An error was received. + */ + Error = 'bootloader.flash.error', +} + +const flash: Reducer = ( + state = FirmwareFlashState.EndDisconnect, + action, +) => { + switch (action.type) { + case BootloaderRequestActionType.Erase: + return FirmwareFlashState.BeginErase; + case BootloaderResponseActionType.Erase: + return FirmwareFlashState.EndErase; + case BootloaderRequestActionType.Program: + return FirmwareFlashState.BeginProgram; + case BootloaderResponseActionType.Program: + return FirmwareFlashState.EndProgram; + case BootloaderRequestActionType.Reboot: + return FirmwareFlashState.BeginReboot; + case BootloaderRequestActionType.Init: + return FirmwareFlashState.BeginInit; + case BootloaderResponseActionType.Init: + return FirmwareFlashState.EndInit; + case BootloaderRequestActionType.Info: + return FirmwareFlashState.BeginInfo; + case BootloaderResponseActionType.Info: + return FirmwareFlashState.EndInfo; + case BootloaderRequestActionType.Checksum: + return FirmwareFlashState.BeginChecksum; + case BootloaderResponseActionType.Checksum: + return FirmwareFlashState.EndChecksum; + case BootloaderRequestActionType.State: + return FirmwareFlashState.BeginState; + case BootloaderResponseActionType.State: + return FirmwareFlashState.EndState; + case BootloaderRequestActionType.Disconnect: + return FirmwareFlashState.BeginDisconnect; + case BootloaderConnectionActionType.DidDisconnect: + return FirmwareFlashState.EndDisconnect; + case BootloaderResponseActionType.Error: + return FirmwareFlashState.Error; + default: + return state; + } +}; + +export interface BootloaderState { + readonly connection: BootloaderConnectionState; + readonly flash: FirmwareFlashState; +} + +export default combineReducers({ connection, flash }); diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 6d0ed698..c99f554e 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,4 +1,5 @@ import { combineReducers } from 'redux'; +import bootloader, { BootloaderState } from './bootloader'; import ble, { BLEState } from './ble'; import editor, { EditorState } from './editor'; import hub, { HubState } from './hub'; @@ -7,9 +8,10 @@ import hub, { HubState } from './hub'; * Root state for redux store. */ export interface RootState { + readonly bootloader: BootloaderState; readonly ble: BLEState; readonly editor: EditorState; readonly hub: HubState; } -export default combineReducers({ ble, editor, hub }); +export default combineReducers({ bootloader, ble, editor, hub }); diff --git a/src/sagas/bootloader.ts b/src/sagas/bootloader.ts new file mode 100644 index 00000000..7cd62cba --- /dev/null +++ b/src/sagas/bootloader.ts @@ -0,0 +1,269 @@ +import { Channel, buffers } from 'redux-saga'; +import { + Effect, + race, + put, + take, + takeEvery, + delay, + fork, + actionChannel, +} from 'redux-saga/effects'; +import { Action } from 'redux'; +import { + BootloaderActionType, + infoRequest, + BootloaderResponseActionType, + BootloaderInfoResponseAction, + BootloaderEraseResponseAction, + BootloaderFlashFirmwareAction, + BootloaderInitResponseAction, + eraseRequest, + initRequest, + programRequest, + BootloaderProgramResponseAction, + rebootRequest, + BootloaderErrorResponseAction, + BootloaderRequestActionType, + BootloaderRequestAction, + eraseResponse, + programResponse, + initResponse, + infoResponse, + checksumResponse, + stateResponse, + BootloaderConnectionActionType, + connect, + BootloaderConnectionDidErrorAction, + BootloaderConnectionDidConnectAction, + BootloaderConnectionDidCancelAction, + send, + BootloaderConnectionDidReceiveAction, + BootloaderChecksumResponseAction, + checksumRequest, +} from '../actions/bootloader'; +import { + createEraseFlashRequest, + createProgramFlashRequest, + createStartAppRequest, + createInitLoaderRequest, + createGetInfoRequest, + createGetChecksumRequest, + createGetFlashStateRequest, + createDisconnectRequest, + getMessageType, + parseEraseFlashResponse, + Command, + parseProgramFlashResponse, + parseInitLoaderResponse, + parseGetInfoResponse, + parseGetChecksumResponse, + parseGetFlashStateResponse, + ErrorBytecode, + MaxProgramFlashSize, +} from '../protocols/bootloader'; + +/** + * Converts a request action into bytecodes and creates a new action to send + * the bytecodes to to the device. + * @param action The request action that was observed. + */ +function* encodeRequest(): Generator { + // Using a while loop to serialize sending data to avoid "busy" errors. + + const chan = (yield actionChannel( + (a: Action) => Object.values(BootloaderRequestActionType).includes(a.type), + buffers.expanding(), + )) as Channel; + while (true) { + const action = (yield take(chan)) as BootloaderRequestAction; + + switch (action.type) { + case BootloaderRequestActionType.Erase: + yield put(send(createEraseFlashRequest())); + break; + case BootloaderRequestActionType.Program: + yield put( + send(createProgramFlashRequest(action.address, action.payload)), + ); + break; + case BootloaderRequestActionType.Reboot: + yield put(send(createStartAppRequest())); + break; + case BootloaderRequestActionType.Init: + yield put(send(createInitLoaderRequest(action.firmwareSize))); + break; + case BootloaderRequestActionType.Info: + yield put(send(createGetInfoRequest())); + break; + case BootloaderRequestActionType.Checksum: + yield put(send(createGetChecksumRequest())); + break; + case BootloaderRequestActionType.State: + yield put(send(createGetFlashStateRequest())); + break; + case BootloaderRequestActionType.Disconnect: + yield put(send(createDisconnectRequest())); + break; + default: + console.error(`Unknown bootloader request action ${action}`); + break; + } + + yield take(BootloaderConnectionActionType.DidSend); + } +} + +/** + * Converts an incoming connection message to a response action. + * @param action The received response action. + */ +function* decodeResponse(action: BootloaderConnectionDidReceiveAction): Generator { + const responseType = getMessageType(action.data); + switch (responseType) { + case Command.EraseFlash: + yield put(eraseResponse(parseEraseFlashResponse(action.data))); + break; + case Command.ProgramFlash: + yield put(programResponse(...parseProgramFlashResponse(action.data))); + break; + case Command.InitLoader: + yield put(initResponse(parseInitLoaderResponse(action.data))); + break; + case Command.GetInfo: + yield put(infoResponse(...parseGetInfoResponse(action.data))); + break; + case Command.GetChecksum: + yield put(checksumResponse(parseGetChecksumResponse(action.data))); + break; + case Command.GetFlashState: + yield put(stateResponse(parseGetFlashStateResponse(action.data))); + break; + case ErrorBytecode: + yield put(stateResponse(parseGetFlashStateResponse(action.data))); + break; + default: + console.error(`Unknown bootloader response action ${action}`); + } +} + +/** + * Helper type for return value of wait() function. + */ +type WaitResponse> = [ + T, + BootloaderErrorResponseAction, + boolean, +]; + +/** + * Waits for a response action, an error response or timeout, whichever comes + * first. + * @param type The action type to wait for. + * @param timeout The timeout in milliseconds. + */ +function wait(type: BootloaderResponseActionType, timeout = 500): Effect { + return race([take(type), take(BootloaderResponseActionType.Error), delay(timeout)]); +} + +/** + * Flashes firmware to a Powered Up device. + * @param action The action that triggered this saga. + */ +function* flashFirmware(action: BootloaderFlashFirmwareAction): Generator { + yield put(connect()); + const didConnect = (yield take([ + BootloaderConnectionActionType.DidConnect, + BootloaderConnectionActionType.DidCancel, + BootloaderConnectionActionType.DidError, + ])) as + | BootloaderConnectionDidConnectAction + | BootloaderConnectionDidCancelAction + | BootloaderConnectionDidErrorAction; + + if (didConnect.type === BootloaderConnectionActionType.DidCancel) { + return; + } + + if (didConnect.type === BootloaderConnectionActionType.DidError) { + // TODO: proper error handling + throw didConnect.err; + } + + yield put(infoRequest()); + const info = (yield wait(BootloaderResponseActionType.Info)) as WaitResponse< + BootloaderInfoResponseAction + >; + if (!info[0]) { + throw Error(`failed to get info: ${info}`); + } + + // TODO: verify hubType === info.response.hubType + + yield put(eraseRequest()); + const erase = (yield wait( + BootloaderResponseActionType.Erase, + 5000, + )) as WaitResponse; + if (!erase[0] || erase[0].result) { + // TODO: proper error handling + throw Error(`Failed to erase: ${erase}`); + } + + yield put(initRequest(action.data.byteLength)); + const init = (yield wait(BootloaderResponseActionType.Init)) as WaitResponse< + BootloaderInitResponseAction + >; + if (!init[0] || init[0].result) { + // TODO: proper error handling + throw Error(`Failed to init: ${init}`); + } + + 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)); + + // TODO: dispatch progress action + + // request checksum every so often to prevent buffer overrun on the hub + // because of sending too much data at once + if (++count % 10 === 0) { + yield put(checksumRequest()); + const checksum = (yield wait( + BootloaderResponseActionType.Checksum, + 5000, + )) as WaitResponse; + if (!checksum[0]) { + // TODO: proper error handling + throw Error(`Failed to get checksum: ${checksum}`); + } + } + } + + const flash = (yield wait( + BootloaderResponseActionType.Program, + 5000, + )) as WaitResponse; + if (!flash[0]) { + throw Error(`failed to get final response: ${flash}`); + } + if (flash[0].count !== action.data.byteLength) { + // TODO: proper error handling + throw Error("Didn't flash all bytes"); + } + + // this will cause the remote device to disconnect and reboot + yield put(rebootRequest()); +} + +export default function* (): Generator { + yield fork(encodeRequest); + yield takeEvery(BootloaderConnectionActionType.DidReceive, decodeResponse); + yield takeEvery(BootloaderActionType.FlashFirmware, flashFirmware); +} diff --git a/src/sagas/index.ts b/src/sagas/index.ts new file mode 100644 index 00000000..a89013e5 --- /dev/null +++ b/src/sagas/index.ts @@ -0,0 +1,6 @@ +import { all } from 'redux-saga/effects'; +import bootloader from './bootloader'; + +export default function* (): Generator { + yield all([bootloader()]); +} diff --git a/src/services/bootloader.ts b/src/services/bootloader.ts new file mode 100644 index 00000000..b6a177e6 --- /dev/null +++ b/src/services/bootloader.ts @@ -0,0 +1,91 @@ +import { Action, Dispatch } from 'redux'; +import { ServiceUUID, CharacteristicUUID } from '../protocols/bootloader'; +import { + BootloaderConnectionActionType, + didCancel, + didError, + didReceive, + didDisconnect, + didConnect, + BootloaderConnectionSendAction, + didSend, +} from '../actions/bootloader'; +import { combineServices } from '.'; + +let device: BluetoothDevice | undefined; +let char: BluetoothRemoteGATTCharacteristic | undefined; + +async function connect(action: Action, dispatch: Dispatch): Promise { + if (action.type !== BootloaderConnectionActionType.Connect) { + return; + } + + try { + if (device) { + throw Error('already connected'); + } + if (navigator.bluetooth === undefined) { + throw Error('No web bluetooth'); + } + try { + device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [ServiceUUID] }], + optionalServices: [ServiceUUID], + }); + } catch (err) { + if ( + err instanceof DOMException && + err.code === DOMException.NOT_FOUND_ERR + ) { + // this error is received if the user clicks the cancel button in + // the bluetooth scan dialog + dispatch(didCancel()); + return; + } + throw err; + } + if (device.gatt === undefined) { + throw Error('Device does not support GATT'); + } + device.addEventListener('gattserverdisconnected', () => { + device = undefined; + char = undefined; + dispatch(didDisconnect()); + }); + const server = await device.gatt.connect(); + try { + const service = await server.getPrimaryService(ServiceUUID); + char = await service.getCharacteristic(CharacteristicUUID); + char.addEventListener('characteristicvaluechanged', () => { + if (!char || !char.value) { + return; + } + dispatch(didReceive(char.value)); + }); + await char.startNotifications(); + } catch (err) { + device.gatt.disconnect(); + throw err; + } + dispatch(didConnect()); + } catch (err) { + dispatch(didError(err)); + } +} + +async function send(action: Action, dispatch: Dispatch): Promise { + if (action.type !== BootloaderConnectionActionType.Send) { + return; + } + try { + if (!char) { + throw Error('Not connected'); + } + await char.writeValue((action as BootloaderConnectionSendAction).data); + dispatch(didSend()); + } catch (err) { + dispatch(didSend(err)); + } +} + +export default combineServices(connect, send); diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 00000000..c6d28c93 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,26 @@ +import { Action, Dispatch, Middleware } from 'redux'; +import bootloader from './bootloader'; + +type Service = (action: Action, dispatch: Dispatch) => Promise; + +function runService(service: Service, action: Action, dispatch: Dispatch): void { + service(action, dispatch).catch((err) => + console.log(`Unhandled exception in service: ${err}`), + ); +} + +export function combineServices(...services: Service[]): Service { + return (a, d): Promise => { + services.forEach((s) => runService(s, a, d)); + return Promise.resolve(); + }; +} + +const rootService = combineServices(bootloader); + +const serviceMiddleware: Middleware = (store) => (next) => (action): unknown => { + runService(rootService, action, store.dispatch); + return next(action); +}; + +export default serviceMiddleware; diff --git a/yarn.lock b/yarn.lock index d971d3a7..fb907241 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1876,6 +1876,50 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.3.3.tgz#8731722aeb7330e8fd9eb5d424be6b98dea7d6da" integrity sha512-yEvVC8RfhRPkD9TUn7cFcLcgoJePgZRAOR7T21rcRY5I8tpuhzeWfGa7We7tB14fe9R7wENdqUABcMdwD4SQLw== +"@redux-saga/core@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@redux-saga/core/-/core-1.1.3.tgz#3085097b57a4ea8db5528d58673f20ce0950f6a4" + integrity sha512-8tInBftak8TPzE6X13ABmEtRJGjtK17w7VUs7qV17S8hCO5S3+aUTWZ/DBsBJPdE8Z5jOPwYALyvofgq1Ws+kg== + dependencies: + "@babel/runtime" "^7.6.3" + "@redux-saga/deferred" "^1.1.2" + "@redux-saga/delay-p" "^1.1.2" + "@redux-saga/is" "^1.1.2" + "@redux-saga/symbols" "^1.1.2" + "@redux-saga/types" "^1.1.0" + redux "^4.0.4" + typescript-tuple "^2.2.1" + +"@redux-saga/deferred@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@redux-saga/deferred/-/deferred-1.1.2.tgz#59937a0eba71fff289f1310233bc518117a71888" + integrity sha512-908rDLHFN2UUzt2jb4uOzj6afpjgJe3MjICaUNO3bvkV/kN/cNeI9PMr8BsFXB/MR8WTAZQq/PlTq8Kww3TBSQ== + +"@redux-saga/delay-p@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@redux-saga/delay-p/-/delay-p-1.1.2.tgz#8f515f4b009b05b02a37a7c3d0ca9ddc157bb355" + integrity sha512-ojc+1IoC6OP65Ts5+ZHbEYdrohmIw1j9P7HS9MOJezqMYtCDgpkoqB5enAAZrNtnbSL6gVCWPHaoaTY5KeO0/g== + dependencies: + "@redux-saga/symbols" "^1.1.2" + +"@redux-saga/is@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@redux-saga/is/-/is-1.1.2.tgz#ae6c8421f58fcba80faf7cadb7d65b303b97e58e" + integrity sha512-OLbunKVsCVNTKEf2cH4TYyNbbPgvmZ52iaxBD4I1fTif4+MTXMa4/Z07L83zW/hTCXwpSZvXogqMqLfex2Tg6w== + dependencies: + "@redux-saga/symbols" "^1.1.2" + "@redux-saga/types" "^1.1.0" + +"@redux-saga/symbols@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@redux-saga/symbols/-/symbols-1.1.2.tgz#216a672a487fc256872b8034835afc22a2d0595d" + integrity sha512-EfdGnF423glv3uMwLsGAtE6bg+R9MdqlHEzExnfagXPrIiuxwr3bdiAwz3gi+PsrQ3yBlaBpfGLtDG8rf3LgQQ== + +"@redux-saga/types@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@redux-saga/types/-/types-1.1.0.tgz#0e81ce56b4883b4b2a3001ebe1ab298b84237204" + integrity sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg== + "@restart/context@^2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@restart/context/-/context-2.1.4.tgz#a99d87c299a34c28bd85bb489cb07bfd23149c02" @@ -10279,6 +10323,13 @@ redux-observable@^1.2.0: resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.2.0.tgz#ff51b6c6be2598e9b5e89fc36639186bb0e669c7" integrity sha512-yeR90RP2WzZzCxxnQPlh2uFzyfFLsfXu8ROh53jGDPXVqj71uNDMmvi/YKQkd9ofiVoO4OYb1snbowO49tCEMg== +redux-saga@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.1.3.tgz#9f3e6aebd3c994bbc0f6901a625f9a42b51d1112" + integrity sha512-RkSn/z0mwaSa5/xH/hQLo8gNf4tlvT18qXDNvedihLcfzh+jMchDgaariQoehCpgRltEm4zHKJyINEz6aqswTw== + dependencies: + "@redux-saga/core" "^1.1.3" + redux-thunk@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" @@ -10294,7 +10345,7 @@ redux@^3.6.0: loose-envify "^1.1.0" symbol-observable "^1.0.3" -redux@^4.0.0, redux@^4.0.5: +redux@^4.0.0, redux@^4.0.4, redux@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -11889,6 +11940,25 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +typescript-compare@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/typescript-compare/-/typescript-compare-0.0.2.tgz#7ee40a400a406c2ea0a7e551efd3309021d5f425" + integrity sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA== + dependencies: + typescript-logic "^0.0.0" + +typescript-logic@^0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/typescript-logic/-/typescript-logic-0.0.0.tgz#66ebd82a2548f2b444a43667bec120b496890196" + integrity sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q== + +typescript-tuple@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/typescript-tuple/-/typescript-tuple-2.2.1.tgz#7d9813fb4b355f69ac55032e0363e8bb0f04dad2" + integrity sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q== + dependencies: + typescript-compare "^0.0.2" + typescript@~3.7.2: version "3.7.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae"