Merge pull request #353 from pybricks/dlech

Pybricks BLE service implementation
This commit is contained in:
David Lechner
2021-03-12 08:58:32 -06:00
committed by GitHub
19 changed files with 1670 additions and 1799 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
"start": "craco start",
"build": "craco build",
"test": "craco test --env=./test/env.js",
"coverage": "craco test --env=./test/env.js --coverage --watchAll=false",
"coverage": "yarn test --coverage --watchAll=false",
"coverage:html": "yarn coverage --coverageReporters html",
"eject": "react-scripts eject",
"lint": "tsc --noEmit && eslint \"*/**/*.{js,ts,tsx}\" --quiet --fix"
+8
View File
@@ -3,6 +3,11 @@
import { Dispatch as ReduxDispatch } from 'redux';
import { AppAction } from './app/actions';
import {
BlePybricksServiceAction,
BlePybricksServiceCommandAction,
BlePybricksServiceEventAction,
} from './ble-pybricks-service/actions';
import { BleUartAction } from './ble-uart/actions';
import { BLEAction, BLEConnectAction } from './ble/actions';
import { EditorAction } from './editor/actions';
@@ -29,6 +34,9 @@ export type Action =
| AppAction
| BLEAction
| BLEConnectAction
| BlePybricksServiceAction
| BlePybricksServiceCommandAction
| BlePybricksServiceEventAction
| BleUartAction
| BootloaderConnectionAction
| BootloaderDidRequestAction
+228
View File
@@ -0,0 +1,228 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// actions/blePybricksService.ts: Actions for Bluetooth Low Energy Pybricks service
import { Action } from 'redux';
/**
* BLE Pybricks service actions types.
*/
export enum BlePybricksServiceActionType {
/**
* Write command to control characteristic.
*/
WriteCommand = 'blePybricksService.action.writeCommand',
/**
* Writing command to control characteristic completed successfully.
*/
DidWriteCommand = 'blePybricksService.action.didWriteCommand',
/**
* Writing command to control characteristic failed.
*/
DidFailToWriteCommand = 'blePybricksService.action.didFailToWriteCommand',
/**
* Event notification was received from the control characteristic.
*/
DidNotifyEvent = 'blePybricksService.action.didNotifyEvent',
}
/**
* Action that request to write a command to the Pybricks service control characteristic.
*/
export type BlePybricksServiceWriteCommandAction = Action<BlePybricksServiceActionType.WriteCommand> & {
id: number;
value: Uint8Array;
};
/**
* Action that request to write a command to the Pybricks service control characteristic.
*/
export function writeCommand(
id: number,
value: Uint8Array,
): BlePybricksServiceWriteCommandAction {
return {
type: BlePybricksServiceActionType.WriteCommand,
id,
value,
};
}
/**
* Action that indicates sending a command to the Pybricks service control characteristic was successful.
*/
export type BlePybricksServiceDidWriteCommandAction = Action<BlePybricksServiceActionType.DidWriteCommand> & {
id: number;
};
/**
* Action that indicates sending a command to the Pybricks service control characteristic was successful.
*/
export function didWriteCommand(id: number): BlePybricksServiceDidWriteCommandAction {
return {
type: BlePybricksServiceActionType.DidWriteCommand,
id,
};
}
/**
* Action that indicates sending a command to the Pybricks service control characteristic failed.
*/
export type BlePybricksServiceDidFailToWriteCommandAction = Action<BlePybricksServiceActionType.DidFailToWriteCommand> & {
id: number;
err: Error;
};
/**
* Action that indicates sending a command to the Pybricks service control characteristic failed.
*/
export function didFailToWriteCommand(
id: number,
err: Error,
): BlePybricksServiceDidFailToWriteCommandAction {
return {
type: BlePybricksServiceActionType.DidFailToWriteCommand,
id,
err,
};
}
/**
* Action that indicates an event notification was received on the Pybricks service control characteristic.
*/
export type BlePybricksServiceDidNotifyEventAction = Action<BlePybricksServiceActionType.DidNotifyEvent> & {
value: DataView;
};
/**
* Action that indicates an event notification was received on the Pybricks service control characteristic.
*/
export function didNotifyEvent(
value: DataView,
): BlePybricksServiceDidNotifyEventAction {
return {
type: BlePybricksServiceActionType.DidNotifyEvent,
value,
};
}
/** Common type for BLE Pybricks service actions. */
export type BlePybricksServiceAction =
| BlePybricksServiceWriteCommandAction
| BlePybricksServiceDidWriteCommandAction
| BlePybricksServiceDidFailToWriteCommandAction
| BlePybricksServiceDidNotifyEventAction;
/** Action types for commands sent via the Pybricks service control characteristic. */
export enum BlePybricksServiceCommandActionType {
SendStopUserProgram = 'blePybricksServiceCommand.action.sendStopUserProgram',
DidSend = 'blePybricksServiceCommand.action.didSend',
DidFailToSend = 'blePybricksServiceCommand.action.didFailToSend',
}
type TransactionId = {
/** Unique identifier for the transaction set in the "send" command. */
id: number;
};
/** Action that requests a stop user program to be sent. */
export type BlePybricksServiceCommandSendStopUserProgram = Action<BlePybricksServiceCommandActionType.SendStopUserProgram> &
TransactionId;
/**
* Action that requests a stop user program to be sent.
* @param id Unique identifier for this transaction.
*/
export function sendStopUserProgramCommand(
id: number,
): BlePybricksServiceCommandSendStopUserProgram {
return { type: BlePybricksServiceCommandActionType.SendStopUserProgram, id };
}
/**
* Action that indicates that a command was successfully sent.
*/
export type BlePybricksServiceCommandDidSendAction = Action<BlePybricksServiceCommandActionType.DidSend> &
TransactionId;
/**
* Action that indicates that a command was successfully sent.
* @param id Unique identifier for the transaction from the corresponding "send" command.
*/
export function didSendCommand(id: number): BlePybricksServiceCommandDidSendAction {
return { type: BlePybricksServiceCommandActionType.DidSend, id };
}
/**
* Action that indicates that a command was not sent.
*/
export type BlePybricksServiceCommandDidFailToSendAction = Action<BlePybricksServiceCommandActionType.DidFailToSend> &
TransactionId & {
/** The error that was raised. */
err: Error;
};
/**
* Action that indicates that a command was not sent.
* @param id Unique identifier for the transaction from the corresponding "send" command.
* @param err The error that was raised.
*/
export function didFailToSendCommand(
id: number,
err: Error,
): BlePybricksServiceCommandDidFailToSendAction {
return { type: BlePybricksServiceCommandActionType.DidFailToSend, id, err };
}
/** Common type for Pybricks control characteristic send command actions. */
export type BlePybricksServiceCommandAction =
| BlePybricksServiceCommandSendStopUserProgram
| BlePybricksServiceCommandDidSendAction
| BlePybricksServiceCommandDidFailToSendAction;
/** Action types for events received from the Pybricks service control characteristic. */
export enum BlePybricksServiceEventActionType {
/** A status report event. */
StatusReport = 'blePybricksServiceEvent.action.statusReport',
/** A pseudo-event indicating there was a protocol error (not directly received from the hub). */
ProtocolError = 'blePybricksServiceEvent.action.protocolError',
}
/**
* Action that represents a status report event received from the hub.
*/
export type BlePybricksServiceEventStatusReportAction = Action<BlePybricksServiceEventActionType.StatusReport> & {
statusFlags: number;
};
/**
* Action that represents a status report event received from the hub.
* @param statusFlags The status flags.
*/
export function statusReportEvent(
statusFlags: number,
): BlePybricksServiceEventStatusReportAction {
return { type: BlePybricksServiceEventActionType.StatusReport, statusFlags };
}
/**
* Pseudo-event (not received from hub) indicating that there was a protocol error.
*/
export type BlePybricksServiceEventProtocolErrorAction = Action<BlePybricksServiceEventActionType.ProtocolError> & {
err: Error;
};
/**
* Pseudo-event (not received from hub) indicating that there was a protocol error.
* @param err The error that was caught.
*/
export function eventProtocolError(
err: Error,
): BlePybricksServiceEventProtocolErrorAction {
return { type: BlePybricksServiceEventActionType.ProtocolError, err };
}
/** Common type for Pybricks control characteristic event actions. */
export type BlePybricksServiceEventAction =
| BlePybricksServiceEventStatusReportAction
| BlePybricksServiceEventProtocolErrorAction;
+10
View File
@@ -0,0 +1,10 @@
import { Status, statusToFlag } from './protocol';
describe('status flags should fit in 32 bits', () => {
test.each(Object.values(Status).filter((x) => typeof x === 'number'))(
'%s',
(status) => {
expect(Math.log2(statusToFlag(status as Status))).toBe(status);
},
);
});
+84
View File
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
//
// Definitions related to the Pybricks Bluetooth low energy GATT service.
import { assert } from '../utils';
/** Pybricks service UUID. */
export const ServiceUUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef';
/** Pybricks control characteristic UUID. */
export const ControlCharacteristicUUID = 'c5f50002-8280-46da-89f4-6d8051e4aeef';
/** Commands are instructions sent to the hub. */
export enum CommandType {
/** Request to stop the user program, if it is running. */
StopUserProgram = 0,
}
/**
* Creates a stop user program command message.
*/
export function createStopUserProgramCommand(): Uint8Array {
const msg = new Uint8Array(1);
msg[0] = CommandType.StopUserProgram;
return msg;
}
/** Events are notifications received from the hub. */
export enum EventType {
/** Status report. Received when notifications are enabled and when status changes. */
StatusReport = 0,
}
/** Status indications received by Event.StatusReport */
export enum Status {
/** Battery voltage is low. */
BatteryLowVoltageWarning = 0,
/** Battery voltage is critically low. */
BatteryLowVoltageShutdown = 1,
/** Battery current is too high. */
BatteryHighCurrent = 2,
/** Bluetooth Low Energy is advertising/discoverable. */
BLEAdvertising = 3,
/** Bluetooth Low Energy has low signal. */
BLELowSignal = 4,
/** Power button is currently pressed. */
PowerButtonPressed = 5,
/** User program is currently running. */
UserProgramRunning = 6,
}
/** Converts a Status enum value to a bit flag. */
export function statusToFlag(status: Status): number {
return 1 << status;
}
/** Gets the event type from a message. */
export function getEventType(msg: DataView): EventType {
return msg.getUint8(0) as EventType;
}
/**
* Parses the payload of a status report message.
* @param msg The raw message data.
* @returns The status as bit flags.
*/
export function parseStatusReport(msg: DataView): number {
assert(msg.getUint8(0) === EventType.StatusReport, 'expecting status report event');
return msg.getUint32(1, true);
}
/**
* Protocol error. Thrown e.g. when there is a malformed message.
*/
export class ProtocolError extends Error {
/**
* Creates a new ProtocolError.
* @param message The error message
* @param value The bytecodes that caused the error
*/
constructor(message: string, public value: DataView) {
super(message);
}
}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { AsyncSaga } from '../../test';
import {
didFailToSendCommand,
didFailToWriteCommand,
didNotifyEvent,
didSendCommand,
didWriteCommand,
eventProtocolError,
sendStopUserProgramCommand,
statusReportEvent,
writeCommand,
} from './actions';
import { CommandType, ProtocolError } from './protocol';
import blePybricksService from './sagas';
describe('command encoder', () => {
test.each([
[
'stop user program',
sendStopUserProgramCommand(0),
[
0x00, // stop user program command
],
],
])('encode %s request', async (_n, request, expected) => {
const saga = new AsyncSaga(blePybricksService);
saga.put(request);
const message = new Uint8Array(expected);
const action = await saga.take();
expect(action).toEqual(writeCommand(0, message));
await saga.end();
});
test('commands are serialized', async () => {
const saga = new AsyncSaga(blePybricksService);
// we send 4 commands
saga.put(sendStopUserProgramCommand(0));
saga.put(sendStopUserProgramCommand(1));
saga.put(sendStopUserProgramCommand(2));
saga.put(sendStopUserProgramCommand(3));
// but only two didSendCommand actions meaning only the first two completed
saga.put(didWriteCommand(0));
saga.put(didWriteCommand(1));
// So only 3 commands were actually sent and two didSendCommand were
// dispatched (making 5 total dispatches). The last request is still
// buffered and has not been dispatched.
const numPending = saga.numPending();
expect(numPending).toEqual(5);
const message = new Uint8Array([CommandType.StopUserProgram]);
// every other action is the "write command" action
// and the interleaving actions are "did send command" actions
const action0 = await saga.take();
expect(action0).toEqual(writeCommand(0, message));
const action1 = await saga.take();
expect(action1).toEqual(didSendCommand(0));
const action2 = await saga.take();
expect(action2).toEqual(writeCommand(1, message));
const action3 = await saga.take();
expect(action3).toEqual(didSendCommand(1));
const action4 = await saga.take();
expect(action4).toEqual(writeCommand(2, message));
await saga.end();
});
test('fail to send triggers fail to write', async () => {
const saga = new AsyncSaga(blePybricksService);
saga.put(sendStopUserProgramCommand(0));
const message = new Uint8Array([0x00]);
const action1 = await saga.take();
expect(action1).toEqual(writeCommand(0, message));
const err = new Error('test error');
saga.put(didFailToWriteCommand(0, err));
const action2 = await saga.take();
expect(action2).toEqual(didFailToSendCommand(0, err));
await saga.end();
});
});
describe('event decoder', () => {
test.each([
[
'status report',
[
0x00, // status report event
0x01, // flags count LSB
0x00, // .
0x00, // .
0x00, // flags count MSB
],
statusReportEvent(0x00000001),
],
])('decode %s event', async (_n, message, expected) => {
const saga = new AsyncSaga(blePybricksService);
const notification = new Uint8Array(message);
saga.put(didNotifyEvent(new DataView(notification.buffer)));
const action = await saga.take();
expect(action).toEqual(expected);
await saga.end();
});
test.each([
[
'unknown event',
[
0xff, // **bad event**
0x01, // **junk**
0x02, // **junk**
0x03, // **junk**
0x04, // **junk**
],
eventProtocolError(
new ProtocolError(
'unknown pybricks event type: 0xff',
new DataView(new Uint8Array().buffer),
),
),
],
])('protocol error', async (_n, message, expected) => {
const saga = new AsyncSaga(blePybricksService);
const notification = new Uint8Array(message);
saga.put(didNotifyEvent(new DataView(notification.buffer)));
const action = await saga.take();
expect(action).toEqual(expected);
await saga.end();
});
});
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
//
// Handles Pybricks protocol.
import {
actionChannel,
fork,
put,
race,
take,
takeEvery,
} from 'typed-redux-saga/macro';
import { Action } from '../actions';
import { hex } from '../utils';
import {
BlePybricksServiceActionType,
BlePybricksServiceCommandAction,
BlePybricksServiceCommandActionType,
BlePybricksServiceDidFailToWriteCommandAction,
BlePybricksServiceDidNotifyEventAction,
BlePybricksServiceDidWriteCommandAction,
didFailToSendCommand,
didSendCommand,
eventProtocolError,
statusReportEvent,
writeCommand,
} from './actions';
import {
EventType,
ProtocolError,
createStopUserProgramCommand,
getEventType,
parseStatusReport,
} from './protocol';
/**
* Converts a request action into bytecodes and creates a new action to send
* the bytecodes to to the device.
*/
function* encodeRequest(): Generator {
// Using a while loop to serialize sending data to avoid "busy" errors.
const sendCommands: readonly BlePybricksServiceCommandActionType[] = Object.values(
BlePybricksServiceCommandActionType,
).filter(
(x) =>
x !== BlePybricksServiceCommandActionType.DidSend &&
x != BlePybricksServiceCommandActionType.DidFailToSend,
);
const chan = yield* actionChannel<BlePybricksServiceCommandAction>((a: Action) =>
sendCommands.includes(a.type as BlePybricksServiceCommandActionType),
);
while (true) {
const action = yield* take(chan);
switch (action.type) {
case BlePybricksServiceCommandActionType.SendStopUserProgram:
yield* put(writeCommand(action.id, createStopUserProgramCommand()));
break;
/* istanbul ignore next: should not be possible to reach */
default:
console.error(`Unknown Pybricks service command ${action.type}`);
continue;
}
const { failedToSend } = yield* race({
sent: take<BlePybricksServiceDidWriteCommandAction>(
BlePybricksServiceActionType.DidWriteCommand,
),
failedToSend: take<BlePybricksServiceDidFailToWriteCommandAction>(
BlePybricksServiceActionType.DidFailToWriteCommand,
),
});
if (failedToSend) {
yield* put(didFailToSendCommand(action.id, failedToSend.err));
} else {
yield* put(didSendCommand(action.id));
}
}
}
/**
* Converts an incoming connection message to a response action.
* @param action The received response action.
*/
function* decodeResponse(action: BlePybricksServiceDidNotifyEventAction): Generator {
try {
const responseType = getEventType(action.value);
switch (responseType) {
case EventType.StatusReport:
yield* put(statusReportEvent(parseStatusReport(action.value)));
break;
default:
throw new ProtocolError(
`unknown pybricks event type: ${hex(responseType, 2)}`,
action.value,
);
}
} catch (err) {
yield* put(eventProtocolError(err));
}
}
export default function* (): Generator {
yield* fork(encodeRequest);
yield* takeEvery(BlePybricksServiceActionType.DidNotifyEvent, decodeResponse);
}
-9
View File
@@ -1,9 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
//
// 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';
+8 -8
View File
@@ -11,19 +11,19 @@ export enum BleUartActionType {
/**
* Write data.
*/
Write = 'ble.data.action.write',
Write = 'bleUart.action.write',
/**
* Writing completed successfully.
*/
DidWrite = 'ble.data.didWrite',
DidWrite = 'bleUart.didWrite',
/**
* Writing failed.
*/
DidFailToWrite = 'ble.data.action.didFailToWrite',
DidFailToWrite = 'bleUart.action.didFailToWrite',
/**
* Notify that data was received.
*/
Notify = 'ble.data.action.receive',
DidNotify = 'bleUart.action.didNotify',
}
export type BleUartWriteAction = Action<BleUartActionType.Write> & {
@@ -52,12 +52,12 @@ export function didFailToWrite(id: number, err: Error): BleUartDidFailToWriteAct
return { type: BleUartActionType.DidFailToWrite, id, err };
}
export type BleUartNotifyAction = Action<BleUartActionType.Notify> & {
export type BleUartDidNotifyAction = Action<BleUartActionType.DidNotify> & {
value: DataView;
};
export function notify(value: DataView): BleUartNotifyAction {
return { type: BleUartActionType.Notify, value };
export function didNotify(value: DataView): BleUartDidNotifyAction {
return { type: BleUartActionType.DidNotify, value };
}
/** Common type for low-level BLE data actions. */
@@ -65,4 +65,4 @@ export type BleUartAction =
| BleUartWriteAction
| BleUartDidWriteAction
| BleUartDidFailToWriteAction
| BleUartNotifyAction;
| BleUartDidNotifyAction;
+161 -48
View File
@@ -1,9 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2021 The Pybricks Authors
//
// Manages connection to a Bluetooth Low Energy device with the Nordic (nRF) UART service.
// Manages connection to a Bluetooth Low Energy device running Pybricks firmware.
import { END, eventChannel } from 'redux-saga';
// TODO: this file needs to be combined with the firmware BLE connection management
// to reduce duplicated code
import { END, Task, eventChannel } from 'redux-saga';
import {
call,
cancel,
@@ -12,7 +15,16 @@ import {
takeEvery,
takeMaybe,
} from 'typed-redux-saga/macro';
import { ServiceUUID as pybricksServiceUUID } from '../ble-pybricks/protocol';
import {
BlePybricksServiceActionType,
didFailToWriteCommand,
didNotifyEvent,
didWriteCommand,
} from '../ble-pybricks-service/actions';
import {
ControlCharacteristicUUID as pybricksCommandCharacteristicUUID,
ServiceUUID as pybricksServiceUUID,
} from '../ble-pybricks-service/protocol';
import {
BLEActionType,
BleDeviceActionType as BLEDeviceActionType,
@@ -31,14 +43,14 @@ import { RootState } from '../reducers';
import {
BleUartActionType,
BleUartWriteAction,
didFailToWrite,
didWrite,
notify,
didFailToWrite as didFailToWriteUart,
didNotify as didNotifyUart,
didWrite as didWriteUart,
} from './actions';
import {
RxCharUUID as uartRxCharUUID,
ServiceUUID as uartServiceUUID,
TxCharUUID as uartTxCharUUID,
RxCharUUID as urtRxCharUUID,
} from './protocol';
function disconnect(
@@ -48,19 +60,35 @@ function disconnect(
server.disconnect();
}
function* handleValueChanged(data: DataView): Generator {
yield* put(notify(data));
function* handlePybricksControlValueChanged(data: DataView): Generator {
yield* put(didNotifyEvent(data));
}
function* write(
rxChar: BluetoothRemoteGATTCharacteristic,
function* writePybricksCommand(
char: BluetoothRemoteGATTCharacteristic,
action: BleUartWriteAction,
): Generator {
try {
yield* call(() => rxChar.writeValueWithoutResponse(action.value.buffer));
yield* put(didWrite(action.id));
yield* call(() => char.writeValueWithoutResponse(action.value.buffer));
yield* put(didWriteCommand(action.id));
} catch (err) {
yield* put(didFailToWrite(action.id, err));
yield* put(didFailToWriteCommand(action.id, err));
}
}
function* handleUartValueChanged(data: DataView): Generator {
yield* put(didNotifyUart(data));
}
function* writeUart(
char: BluetoothRemoteGATTCharacteristic,
action: BleUartWriteAction,
): Generator {
try {
yield* call(() => char.writeValueWithoutResponse(action.value.buffer));
yield* put(didWriteUart(action.id));
} catch (err) {
yield* put(didFailToWriteUart(action.id, err));
}
}
@@ -81,7 +109,7 @@ function* connect(_action: BleDeviceConnectAction): Generator {
device = yield* call(() =>
navigator.bluetooth.requestDevice({
filters: [{ services: [pybricksServiceUUID] }],
optionalServices: [uartServiceUUID],
optionalServices: [pybricksServiceUUID, uartServiceUUID],
}),
);
} catch (err) {
@@ -117,15 +145,16 @@ function* connect(_action: BleDeviceConnectAction): Generator {
yield* takeEvery(BLEDeviceActionType.Disconnect, disconnect, server);
let service: BluetoothRemoteGATTService;
let pybricksService: BluetoothRemoteGATTService;
try {
service = yield* call([server, 'getPrimaryService'], uartServiceUUID);
pybricksService = yield* call(
[server, 'getPrimaryService'],
pybricksServiceUUID,
);
} catch (err) {
server.disconnect();
yield* takeMaybe(disconnectChannel);
if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) {
// Possibly/probably caused by Chrome BlueZ back-end bug
// https://chromium-review.googlesource.com/c/chromium/src/+/2214098
yield* put(didFailToConnect({ reason: Reason.NoService }));
} else {
yield* put(didFailToConnect({ reason: Reason.Unknown, err }));
@@ -133,9 +162,12 @@ function* connect(_action: BleDeviceConnectAction): Generator {
return;
}
let rxChar: BluetoothRemoteGATTCharacteristic;
let pybricksControlChar: BluetoothRemoteGATTCharacteristic;
try {
rxChar = yield* call([service, 'getCharacteristic'], urtRxCharUUID);
pybricksControlChar = yield* call(
[pybricksService, 'getCharacteristic'],
pybricksCommandCharacteristicUUID,
);
} catch (err) {
server.disconnect();
yield* takeMaybe(disconnectChannel);
@@ -143,56 +175,137 @@ function* connect(_action: BleDeviceConnectAction): Generator {
return;
}
let txChar: BluetoothRemoteGATTCharacteristic;
try {
txChar = yield* call([service, 'getCharacteristic'], uartTxCharUUID);
} catch (err) {
server.disconnect();
yield* takeMaybe(disconnectChannel);
yield* put(didFailToConnect({ reason: Reason.Unknown, err }));
return;
}
const txChannel = eventChannel<DataView>((emitter) => {
const pybricksControlChannel = eventChannel<DataView>((emitter) => {
const listener = (): void => {
if (!txChar.value) {
if (!pybricksControlChar.value) {
return;
}
emitter(txChar.value);
emitter(pybricksControlChar.value);
};
txChar.addEventListener('characteristicvaluechanged', listener);
pybricksControlChar.addEventListener('characteristicvaluechanged', listener);
return (): void =>
txChar.removeEventListener('characteristicvaluechanged', listener);
pybricksControlChar.removeEventListener(
'characteristicvaluechanged',
listener,
);
});
// forked tasks that will need to be canceled later
const tasks = new Array<Task>();
tasks.push(
yield* takeEvery(pybricksControlChannel, handlePybricksControlValueChanged),
);
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']);
yield* call([pybricksControlChar, 'stopNotifications']);
yield* call([pybricksControlChar, 'startNotifications']);
} catch (err) {
txChannel.close();
yield* cancel(tasks);
pybricksControlChannel.close();
server.disconnect();
yield* takeMaybe(disconnectChannel);
yield* put(didFailToConnect({ reason: Reason.Unknown, err }));
return;
}
yield* takeEvery(txChannel, handleValueChanged);
yield* takeEvery(BleUartActionType.Write, write, rxChar);
tasks.push(
yield* takeEvery(
BlePybricksServiceActionType.WriteCommand,
writePybricksCommand,
pybricksControlChar,
),
);
let uartService: BluetoothRemoteGATTService;
try {
uartService = yield* call([server, 'getPrimaryService'], uartServiceUUID);
} catch (err) {
yield* cancel(tasks);
pybricksControlChannel.close();
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 uartRxChar: BluetoothRemoteGATTCharacteristic;
try {
uartRxChar = yield* call([uartService, 'getCharacteristic'], uartRxCharUUID);
} catch (err) {
yield* cancel(tasks);
pybricksControlChannel.close();
server.disconnect();
yield* takeMaybe(disconnectChannel);
yield* put(didFailToConnect({ reason: Reason.Unknown, err }));
return;
}
let uartTxChar: BluetoothRemoteGATTCharacteristic;
try {
uartTxChar = yield* call([uartService, 'getCharacteristic'], uartTxCharUUID);
} catch (err) {
yield* cancel(tasks);
pybricksControlChannel.close();
server.disconnect();
yield* takeMaybe(disconnectChannel);
yield* put(didFailToConnect({ reason: Reason.Unknown, err }));
return;
}
const uartTxChannel = eventChannel<DataView>((emitter) => {
const listener = (): void => {
if (!uartTxChar.value) {
return;
}
emitter(uartTxChar.value);
};
uartTxChar.addEventListener('characteristicvaluechanged', listener);
return (): void =>
uartTxChar.removeEventListener('characteristicvaluechanged', listener);
});
tasks.push(yield* takeEvery(uartTxChannel, handleUartValueChanged));
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([uartTxChar, 'stopNotifications']);
yield* call([uartTxChar, 'startNotifications']);
} catch (err) {
yield* cancel(tasks);
uartTxChannel.close();
pybricksControlChannel.close();
server.disconnect();
yield* takeMaybe(disconnectChannel);
yield* put(didFailToConnect({ reason: Reason.Unknown, err }));
return;
}
tasks.push(yield* takeEvery(BleUartActionType.Write, writeUart, uartRxChar));
yield* put(didConnect());
// wait for disconnection
yield* takeMaybe(disconnectChannel);
txChannel.close();
try {
yield* cancel(); // have to cancel to stop forked effects
} finally {
yield* put(didDisconnect());
}
yield* cancel(tasks);
uartTxChannel.close();
pybricksControlChannel.close();
yield* put(didDisconnect());
}
function* toggle(_action: BLEToggleAction): Generator {
+11
View File
@@ -2,6 +2,7 @@
// Copyright (c) 2020 The Pybricks Authors
import { AsyncSaga } from '../../test';
import { eventProtocolError } from '../ble-pybricks-service/actions';
import { didFailToWrite } from '../ble-uart/actions';
import {
BleDeviceFailToConnectReasonType,
@@ -46,6 +47,16 @@ test('bleDataDidFailToWrite', async () => {
await saga.end();
});
test('eventProtocolError', async () => {
const saga = new AsyncSaga(errorLog);
console.error = jest.fn();
saga.put(eventProtocolError(new Error('test error')));
expect(console.error).toHaveBeenCalledTimes(1);
await saga.end();
});
test('bootloaderDidFailToConnect', async () => {
const saga = new AsyncSaga(errorLog);
+15 -1
View File
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2021 The Pybricks Authors
import { takeEvery } from 'typed-redux-saga/macro';
import {
BlePybricksServiceEventActionType,
BlePybricksServiceEventProtocolErrorAction,
} from '../ble-pybricks-service/actions';
import { BleUartActionType, BleUartDidFailToWriteAction } from '../ble-uart/actions';
import {
BleDeviceActionType,
@@ -25,6 +29,12 @@ function bleDeviceDidFailToConnect(action: BleDeviceDidFailToConnectAction): voi
}
}
function pybricksProtocolError(
action: BlePybricksServiceEventProtocolErrorAction,
): void {
console.error(action.err);
}
function bleDataDidFailToWrite(action: BleUartDidFailToWriteAction): void {
console.error(action.err);
}
@@ -47,6 +57,10 @@ function licenseDidFailToFetch(action: LicenseDidFailToFetchListAction): void {
export default function* (): Generator {
yield* takeEvery(BleDeviceActionType.DidFailToConnect, bleDeviceDidFailToConnect);
yield* takeEvery(
BlePybricksServiceEventActionType.ProtocolError,
pybricksProtocolError,
);
yield* takeEvery(BleUartActionType.DidFailToWrite, bleDataDidFailToWrite);
yield* takeEvery(
BootloaderConnectionActionType.DidFailToConnect,
+7 -3
View File
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2021 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import { Action } from '../actions';
import { BlePybricksServiceEventActionType } from '../ble-pybricks-service/actions';
import { Status, statusToFlag } from '../ble-pybricks-service/protocol';
import { BleDeviceActionType } from '../ble/actions';
import { HubMessageActionType, HubRuntimeStatusType } from './actions';
@@ -47,8 +49,6 @@ const runtime: Reducer<HubRuntimeState, Action> = (
switch (action.type) {
case BleDeviceActionType.DidDisconnect:
return HubRuntimeState.Disconnected;
case BleDeviceActionType.DidConnect:
return HubRuntimeState.Unknown;
case HubMessageActionType.RuntimeStatus:
switch (action.newStatus) {
case HubRuntimeStatusType.Idle:
@@ -65,6 +65,10 @@ const runtime: Reducer<HubRuntimeState, Action> = (
console.error(`bad action/state: ${action.newStatus}`);
return state;
}
case BlePybricksServiceEventActionType.StatusReport:
return action.statusFlags & statusToFlag(Status.UserProgramRunning)
? HubRuntimeState.Running
: HubRuntimeState.Idle;
default:
return state;
}
+17 -4
View File
@@ -4,6 +4,11 @@
import { Ace } from 'ace-builds';
import { mock } from 'jest-mock-extended';
import { AsyncSaga } from '../../test';
import {
BlePybricksServiceCommandActionType,
BlePybricksServiceCommandSendStopUserProgram,
didSendCommand,
} from '../ble-pybricks-service/actions';
import { BleUartActionType, BleUartWriteAction, didWrite } from '../ble-uart/actions';
import { MpyActionType, didCompile } from '../mpy/actions';
import { createCountFunc } from '../utils/iter';
@@ -82,8 +87,8 @@ test('repl', async () => {
saga.put(repl());
const compileAction = await saga.take();
expect(compileAction.type).toBe(BleUartActionType.Write);
const action = await saga.take();
expect(action.type).toBe(BleUartActionType.Write);
await saga.end();
});
@@ -93,8 +98,16 @@ test('stop', async () => {
saga.put(stop());
const compileAction = await saga.take();
expect(compileAction.type).toBe(BleUartActionType.Write);
const pybricksServiceAction = await saga.take();
expect(pybricksServiceAction.type).toBe(
BlePybricksServiceCommandActionType.SendStopUserProgram,
);
saga.put(
didSendCommand(
(pybricksServiceAction as BlePybricksServiceCommandSendStopUserProgram).id,
),
);
await saga.end();
});
+26 -4
View File
@@ -12,6 +12,12 @@ import {
takeEvery,
} from 'typed-redux-saga/macro';
import { Action } from '../actions';
import {
BlePybricksServiceCommandActionType,
BlePybricksServiceCommandDidFailToSendAction,
BlePybricksServiceCommandDidSendAction,
sendStopUserProgramCommand,
} from '../ble-pybricks-service/actions';
import {
BleUartActionType,
BleUartDidFailToWriteAction,
@@ -151,12 +157,28 @@ function* startRepl(_action: HubReplAction): Generator {
yield* put(write(nextMessageId(), startReplCommand));
}
// CTRL+C, CTRL+C, CTRL+D
const stopCommand = new Uint8Array([0x03, 0x03, 0x04]);
function* stop(_action: HubStopAction): Generator {
const nextMessageId = yield* getContext<() => number>('nextMessageId');
yield* put(write(nextMessageId(), stopCommand));
const id = nextMessageId();
yield* put(sendStopUserProgramCommand(id));
// REVISIT: may want to disable button while attempting to send command
// this would mean didSendStop() and didFailToSendStop() actions here
const { failedToSend } = yield* race({
sent: take<BlePybricksServiceCommandDidSendAction>(
(a: Action) =>
a.type === BlePybricksServiceCommandActionType.DidSend && a.id === id,
),
failedToSend: take<BlePybricksServiceCommandDidFailToSendAction>(
(a: Action) =>
a.type === BlePybricksServiceCommandActionType.DidFailToSend &&
a.id === id,
),
});
if (failedToSend) {
// TODO: probably want to check error. If hub disconnected, ignore error
// otherwise indicate error to user
console.error(failedToSend.err);
}
}
export default function* (): Generator {
+2
View File
@@ -4,6 +4,7 @@
import { all, put } from 'typed-redux-saga/macro';
import { didStart } from './app/actions';
import app from './app/sagas';
import blePybricksService from './ble-pybricks-service/sagas';
import bleUart from './ble-uart/sagas';
import editor from './editor/sagas';
import errorLog from './error-log/sagas';
@@ -21,6 +22,7 @@ import terminal from './terminal/sagas';
export default function* (): Generator {
yield* all([
app(),
blePybricksService(),
bleUart(),
lwp3BootloaderBle(),
lwp3BootloaderProtocol(),
+4 -263
View File
@@ -7,15 +7,10 @@ import {
BleUartActionType,
BleUartWriteAction,
didFailToWrite,
didNotify,
didWrite,
notify,
} from '../ble-uart/actions';
import {
HubChecksumMessageAction,
HubMessageActionType,
HubRuntimeStatusMessageAction,
HubRuntimeStatusType,
} from '../hub/actions';
import { HubChecksumMessageAction, HubMessageActionType } from '../hub/actions';
import { HubRuntimeState } from '../hub/reducers';
import { createCountFunc } from '../utils/iter';
import {
@@ -35,7 +30,7 @@ describe('Data receiver filters out hub status', () => {
);
// sending ASCII space character
saga.put(notify(new DataView(new Uint8Array([0x20]).buffer)));
saga.put(didNotify(new DataView(new Uint8Array([0x20]).buffer)));
const action = await saga.take();
expect(action.type).toBe(TerminalActionType.SendData);
@@ -51,7 +46,7 @@ describe('Data receiver filters out hub status', () => {
{ nextMessageId: createCountFunc() },
);
saga.put(notify(new DataView(new Uint8Array([0xaa]).buffer)));
saga.put(didNotify(new DataView(new Uint8Array([0xaa]).buffer)));
const action = await saga.take();
expect(action.type).toBe(HubMessageActionType.Checksum);
@@ -59,260 +54,6 @@ describe('Data receiver filters out hub status', () => {
await saga.end();
});
test('idle message', async () => {
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '>>>> IDLE'
saga.put(
notify(
new DataView(
new Uint8Array([
0x3e,
0x3e,
0x3e,
0x3e,
0x20,
0x49,
0x44,
0x4c,
0x45,
]).buffer,
),
),
);
const action = await saga.take();
expect(action.type).toBe(HubMessageActionType.RuntimeStatus);
expect((action as HubRuntimeStatusMessageAction).newStatus).toBe(
HubRuntimeStatusType.Idle,
);
await saga.end();
});
test('idle message with extra text', async () => {
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '0>>>> IDLE1'
saga.put(
notify(
new DataView(
new Uint8Array([
0x30,
0x3e,
0x3e,
0x3e,
0x3e,
0x20,
0x49,
0x44,
0x4c,
0x45,
0x31,
]).buffer,
),
),
);
// this should get split into '0', idle status, '1'
const action1 = await saga.take();
expect(action1.type).toBe(TerminalActionType.SendData);
expect((action1 as TerminalDataSendDataAction).value).toBe('0');
const action2 = await saga.take();
expect(action2.type).toBe(HubMessageActionType.RuntimeStatus);
expect((action2 as HubRuntimeStatusMessageAction).newStatus).toBe(
HubRuntimeStatusType.Idle,
);
const action3 = await saga.take();
expect(action3.type).toBe(TerminalActionType.SendData);
expect((action3 as TerminalDataSendDataAction).value).toBe('1');
await saga.end();
});
test('error message', async () => {
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '>>>> ERROR'
saga.put(
notify(
new DataView(
new Uint8Array([
0x3e,
0x3e,
0x3e,
0x3e,
0x20,
0x45,
0x52,
0x52,
0x4f,
0x52,
]).buffer,
),
),
);
const action = await saga.take();
expect(action.type).toBe(HubMessageActionType.RuntimeStatus);
expect((action as HubRuntimeStatusMessageAction).newStatus).toBe(
HubRuntimeStatusType.Error,
);
await saga.end();
});
test('error message with extra text', async () => {
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '0>>>> ERROR1'
saga.put(
notify(
new DataView(
new Uint8Array([
0x30,
0x3e,
0x3e,
0x3e,
0x3e,
0x20,
0x45,
0x52,
0x52,
0x4f,
0x52,
0x31,
]).buffer,
),
),
);
// this should get split into '0', error status, '1'
const action1 = await saga.take();
expect(action1.type).toBe(TerminalActionType.SendData);
expect((action1 as TerminalDataSendDataAction).value).toBe('0');
const action2 = await saga.take();
expect(action2.type).toBe(HubMessageActionType.RuntimeStatus);
expect((action2 as HubRuntimeStatusMessageAction).newStatus).toBe(
HubRuntimeStatusType.Error,
);
const action3 = await saga.take();
expect(action3.type).toBe(TerminalActionType.SendData);
expect((action3 as TerminalDataSendDataAction).value).toBe('1');
await saga.end();
});
test('running message', async () => {
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '>>>> ERROR'
saga.put(
notify(
new DataView(
new Uint8Array([
0x3e,
0x3e,
0x3e,
0x3e,
0x20,
0x52,
0x55,
0x4e,
0x4e,
0x49,
0x4e,
0x47,
]).buffer,
),
),
);
const action = await saga.take();
expect(action.type).toBe(HubMessageActionType.RuntimeStatus);
expect((action as HubRuntimeStatusMessageAction).newStatus).toBe(
HubRuntimeStatusType.Running,
);
await saga.end();
});
test('running message with extra text', async () => {
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '0>>>> RUNNING1'
saga.put(
notify(
new DataView(
new Uint8Array([
0x30,
0x3e,
0x3e,
0x3e,
0x3e,
0x20,
0x52,
0x55,
0x4e,
0x4e,
0x49,
0x4e,
0x47,
0x31,
]).buffer,
),
),
);
// this should get split into '0', running status, '1'
const action1 = await saga.take();
expect(action1.type).toBe(TerminalActionType.SendData);
expect((action1 as TerminalDataSendDataAction).value).toBe('0');
const action2 = await saga.take();
expect(action2.type).toBe(HubMessageActionType.RuntimeStatus);
expect((action2 as HubRuntimeStatusMessageAction).newStatus).toBe(
HubRuntimeStatusType.Running,
);
const action3 = await saga.take();
expect(action3.type).toBe(TerminalActionType.SendData);
expect((action3 as TerminalDataSendDataAction).value).toBe('1');
await saga.end();
});
});
test('Terminal data source responds to send data actions', async () => {
+5 -51
View File
@@ -13,9 +13,9 @@ import {
takeEvery,
} from 'typed-redux-saga/macro';
import { Action } from '../actions';
import { BleUartActionType, BleUartNotifyAction, write } from '../ble-uart/actions';
import { BleUartActionType, BleUartDidNotifyAction, write } from '../ble-uart/actions';
import { SafeTxCharLength } from '../ble-uart/protocol';
import { HubRuntimeStatusType, checksum, updateStatus } from '../hub/actions';
import { checksum } from '../hub/actions';
import { HubRuntimeState } from '../hub/reducers';
import { RootState } from '../reducers';
import { defined } from '../utils';
@@ -25,28 +25,7 @@ import { TerminalActionType, TerminalDataReceiveDataAction, sendData } from './a
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function* handleMatch(
match: RegExpMatchArray | null,
status: HubRuntimeStatusType,
): Generator<unknown, boolean> {
if (!match) {
return false;
}
if (match[1]) {
yield* put(sendData(match[1]));
}
yield* put(updateStatus(status));
if (match[2]) {
yield* put(sendData(match[2]));
}
return true;
}
function* receiveUartData(action: BleUartNotifyAction): Generator {
function* receiveUartData(action: BleUartDidNotifyAction): Generator {
const hubState = yield* select((s: RootState) => s.hub.runtime);
if (hubState === HubRuntimeState.Loading && action.value.buffer.byteLength === 1) {
@@ -56,31 +35,6 @@ function* receiveUartData(action: BleUartNotifyAction): Generator {
}
const value = decoder.decode(action.value.buffer);
if (
yield* handleMatch(value.match(/(.*)>>>> IDLE(.*)/), HubRuntimeStatusType.Idle)
) {
return;
}
if (
yield* handleMatch(
value.match(/(.*)>>>> ERROR(.*)/),
HubRuntimeStatusType.Error,
)
) {
return;
}
if (
yield* handleMatch(
value.match(/(.*)>>>> RUNNING(.*)/),
HubRuntimeStatusType.Running,
)
) {
return;
}
yield* put(sendData(value));
}
@@ -123,7 +77,7 @@ function* receiveTerminalData(): Generator {
);
// wait for echo so tht we don't overrun the hub with messages
yield* race([take(BleUartActionType.Notify), delay(100)]);
yield* race([take(BleUartActionType.DidNotify), delay(100)]);
}
}
}
@@ -135,7 +89,7 @@ function* sendTerminalData(action: TerminalDataReceiveDataAction): Generator {
}
export default function* (): Generator {
yield* takeEvery(BleUartActionType.Notify, receiveUartData);
yield* takeEvery(BleUartActionType.DidNotify, receiveUartData);
yield* fork(receiveTerminalData);
yield* takeEvery(TerminalActionType.SendData, sendTerminalData);
}
+826 -1407
View File
File diff suppressed because it is too large Load Diff