From bda007c17a14cd1847ccb946fe7d19082739c67d Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 11 Jun 2020 22:48:33 -0500 Subject: [PATCH] convert ble service to saga --- src/actions/ble-uart.ts | 2 - src/actions/ble.ts | 146 +++++++++++++----- src/components/BluetoothButton.tsx | 6 +- src/components/Notification.tsx | 5 +- src/components/NotificationStack.tsx | 1 + src/components/notification.en.json | 3 +- src/protocols/nrf-uart.ts | 23 +++ src/protocols/pybricks.ts | 9 ++ src/reducers/ble.ts | 41 ++--- src/reducers/hub.ts | 6 +- src/reducers/index.ts | 4 +- src/reducers/notification.ts | 58 ++++++- src/sagas/ble-uart.ts | 218 +++++++++++++++++++++++++++ src/sagas/error-log.test.ts | 25 +++ src/sagas/error-log.ts | 12 ++ src/sagas/hub.ts | 8 +- src/sagas/index.ts | 2 + src/sagas/terminal.ts | 7 +- src/services/ble.ts | 148 ------------------ src/services/index.ts | 3 +- 20 files changed, 500 insertions(+), 227 deletions(-) create mode 100644 src/protocols/nrf-uart.ts create mode 100644 src/protocols/pybricks.ts create mode 100644 src/sagas/ble-uart.ts delete mode 100644 src/services/ble.ts diff --git a/src/actions/ble-uart.ts b/src/actions/ble-uart.ts index c9956194..56e92107 100644 --- a/src/actions/ble-uart.ts +++ b/src/actions/ble-uart.ts @@ -3,7 +3,6 @@ // actions/ble-uart.ts: Actions for Bluetooth Low Energy nRF UART service import { Action } from 'redux'; -import { assert } from '../utils'; import { createCountFunc } from '../utils/iter'; /** @@ -36,7 +35,6 @@ export type BleUartWriteAction = Action & { }; export function write(value: Uint8Array): BleUartWriteAction { - assert(value.length <= 20, 'value can be at most 20 bytes'); return { type: BleUartActionType.Write, id: nextId(), value }; } diff --git a/src/actions/ble.ts b/src/actions/ble.ts index 00c45fd0..b65e88e2 100644 --- a/src/actions/ble.ts +++ b/src/actions/ble.ts @@ -1,62 +1,136 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors +// File: actions/ble.ts +// Actions for managing Bluetooth Low Energy connections. import { Action } from 'redux'; /** - * Bluetooth low energy connection action types. + * Bluetooth low energy device action types. */ -export enum BLEConnectActionType { +export enum BleDeviceActionType { /** * Connecting to a device has been requested. */ - Connect = 'ble.action.connect', + Connect = 'ble.device.action.connect', /** * The connection completed successfully. */ - DidConnect = 'ble.action.did.connect', + DidConnect = 'ble.device.action.didConnect', + /** + * The connection did not complete successfully. + */ + DidFailToConnect = 'ble.device.action.didFailToConnect', /** * Disconnecting from a device has been requested. */ - Disconnect = 'ble.action.disconnect', + Disconnect = 'ble.device.action.disconnect', /** - * End async disconnect (can be sent without sending BeginDisconnect first). + * The device was disconnected. */ - DidDisconnect = 'ble.action.did.disconnect', + DidDisconnect = 'ble.device.action.didDisconnect', +} + +export type BleDeviceConnectAction = Action; + +/** + * Creates an action that indicates connecting has been requested. + */ +export function connect(): BleDeviceConnectAction { + return { type: BleDeviceActionType.Connect }; +} + +export type BleDeviceDidConnectAction = Action; + +/** + * Creates an action that indicates a device was connected. + */ +export function didConnect(): BleDeviceDidConnectAction { + return { type: BleDeviceActionType.DidConnect }; +} + +export enum BleDeviceFailToConnectReasonType { + NoWebBluetooth = 'ble.device.didFailToConnect.noWebBluetooth', + Canceled = 'ble.device.didFailToConnect.canceled', + NoGatt = 'ble.device.didFailToConnect.noGatt', + NoService = 'ble.device.didFailToConnect.noService', + Unknown = 'ble.device.didFailToConnect.unknown', +} + +type Reason = { + reason: T; +}; + +export type BleDeviceFailToConnectNoWebBluetoothReason = Reason< + BleDeviceFailToConnectReasonType.NoWebBluetooth +>; + +export type BleDeviceFailToConnectCanceledReason = Reason< + BleDeviceFailToConnectReasonType.Canceled +>; + +export type BleDeviceFailToConnectNoGattReason = Reason< + BleDeviceFailToConnectReasonType.NoGatt +>; + +export type BleDeviceFailToConnectNoServiceReason = Reason< + BleDeviceFailToConnectReasonType.NoService +>; + +export type BleDeviceFailToConnectUnknownReason = Reason< + BleDeviceFailToConnectReasonType.Unknown +> & { + err: Error; +}; + +export type BleDeviceDidFailToConnectReason = + | BleDeviceFailToConnectNoWebBluetoothReason + | BleDeviceFailToConnectCanceledReason + | BleDeviceFailToConnectNoGattReason + | BleDeviceFailToConnectNoServiceReason + | BleDeviceFailToConnectUnknownReason; + +export type BleDeviceDidFailToConnectAction = Action< + BleDeviceActionType.DidFailToConnect +> & + BleDeviceDidFailToConnectReason; + +/** + * Creates an action that indicates a device failed to connect. + */ +export function didFailToConnect( + reason: BleDeviceDidFailToConnectReason, +): BleDeviceDidFailToConnectAction { + return { type: BleDeviceActionType.DidFailToConnect, ...reason }; +} + +export type BleDeviceDisconnectAction = Action; + +/** + * Creates an action that indicates disconnecting was requested. + */ +export function disconnect(): BleDeviceDisconnectAction { + return { type: BleDeviceActionType.Disconnect }; +} + +export type BleDeviceDidDisconnectAction = Action; + +/** + * Creates an action that indicates a device was disconnected. + */ +export function didDisconnect(): BleDeviceDidDisconnectAction { + return { type: BleDeviceActionType.DidDisconnect }; } /** * Common type for all BLE connection actions. */ -export type BLEConnectAction = Action; - -/** - * Creates an action that indicates connecting has been requested. - */ -export function connect(): BLEConnectAction { - return { type: BLEConnectActionType.Connect }; -} - -/** - * Creates an action that indicates a device was connected. - */ -export function didConnect(): BLEConnectAction { - return { type: BLEConnectActionType.DidConnect }; -} - -/** - * Creates an action that indicates disconnecting was requested. - */ -export function disconnect(): BLEConnectAction { - return { type: BLEConnectActionType.Disconnect }; -} - -/** - * Creates an action that indicates a device was disconnected. - */ -export function didDisconnect(): BLEConnectAction { - return { type: BLEConnectActionType.DidDisconnect }; -} +export type BLEConnectAction = + | BleDeviceConnectAction + | BleDeviceDidConnectAction + | BleDeviceDidFailToConnectAction + | BleDeviceDisconnectAction + | BleDeviceDidDisconnectAction; /** * High-level BLE actions. diff --git a/src/components/BluetoothButton.tsx b/src/components/BluetoothButton.tsx index 6b8b5cda..33702631 100644 --- a/src/components/BluetoothButton.tsx +++ b/src/components/BluetoothButton.tsx @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { Action, Dispatch } from '../actions'; import { toggleBluetooth } from '../actions/ble'; import { RootState } from '../reducers'; -import { BLEConnectionState } from '../reducers/ble'; +import { BleConnectionState } from '../reducers/ble'; import { BootloaderConnectionState } from '../reducers/bootloader'; import ActionButton, { ActionButtonProps } from './ActionButton'; import { TooltipId } from './button'; @@ -17,7 +17,7 @@ type DispatchProps = Pick; const mapStateToProps = (state: RootState): StateProps => { if ( - state.ble.connection === BLEConnectionState.Disconnected && + state.ble.connection === BleConnectionState.Disconnected && state.bootloader.connection === BootloaderConnectionState.Disconnected ) { return { @@ -29,7 +29,7 @@ const mapStateToProps = (state: RootState): StateProps => { return { tooltip: TooltipId.BluetoothDisconnect, icon: btConnectedIcon, - enabled: state.ble.connection === BLEConnectionState.Connected, + enabled: state.ble.connection === BleConnectionState.Connected, }; } }; diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx index 2f34cd5b..24e58d44 100644 --- a/src/components/Notification.tsx +++ b/src/components/Notification.tsx @@ -3,6 +3,7 @@ import { IconName, Intent, Toast } from '@blueprintjs/core'; import { WithI18nProps, withI18n } from '@shopify/react-i18n'; +import { PrimitiveReplacementDictionary } from '@shopify/react-i18n/dist/src/types'; import React from 'react'; import { connect } from 'react-redux'; import { Action, Dispatch } from '../actions'; @@ -20,6 +21,7 @@ interface OwnProps { level: NotificationLevel; message?: string; messageId?: string; + replacements?: PrimitiveReplacementDictionary; helpUrl?: string; action?: MessageAction; } @@ -63,6 +65,7 @@ class Notification extends React.Component { messageId, onAction, onClose, + replacements, } = this.props; return ( {

{messageId - ? i18n.translate(messageId) + ? i18n.translate(messageId, replacements) : message || 'missing message!'}

{helpUrl && ( diff --git a/src/components/NotificationStack.tsx b/src/components/NotificationStack.tsx index 83e3d277..415a2809 100644 --- a/src/components/NotificationStack.tsx +++ b/src/components/NotificationStack.tsx @@ -25,6 +25,7 @@ class NotificationStack extends React.Component { level={n.level} message={n.message} messageId={n.messageId} + replacements={n.replacements} helpUrl={n.helpUrl} action={n.action} /> diff --git a/src/components/notification.en.json b/src/components/notification.en.json index 300a4b21..fe5be52d 100644 --- a/src/components/notification.en.json +++ b/src/components/notification.en.json @@ -1,7 +1,8 @@ { "ble": { "cannotWriteWithoutResponse": "This web browser does not support Web Bluetooth Write Characteristic Without Response. Flashing firmware will take a long time.", - "gattServiceNotFound": "Connected to hub but failed to get LEGO bootloader service. Try removing the \"LEGO Bootloader\" device in your OS Bluetooth settings, then try again.", + "gattPermission": "The web browser did not give permission to use Bluetooth Low Energy", + "gattServiceNotFound": "Connected to hub but failed to get {serviceName} service. Try removing the \"{hubName}\" device in your OS Bluetooth settings, then try again.", "noWebBluetooth": "This web browser does not support Web Bluetooth or it is not enabled.", "connectFailed": "Unexpected error while trying to connect. Check console log and report the error." }, diff --git a/src/protocols/nrf-uart.ts b/src/protocols/nrf-uart.ts new file mode 100644 index 00000000..b8c9421e --- /dev/null +++ b/src/protocols/nrf-uart.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors +// File: protocols/nrf-uart.ts +// Definitions related to the nRF UART Bluetooth low energy GATT service. + +// The Nordic Semiconductor nRF UART service is a defacto standard for providing +// serial communication using Bluetooth Low Energy. +// https://infocenter.nordicsemi.com/topic/sdk_nrf5_v16.0.0/ble_sdk_app_nus_eval.html + +/** nRF UART Service UUID. */ +export const ServiceUUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; + +/** nRF UART RX Characteristic UUID. Supports Write or Write without response. */ +export const RxCharUUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; + +/** nRF UART TX Characteristic UUID. Supports Notifications. */ +export const TxCharUUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; + +/** + * This is the largest data size for the TX characteristic that is safe to use + * when the negotiated MTU is unknown. + */ +export const SafeTxCharLength = 20; diff --git a/src/protocols/pybricks.ts b/src/protocols/pybricks.ts new file mode 100644 index 00000000..24dfce0f --- /dev/null +++ b/src/protocols/pybricks.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors +// File: protocols/pybricks.ts +// Definitions related to the Pybricks Bluetooth low energy GATT service. + +// Protocol details have not been defined yet. + +/** Pybricks Service UUID. */ +export const ServiceUUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef'; diff --git a/src/reducers/ble.ts b/src/reducers/ble.ts index a36b4c85..c229c55a 100644 --- a/src/reducers/ble.ts +++ b/src/reducers/ble.ts @@ -1,51 +1,56 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors +// File: reducers/ble.ts +// Manages state for the Bluetooth Low Energy connection. +// This assumes that there is only one global connection to a single device. import { Reducer, combineReducers } from 'redux'; -import { BLEConnectActionType } from '../actions/ble'; +import { Action } from '../actions'; +import { BleDeviceActionType } from '../actions/ble'; /** * Describes the state of the BLE connection. */ -export enum BLEConnectionState { +export enum BleConnectionState { /** * No device is connected. */ - Disconnected = 'ble.connection.disconnected', + Disconnected = 'ble.connection.state.disconnected', /** * Connecting to a device. */ - Connecting = 'ble.connection.connecting', + Connecting = 'ble.connection.state.connecting', /** * Connected to a device. */ - Connected = 'ble.connection.connected', + Connected = 'ble.connection.state.connected', /** * Disconnecting from a device. */ - Disconnecting = 'ble.connection.disconnecting', + Disconnecting = 'ble.connection.state.disconnecting', } -const connection: Reducer = ( - state = BLEConnectionState.Disconnected, +const connection: Reducer = ( + state = BleConnectionState.Disconnected, action, ) => { switch (action.type) { - case BLEConnectActionType.Connect: - return BLEConnectionState.Connecting; - case BLEConnectActionType.DidConnect: - return BLEConnectionState.Connected; - case BLEConnectActionType.Disconnect: - return BLEConnectionState.Disconnecting; - case BLEConnectActionType.DidDisconnect: - return BLEConnectionState.Disconnected; + case BleDeviceActionType.Connect: + return BleConnectionState.Connecting; + case BleDeviceActionType.DidConnect: + return BleConnectionState.Connected; + case BleDeviceActionType.Disconnect: + return BleConnectionState.Disconnecting; + case BleDeviceActionType.DidFailToConnect: + case BleDeviceActionType.DidDisconnect: + return BleConnectionState.Disconnected; default: return state; } }; -export interface BLEState { - readonly connection: BLEConnectionState; +export interface BleState { + readonly connection: BleConnectionState; } export default combineReducers({ connection }); diff --git a/src/reducers/hub.ts b/src/reducers/hub.ts index ffaf4b84..89cea018 100644 --- a/src/reducers/hub.ts +++ b/src/reducers/hub.ts @@ -3,7 +3,7 @@ import { Reducer, combineReducers } from 'redux'; import { Action } from '../actions'; -import { BLEConnectActionType } from '../actions/ble'; +import { BleDeviceActionType } from '../actions/ble'; import { HubMessageActionType, HubRuntimeStatusType } from '../actions/hub'; /** @@ -45,9 +45,9 @@ const runtime: Reducer = ( action, ) => { switch (action.type) { - case BLEConnectActionType.DidDisconnect: + case BleDeviceActionType.DidDisconnect: return HubRuntimeState.Disconnected; - case BLEConnectActionType.DidConnect: + case BleDeviceActionType.DidConnect: return HubRuntimeState.Unknown; case HubMessageActionType.RuntimeStatus: switch (action.newStatus) { diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 4b918336..03430050 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -2,7 +2,7 @@ // Copyright (c) 2020 The Pybricks Authors import { combineReducers } from 'redux'; -import ble, { BLEState } from './ble'; +import ble, { BleState } from './ble'; import bootloader, { BootloaderState } from './bootloader'; import editor, { EditorState } from './editor'; import hub, { HubState } from './hub'; @@ -15,7 +15,7 @@ import terminal, { TerminalState } from './terminal'; */ export interface RootState { readonly bootloader: BootloaderState; - readonly ble: BLEState; + readonly ble: BleState; readonly editor: EditorState; readonly hub: HubState; readonly notification: NotificationState; diff --git a/src/reducers/notification.ts b/src/reducers/notification.ts index 21a9f493..1bbcd71c 100644 --- a/src/reducers/notification.ts +++ b/src/reducers/notification.ts @@ -1,9 +1,11 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors +import { PrimitiveReplacementDictionary } from '@shopify/react-i18n/dist/src/types'; import { Reducer } from 'react'; import { combineReducers } from 'redux'; import { Action } from '../actions'; +import { BleDeviceActionType, BleDeviceFailToConnectReasonType } from '../actions/ble'; import { EditorActionType, reloadProgram } from '../actions/editor'; import { BootloaderConnectionActionType, @@ -17,6 +19,7 @@ import { createCountFunc } from '../utils/iter'; export enum MessageId { BleCannotWriteWithoutResponse = 'ble.cannotWriteWithoutResponse', BleConnectFailed = 'ble.connectFailed', + BleGattPermission = 'ble.gattPermission', BleGattServiceNotFound = 'ble.gattServiceNotFound', BleNoWebBluetooth = 'ble.noWebBluetooth', ProgramChanged = 'editor.programChanged', @@ -53,6 +56,7 @@ export interface Notification { readonly level: Level; readonly message?: string; readonly messageId?: MessageId; + readonly replacements?: PrimitiveReplacementDictionary; readonly helpUrl?: string; readonly action?: MessageAction; } @@ -65,20 +69,48 @@ function append( state: NotificationList, level: Level, messageId: MessageId, + replacements?: PrimitiveReplacementDictionary, helpUrl?: string, action?: MessageAction, ): NotificationList { - return [...state, { id: nextId(), level, messageId, helpUrl, action }]; + return [ + ...state, + { id: nextId(), level, messageId, replacements, helpUrl, action }, + ]; } const list: Reducer = (state = [], action) => { switch (action.type) { + case BleDeviceActionType.DidFailToConnect: + switch (action.reason) { + case BleDeviceFailToConnectReasonType.NoGatt: + return append(state, Level.Error, MessageId.BleGattPermission); + case BleDeviceFailToConnectReasonType.NoService: + return append( + state, + Level.Error, + MessageId.BleGattServiceNotFound, + { serviceName: 'Pybricks', hubName: 'Pybricks Hub' }, + ); + case BleDeviceFailToConnectReasonType.NoWebBluetooth: + return append( + state, + Level.Error, + MessageId.BleNoWebBluetooth, + undefined, + 'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md', + ); + case BleDeviceFailToConnectReasonType.Unknown: + return append(state, Level.Error, MessageId.BleConnectFailed); + } + return state; case BootloaderConnectionActionType.DidConnect: if (!action.canWriteWithoutResponse) { return append( state, Level.Warning, MessageId.BleCannotWriteWithoutResponse, + undefined, 'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md', ); } @@ -86,12 +118,18 @@ const list: Reducer = (state = [], action) => { case BootloaderConnectionActionType.DidFailToConnect: switch (action.reason) { case BootloaderConnectionFailureReason.GattServiceNotFound: - return append(state, Level.Error, MessageId.BleGattServiceNotFound); + return append( + state, + Level.Error, + MessageId.BleGattServiceNotFound, + { serviceName: 'LEGO Bootloader', hubName: 'LEGO Bootloader' }, + ); case BootloaderConnectionFailureReason.NoWebBluetooth: return append( state, Level.Error, MessageId.BleNoWebBluetooth, + undefined, 'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md', ); case BootloaderConnectionFailureReason.Unknown: @@ -103,10 +141,17 @@ const list: Reducer = (state = [], action) => { // don't show message again if it is already shown return state; } - return append(state, Level.Info, MessageId.ProgramChanged, undefined, { - titleId: MessageId.YesReloadProgram, - action: reloadProgram(), - }); + return append( + state, + Level.Info, + MessageId.ProgramChanged, + undefined, + undefined, + { + titleId: MessageId.YesReloadProgram, + action: reloadProgram(), + }, + ); case MpyActionType.DidFailToCompile: return [ ...state, @@ -129,6 +174,7 @@ const list: Reducer = (state = [], action) => { state, Level.Info, MessageId.ServiceWorkerUpdate, + undefined, 'https://bit.ly/CRA-PWA', ); case ServiceWorkerActionType.Success: diff --git a/src/sagas/ble-uart.ts b/src/sagas/ble-uart.ts new file mode 100644 index 00000000..cecf8473 --- /dev/null +++ b/src/sagas/ble-uart.ts @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors +// File: sagas/ble-uart.ts +// Manages connection to a Bluetooth Low Energy device with the Nordic (nRF) UART service. + +import { END, eventChannel } from 'redux-saga'; +import { call, cancel, put, select, takeEvery, takeMaybe } from 'redux-saga/effects'; +import { + BLEActionType, + BleDeviceActionType as BLEDeviceActionType, + BLEToggleAction, + BleDeviceConnectAction, + BleDeviceDisconnectAction, + BleDeviceFailToConnectReasonType as Reason, + connect as connectAction, + didConnect, + didDisconnect, + didFailToConnect, + disconnect as disconnectAction, +} from '../actions/ble'; +import { + BleUartActionType, + BleUartWriteAction, + didFailToWrite, + didWrite, + notify, +} from '../actions/ble-uart'; +import { + ServiceUUID as uartServiceUUID, + TxCharUUID as uartTxCharUUID, + RxCharUUID as urtRxCharUUID, +} from '../protocols/nrf-uart'; +import { ServiceUUID as pybricksServiceUUID } from '../protocols/pybricks'; +import { RootState } from '../reducers'; +import { BleConnectionState } from '../reducers/ble'; +import { + PolyfillBluetoothRemoteGATTCharacteristic, + polyfillBluetoothRemoteGATTCharacteristic, +} from '../utils/web-bluetooth'; + +function disconnect( + server: BluetoothRemoteGATTServer, + _action: BleDeviceDisconnectAction, +): void { + server.disconnect(); +} + +function* handleValueChanged(data: DataView): Generator { + yield put(notify(data)); +} + +function* write( + rxChar: PolyfillBluetoothRemoteGATTCharacteristic, + action: BleUartWriteAction, +): Generator { + try { + yield call(() => rxChar.xWriteValueWithoutResponse(action.value.buffer)); + yield put(didWrite(action.id)); + } catch (err) { + yield put(didFailToWrite(action.id, err)); + } +} + +function* connect(_action: BleDeviceConnectAction): Generator { + if (navigator.bluetooth === undefined) { + yield put(didFailToConnect({ reason: Reason.NoWebBluetooth })); + return; + } + + // TODO: check navigator.bluetooth.getAvailability() + + let device: BluetoothDevice; + try { + device = (yield call(() => + navigator.bluetooth.requestDevice({ + filters: [{ services: [pybricksServiceUUID] }], + optionalServices: [uartServiceUUID], + }), + )) as BluetoothDevice; + } catch (err) { + if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { + // this can happen if the use cancels the dialog + yield put(didFailToConnect({ reason: Reason.Canceled })); + } else { + yield put(didFailToConnect({ reason: Reason.Unknown, err })); + } + return; + } + + if (device.gatt === undefined) { + yield put(didFailToConnect({ reason: Reason.NoGatt })); + return; + } + + const disconnectChannel = eventChannel((emitter) => { + const listener = (): void => emitter(END); + device.addEventListener('gattserverdisconnected', listener); + return (): void => + device.removeEventListener('gattserverdisconnected', listener); + }); + + let server: BluetoothRemoteGATTServer; + try { + server = (yield call([device.gatt, 'connect'])) as BluetoothRemoteGATTServer; + } catch (err) { + disconnectChannel.close(); + yield put(didFailToConnect({ reason: Reason.Unknown, err })); + return; + } + + yield takeEvery(BLEDeviceActionType.Disconnect, disconnect, server); + + let service: BluetoothRemoteGATTService; + try { + service = (yield call( + [server, 'getPrimaryService'], + uartServiceUUID, + )) as BluetoothRemoteGATTService; + } catch (err) { + server.disconnect(); + yield takeMaybe(disconnectChannel); + if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { + yield put(didFailToConnect({ reason: Reason.NoService })); + } else { + yield put(didFailToConnect({ reason: Reason.Unknown, err })); + } + return; + } + + let rxChar: PolyfillBluetoothRemoteGATTCharacteristic; + try { + rxChar = polyfillBluetoothRemoteGATTCharacteristic( + (yield call( + [service, 'getCharacteristic'], + urtRxCharUUID, + )) as BluetoothRemoteGATTCharacteristic, + ); + } catch (err) { + server.disconnect(); + yield takeMaybe(disconnectChannel); + yield put(didFailToConnect({ reason: Reason.Unknown, err })); + return; + } + + let txChar: BluetoothRemoteGATTCharacteristic; + try { + txChar = (yield call( + [service, 'getCharacteristic'], + uartTxCharUUID, + )) as BluetoothRemoteGATTCharacteristic; + } catch (err) { + server.disconnect(); + yield takeMaybe(disconnectChannel); + yield put(didFailToConnect({ reason: Reason.Unknown, err })); + return; + } + + const txChannel = eventChannel((emitter) => { + const listener = (): void => { + if (!txChar.value) { + return; + } + emitter(txChar.value); + }; + txChar.addEventListener('characteristicvaluechanged', listener); + return (): void => + txChar.removeEventListener('characteristicvaluechanged', listener); + }); + + try { + // REVISIT: possible Pybricks firmware bug (or chromium bug on Linux) + // where 'characteristicvaluechanged' is not called after disconnecting + // and reconnecting unless we stop notifications before we start them + // again. Wireshark shows that no enable notification descriptor write + // is performed but notifications are received. + yield call([txChar, 'stopNotifications']); + yield call([txChar, 'startNotifications']); + } catch (err) { + txChannel.close(); + server.disconnect(); + yield takeMaybe(disconnectChannel); + yield put(didFailToConnect({ reason: Reason.Unknown, err })); + return; + } + + yield takeEvery(txChannel, handleValueChanged); + yield takeEvery(BleUartActionType.Write, write, rxChar); + + yield put(didConnect()); + + yield takeMaybe(disconnectChannel); + txChannel.close(); + try { + yield cancel(); // have to cancel to stop forked effects + } finally { + yield put(didDisconnect()); + } +} + +function* toggle(_action: BLEToggleAction): Generator { + const connectionState = (yield select( + (s: RootState) => s.ble.connection, + )) as BleConnectionState; + + switch (connectionState) { + case BleConnectionState.Connected: + yield put(disconnectAction()); + break; + case BleConnectionState.Disconnected: + yield put(connectAction()); + break; + } +} + +export default function* (): Generator { + yield takeEvery(BLEDeviceActionType.Connect, connect); + yield takeEvery(BLEActionType.Toggle, toggle); +} diff --git a/src/sagas/error-log.test.ts b/src/sagas/error-log.test.ts index faa314f5..15478f98 100644 --- a/src/sagas/error-log.test.ts +++ b/src/sagas/error-log.test.ts @@ -2,6 +2,10 @@ // Copyright (c) 2020 The Pybricks Authors import { AsyncSaga } from '../../test'; +import { + BleDeviceFailToConnectReasonType, + didFailToConnect as bleDidFailToConnect, +} from '../actions/ble'; import { didFailToWrite } from '../actions/ble-uart'; import { BootloaderConnectionFailureReason, @@ -10,6 +14,27 @@ import { } from '../actions/lwp3-bootloader'; import errorLog from './error-log'; +test('bleDeviceDidFailToConnect', async () => { + const saga = new AsyncSaga(errorLog); + + console.error = jest.fn(); + + saga.put( + bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Canceled }), + ); + expect(console.error).toHaveBeenCalledTimes(0); + + saga.put( + bleDidFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: new Error('test error'), + }), + ); + expect(console.error).toHaveBeenCalledTimes(1); + + await saga.end(); +}); + test('bleDataDidFailToWrite', async () => { const saga = new AsyncSaga(errorLog); diff --git a/src/sagas/error-log.ts b/src/sagas/error-log.ts index 7c26917f..7457c029 100644 --- a/src/sagas/error-log.ts +++ b/src/sagas/error-log.ts @@ -2,6 +2,11 @@ // Copyright (c) 2020 The Pybricks Authors import { takeEvery } from 'redux-saga/effects'; +import { + BleDeviceActionType, + BleDeviceDidFailToConnectAction, + BleDeviceFailToConnectReasonType, +} from '../actions/ble'; import { BleUartActionType, BleUartDidFailToWriteAction } from '../actions/ble-uart'; import { BootloaderConnectionActionType, @@ -10,6 +15,12 @@ import { BootloaderConnectionFailureReason, } from '../actions/lwp3-bootloader'; +function bleDeviceDidFailToConnect(action: BleDeviceDidFailToConnectAction): void { + if (action.reason === BleDeviceFailToConnectReasonType.Unknown) { + console.error(action.err); + } +} + function bleDataDidFailToWrite(action: BleUartDidFailToWriteAction): void { console.error(action.err); } @@ -29,6 +40,7 @@ function bootloaderDidError(action: BootloaderConnectionDidErrorAction): void { } export default function* (): Generator { + yield takeEvery(BleDeviceActionType.DidFailToConnect, bleDeviceDidFailToConnect); yield takeEvery(BleUartActionType.DidFailToWrite, bleDataDidFailToWrite); yield takeEvery( BootloaderConnectionActionType.DidFailToConnect, diff --git a/src/sagas/hub.ts b/src/sagas/hub.ts index a12eae43..91e17530 100644 --- a/src/sagas/hub.ts +++ b/src/sagas/hub.ts @@ -14,6 +14,7 @@ import { takeEvery, } from 'redux-saga/effects'; import { Action } from '../actions'; +import { BleDeviceActionType } from '../actions/ble'; import { BleUartActionType, BleUartDidFailToWriteAction, @@ -37,6 +38,7 @@ import { MpyDidFailToCompileAction, compile, } from '../actions/mpy'; +import { SafeTxCharLength } from '../protocols/nrf-uart'; import { RootState } from '../reducers'; import { xor8 } from '../utils/math'; @@ -109,9 +111,9 @@ function* downloadAndRun(_action: HubDownloadAndRunAction): Generator { const chunk = mpy.data.slice(i, i + downloadChunkSize); // we can actually only write 20 bytes at a time - for (let j = 0; j < chunk.length; j += 20) { + for (let j = 0; j < chunk.length; j += SafeTxCharLength) { const writeAction = (yield put( - write(chunk.slice(j, j + 20)), + write(chunk.slice(j, j + SafeTxCharLength)), )) as BleUartWriteAction; const [, didFailToWrite] = (yield waitForWrite(writeAction.id)) as [ BleUartDidWriteAction, @@ -158,4 +160,6 @@ export default function* (): Generator { yield takeEvery(HubActionType.DownloadAndRun, downloadAndRun); yield takeEvery(HubActionType.Repl, startRepl); yield takeEvery(HubActionType.Stop, stop); + // calling stop right after connecting should get the hub into a known state + yield takeEvery(BleDeviceActionType.DidConnect, stop); } diff --git a/src/sagas/index.ts b/src/sagas/index.ts index 46a79d15..efc8649e 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -2,6 +2,7 @@ // Copyright (c) 2020 The Pybricks Authors import { all } from 'redux-saga/effects'; +import bleUart from './ble-uart'; import editor from './editor'; import errorLog from './error-log'; import flashFirmware from './flash-firmare'; @@ -13,6 +14,7 @@ import terminal from './terminal'; /* istanbul ignore next */ export default function* (): Generator { yield all([ + bleUart(), bootloader(), editor(), errorLog(), diff --git a/src/sagas/terminal.ts b/src/sagas/terminal.ts index 0a20bdb7..f3ddb4bb 100644 --- a/src/sagas/terminal.ts +++ b/src/sagas/terminal.ts @@ -27,6 +27,7 @@ import { sendData, setDataSource, } from '../actions/terminal'; +import { SafeTxCharLength } from '../protocols/nrf-uart'; import { RootState } from '../reducers'; import { HubRuntimeState } from '../reducers/hub'; @@ -103,7 +104,7 @@ function* receiveTerminalData(): Generator { let value = action.value; // Try to collect more data so that we aren't sending just one byte at time - while (value.length < 20) { + while (value.length < SafeTxCharLength) { const [action, timeout] = (yield race([take(channel), delay(20)])) as [ TerminalDataReceiveDataAction, boolean, @@ -116,9 +117,9 @@ function* receiveTerminalData(): Generator { // stdin gets piped to BLE connection const data = encoder.encode(value); - for (let i = 0; i < data.length; i += 20) { + for (let i = 0; i < data.length; i += SafeTxCharLength) { const { id } = (yield put( - write(data.slice(i, i + 20)), + write(data.slice(i, i + SafeTxCharLength)), )) as BleUartWriteAction; yield take( diff --git a/src/services/ble.ts b/src/services/ble.ts deleted file mode 100644 index e729f840..00000000 --- a/src/services/ble.ts +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors - -import { Action, Dispatch } from '../actions'; -import { - BLEActionType, - BLEConnectActionType, - connect as connectAction, - didConnect, - didDisconnect, - disconnect as disconnectAction, -} from '../actions/ble'; -import { - BleUartActionType, - didFailToWrite, - didWrite, - notify, -} from '../actions/ble-uart'; -import { stop } from '../actions/hub'; -import * as notification from '../actions/notification'; -import { RootState } from '../reducers'; -import { BLEConnectionState } from '../reducers/ble'; -import { - PolyfillBluetoothRemoteGATTCharacteristic, - polyfillBluetoothRemoteGATTCharacteristic, -} from '../utils/web-bluetooth'; -import { combineServices } from '.'; - -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'; - -let device: BluetoothDevice | undefined; -let rxChar: PolyfillBluetoothRemoteGATTCharacteristic | undefined; - -async function connect(action: Action, dispatch: Dispatch): Promise { - if (action.type !== BLEConnectActionType.Connect) { - return; - } - - if (device !== undefined) { - dispatch(notification.add('error', 'A device is already connected.')); - return; - } - if (navigator.bluetooth === undefined) { - dispatch( - notification.add( - 'error', - 'This web browser does not support Web Bluetooth or it is not enabled.', - 'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md', - ), - ); - return; - } - // TODO: check navigator.bluetooth.getAvailability() - try { - device = await navigator.bluetooth.requestDevice({ - filters: [{ services: [pybricksServiceUUID] }], - optionalServices: [bleNusServiceUUID], - }); - } catch (err) { - if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - // this can happen if the use cancels the dialog - console.debug('User cancelled connect'); - } else { - console.error(err); - dispatch( - notification.add( - 'error', - 'Unexpected error, check developer console for details.', - ), - ); - } - dispatch(didDisconnect()); - return; - } - if (device.gatt === undefined) { - dispatch(notification.add('error', 'Device does not support GATT.')); - dispatch(didDisconnect()); - return; - } - device.addEventListener('gattserverdisconnected', () => { - device = undefined; - rxChar = undefined; - dispatch(didDisconnect()); - }); - const server = await device.gatt.connect(); - try { - const service = await server.getPrimaryService(bleNusServiceUUID); - rxChar = polyfillBluetoothRemoteGATTCharacteristic( - await service.getCharacteristic(bleNusCharRXUUID), - ); - const txChar = await service.getCharacteristic(bleNusCharTXUUID); - txChar.addEventListener('characteristicvaluechanged', () => { - if (!txChar.value) { - return; - } - dispatch(notify(txChar.value)); - }); - await txChar.startNotifications(); - } catch (err) { - console.error(err); - dispatch(notification.add('error', 'Getting nRF UART service failed.')); - device.gatt.disconnect(); - return; - } - dispatch(didConnect()); - // Try to force a soft reset so the hub is in a known state - dispatch(stop()); -} - -function disconnect(action: Action): void { - if (action.type !== BLEConnectActionType.Disconnect) { - return; - } - device?.gatt?.disconnect(); -} - -async function write(action: Action, dispatch: Dispatch): Promise { - if (action.type !== BleUartActionType.Write) { - return; - } - try { - await rxChar?.xWriteValueWithoutResponse(action.value.buffer); - dispatch(didWrite(action.id)); - } catch (err) { - dispatch(didFailToWrite(action.id, err)); - } -} - -function toggle(action: Action, dispatch: Dispatch, state: RootState): void { - if (action.type !== BLEActionType.Toggle) { - return; - } - switch (state.ble.connection) { - case BLEConnectionState.Connected: - dispatch(disconnectAction()); - break; - case BLEConnectionState.Disconnected: - dispatch(connectAction()); - break; - } -} - -export default combineServices(connect, disconnect, write, toggle); diff --git a/src/services/index.ts b/src/services/index.ts index 55e58def..fd227409 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -4,7 +4,6 @@ import { Middleware } from 'redux'; import { Action, Dispatch } from '../actions'; import { RootState } from '../reducers'; -import ble from './ble'; import bootloader from './lwp3-bootloader'; type Service = ( @@ -36,7 +35,7 @@ export function combineServices(...services: Service[]): Service { }; } -const rootService = combineServices(ble, bootloader); +const rootService = combineServices(bootloader); const serviceMiddleware: Middleware = (store) => (next) => (action): unknown => { runService(rootService, action, store.dispatch, store.getState());