convert ble service to saga

This commit is contained in:
David Lechner
2020-06-11 22:53:38 -05:00
committed by David Lechner
parent f3425e2f0f
commit bda007c17a
20 changed files with 500 additions and 227 deletions
-2
View File
@@ -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<BleUartActionType.Write> & {
};
export function write(value: Uint8Array): BleUartWriteAction {
assert(value.length <= 20, 'value can be at most 20 bytes');
return { type: BleUartActionType.Write, id: nextId(), value };
}
+110 -36
View File
@@ -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<BleDeviceActionType.Connect>;
/**
* Creates an action that indicates connecting has been requested.
*/
export function connect(): BleDeviceConnectAction {
return { type: BleDeviceActionType.Connect };
}
export type BleDeviceDidConnectAction = Action<BleDeviceActionType.DidConnect>;
/**
* 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<T extends BleDeviceFailToConnectReasonType> = {
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<BleDeviceActionType.Disconnect>;
/**
* Creates an action that indicates disconnecting was requested.
*/
export function disconnect(): BleDeviceDisconnectAction {
return { type: BleDeviceActionType.Disconnect };
}
export type BleDeviceDidDisconnectAction = Action<BleDeviceActionType.DidDisconnect>;
/**
* 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<BLEConnectActionType>;
/**
* 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.
+3 -3
View File
@@ -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<ActionButtonProps, 'onAction'>;
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,
};
}
};
+4 -1
View File
@@ -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<NotificationProps> {
messageId,
onAction,
onClose,
replacements,
} = this.props;
return (
<Toast
@@ -74,7 +77,7 @@ class Notification extends React.Component<NotificationProps> {
<div>
<p>
{messageId
? i18n.translate(messageId)
? i18n.translate(messageId, replacements)
: message || 'missing message!'}
</p>
{helpUrl && (
+1
View File
@@ -25,6 +25,7 @@ class NotificationStack extends React.Component<NotificationStackProps> {
level={n.level}
message={n.message}
messageId={n.messageId}
replacements={n.replacements}
helpUrl={n.helpUrl}
action={n.action}
/>
+2 -1
View File
@@ -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."
},
+23
View File
@@ -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;
+9
View File
@@ -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';
+23 -18
View File
@@ -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<BLEConnectionState> = (
state = BLEConnectionState.Disconnected,
const connection: Reducer<BleConnectionState, Action> = (
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 });
+3 -3
View File
@@ -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<HubRuntimeState, Action> = (
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) {
+2 -2
View File
@@ -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;
+52 -6
View File
@@ -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<NotificationList, Action> = (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<NotificationList, Action> = (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<NotificationList, Action> = (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<NotificationList, Action> = (state = [], action) => {
state,
Level.Info,
MessageId.ServiceWorkerUpdate,
undefined,
'https://bit.ly/CRA-PWA',
);
case ServiceWorkerActionType.Success:
+218
View File
@@ -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<DataView>((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);
}
+25
View File
@@ -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);
+12
View File
@@ -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,
+6 -2
View File
@@ -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);
}
+2
View File
@@ -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(),
+4 -3
View File
@@ -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(
-148
View File
@@ -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<void> {
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<void> {
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);
+1 -2
View File
@@ -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());