diff --git a/package.json b/package.json index fc029c2d..5292af06 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react-scripts": "3.4.1", "redux": "^4.0.5", "redux-logger": "^3.0.6", + "redux-observable": "^1.2.0", "redux-thunk": "^2.3.0", "typescript": "~3.7.2", "xterm": "^4.5.0" diff --git a/src/actions/ble.ts b/src/actions/ble.ts index eecb0478..9cf34653 100644 --- a/src/actions/ble.ts +++ b/src/actions/ble.ts @@ -2,9 +2,12 @@ import { Action } from 'redux'; import { ThunkAction } from 'redux-thunk'; const pybricksServiceUUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef'; + +// nRF UART service (Nus) const bleNusServiceUUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; const bleNusCharRXUUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; const bleNusCharTXUUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; +const bleNusMaxSize = 20; let device: BluetoothDevice | undefined; let rxChar: BluetoothRemoteGATTCharacteristic | undefined; @@ -30,7 +33,7 @@ export enum BLEConnectActionType { type BLEConnectAction = Action; -enum BLEDataActionType { +export enum BLEDataActionType { /** * Send data. */ @@ -41,13 +44,13 @@ enum BLEDataActionType { ReceivedData = 'ble.data.receive', } -interface BLEDataAction extends Action { +export interface BLEDataAction extends Action { value: DataView; } type AnyBLEAction = BLEConnectAction | BLEDataAction; -type BLEThunkAction = ThunkAction, {}, {}, AnyBLEAction>; +export type BLEThunkAction = ThunkAction, {}, {}, AnyBLEAction>; function beginConnect(): BLEConnectAction { return { type: BLEConnectActionType.BeginConnect }; @@ -117,3 +120,12 @@ export function disconnect(): BLEThunkAction { device?.gatt?.disconnect(); }; } + +export function write(value: ArrayBuffer): BLEThunkAction { + return async function (): Promise { + // TODO: do we need to dispatch any Action<>s here? + for (let i = 0; i < value.byteLength; i += bleNusMaxSize) { + await rxChar?.writeValue(value.slice(i, i + bleNusMaxSize)); + } + }; +} diff --git a/src/actions/terminal.ts b/src/actions/terminal.ts new file mode 100644 index 00000000..b4aba957 --- /dev/null +++ b/src/actions/terminal.ts @@ -0,0 +1,24 @@ +import { Action } from 'redux'; + +export enum TerminalDataActionType { + /** + * Send data. + */ + SendData = 'terminal.data.send', + /** + * Data was received. + */ + ReceivedData = 'terminal.data.receive', +} + +export interface TerminalDataAction extends Action { + value: string; +} + +export function sendData(data: string): TerminalDataAction { + return { type: TerminalDataActionType.SendData, value: data }; +} + +export function receiveData(data: string): TerminalDataAction { + return { type: TerminalDataActionType.ReceivedData, value: data }; +} diff --git a/src/components/Terminal.tsx b/src/components/Terminal.tsx index 25bd3d39..477aaa91 100644 --- a/src/components/Terminal.tsx +++ b/src/components/Terminal.tsx @@ -2,14 +2,26 @@ import React from 'react'; import { Terminal as XTerm } from 'xterm'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import { receiveData } from '../actions/terminal'; +import { terminalOutput } from '../epics/terminal'; +import { Subscription } from 'rxjs'; import 'xterm/css/xterm.css'; -class Terminal extends React.Component { +interface DispatchProps { + onData: (data: string) => void; +} + +type TerminalProps = DispatchProps; + +class Terminal extends React.Component { private xterm: XTerm; private terminalRef: React.RefObject; + private subscription?: Subscription; - constructor(props: {}) { + constructor(props: TerminalProps) { super(props); this.xterm = new XTerm({ cursorBlink: true, @@ -24,7 +36,7 @@ class Terminal extends React.Component { selection: 'rgba(181,213,255,0.5)', // this should match AceEditor theme }, }); - this.xterm.onData((data) => this.xterm.write(data)); + this.xterm.onData((d) => this.props.onData(d)); this.terminalRef = React.createRef(); } @@ -34,6 +46,12 @@ class Terminal extends React.Component { return; } this.xterm.open(this.terminalRef.current); + this.subscription = terminalOutput.subscribe((v) => this.xterm.write(v)); + } + + componentWillUnmount(): void { + this.subscription?.unsubscribe(); + this.xterm.dispose(); } render(): JSX.Element { @@ -47,4 +65,10 @@ class Terminal extends React.Component { } } -export default Terminal; +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onData: (d): void => { + dispatch(receiveData(d)); + }, +}); + +export default connect(null, mapDispatchToProps)(Terminal); diff --git a/src/epics/ble.ts b/src/epics/ble.ts new file mode 100644 index 00000000..2837851a --- /dev/null +++ b/src/epics/ble.ts @@ -0,0 +1,17 @@ +import { combineEpics, Epic, ofType } from 'redux-observable'; +import { BLEDataActionType, BLEDataAction } from '../actions/ble'; +import { map } from 'rxjs/operators'; +import { TerminalDataAction, sendData } from '../actions/terminal'; + +const decoder = new TextDecoder(); + +const rxUartData: Epic = (action$) => + action$.pipe( + ofType(BLEDataActionType.ReceivedData), + map( + (a: BLEDataAction): TerminalDataAction => + sendData(decoder.decode(a.value.buffer)), + ), + ); + +export default combineEpics(rxUartData); diff --git a/src/epics/index.ts b/src/epics/index.ts new file mode 100644 index 00000000..44dbc556 --- /dev/null +++ b/src/epics/index.ts @@ -0,0 +1,14 @@ +import { combineEpics, Epic } from 'redux-observable'; +import { catchError } from 'rxjs/operators'; +import ble from './ble'; +import terminal from './terminal'; + +const rootEpic: Epic = (action$, store$, dependencies) => + combineEpics(ble, terminal)(action$, store$, dependencies).pipe( + catchError((error, source) => { + console.error(error); + return source; + }), + ); + +export default rootEpic; diff --git a/src/epics/terminal.ts b/src/epics/terminal.ts new file mode 100644 index 00000000..fae0f805 --- /dev/null +++ b/src/epics/terminal.ts @@ -0,0 +1,28 @@ +import { combineEpics, Epic, ofType } from 'redux-observable'; +import { write, BLEThunkAction } from '../actions/ble'; +import { Subject } from 'rxjs'; +import { map, tap, ignoreElements } from 'rxjs/operators'; +import { TerminalDataAction, TerminalDataActionType } from '../actions/terminal'; + +const encoder = new TextEncoder(); + +const terminalOutputSubject = new Subject(); +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))), + ); + +// 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)), + ignoreElements(), + ); + +export default combineEpics(receiveTerminalData, sendTerminalData); diff --git a/src/index.tsx b/src/index.tsx index 1f7a8456..ee96de27 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,9 @@ import thunkMiddleware from 'redux-thunk'; import { createLogger } from 'redux-logger'; +import { createEpicMiddleware } from 'redux-observable'; import { createStore, applyMiddleware } from 'redux'; import rootReducer from './reducers'; +import rootEpic from './epics'; import React from 'react'; import { Provider } from 'react-redux'; import ReactDOM from 'react-dom'; @@ -9,13 +11,16 @@ import './index.scss'; import App from './components/App'; import * as serviceWorker from './serviceWorker'; +const epicMiddleware = createEpicMiddleware(); const loggerMiddleware = createLogger(); const store = createStore( rootReducer, - applyMiddleware(thunkMiddleware, loggerMiddleware), + applyMiddleware(thunkMiddleware, epicMiddleware, loggerMiddleware), ); +epicMiddleware.run(rootEpic); + ReactDOM.render( diff --git a/yarn.lock b/yarn.lock index c951aa2d..a52c74fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10256,6 +10256,11 @@ redux-logger@^3.0.6: dependencies: deep-diff "^0.3.5" +redux-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.2.0.tgz#ff51b6c6be2598e9b5e89fc36639186bb0e669c7" + integrity sha512-yeR90RP2WzZzCxxnQPlh2uFzyfFLsfXu8ROh53jGDPXVqj71uNDMmvi/YKQkd9ofiVoO4OYb1snbowO49tCEMg== + redux-thunk@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622"