diff --git a/package.json b/package.json index f0c2a717..0041dcce 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "react-ace": "^8.1.0", "react-dom": "^16.13.1", "react-scripts": "3.4.1", - "typescript": "~3.7.2" + "typescript": "~3.7.2", + "xterm": "^4.4.0" }, "scripts": { "start": "react-scripts start", @@ -40,6 +41,7 @@ ] }, "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/App.tsx b/src/App.tsx index 40f25192..253e91b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,8 @@ import React from 'react'; import AceEditor from 'react-ace'; import './App.css'; +import { Connection } from './Connection'; +import { Terminal } from './Terminal'; import 'ace-builds/src-noconflict/mode-python'; import 'ace-builds/src-noconflict/theme-github'; @@ -10,27 +12,27 @@ function onChange(newValue: string): void { } function App(): JSX.Element { + const connection = React.createRef(); + const terminal = React.createRef(); return (
-

- Edit src/App.tsx and save to reload. -

- - Learn React - + terminal.current?.write(e)} + ref={connection} + />
+ connection.current?.write(d)} + ref={terminal} + />
); diff --git a/src/Connection.tsx b/src/Connection.tsx new file mode 100644 index 00000000..735db252 --- /dev/null +++ b/src/Connection.tsx @@ -0,0 +1,133 @@ +import React from 'react'; + +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'; + +enum connectionState { + disconnected, + connecting, + connected, + disconnecting, +} + +function connectionStateToButtonText(state: connectionState): string { + switch (state) { + case connectionState.disconnected: + return 'Connect'; + case connectionState.connecting: + return 'Connecting...'; + case connectionState.connected: + return 'Disconnect'; + case connectionState.disconnecting: + return 'Disconnecting...'; + default: + return 'Error'; + } +} + +type ConnectionProperties = { + onData: (data: Uint8Array) => void; +}; +type ConnectionState = { + connection: connectionState; +}; + +class Connection extends React.Component { + private device?: BluetoothDevice; + private rxChar?: BluetoothRemoteGATTCharacteristic; + + constructor(props: ConnectionProperties) { + super(props); + this.state = { connection: connectionState.disconnected }; + this.onConnectClicked = this.onConnectClicked.bind(this); + } + + public write(data: Uint8Array): void { + this.rxChar?.writeValue(data); + } + + private async connect(): Promise { + try { + if (this.device !== undefined) { + throw Error('Already connected.'); + } + if (navigator.bluetooth === undefined) { + // TODO: custom exception type + throw Error( + 'WebBluetooth API is not available. Please make sure the Web Bluetooth flag is enabled.', + ); + } + this.device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [pybricksServiceUUID] }], + }); + if (this.device.gatt === undefined) { + throw Error('Device does not support GATT'); + } + this.device.addEventListener('gattserverdisconnected', () => + this.setState({ connection: connectionState.disconnected }), + ); + } catch (err) { + this.setState({ connection: connectionState.disconnected }); + throw err; + } + const server = await this.device.gatt.connect(); + const service = await server.getPrimaryService(bleNusServiceUUID); + this.rxChar = await service.getCharacteristic(bleNusCharRXUUID); + const txChar = await service.getCharacteristic(bleNusCharTXUUID); + txChar.addEventListener('characteristicvaluechanged', () => { + if (!txChar.value) { + return; + } + this.props.onData(new Uint8Array(txChar.value.buffer)); + }); + await txChar.startNotifications(); + this.setState({ connection: connectionState.connected }); + } + + private disconnect(): void { + if (this.device !== undefined) { + this.device.gatt?.disconnect(); + this.device = undefined; + this.rxChar = undefined; + } + } + + private async onConnectClicked(): Promise { + if (this.state.connection === connectionState.disconnected) { + this.setState({ connection: connectionState.connecting }); + try { + await this.connect(); + } catch (err) { + // FIXME: need proper error dialog + alert(err); + } + } else { + this.setState({ connection: connectionState.disconnecting }); + try { + this.disconnect(); + } catch (err) { + // FIXME: need proper error dialog + alert(err); + } + } + } + + render(): JSX.Element { + return ( + + ); + } +} + +export { Connection }; diff --git a/src/Terminal.tsx b/src/Terminal.tsx new file mode 100644 index 00000000..e0268dce --- /dev/null +++ b/src/Terminal.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Terminal } from 'xterm'; + +const encoder = new TextEncoder(); + +type TerminalProps = { + onData: (data: Uint8Array) => void; +}; + +export default class TerminalComponent extends React.Component { + private term: Terminal; + private terminalRef: React.RefObject; + + constructor(props: TerminalProps) { + super(props); + this.term = new Terminal(); + this.terminalRef = React.createRef(); + this.term.onData((data) => this.props.onData(encoder.encode(data))); + } + + public write(data: Uint8Array): void { + this.term.write(data); + } + + componentDidMount(): void { + if (!this.terminalRef.current) { + return; + } + this.term.open(this.terminalRef.current); + } + + render(): JSX.Element { + return ( +
+
+
+ ); + } +} +export { TerminalComponent as Terminal }; diff --git a/yarn.lock b/yarn.lock index bf70e454..cb40ece6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2183,6 +2183,11 @@ "@types/testing-library__dom" "*" pretty-format "^25.1.0" +"@types/web-bluetooth@^0.0.5": + version "0.0.5" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.5.tgz#f952d1481572798dd20a381135bef3d8b1ef305a" + integrity sha512-gaSAxNePCVJUR1a+4jKMaukjEzE4XuJd9fzSx/mAy6BrVoJXnACmfaBkaXU/K0frxJVfHvMDJx96hbNt4NXd9Q== + "@types/yargs-parser@*": version "13.1.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-13.1.0.tgz#c563aa192f39350a1d18da36c5a8da382bbd8228" @@ -11979,6 +11984,11 @@ xtend@^4.0.0, xtend@~4.0.1: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +xterm@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.4.0.tgz#5915d3c4c8800fadbcf555a0a603c672ab9df589" + integrity sha512-JGIpigWM3EBWvnS3rtBuefkiToIILSK1HYMXy4BCsUpO+O4UeeV+/U1AdAXgCB6qJrnPNb7yLgBsVCQUNMteig== + "y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"