add support for Pybricks Profile v1.3.0

This commit is contained in:
David Lechner
2023-04-19 13:03:48 -05:00
committed by David Lechner
parent 63a1026dae
commit 337209744c
9 changed files with 493 additions and 36 deletions
+27 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
// Copyright (c) 2021-2023 The Pybricks Authors
//
// Actions for Bluetooth Low Energy Pybricks service
@@ -109,6 +109,21 @@ export const sendWriteUserRamCommand = createAction(
}),
);
/**
* Action that requests to write to stdin.
* @param id Unique identifier for this transaction.
* @param payload The bytes to write.
*
* @since Pybricks Profile v1.3.0.
*/
export const sendWriteStdinCommand = createAction(
(id: number, payload: ArrayBuffer) => ({
type: 'blePybricksServiceCommand.action.sendWriteStdinCommand',
id,
payload,
}),
);
/**
* Action that indicates that a command was successfully sent.
* @param id Unique identifier for the transaction from the corresponding "send" command.
@@ -140,6 +155,17 @@ export const didReceiveStatusReport = createAction((statusFlags: number) => ({
statusFlags,
}));
/**
* Action that represents a status report event received from the hub.
* @param statusFlags The status flags.
*
* @since Pybricks Profile v1.3.0
*/
export const didReceiveWriteStdout = createAction((payload: ArrayBuffer) => ({
type: 'blePybricksServiceEvent.action.didReceiveWriteStdout',
payload,
}));
/**
* Pseudo-event = actionCreator((not received from hub) indicating that there was a protocol error.
* @param error The error that was caught.
+39 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
// Copyright (c) 2020-2023 The Pybricks Authors
//
// Definitions related to the Pybricks Bluetooth low energy GATT service.
@@ -52,6 +52,12 @@ export enum CommandType {
* @since Pybricks Profile v1.2.0
*/
ResetInUpdateMode = 5,
/**
* Request to write data to stdin.
*
* @since Pybricks Profile v1.3.0
*/
WriteStdin = 6,
}
/**
@@ -120,6 +126,20 @@ export function createWriteUserRamCommand(
return msg;
}
/**
* Creates a {@link CommandType.WriteStdin} message.
* @param payload The bytes to write.
*
* @since Pybricks Profile v1.3.0.
*/
export function createWriteStdinCommand(payload: ArrayBuffer): Uint8Array {
const msg = new Uint8Array(1 + payload.byteLength);
const view = new DataView(msg.buffer);
view.setUint8(0, CommandType.WriteStdin);
msg.set(new Uint8Array(payload), 1);
return msg;
}
/** Events are notifications received from the hub. */
export enum EventType {
/**
@@ -130,6 +150,12 @@ export enum EventType {
* @since Pybricks Profile v1.0.0
*/
StatusReport = 0,
/**
* Hub wrote to stdout event.
*
* @since Pybricks Profile v1.3.0
*/
WriteStdout = 1,
}
/** Status indications received by Event.StatusReport */
@@ -206,6 +232,18 @@ export function parseStatusReport(msg: DataView): number {
return msg.getUint32(1, true);
}
/**
* Parses the payload of a write stdout.
* @param msg The raw message data.
* @returns The bytes that were written.
*
* @since Pybricks Profile v1.3.0
*/
export function parseWriteStdout(msg: DataView): ArrayBuffer {
assert(msg.getUint8(0) === EventType.WriteStdout, 'expecting write stdout event');
return msg.buffer.slice(1);
}
/**
* Protocol error. Thrown e.g. when there is a malformed message.
*/
+76 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2023 The Pybricks Authors
import { AsyncSaga } from '../../test';
import {
@@ -7,10 +7,16 @@ import {
didFailToWriteCommand,
didNotifyEvent,
didReceiveStatusReport,
didReceiveWriteStdout,
didSendCommand,
didWriteCommand,
eventProtocolError,
sendStartReplCommand,
sendStartUserProgramCommand,
sendStopUserProgramCommand,
sendWriteStdinCommand,
sendWriteUserProgramMetaCommand,
sendWriteUserRamCommand,
writeCommand,
} from './actions';
import { CommandType, ProtocolError } from './protocol';
@@ -25,6 +31,57 @@ describe('command encoder', () => {
0x00, // stop user program command
],
],
[
'start user program',
sendStartUserProgramCommand(0),
[
0x01, // start user program command
],
],
[
'start repl',
sendStartReplCommand(0),
[
0x02, // start repl command
],
],
[
'write user program meta',
sendWriteUserProgramMetaCommand(0, 100),
[
0x03, // write user program meta command
0x64, // program size LSB
0x00,
0x00,
0x00, // program size MSB
],
],
[
'write user ram',
sendWriteUserRamCommand(0, 100, new Uint8Array([1, 2, 3, 4]).buffer),
[
0x04, // write user ram command
0x64, // offset size LSB
0x00,
0x00,
0x00, // offset size MSB
0x01, // payload start
0x02,
0x03,
0x04, // payload end
],
],
[
'write stdin',
sendWriteStdinCommand(0, new Uint8Array([1, 2, 3, 4]).buffer),
[
0x06, // write stdin command
0x01, // payload start
0x02,
0x03,
0x04, // payload end
],
],
])('encode %s request', async (_n, request, expected) => {
const saga = new AsyncSaga(blePybricksService);
saga.put(request);
@@ -103,6 +160,24 @@ describe('event decoder', () => {
],
didReceiveStatusReport(0x00000001),
],
[
'write stdout',
[
0x01, // write stdout event
't'.charCodeAt(0), //payload
'e'.charCodeAt(0),
't'.charCodeAt(0),
't'.charCodeAt(0),
],
didReceiveWriteStdout(
new Uint8Array([
't'.charCodeAt(0),
'e'.charCodeAt(0),
't'.charCodeAt(0),
't'.charCodeAt(0),
]).buffer,
),
],
])('decode %s event', async (_n, message, expected) => {
const saga = new AsyncSaga(blePybricksService);
const notification = new Uint8Array(message);
+14 -3
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
// Copyright (c) 2021-2023 The Pybricks Authors
//
// Handles Pybricks protocol.
@@ -18,12 +18,14 @@ import {
didFailToWriteCommand,
didNotifyEvent,
didReceiveStatusReport,
didReceiveWriteStdout,
didSendCommand,
didWriteCommand,
eventProtocolError,
sendStartReplCommand,
sendStartUserProgramCommand,
sendStopUserProgramCommand,
sendWriteStdinCommand,
sendWriteUserProgramMetaCommand,
sendWriteUserRamCommand,
writeCommand,
@@ -34,10 +36,12 @@ import {
createStartReplCommand,
createStartUserProgramCommand,
createStopUserProgramCommand,
createWriteStdinCommand,
createWriteUserProgramMetaCommand,
createWriteUserRamCommand,
getEventType,
parseStatusReport,
parseWriteStdout,
} from './protocol';
/**
@@ -45,7 +49,7 @@ import {
* the bytecodes to to the device.
*/
function* encodeRequest(): Generator {
// Using a while loop to serialize sending data to avoid "busy" errors.
// Using a loop to serialize sending data to avoid "busy" errors.
const chan = yield* actionChannel(
(a: AnyAction) =>
@@ -53,7 +57,7 @@ function* encodeRequest(): Generator {
a.type.startsWith('blePybricksServiceCommand.action.send'),
);
while (true) {
for (;;) {
const action = yield* take(chan);
/* istanbul ignore else: should not be possible to reach */
@@ -74,6 +78,10 @@ function* encodeRequest(): Generator {
createWriteUserRamCommand(action.offset, action.payload),
),
);
} else if (sendWriteStdinCommand.matches(action)) {
yield* put(
writeCommand(action.id, createWriteStdinCommand(action.payload)),
);
} else {
console.error(`Unknown Pybricks service command ${action.type}`);
continue;
@@ -103,6 +111,9 @@ function* decodeResponse(action: ReturnType<typeof didNotifyEvent>): Generator {
case EventType.StatusReport:
yield* put(didReceiveStatusReport(parseStatusReport(action.value)));
break;
case EventType.WriteStdout:
yield* put(didReceiveWriteStdout(parseWriteStdout(action.value)));
break;
default:
throw new ProtocolError(
`unknown pybricks event type: ${hex(responseType, 2)}`,
+21 -2
View File
@@ -7,8 +7,7 @@ import {
bleDidDisconnectPybricks,
bleDisconnectPybricks,
} from '../ble/actions';
import { didReceiveStatusReport } from '../ble-pybricks-service/actions';
import { Status, statusToFlag } from '../ble-pybricks-service/protocol';
import { bleDIServiceDidReceiveSoftwareRevision } from '../ble-device-info-service/actions';
import { PnpId } from '../ble-device-info-service/protocol';
import { HubType } from '../ble-lwp3-service/protocol';
import {
@@ -48,6 +47,7 @@ test('initial state', () => {
"preferredFileFormat": null,
"runtime": "hub.runtime.disconnected",
"useLegacyDownload": false,
"useLegacyStdio": false,
}
`);
});
@@ -406,3 +406,22 @@ describe('useLegacyDownload', () => {
});
});
describe('useLegacyStdio', () => {
test('old', () => {
expect(
reducers(
{ useLegacyStdio: false } as State,
bleDIServiceDidReceiveSoftwareRevision('1.2.0'),
).useLegacyStdio,
).toBeTruthy();
});
test('new', () => {
expect(
reducers(
{ useLegacyStdio: true } as State,
bleDIServiceDidReceiveSoftwareRevision('1.3.0'),
).useLegacyStdio,
).toBeFalsy();
});
});
+14
View File
@@ -8,6 +8,7 @@ import {
bleDidDisconnectPybricks,
bleDisconnectPybricks,
} from '../ble/actions';
import { bleDIServiceDidReceiveSoftwareRevision } from '../ble-device-info-service/actions';
import { HubType } from '../ble-lwp3-service/protocol';
import {
blePybricksServiceDidNotReceiveHubCapabilities,
@@ -250,6 +251,18 @@ const useLegacyDownload: Reducer<boolean> = (state = false, action) => {
return state;
};
/**
* When true, use NUS for stdio instead of Pybricks control characteristic.
*/
const useLegacyStdio: Reducer<boolean> = (state = false, action) => {
if (bleDIServiceDidReceiveSoftwareRevision.matches(action)) {
// Behavior changed starting with Pybricks Profile v1.3.0.
return !semver.satisfies(action.version, '^1.3.0');
}
return state;
};
export default combineReducers({
runtime,
downloadProgress,
@@ -258,4 +271,5 @@ export default combineReducers({
hasRepl,
preferredFileFormat,
useLegacyDownload,
useLegacyStdio,
});
+230 -16
View File
@@ -9,6 +9,12 @@ import {
didWrite,
write,
} from '../ble-nordic-uart-service/actions';
import {
didFailToSendCommand,
didReceiveWriteStdout,
didSendCommand,
sendWriteStdinCommand,
} from '../ble-pybricks-service/actions';
import { checksum, hubDidStartRepl } from '../hub/actions';
import { HubRuntimeState } from '../hub/reducers';
import { createCountFunc } from '../utils/iter';
@@ -17,28 +23,68 @@ import terminal from './sagas';
const encoder = new TextEncoder();
describe('Data receiver filters out hub status', () => {
test('normal message - no status', async () => {
describe('receiving stdout from hub', () => {
test('legacy UART message', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({ hub: { useLegacyDownload: true, useLegacyStdio: true } });
// sending ASCII space character
saga.put(didNotify(new DataView(new Uint8Array([0x20]).buffer)));
const action = await saga.take();
expect(action).toEqual(sendData(' '));
await expect(saga.take()).resolves.toEqual(sendData(' '));
await saga.end();
});
test('checksum message', async () => {
test('legacy download checksum message', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({ hub: { runtime: HubRuntimeState.Loading } });
saga.updateState({
hub: {
runtime: HubRuntimeState.Loading,
useLegacyDownload: true,
useLegacyStdio: true,
},
});
saga.put(didNotify(new DataView(new Uint8Array([0xaa]).buffer)));
const action = await saga.take();
expect(action).toEqual(checksum(0xaa));
await expect(saga.take()).resolves.toEqual(checksum(0xaa));
await saga.end();
});
test('Pybricks Profile v1.3.0 ignores UART service', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({
hub: {
useLegacyDownload: false,
useLegacyStdio: false,
},
});
saga.put(didNotify(new DataView(new Uint8Array([0x20]).buffer)));
await delay(50);
// no further actions should be pending
await saga.end();
});
test('Pybricks Profile v1.3.0 write stdout command', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({
hub: {
useLegacyDownload: false,
useLegacyStdio: false,
},
});
saga.put(didReceiveWriteStdout(new Uint8Array([0x20]).buffer));
await expect(saga.take()).resolves.toEqual(sendData(' '));
await saga.end();
});
@@ -71,7 +117,161 @@ describe('Terminal data source responds to receive data actions', () => {
test('basic function works', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({ hub: { runtime: HubRuntimeState.Running } });
saga.updateState({
hub: {
runtime: HubRuntimeState.Running,
useLegacyStdio: false,
maxBleWriteSize: 20,
},
});
saga.put(receiveData('test1234'));
await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(0, expected));
await saga.end();
});
test('messages are queued until previous has completed', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({
hub: {
runtime: HubRuntimeState.Running,
useLegacyStdio: false,
maxBleWriteSize: 20,
},
});
saga.put(receiveData('test1234'));
await delay(50); // without delay, messages are combined
saga.put(receiveData('test1234'));
// second message is queued until didSend or didFailToSend
expect(saga.numPending()).toBe(1);
await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(0, expected));
// second message is queued until didSend or didFailToSend
expect(saga.numPending()).toBe(0);
saga.put(didSendCommand(0));
await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(1, expected));
saga.put(didSendCommand(1));
await saga.end();
});
test('messages are queued until previous has failed', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({
hub: {
runtime: HubRuntimeState.Running,
useLegacyStdio: false,
maxBleWriteSize: 20,
},
});
saga.put(receiveData('test1234'));
await delay(50); // without delay, messages are combined
saga.put(receiveData('test1234'));
// second message is queued until didSend or didFailToSend
expect(saga.numPending()).toBe(1);
await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(0, expected));
// second message is queued until didSend or didFailToSend
expect(saga.numPending()).toBe(0);
saga.put(didFailToSendCommand(0, new Error('test error')));
await expect(saga.take()).resolves.toEqual(sendWriteStdinCommand(1, expected));
saga.put(didSendCommand(1));
await saga.end();
});
test('small messages are combined', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({
hub: {
runtime: HubRuntimeState.Running,
useLegacyStdio: false,
maxBleWriteSize: 20,
},
});
saga.put(receiveData('test1234'));
saga.put(receiveData('test1234'));
await expect(saga.take()).resolves.toEqual(
sendWriteStdinCommand(0, new Uint8Array([...expected, ...expected])),
);
await saga.end();
});
test('long messages are split', async () => {
const testData = '012345678901234567890123456789';
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({
hub: {
runtime: HubRuntimeState.Running,
useLegacyStdio: false,
maxBleWriteSize: 20,
},
});
saga.put(receiveData(testData));
await expect(saga.take()).resolves.toEqual(
sendWriteStdinCommand(0, encoder.encode(testData.slice(0, 20))),
);
saga.put(didSendCommand(0));
await expect(saga.take()).resolves.toEqual(
sendWriteStdinCommand(1, encoder.encode(testData.slice(20, 40))),
);
saga.put(didSendCommand(1));
await saga.end();
});
test('if user program is not running echo BEL', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({
hub: {
runtime: HubRuntimeState.Disconnected,
useLegacyStdio: false,
maxBleWriteSize: 20,
},
});
saga.put(receiveData('test1234'));
await delay(50);
// sends BEL character on error
await expect(saga.take()).resolves.toEqual(sendData('\x07'));
await saga.end();
});
});
describe('Terminal data source responds to receive data actions (legacy)', () => {
const expected = encoder.encode('test1234');
test('basic function works', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({
hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true },
});
saga.put(receiveData('test1234'));
@@ -83,7 +283,9 @@ describe('Terminal data source responds to receive data actions', () => {
test('messages are queued until previous has completed', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({ hub: { runtime: HubRuntimeState.Running } });
saga.updateState({
hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true },
});
saga.put(receiveData('test1234'));
await delay(50); // without delay, messages are combined
@@ -110,7 +312,9 @@ describe('Terminal data source responds to receive data actions', () => {
test('messages are queued until previous has failed', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({ hub: { runtime: HubRuntimeState.Running } });
saga.updateState({
hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true },
});
saga.put(receiveData('test1234'));
await delay(50); // without delay, messages are combined
@@ -137,7 +341,9 @@ describe('Terminal data source responds to receive data actions', () => {
test('small messages are combined', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({ hub: { runtime: HubRuntimeState.Running } });
saga.updateState({
hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true },
});
saga.put(receiveData('test1234'));
saga.put(receiveData('test1234'));
@@ -152,7 +358,9 @@ describe('Terminal data source responds to receive data actions', () => {
const testData = '012345678901234567890123456789';
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({ hub: { runtime: HubRuntimeState.Running } });
saga.updateState({
hub: { runtime: HubRuntimeState.Running, useLegacyStdio: true },
});
saga.put(receiveData(testData));
@@ -169,13 +377,19 @@ describe('Terminal data source responds to receive data actions', () => {
await saga.end();
});
test('if user program is not running, do not dispatch write', async () => {
test('if user program is not running, echo BEL', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
saga.updateState({ hub: { runtime: HubRuntimeState.Disconnected } });
saga.updateState({
hub: { runtime: HubRuntimeState.Disconnected, useLegacyStdio: true },
});
saga.put(receiveData('test1234'));
// line below will fail with unhandled pending dispatches if not working correctly
await delay(50);
// sends BEL character on error
await expect(saga.take()).resolves.toEqual(sendData('\x07'));
await saga.end();
});
});
+69 -12
View File
@@ -20,10 +20,16 @@ import {
write,
} from '../ble-nordic-uart-service/actions';
import { nordicUartSafeTxCharLength } from '../ble-nordic-uart-service/protocol';
import {
didFailToSendCommand,
didReceiveWriteStdout,
didSendCommand,
sendWriteStdinCommand,
} from '../ble-pybricks-service/actions';
import { checksum, hubDidStartRepl } from '../hub/actions';
import { HubRuntimeState } from '../hub/reducers';
import { RootState } from '../reducers';
import { defined } from '../utils';
import { assert, defined } from '../utils';
import { TerminalContextValue } from './TerminalContext';
import { receiveData, sendData } from './actions';
@@ -36,7 +42,13 @@ const encoder = new TextEncoder();
const decoder = new TextDecoder();
function* receiveUartData(action: ReturnType<typeof didNotify>): Generator {
const hubState = yield* select((s: RootState) => s.hub.runtime);
const { runtime: hubState, useLegacyStdio } = yield* select(
(s: RootState) => s.hub,
);
if (!useLegacyStdio) {
return;
}
if (hubState === HubRuntimeState.Loading && action.value.buffer.byteLength === 1) {
const view = new DataView(action.value.buffer);
@@ -48,6 +60,13 @@ function* receiveUartData(action: ReturnType<typeof didNotify>): Generator {
yield* put(sendData(value));
}
function* handleReceiveWriteStdout(
action: ReturnType<typeof didReceiveWriteStdout>,
): Generator {
const value = decoder.decode(action.payload);
yield* put(sendData(value));
}
function* receiveTerminalData(): Generator {
const nextMessageId = yield* getContext<() => number>('nextMessageId');
const channel = yield* actionChannel(receiveData);
@@ -87,18 +106,55 @@ function* receiveTerminalData(): Generator {
// stdin gets piped to BLE connection
const data = encoder.encode(value);
for (let i = 0; i < data.length; i += nordicUartSafeTxCharLength) {
const { id } = yield* put(
write(nextMessageId(), data.slice(i, i + nordicUartSafeTxCharLength)),
);
const { useLegacyStdio, maxBleWriteSize } = yield* select(
(s: RootState) => s.hub,
);
yield* take(
(a: AnyAction) =>
(didWrite.matches(a) || didFailToWrite.matches(a)) && a.id === id,
);
if (useLegacyStdio) {
for (let i = 0; i < data.length; i += nordicUartSafeTxCharLength) {
const { id } = yield* put(
write(
nextMessageId(),
data.slice(i, i + nordicUartSafeTxCharLength),
),
);
// wait for echo so tht we don't overrun the hub with messages
yield* race([take(didNotify), delay(100)]);
yield* take(
(a: AnyAction) =>
(didWrite.matches(a) || didFailToWrite.matches(a)) &&
a.id === id,
);
// wait for echo so tht we don't overrun the hub with messages
yield* race([take(didNotify), delay(100)]);
}
} else {
// maxBleWriteSize should always be set to a valid value when useLegacyStdio is false
assert(maxBleWriteSize >= 20, 'bad maxBleWriteSize');
for (let i = 0; i < data.length; i += maxBleWriteSize) {
const { id } = yield* put(
sendWriteStdinCommand(
nextMessageId(),
data.slice(i, i + maxBleWriteSize),
),
);
const { didFail } = yield* race({
didSucceed: take(didSendCommand.when((a) => a.id === id)),
didFail: take(didFailToSendCommand.when((a) => a.id === id)),
});
if (didFail) {
// istanbul ignore if
if (process.env.NODE_ENV !== 'test') {
console.error(didFail.error);
}
// REVISIT: should we provide UI feedback?
// could echo BEL character as above
}
}
}
}
}
@@ -115,6 +171,7 @@ function handleHubDidStartRepl(): void {
export default function* (): Generator {
yield* takeEvery(didNotify, receiveUartData);
yield* takeEvery(didReceiveWriteStdout, handleReceiveWriteStdout);
yield* fork(receiveTerminalData);
yield* takeEvery(sendData, sendTerminalData);
yield* takeEvery(hubDidStartRepl, handleHubDidStartRepl);