wire up flash button

This commit is contained in:
David Lechner
2020-04-18 21:58:03 -05:00
parent 629fbb928d
commit a753fd77a7
16 changed files with 1408 additions and 31 deletions
+307
View File
@@ -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<BootloaderConnectionActionType.DidError> {
err: Error;
}
export function didError(err: Error): BootloaderConnectionDidErrorAction {
return { type: BootloaderConnectionActionType.DidError, err };
}
export interface BootloaderConnectionSendAction
extends Action<BootloaderConnectionActionType.Send> {
readonly data: ArrayBuffer;
}
export function send(data: ArrayBuffer): BootloaderConnectionSendAction {
return { type: BootloaderConnectionActionType.Send, data };
}
export interface BootloaderConnectionDidSendAction
extends Action<BootloaderConnectionActionType.DidSend> {
err?: Error;
}
export function didSend(err?: Error): BootloaderConnectionDidSendAction {
return { type: BootloaderConnectionActionType.DidSend, err };
}
export interface BootloaderConnectionDidReceiveAction
extends Action<BootloaderConnectionActionType.DidReceive> {
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<BootloaderRequestActionType.Erase>;
export function eraseRequest(): BootloaderEraseRequestAction {
return { type: BootloaderRequestActionType.Erase };
}
export interface BootloaderProgramRequestAction
extends Action<BootloaderRequestActionType.Program> {
address: number;
payload: ArrayBuffer;
}
export function programRequest(
address: number,
payload: ArrayBuffer,
): BootloaderProgramRequestAction {
return { type: BootloaderRequestActionType.Program, address, payload };
}
export type BootloaderRebootRequestAction = Action<BootloaderRequestActionType.Reboot>;
export function rebootRequest(): BootloaderRebootRequestAction {
return { type: BootloaderRequestActionType.Reboot };
}
export interface BootloaderInitRequestAction
extends Action<BootloaderRequestActionType.Init> {
firmwareSize: number;
}
export function initRequest(firmwareSize: number): BootloaderInitRequestAction {
return { type: BootloaderRequestActionType.Init, firmwareSize };
}
export type BootloaderInfoRequestAction = Action<BootloaderRequestActionType.Info>;
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<BootloaderRequestActionType.State>;
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<BootloaderResponseActionType.Erase> {
result: Result;
}
export function eraseResponse(result: Result): BootloaderEraseResponseAction {
return { type: BootloaderResponseActionType.Erase, result };
}
export interface BootloaderProgramResponseAction
extends Action<BootloaderResponseActionType.Program> {
checksum: number;
count: number;
}
export function programResponse(
checksum: number,
count: number,
): BootloaderProgramResponseAction {
return { type: BootloaderResponseActionType.Program, checksum, count };
}
export interface BootloaderInitResponseAction
extends Action<BootloaderResponseActionType.Init> {
result: Result;
}
export function initResponse(result: Result): BootloaderInitResponseAction {
return { type: BootloaderResponseActionType.Init, result };
}
export interface BootloaderInfoResponseAction
extends Action<BootloaderResponseActionType.Info> {
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<BootloaderResponseActionType.Checksum> {
checksum: number;
}
export function checksumResponse(checksum: number): BootloaderChecksumResponseAction {
return { type: BootloaderResponseActionType.Checksum, checksum };
}
export interface BootloaderStateResponseAction
extends Action<BootloaderResponseActionType.State> {
level: ProtectionLevel;
}
export function stateResponse(level: ProtectionLevel): BootloaderStateResponseAction {
return { type: BootloaderResponseActionType.State, level };
}
export interface BootloaderErrorResponseAction
extends Action<BootloaderResponseActionType.Error> {
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<BootloaderActionType.FlashFirmware> {
data: ArrayBuffer;
}
export function flashFirmware(data: ArrayBuffer): BootloaderFlashFirmwareAction {
return { type: BootloaderActionType.FlashFirmware, data };
}
+5 -1
View File
@@ -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<ButtonProps, 'tooltip' | 'icon' | 'context' | 'enabled'>;
type DispatchProps = Pick<ButtonProps, 'onAction'>;
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',
+38
View File
@@ -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<OpenFileButtonProps, 'enabled'>;
type DispatchProps = Pick<OpenFileButtonProps, 'onFile'>;
type OwnProps = Pick<OpenFileButtonProps, 'id'>;
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);
+102
View File
@@ -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<OpenFileButtonProps> {
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 (
<Dropzone
onDropAccepted={this.onDropAccepted}
onDropRejected={this.onDropRejected}
accept={this.props.fileExtension}
multiple={false}
>
{({ getRootProps, getInputProps }): JSX.Element => (
<OverlayTrigger
placement="bottom"
overlay={
<Tooltip id={`${this.props.id}-tooltip`}>
{this.props.tooltip}.
</Tooltip>
}
>
<Button
{...getRootProps()}
variant="primary"
disabled={this.props.enabled === false}
style={
this.props.enabled === false
? { pointerEvents: 'none' }
: undefined
}
>
<input {...getInputProps()} />
<Image
src={`/static/images/${this.props.icon}`}
alt={this.props.id}
/>
</Button>
</OverlayTrigger>
)}
</Dropzone>
);
}
}
export default OpenFileButton;
+3 -27
View File
@@ -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 (
<Row>
@@ -41,12 +22,7 @@ class Toolbar extends React.Component<{}, ToolbarState> {
</ButtonGroup>
<ButtonGroup className="mr-2" size="lg">
<ReplButton id="repl" />
<ActionButton
id="flash"
tooltip="Flash hub firmware"
icon="firmware.svg"
onAction={this.onAction}
/>
<FlashButton id="flash" />
</ButtonGroup>
</ButtonToolbar>
</Col>
+12 -1
View File
@@ -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(
+285
View File
@@ -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;
}
+171
View File
@@ -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<BootloaderConnectionState> = (
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<FirmwareFlashState> = (
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 });
+3 -1
View File
@@ -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 });
+269
View File
@@ -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<BootloaderRequestAction>;
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 extends Action<BootloaderResponseActionType>> = [
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<BootloaderEraseResponseAction>;
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<BootloaderChecksumResponseAction>;
if (!checksum[0]) {
// TODO: proper error handling
throw Error(`Failed to get checksum: ${checksum}`);
}
}
}
const flash = (yield wait(
BootloaderResponseActionType.Program,
5000,
)) as WaitResponse<BootloaderProgramResponseAction>;
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);
}
+6
View File
@@ -0,0 +1,6 @@
import { all } from 'redux-saga/effects';
import bootloader from './bootloader';
export default function* (): Generator {
yield all([bootloader()]);
}
+91
View File
@@ -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<void> {
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<void> {
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);
+26
View File
@@ -0,0 +1,26 @@
import { Action, Dispatch, Middleware } from 'redux';
import bootloader from './bootloader';
type Service = (action: Action, dispatch: Dispatch) => Promise<void>;
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<void> => {
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;