diff --git a/package.json b/package.json index 08828414..fc029c2d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "@types/node": "^12.0.0", "@types/react": "^16.9.0", "@types/react-dom": "^16.9.0", + "@types/react-redux": "^7.1.7", + "@types/redux-logger": "^3.0.7", + "@types/web-bluetooth": "^0.0.5", "ace-builds": "^1.4.9", "bootstrap": "^4.4.1", "bootswatch": "^4.4.1", @@ -19,7 +22,11 @@ "react-bootstrap": "^1.0.0", "react-dom": "^16.13.1", "react-dropzone": "^10.2.2", + "react-redux": "^7.2.0", "react-scripts": "3.4.1", + "redux": "^4.0.5", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.3.0", "typescript": "~3.7.2", "xterm": "^4.5.0" }, @@ -47,7 +54,6 @@ ] }, "devDependencies": { - "@types/web-bluetooth": "^0.0.5", "@typescript-eslint/eslint-plugin": "^2.27.0", "@typescript-eslint/parser": "^2.27.0", "eslint": "^6.8.0", diff --git a/src/actions/ble.ts b/src/actions/ble.ts new file mode 100644 index 00000000..eecb0478 --- /dev/null +++ b/src/actions/ble.ts @@ -0,0 +1,119 @@ +import { Action } from 'redux'; +import { ThunkAction } from 'redux-thunk'; + +const pybricksServiceUUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef'; +const bleNusServiceUUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; +const bleNusCharRXUUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; +const bleNusCharTXUUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; + +let device: BluetoothDevice | undefined; +let rxChar: BluetoothRemoteGATTCharacteristic | undefined; + +export enum BLEConnectActionType { + /** + * Begin async connect. + */ + BeginConnect = 'ble.connect.begin', + /** + * End async connect (success). + */ + EndConnect = 'ble.connect.end', + /** + * Begin async disconnect. + */ + BeginDisconnect = 'ble.disconnect.begin', + /** + * End async disconnect (can be sent without sending BeginDisconnect first). + */ + EndDisconnect = 'ble.disconnect.end', +} + +type BLEConnectAction = Action; + +enum BLEDataActionType { + /** + * Send data. + */ + SendData = 'ble.data.send', + /** + * Data was received. + */ + ReceivedData = 'ble.data.receive', +} + +interface BLEDataAction extends Action { + value: DataView; +} + +type AnyBLEAction = BLEConnectAction | BLEDataAction; + +type BLEThunkAction = ThunkAction, {}, {}, AnyBLEAction>; + +function beginConnect(): BLEConnectAction { + return { type: BLEConnectActionType.BeginConnect }; +} + +function endConnect(): BLEConnectAction { + return { type: BLEConnectActionType.EndConnect }; +} + +function beginDisconnect(): BLEConnectAction { + return { type: BLEConnectActionType.BeginDisconnect }; +} + +function endDisconnect(): BLEConnectAction { + return { type: BLEConnectActionType.EndDisconnect }; +} + +export function connect(): BLEThunkAction { + return async function (dispatch): Promise { + if (device !== undefined) { + console.error('Already have a connected device'); + return; + } + if (navigator.bluetooth === undefined) { + // TODO: dispatch error toast action + console.error('Browser does not support WebBluetooth or it is not enabled'); + return; + } + dispatch(beginConnect()); + device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [pybricksServiceUUID] }], + optionalServices: [bleNusServiceUUID], + }); + if (device.gatt === undefined) { + console.error('Device does not support GATT'); + return; + } + device.addEventListener('gattserverdisconnected', () => { + device = undefined; + rxChar = undefined; + dispatch(endDisconnect()); + }); + const server = await device.gatt.connect(); + try { + const service = await server.getPrimaryService(bleNusServiceUUID); + rxChar = await service.getCharacteristic(bleNusCharRXUUID); + const txChar = await service.getCharacteristic(bleNusCharTXUUID); + txChar.addEventListener('characteristicvaluechanged', () => { + if (!txChar.value) { + return; + } + dispatch({ type: BLEDataActionType.ReceivedData, value: txChar.value }); + }); + await txChar.startNotifications(); + } catch (err) { + console.error('getting nRF UART service failed'); + device.gatt.disconnect(); + return; + } + dispatch(endConnect()); + }; +} + +export function disconnect(): BLEThunkAction { + return async function (dispatch): Promise { + dispatch(beginDisconnect()); + device?.gatt?.disconnect(); + }; +} diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx index 3a80431f..b3c60ece 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -4,31 +4,35 @@ import Tooltip from 'react-bootstrap/Tooltip'; import Image from 'react-bootstrap/Image'; import React from 'react'; -interface ActionButtonProperties { +interface ActionButtonProps { + /** A unique id for each instance. */ readonly id: string; - readonly action: { - readonly id: string; - readonly tooltip: string; - readonly icon: string; - }; - readonly onAction: (action: string) => void; + /** 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; + /** Optional action hint passed to the onAction() callback. */ + readonly action?: string; + /** Callback that is called when the button is activated (clicked). */ + readonly onAction: (action?: string) => void; } -class ActionButton extends React.Component { +class ActionButton extends React.Component { render(): JSX.Element { return ( - {this.props.action.tooltip}. + {this.props.tooltip}. } > diff --git a/src/components/BluetoothButton.tsx b/src/components/BluetoothButton.tsx new file mode 100644 index 00000000..adbc62bf --- /dev/null +++ b/src/components/BluetoothButton.tsx @@ -0,0 +1,50 @@ +import ActionButton from './ActionButton'; +import { connect } from 'react-redux'; +import { connect as bleConnect, disconnect as bleDisconnect } from '../actions/ble'; +import { RootState } from '../reducers'; +import { BLEConnectionState } from '../reducers/ble'; +import { ThunkDispatch } from 'redux-thunk'; +import { AnyAction } from 'redux'; + +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; +} + +const mapStateToProps = (state: RootState): StateProps => { + if (state.ble.connection === BLEConnectionState.Disconnected) { + return { + tooltip: 'Connect using Bluetooth', + icon: 'btdisconnected.svg', + action: 'connect', + enabled: true, + }; + } else { + return { + tooltip: 'Disconnect Bluetooth', + icon: 'btconnected.svg', + action: 'disconnect', + enabled: state.ble.connection === BLEConnectionState.Connected, + }; + } +}; + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onAction: (a): void => { + if (a === 'connect') { + dispatch(bleConnect()); + } else { + dispatch(bleDisconnect()); + } + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ActionButton); diff --git a/src/components/Statusbar.tsx b/src/components/Statusbar.tsx index 768b2dd4..3f1df8b3 100644 --- a/src/components/Statusbar.tsx +++ b/src/components/Statusbar.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; import ProgressBar from 'react-bootstrap/ProgressBar'; -import { BLEConnectionState } from '../services/BLEConnection'; +import { BLEConnectionState } from '../reducers/ble'; interface StatusBarState { bleState: BLEConnectionState; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index ec63614c..d61c772b 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -4,19 +4,13 @@ import ButtonGroup from 'react-bootstrap/ButtonGroup'; import ButtonToolbar from 'react-bootstrap/ButtonToolbar'; import React from 'react'; import ActionButton from './ActionButton'; -import { BLEConnectionState } from '../services/BLEConnection'; +import { BLEConnectionState } from '../reducers/ble'; +import BluetoothButton from './BluetoothButton'; interface ToolbarState { bleState: BLEConnectionState; } -function oneOf( - bleState: BLEConnectionState, - ...matches: BLEConnectionState[] -): boolean { - return matches.indexOf(bleState) >= 0; -} - class Toolbar extends React.Component<{}, ToolbarState> { constructor(props: {}) { super(props); @@ -24,7 +18,7 @@ class Toolbar extends React.Component<{}, ToolbarState> { this.onAction = this.onAction.bind(this); } - private onAction(action: string): void { + private onAction(action?: string): void { console.log(action); } @@ -38,92 +32,32 @@ class Toolbar extends React.Component<{}, ToolbarState> { - + diff --git a/src/index.tsx b/src/index.tsx index 4916c96e..1f7a8456 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,12 +1,26 @@ +import thunkMiddleware from 'redux-thunk'; +import { createLogger } from 'redux-logger'; +import { createStore, applyMiddleware } from 'redux'; +import rootReducer from './reducers'; import React from 'react'; +import { Provider } from 'react-redux'; import ReactDOM from 'react-dom'; import './index.scss'; import App from './components/App'; import * as serviceWorker from './serviceWorker'; +const loggerMiddleware = createLogger(); + +const store = createStore( + rootReducer, + applyMiddleware(thunkMiddleware, loggerMiddleware), +); + ReactDOM.render( - + + + , document.getElementById('root'), ); diff --git a/src/reducers/ble.ts b/src/reducers/ble.ts new file mode 100644 index 00000000..ed0c8f96 --- /dev/null +++ b/src/reducers/ble.ts @@ -0,0 +1,51 @@ +import { Reducer, combineReducers } from 'redux'; +import { BLEConnectActionType } from '../actions/ble'; + +/** + * Describes the state of the BLE connection. + */ +export enum BLEConnectionState { + /** + * No device is connected. + */ + Disconnected = 'ble.connection.disconnected', + /** + * Connecting to a device. + */ + Connecting = 'ble.connection.connecting', + /** + * Connected to a device. + */ + Connected = 'ble.connection.connected', + /** + * Disconnecting from a device. + */ + Disconnecting = 'ble.connection.disconnecting', +} + +/** + * BLE state for redux store. + */ +export interface BLEState { + readonly connection: BLEConnectionState; +} + +const connection: Reducer = ( + state = BLEConnectionState.Disconnected, + action, +) => { + switch (action.type) { + case BLEConnectActionType.BeginConnect: + return BLEConnectionState.Connecting; + case BLEConnectActionType.EndConnect: + return BLEConnectionState.Connected; + case BLEConnectActionType.BeginDisconnect: + return BLEConnectionState.Disconnecting; + case BLEConnectActionType.EndDisconnect: + return BLEConnectionState.Disconnected; + default: + return state; + } +}; + +export default combineReducers({ connection }); diff --git a/src/reducers/index.ts b/src/reducers/index.ts new file mode 100644 index 00000000..3c00b56d --- /dev/null +++ b/src/reducers/index.ts @@ -0,0 +1,11 @@ +import { combineReducers } from 'redux'; +import ble, { BLEState } from './ble'; + +/** + * Root state for redux store. + */ +export interface RootState { + readonly ble: BLEState; +} + +export default combineReducers({ ble }); diff --git a/src/services/BLEConnection.ts b/src/services/BLEConnection.ts deleted file mode 100644 index e48223c0..00000000 --- a/src/services/BLEConnection.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Describes the state of the BLE connection. - */ -enum BLEConnectionState { - /** - * No device is connected. - */ - Disconnected, - /** - * Scanning for devices. - */ - Scanning, - /** - * Connecting to a device. - */ - Connecting, - /** - * Connected, waiting for a command. - */ - Waiting, - /** - * Connected, busy downloading a user program. - */ - Downloading, - /** - * Connected, running a user program. - */ - Running, - /** - * Connected, running REPL. - */ - REPL, -} - -export { BLEConnectionState }; diff --git a/yarn.lock b/yarn.lock index 06d5c588..c951aa2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2096,6 +2096,14 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -2165,6 +2173,16 @@ dependencies: "@types/react" "*" +"@types/react-redux@^7.1.7": + version "7.1.7" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.7.tgz#12a0c529aba660696947384a059c5c6e08185c7a" + integrity sha512-U+WrzeFfI83+evZE2dkZ/oF/1vjIYgqrb5dGgedkqVV8HEfDFujNgWCwHL89TDuWKb47U0nTBT6PLGq4IIogWg== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + "@types/react@*", "@types/react@^16.9.0": version "16.9.32" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.32.tgz#f6368625b224604148d1ddf5920e4fefbd98d383" @@ -2181,6 +2199,13 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/redux-logger@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.7.tgz#163f6f6865c69c21d56f9356dc8d741718ec0db0" + integrity sha512-oV9qiCuowhVR/ehqUobWWkXJjohontbDGLV88Be/7T4bqMQ3kjXwkFNL7doIIqlbg3X2PC5WPziZ8/j/QHNQ4A== + dependencies: + redux "^3.6.0" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -4309,6 +4334,11 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= +deep-diff@^0.3.5: + version "0.3.8" + resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" + integrity sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ= + deep-equal@^1.0.1: version "1.1.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" @@ -5958,6 +5988,13 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoist-non-react-statics@^3.3.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + hosted-git-info@^2.1.4: version "2.8.5" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c" @@ -7520,7 +7557,7 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" -lodash-es@^4.17.15: +lodash-es@^4.17.15, lodash-es@^4.2.1: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== @@ -7570,7 +7607,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.5, lodash@~4.17.12: +"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.5, lodash@^4.2.1, lodash@~4.17.12: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== @@ -9975,7 +10012,7 @@ react-error-overlay@^6.0.7: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108" integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA== -react-is@^16.12.0, react-is@^16.3.2: +react-is@^16.12.0, react-is@^16.3.2, react-is@^16.7.0, react-is@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -10003,6 +10040,17 @@ react-overlays@^3.0.1: uncontrollable "^7.0.0" warning "^4.0.3" +react-redux@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.0.tgz#f970f62192b3981642fec46fd0db18a074fe879d" + integrity sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA== + dependencies: + "@babel/runtime" "^7.5.5" + hoist-non-react-statics "^3.3.0" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^16.9.0" + react-scripts@3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-3.4.1.tgz#f551298b5c71985cc491b9acf3c8e8c0ae3ada0a" @@ -10201,6 +10249,36 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux-logger@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" + integrity sha1-91VZZvMJjzyIYExEnPC69XeCdL8= + dependencies: + deep-diff "^0.3.5" + +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + +redux@^3.6.0: + version "3.7.2" + resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b" + integrity sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A== + dependencies: + lodash "^4.2.1" + lodash-es "^4.2.1" + loose-envify "^1.1.0" + symbol-observable "^1.0.3" + +redux@^4.0.0, 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== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + regenerate-unicode-properties@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e" @@ -11470,6 +11548,11 @@ svgo@^1.0.0, svgo@^1.2.2: unquote "~1.1.1" util.promisify "~1.0.0" +symbol-observable@^1.0.3, symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + symbol-tree@^3.2.2: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"