wire up run button

This commit is contained in:
David Lechner
2020-04-17 16:57:51 -05:00
parent 232a8f5db5
commit 454475980a
16 changed files with 391 additions and 65 deletions
+23
View File
@@ -0,0 +1,23 @@
import { Ace } from 'ace-builds';
import { Action } from 'redux';
export enum EditorActionType {
/**
* The current (active) editor changed.
*/
Current = 'editor.action.current',
}
export interface CurrentEditorAction extends Action<EditorActionType.Current> {
editSession: Ace.EditSession | undefined;
}
/**
* Sets the current (active) edit session.
* @param editSession The new edit session.
*/
export function setEditSession(
editSession: Ace.EditSession | undefined,
): CurrentEditorAction {
return { type: EditorActionType.Current, editSession };
}
+79
View File
@@ -0,0 +1,79 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import { getChecksum } from '../epics/hub';
import { write } from './ble';
export type HubThunkAction = ThunkAction<Promise<void>, {}, {}, HubRuntimeStatusAction>;
export enum HubRuntimeStatusType {
Disconnected = 'disconnected',
Idle = 'idle',
Loading = 'loading',
Loaded = 'loaded',
Running = 'running',
Error = 'error',
}
export enum HubActionType {
/**
* MicroPython runtime status changed.
*/
RuntimeStatus = 'hub.runtime.status',
/**
* The hub has sent a checksum.
*/
Checksum = 'hub.runtime.checksum',
}
export interface HubRuntimeStatusAction extends Action<HubActionType.RuntimeStatus> {
readonly newStatus: HubRuntimeStatusType;
}
export interface HubChecksumAction extends Action<HubActionType.Checksum> {
readonly checksum: number;
}
export function updateStatus(newStatus: HubRuntimeStatusType): HubRuntimeStatusAction {
return {
type: HubActionType.RuntimeStatus,
newStatus,
};
}
export function checksum(checksum: number): HubChecksumAction {
return {
type: HubActionType.Checksum,
checksum,
};
}
const downloadChunkSize = 100;
export function downloadAndRun(data: ArrayBuffer): HubThunkAction {
return async function (dispatch): Promise<void> {
// let everyone know the runtime is busy loading the program
dispatch(updateStatus(HubRuntimeStatusType.Loading));
// TODO: might need to flush checksum queue here
// first send payload size as big-endian 32-bit integer
const sizeBuf = new Uint8Array(4);
const sizeView = new DataView(sizeBuf.buffer);
sizeView.setUint32(0, data.byteLength);
await dispatch(write(sizeBuf));
// Then send payload in 100 byte chunks waiting for checksum after
// each chunk
for (let i = 0; i < data.byteLength; i += downloadChunkSize) {
// need to subscribe to checksum before writing to prevent race condition
const checksum = getChecksum();
await dispatch(write(data.slice(i, i + downloadChunkSize)));
// TODO: verify checksum
console.log(await checksum);
// TODO: dispatch progress
}
// let everyone know the runtime is done loading the program
dispatch(updateStatus(HubRuntimeStatusType.Loaded));
};
}
+22
View File
@@ -0,0 +1,22 @@
import MpyCross from '@pybricks/mpy-cross';
import { Action } from 'redux';
// this starts the mpy-cross wasm runtime and leaves it running in the background
const mpy = MpyCross({ arguments: ['-mno-unicode'] });
enum MpyActionType {
Compiled = 'mpy.action.compile',
}
interface MpyCompiledAction extends Action<MpyActionType.Compiled> {
/**
* The compiled .mpy data.
*/
data: Uint8Array;
}
export function compile(script: string): MpyCompiledAction {
// TODO: figure out how to capture stderr and emit error action on failure
const data = mpy.compile(script);
return { type: MpyActionType.Compiled, data };
}
+5 -5
View File
@@ -4,7 +4,7 @@ import Tooltip from 'react-bootstrap/Tooltip';
import Image from 'react-bootstrap/Image';
import React from 'react';
interface ActionButtonProps {
export interface ActionButtonProps<T = undefined> {
/** A unique id for each instance. */
readonly id: string;
/** Tooltip text that appears when hovering over the button. */
@@ -14,12 +14,12 @@ interface ActionButtonProps {
/** When true or undefined, the button is enabled. */
readonly enabled?: boolean;
/** Optional action hint passed to the onAction() callback. */
readonly action?: string;
readonly context?: T;
/** Callback that is called when the button is activated (clicked). */
readonly onAction: (action?: string) => void;
readonly onAction: (context?: T) => void;
}
class ActionButton extends React.Component<ActionButtonProps> {
class ActionButton<T = undefined> extends React.Component<ActionButtonProps<T>> {
render(): JSX.Element {
return (
<OverlayTrigger
@@ -32,7 +32,7 @@ class ActionButton extends React.Component<ActionButtonProps> {
>
<Button
variant="primary"
onClick={(): void => this.props.onAction(this.props.action)}
onClick={(): void => this.props.onAction(this.props.context)}
disabled={this.props.enabled === false}
style={
this.props.enabled === false
+8 -15
View File
@@ -4,42 +4,35 @@ import { AnyAction } from 'redux';
import { connect as bleConnect, disconnect as bleDisconnect } from '../actions/ble';
import { RootState } from '../reducers';
import { BLEConnectionState } from '../reducers/ble';
import ActionButton from './ActionButton';
import ActionButton, { ActionButtonProps } from './ActionButton';
type Dispatch = ThunkDispatch<{}, {}, AnyAction>;
interface StateProps {
readonly tooltip: string;
readonly icon: string;
readonly action?: string;
readonly enabled?: boolean;
}
interface DispatchProps {
readonly onAction: (action?: string) => void;
}
type ButtonProps = ActionButtonProps<string>;
type StateProps = Pick<ButtonProps, 'tooltip' | 'icon' | 'context' | 'enabled'>;
type DispatchProps = Pick<ButtonProps, 'onAction'>;
const mapStateToProps = (state: RootState): StateProps => {
if (state.ble.connection === BLEConnectionState.Disconnected) {
return {
tooltip: 'Connect using Bluetooth',
icon: 'btdisconnected.svg',
action: 'connect',
context: 'connect',
enabled: true,
};
} else {
return {
tooltip: 'Disconnect Bluetooth',
icon: 'btconnected.svg',
action: 'disconnect',
context: 'disconnect',
enabled: state.ble.connection === BLEConnectionState.Connected,
};
}
};
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
onAction: (a): void => {
if (a === 'connect') {
onAction: (c): void => {
if (c === 'connect') {
dispatch(bleConnect());
} else {
dispatch(bleDisconnect());
+29 -16
View File
@@ -1,7 +1,9 @@
import React from 'react';
import React, { ReactElement } from 'react';
import { ReactReduxContext } from 'react-redux';
import AceEditor from 'react-ace';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import { setEditSession } from '../actions/editor';
import 'ace-builds/src-noconflict/mode-python';
import 'ace-builds/src-noconflict/theme-xcode';
@@ -13,21 +15,32 @@ class Editor extends React.Component {
return (
<Row className="px-2 py-4 bg-primary">
<Col>
<AceEditor
mode="python"
theme="xcode"
fontSize="16pt"
width="100"
focus={true}
placeholder="Write your program here..."
defaultValue={localStorage.getItem('program') || undefined}
editorProps={{ $blockScrolling: true }}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
}}
onChange={(v): void => localStorage.setItem('program', v)}
/>
<ReactReduxContext.Consumer>
{({ store }): ReactElement => (
<AceEditor
mode="python"
theme="xcode"
fontSize="16pt"
width="100"
focus={true}
placeholder="Write your program here..."
defaultValue={
localStorage.getItem('program') || undefined
}
editorProps={{ $blockScrolling: true }}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
}}
onFocus={(_, e): void => {
store.dispatch(setEditSession(e?.session));
}}
onChange={(v): void =>
localStorage.setItem('program', v)
}
/>
)}
</ReactReduxContext.Consumer>
</Col>
</Row>
);
+50
View File
@@ -0,0 +1,50 @@
import { connect, batch } from 'react-redux';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { Ace } from 'ace-builds';
import { RootState } from '../reducers';
import { HubRuntimeState } from '../reducers/hub';
import { compile } from '../actions/mpy';
import { downloadAndRun } from '../actions/hub';
import ActionButton, { ActionButtonProps } from './ActionButton';
type Dispatch = ThunkDispatch<{}, {}, AnyAction>;
type ButtonProps = ActionButtonProps<Ace.EditSession>;
type StateProps = Pick<ButtonProps, 'enabled' | 'context'>;
type DispatchProps = Pick<ButtonProps, 'onAction'>;
type OwnProps = Pick<ButtonProps, 'id'>;
const mapStateToProps = (state: RootState): StateProps => ({
enabled:
state.editor.current !== null && state.hub.runtime === HubRuntimeState.Idle,
context: state.editor.current || undefined,
});
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
onAction: (c): void => {
if (!c) {
console.error('No current editor');
return;
}
batch(() => {
const script = c.getValue();
const mpy = dispatch(compile(script));
dispatch(downloadAndRun(mpy.data));
});
},
});
const mergeProps = (
stateProps: StateProps,
dispatchProps: DispatchProps,
ownProps: OwnProps,
): ButtonProps => ({
tooltip: 'Download and run this program',
icon: 'run.svg',
...ownProps,
...stateProps,
...dispatchProps,
});
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(ActionButton);
+2 -6
View File
@@ -6,6 +6,7 @@ import React from 'react';
import { BLEConnectionState } from '../reducers/ble';
import ActionButton from './ActionButton';
import BluetoothButton from './BluetoothButton';
import RunButton from './RunButton';
interface ToolbarState {
bleState: BLEConnectionState;
@@ -33,12 +34,7 @@ class Toolbar extends React.Component<{}, ToolbarState> {
<ButtonToolbar className="m-2">
<ButtonGroup className="mr-2" size="lg">
<BluetoothButton id="bluetooth" />
<ActionButton
id="run"
tooltip="Download and run this program"
icon="run.svg"
onAction={this.onAction}
/>
<RunButton id="run" />
<ActionButton
id="stop"
tooltip="Stop everything"
+44 -9
View File
@@ -1,17 +1,52 @@
import { combineEpics, Epic, ofType } from 'redux-observable';
import { map } from 'rxjs/operators';
import { BLEDataActionType, BLEDataAction } from '../actions/ble';
import { TerminalDataAction, sendData } from '../actions/terminal';
import { AnyAction } from 'redux';
import { BLEDataActionType, BLEDataAction, BLEConnectActionType } from '../actions/ble';
import { sendData } from '../actions/terminal';
import { updateStatus, HubRuntimeStatusType, checksum } from '../actions/hub';
import { RootState } from '../reducers';
import { HubRuntimeState } from '../reducers/hub';
const decoder = new TextDecoder();
const rxUartData: Epic = (action$) =>
const connect: Epic = (action$) =>
action$.pipe(
ofType(BLEDataActionType.ReceivedData),
map(
(a: BLEDataAction): TerminalDataAction =>
sendData(decoder.decode(a.value.buffer)),
),
ofType(BLEConnectActionType.EndConnect),
map(() => updateStatus(HubRuntimeStatusType.Idle)),
);
export default combineEpics(rxUartData);
const disconnect: Epic = (action$) =>
action$.pipe(
ofType(BLEConnectActionType.EndDisconnect),
map(() => updateStatus(HubRuntimeStatusType.Disconnected)),
);
const rxUartData: Epic<AnyAction, AnyAction, RootState> = (action$, state$) =>
action$.pipe(
ofType<AnyAction, BLEDataAction>(BLEDataActionType.ReceivedData),
map((a) => {
if (
state$.value.hub.runtime === HubRuntimeState.Loading &&
a.value.buffer.byteLength === 1
) {
const view = new DataView(a.value.buffer);
return checksum(view.getUint8(0));
} else {
const value = decoder.decode(a.value.buffer);
// FIXME: sometimes we get ERROR and IDLE in same message except
// last E is cut off
if (value.match(/>>>> IDLE/)) {
return updateStatus(HubRuntimeStatusType.Idle);
}
if (value.match(/>>>> ERROR/)) {
return updateStatus(HubRuntimeStatusType.Error);
}
if (value.match(/>>>> RUNNING/)) {
return updateStatus(HubRuntimeStatusType.Running);
}
return sendData(value);
}
}),
);
export default combineEpics(connect, disconnect, rxUartData);
+20
View File
@@ -0,0 +1,20 @@
import { combineEpics, Epic, ofType } from 'redux-observable';
import { AnyAction } from 'redux';
import { take, tap, ignoreElements } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { HubActionType, HubChecksumAction } from '../actions/hub';
const checksumSubject = new Subject<number>();
const checksum: Epic = (action$) =>
action$.pipe(
ofType<AnyAction, HubChecksumAction>(HubActionType.Checksum),
tap((a) => checksumSubject.next(a.checksum)),
ignoreElements(),
);
export function getChecksum(): Promise<number> {
return checksumSubject.pipe(take(1)).toPromise();
}
export default combineEpics(checksum);
+2 -1
View File
@@ -1,10 +1,11 @@
import { combineEpics, Epic } from 'redux-observable';
import { catchError } from 'rxjs/operators';
import ble from './ble';
import hub from './hub';
import terminal from './terminal';
const rootEpic: Epic = (action$, store$, dependencies) =>
combineEpics(ble, terminal)(action$, store$, dependencies).pipe(
combineEpics(ble, hub, terminal)(action$, store$, dependencies).pipe(
catchError((error, source) => {
console.error(error);
return source;
+6 -5
View File
@@ -1,7 +1,8 @@
import { combineEpics, Epic, ofType } from 'redux-observable';
import { Subject } from 'rxjs';
import { map, tap, ignoreElements } from 'rxjs/operators';
import { write, BLEThunkAction } from '../actions/ble';
import { AnyAction } from 'redux';
import { write } from '../actions/ble';
import { TerminalDataAction, TerminalDataActionType } from '../actions/terminal';
const encoder = new TextEncoder();
@@ -12,16 +13,16 @@ export const terminalOutput = terminalOutputSubject.asObservable();
// When terminal has focus this receives the input. All input is sent over BLE connection.
const receiveTerminalData: Epic = (action$) =>
action$.pipe(
ofType(TerminalDataActionType.ReceivedData),
map((a: TerminalDataAction): BLEThunkAction => write(encoder.encode(a.value))),
ofType<AnyAction, TerminalDataAction>(TerminalDataActionType.ReceivedData),
map((a) => write(encoder.encode(a.value))),
);
// Request to write to the terminal are handled by an observable rather than
// using redux state.
const sendTerminalData: Epic = (action$) =>
action$.pipe(
ofType(TerminalDataActionType.SendData),
tap((a: TerminalDataAction): void => terminalOutputSubject.next(a.value)),
ofType<AnyAction, TerminalDataAction>(TerminalDataActionType.SendData),
tap((a) => terminalOutputSubject.next(a.value)),
ignoreElements(),
);
+4 -7
View File
@@ -23,13 +23,6 @@ export enum BLEConnectionState {
Disconnecting = 'ble.connection.disconnecting',
}
/**
* BLE state for redux store.
*/
export interface BLEState {
readonly connection: BLEConnectionState;
}
const connection: Reducer<BLEConnectionState> = (
state = BLEConnectionState.Disconnected,
action,
@@ -48,4 +41,8 @@ const connection: Reducer<BLEConnectionState> = (
}
};
export interface BLEState {
readonly connection: BLEConnectionState;
}
export default combineReducers({ connection });
+22
View File
@@ -0,0 +1,22 @@
import { combineReducers, Reducer } from 'redux';
import { Ace } from 'ace-builds';
import { CurrentEditorAction, EditorActionType } from '../actions/editor';
type CurrentEditSession = Ace.EditSession | null;
const current: Reducer<CurrentEditSession, CurrentEditorAction> = (
state = null,
action,
) => {
switch (action.type) {
case EditorActionType.Current:
return action.editSession || null;
default:
return state;
}
};
export interface EditorState {
current: CurrentEditSession;
}
export default combineReducers({ current });
+70
View File
@@ -0,0 +1,70 @@
import { Reducer, combineReducers } from 'redux';
import {
HubRuntimeStatusAction,
HubActionType,
HubRuntimeStatusType,
} from '../actions/hub';
/**
* Describes the state of the MicroPython runtime on the hub.
*/
export enum HubRuntimeState {
/**
* The hub is not connected.
*/
Disconnected = 'hub.runtime.disconnected',
/**
* The runtime is idle waiting for command after soft reboot.
*/
Idle = 'hub.runtime.idle',
/**
* A user program is being copied to the hub.
*/
Loading = 'hub.runtime.loading',
/**
* A user program has been copied to the hub.
*/
Loaded = 'hub.runtime.loaded',
/**
* A user program is running.
*/
Running = 'hub.runtime.running',
/**
* The runtime encountered an error.
*/
Error = 'hub.runtime.error',
}
const runtime: Reducer<HubRuntimeState, HubRuntimeStatusAction> = (
state = HubRuntimeState.Disconnected,
action,
) => {
switch (action.type) {
case HubActionType.RuntimeStatus:
switch (action.newStatus) {
case HubRuntimeStatusType.Disconnected:
return HubRuntimeState.Disconnected;
case HubRuntimeStatusType.Idle:
return HubRuntimeState.Idle;
case HubRuntimeStatusType.Loading:
return HubRuntimeState.Loading;
case HubRuntimeStatusType.Loaded:
return HubRuntimeState.Loaded;
case HubRuntimeStatusType.Running:
return HubRuntimeState.Running;
case HubRuntimeStatusType.Error:
return HubRuntimeState.Error;
default:
console.error(`bad action/state: ${action.newStatus}`);
return state;
}
default:
return state;
}
};
export interface HubState {
readonly runtime: HubRuntimeState;
}
export default combineReducers({ runtime });
+5 -1
View File
@@ -1,11 +1,15 @@
import { combineReducers } from 'redux';
import ble, { BLEState } from './ble';
import editor, { EditorState } from './editor';
import hub, { HubState } from './hub';
/**
* Root state for redux store.
*/
export interface RootState {
readonly ble: BLEState;
readonly editor: EditorState;
readonly hub: HubState;
}
export default combineReducers({ ble });
export default combineReducers({ ble, editor, hub });