mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
Merge pull request #353 from pybricks/dlech
Pybricks BLE service implementation
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user