mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
convert ble service to saga
This commit is contained in:
committed by
David Lechner
parent
f3425e2f0f
commit
bda007c17a
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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,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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user