Implemented pybricks protocol v1.4.0 (#2317)

* Update for changed StartUserProgram command semantics
* Added new capability flags and fixed-purpose slot definitions
* Update for new start REPL command
* Updated StartUserProgram command and Status event for slots
* Added new AppData command and event

---------

Co-authored-by: David Lechner <david@pybricks.com>
This commit is contained in:
Attila Farago
2024-10-27 17:59:04 -05:00
committed by GitHub
co-authored by David Lechner
parent 41a397a8a3
commit 14f4716dbc
15 changed files with 515 additions and 124 deletions
+58 -12
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2023 The Pybricks Authors
// Copyright (c) 2021-2024 The Pybricks Authors
//
// Actions for Bluetooth Low Energy Pybricks service
@@ -59,10 +59,10 @@ export const sendStopUserProgramCommand = createAction((id: number) => ({
* Action that requests a start user program to be sent.
* @param id Unique identifier for this transaction.
*
* @since Pybricks Profile v1.2.0
* @since Pybricks Profile v1.2.0 - removed in v1.4.0
*/
export const sendStartUserProgramCommand = createAction((id: number) => ({
type: 'blePybricksServiceCommand.action.sendStartUserProgram',
export const sendLegacyStartUserProgramCommand = createAction((id: number) => ({
type: 'blePybricksServiceCommand.action.sendLegacyStartUserProgram',
id,
}));
@@ -70,13 +70,27 @@ export const sendStartUserProgramCommand = createAction((id: number) => ({
* Action that requests a start interactive REPL to be sent.
* @param id Unique identifier for this transaction.
*
* @since Pybricks Profile v1.2.0
* @since Pybricks Profile v1.2.0 - removed in v1.4.0
*
*/
export const sendStartReplCommand = createAction((id: number) => ({
type: 'blePybricksServiceCommand.action.sendStartRepl',
export const sendLegacyStartReplCommand = createAction((id: number) => ({
type: 'blePybricksServiceCommand.action.sendLegacyStartRepl',
id,
}));
/**
* Action that requests a start user program to be sent.
* @param id Unique identifier for this transaction.
* @param slot The slot number of the user program to start.
*
* @since Pybricks Profile v1.4.0
*/
export const sendStartUserProgramCommand = createAction((id: number, slot: number) => ({
type: 'blePybricksServiceCommand.action.sendStartUserProgram',
id,
slot,
}));
/**
* Action that requests to write user program metadata.
* @param id Unique identifier for this transaction.
@@ -124,6 +138,23 @@ export const sendWriteStdinCommand = createAction(
}),
);
/**
* Action that requests to write to AppData.
* @param id Unique identifier for this transaction.
* @param offset offset: The offset from the buffer base address
* @param payload The bytes to write.
*
* @since Pybricks Profile v1.4.0.
*/
export const sendWriteAppDataCommand = createAction(
(id: number, offset: number, payload: ArrayBuffer) => ({
type: 'blePybricksServiceCommand.action.sendWriteAppDataCommand',
id,
offset,
payload,
}),
);
/**
* Action that indicates that a command was successfully sent.
* @param id Unique identifier for the transaction from the corresponding "send" command.
@@ -149,15 +180,19 @@ export const didFailToSendCommand = createAction((id: number, error: Error) => (
/**
* Action that represents a status report event received from the hub.
* @param statusFlags The status flags.
* @param slot The slot number of the user program that is running.
*/
export const didReceiveStatusReport = createAction((statusFlags: number) => ({
type: 'blePybricksServiceEvent.action.didReceiveStatusReport',
statusFlags,
}));
export const didReceiveStatusReport = createAction(
(statusFlags: number, slot: number) => ({
type: 'blePybricksServiceEvent.action.didReceiveStatusReport',
statusFlags,
slot,
}),
);
/**
* Action that represents a status report event received from the hub.
* @param statusFlags The status flags.
* @param payload The piece of message received.
*
* @since Pybricks Profile v1.3.0
*/
@@ -166,6 +201,17 @@ export const didReceiveWriteStdout = createAction((payload: ArrayBuffer) => ({
payload,
}));
/**
* Action that represents a write to a buffer that is pre-allocated by a user program received from the hub.
* @param payload The piece of message received.
*
* @since Pybricks Profile v1.4.0
*/
export const didReceiveWriteAppData = createAction((payload: ArrayBuffer) => ({
type: 'blePybricksServiceEvent.action.didReceiveWriteAppData',
payload,
}));
/**
* Pseudo-event = actionCreator((not received from hub) indicating that there was a protocol error.
* @param error The error that was caught.
+126 -13
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2024 The Pybricks Authors
//
// Definitions related to the Pybricks Bluetooth low energy GATT service.
@@ -25,13 +25,13 @@ export enum CommandType {
/**
* Request to start the user program.
*
* @since Pybricks Profile v1.2.0
* @since Pybricks Profile v1.2.0 - changed in v1.4.0
*/
StartUserProgram = 1,
/**
* Request to start the interactive REPL.
*
* @since Pybricks Profile v1.2.0
* @since Pybricks Profile v1.2.0 - removed in v1.4.0
*/
StartRepl = 2,
/**
@@ -58,6 +58,43 @@ export enum CommandType {
* @since Pybricks Profile v1.3.0
*/
WriteStdin = 6,
/**
* Requests to write to a buffer that is pre-allocated by a user program.
*
* Parameters:
* - offset: The offset from the buffer base address (16-bit little-endian
* unsigned integer).
* - payload: The data to write.
*
* @since Pybricks Profile v1.4.0
*/
WriteAppData = 7,
}
/**
* Built-in program ID's for use with {@link CommandType.StartUserProgram}.
*
* @since Pybricks Profile v1.4.0
*/
export enum BuiltinProgramId {
/**
* Requests to start the built-in REPL on stdio.
*
* @since Pybricks Profile v1.4.0
*/
REPL = 0x80,
/**
* Requests to start the built-in sensor port view monitoring program.
*
* @since Pybricks Profile v1.4.0
*/
PortView = 0x81,
/**
* Requests to start the built-in IMU calibration program.
*
* @since Pybricks Profile v1.4.0
*/
IMUCalibration = 0x82,
}
/**
@@ -74,20 +111,38 @@ export function createStopUserProgramCommand(): Uint8Array {
/**
* Creates a {@link CommandType.StartUserProgram} message.
*
* @since Pybricks Profile v1.2.0
* Parameters:
* - slot: Program identifier (one byte). Slots 0--127 are reserved for
* downloaded user programs. Slots 128--255 are for builtin user programs.
*
* @since Pybricks Profile v1.4.0
*/
export function createStartUserProgramCommand(): Uint8Array {
export function createStartUserProgramCommand(
slot: number | BuiltinProgramId,
): Uint8Array {
const msg = new Uint8Array(2);
msg[0] = CommandType.StartUserProgram;
msg[1] = slot;
return msg;
}
/**
* Creates a legacy {@link CommandType.StartUserProgram} message.
*
* @since Pybricks Profile v1.2.0 - removed in v1.4.0
*/
export function createLegacyStartUserProgramCommand(): Uint8Array {
const msg = new Uint8Array(1);
msg[0] = CommandType.StartUserProgram;
return msg;
}
/**
* Creates a {@link CommandType.StartRepl} message.
* Creates a legacy {@link CommandType.StartRepl} message.
*
* @since Pybricks Profile v1.2.0
* @since Pybricks Profile v1.2.0 - removed in v1.4.0
*/
export function createStartReplCommand(): Uint8Array {
export function createLegacyStartReplCommand(): Uint8Array {
const msg = new Uint8Array(1);
msg[0] = CommandType.StartRepl;
return msg;
@@ -140,6 +195,25 @@ export function createWriteStdinCommand(payload: ArrayBuffer): Uint8Array {
return msg;
}
/**
* Creates a {@link CommandType.WriteAppData} message.
* @param offset The offset from the buffer base address
* @param payload The bytes to write.
*
* @since Pybricks Profile v1.4.0.
*/
export function createWriteAppDataCommand(
offset: number,
payload: ArrayBuffer,
): Uint8Array {
const msg = new Uint8Array(1 + 2 + payload.byteLength);
const view = new DataView(msg.buffer);
view.setUint8(0, CommandType.WriteAppData);
view.setUint16(1, offset & 0xffff, true);
msg.set(new Uint8Array(payload), 3);
return msg;
}
/** Events are notifications received from the hub. */
export enum EventType {
/**
@@ -156,6 +230,12 @@ export enum EventType {
* @since Pybricks Profile v1.3.0
*/
WriteStdout = 1,
/**
* Hub wrote to AppData event.
*
* @since Pybricks Profile v1.4.0
*/
WriteAppData = 2,
}
/** Status indications received by Event.StatusReport */
@@ -223,13 +303,16 @@ export function getEventType(msg: DataView): EventType {
/**
* Parses the payload of a status report message.
* @param msg The raw message data.
* @returns The status as bit flags.
* @returns The status as bit flags and the slot number of the running program.
*
* @since Pybricks Profile v1.0.0
* @since Pybricks Profile v1.0.0 - changed in v1.4.0
*/
export function parseStatusReport(msg: DataView): number {
export function parseStatusReport(msg: DataView): { flags: number; slot: number } {
assert(msg.getUint8(0) === EventType.StatusReport, 'expecting status report event');
return msg.getUint32(1, true);
return {
flags: msg.getUint32(1, true),
slot: msg.byteLength > 5 ? msg.getUint8(5) : 0,
};
}
/**
@@ -244,6 +327,18 @@ export function parseWriteStdout(msg: DataView): ArrayBuffer {
return msg.buffer.slice(1);
}
/**
* Parses the payload of a app data message.
* @param msg The raw message data.
* @returns The bytes that were written.
*
* @since Pybricks Profile v1.4.0
*/
export function parseWriteAppData(msg: DataView): ArrayBuffer {
assert(msg.getUint8(0) === EventType.WriteAppData, 'expecting write appdata event');
return msg.buffer.slice(1);
}
/**
* Protocol error. Thrown e.g. when there is a malformed message.
*/
@@ -266,7 +361,9 @@ export class ProtocolError extends Error {
*/
export enum HubCapabilityFlag {
/**
* Hub has an interactive REPL.
* Hub supports {@link CommandType.StartUserProgram} command with
* {@link BuiltinProgramId.REPL} for protocol v1.4.0 and later or hub
* supports {@link CommandType.StartRepl}
*
* @since Pybricks Profile v1.2.0
*/
@@ -285,6 +382,22 @@ export enum HubCapabilityFlag {
* @since Pybricks Profile v1.3.0
*/
UserProgramMultiMpy6Native6p1 = 1 << 2,
/**
* Hub supports {@link CommandType.StartUserProgram} command with
* {@link BuiltinProgramId.PortView}.
*
* @since Pybricks Profile v1.4.0.
*/
HasPortView = 1 << 3,
/**
* Hub supports {@link CommandType.StartUserProgram} command with
* {@link BuiltinProgramId.IMUCalibration}.
*
* @since Pybricks Profile v1.4.0.
*/
HasIMUCalibration = 1 << 4,
}
/** Supported user program file formats. */
+101 -15
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2023 The Pybricks Authors
// Copyright (c) 2021-2024 The Pybricks Authors
import { AsyncSaga } from '../../test';
import {
@@ -7,19 +7,22 @@ import {
didFailToWriteCommand,
didNotifyEvent,
didReceiveStatusReport,
didReceiveWriteAppData,
didReceiveWriteStdout,
didSendCommand,
didWriteCommand,
eventProtocolError,
sendStartReplCommand,
sendLegacyStartReplCommand,
sendLegacyStartUserProgramCommand,
sendStartUserProgramCommand,
sendStopUserProgramCommand,
sendWriteAppDataCommand,
sendWriteStdinCommand,
sendWriteUserProgramMetaCommand,
sendWriteUserRamCommand,
writeCommand,
} from './actions';
import { CommandType, ProtocolError } from './protocol';
import { BuiltinProgramId, CommandType, ProtocolError } from './protocol';
import blePybricksService from './sagas';
describe('command encoder', () => {
@@ -31,16 +34,24 @@ describe('command encoder', () => {
0x00, // stop user program command
],
],
[
'start user program with slot',
sendStartUserProgramCommand(0, 0x2a),
[
0x01, // start user program command
0x2a, // program slot
],
],
[
'start user program',
sendStartUserProgramCommand(0),
sendLegacyStartUserProgramCommand(0),
[
0x01, // start user program command
],
],
[
'start repl',
sendStartReplCommand(0),
sendLegacyStartReplCommand(0),
[
0x02, // start repl command
],
@@ -82,6 +93,19 @@ describe('command encoder', () => {
0x04, // payload end
],
],
[
'write AppData',
sendWriteAppDataCommand(0, 0x2a, new Uint8Array([1, 2, 3, 4]).buffer),
[
0x07, // write AppData command
0x2a, // offset LSB 16bit
0x00, // offset MSB 16bit
0x01, // payload start
0x02,
0x03,
0x04, // payload end
],
],
])('encode %s request', async (_n, request, expected) => {
const saga = new AsyncSaga(blePybricksService);
saga.put(request);
@@ -150,7 +174,7 @@ describe('command encoder', () => {
describe('event decoder', () => {
test.each([
[
'status report',
'legacy status report',
[
0x00, // status report event
0x01, // flags count LSB
@@ -158,7 +182,19 @@ describe('event decoder', () => {
0x00, // .
0x00, // flags count MSB
],
didReceiveStatusReport(0x00000001),
didReceiveStatusReport(0x00000001, 0),
],
[
'status report',
[
0x00, // status report event
0x01, // flags count LSB
0x00, // .
0x00, // .
0x00, // flags count MSB
0x80, // slot
],
didReceiveStatusReport(0x00000001, BuiltinProgramId.REPL),
],
[
'write stdout',
@@ -178,17 +214,67 @@ describe('event decoder', () => {
]).buffer,
),
],
])('decode %s event', async (_n, message, expected) => {
const saga = new AsyncSaga(blePybricksService);
const notification = new Uint8Array(message);
[
'write AppData',
[
0x02, // write AppData event
't'.charCodeAt(0), //payload
'e'.charCodeAt(0),
't'.charCodeAt(0),
't'.charCodeAt(0),
],
didReceiveWriteAppData(
new Uint8Array([
't'.charCodeAt(0),
'e'.charCodeAt(0),
't'.charCodeAt(0),
't'.charCodeAt(0),
]).buffer,
),
],
[
'write appdata mismatch',
[
0x02, // write AppData event
't'.charCodeAt(0), //payload
'e'.charCodeAt(0),
't'.charCodeAt(0),
't'.charCodeAt(0),
],
didReceiveWriteAppData(
new Uint8Array([
't'.charCodeAt(0),
'e'.charCodeAt(0),
'x'.charCodeAt(0),
't'.charCodeAt(0),
]).buffer,
),
true,
false,
],
])(
'decode %s event',
async (_n, message, expected, isEqual = true, isStrictlyEqual = true) => {
const saga = new AsyncSaga(blePybricksService);
const notification = new Uint8Array(message);
saga.put(didNotifyEvent(new DataView(notification.buffer)));
saga.put(didNotifyEvent(new DataView(notification.buffer)));
const action = await saga.take();
expect(action).toEqual(expected);
const action = await saga.take();
if (isEqual) {
expect(action).toEqual(expected);
} else {
expect(action).not.toEqual(expected);
}
if (isStrictlyEqual) {
expect(action).toStrictEqual(expected);
} else {
expect(action).not.toStrictEqual(expected);
}
await saga.end();
});
await saga.end();
},
);
test.each([
[
+30 -8
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2023 The Pybricks Authors
// Copyright (c) 2021-2024 The Pybricks Authors
//
// Handles Pybricks protocol.
@@ -18,13 +18,16 @@ import {
didFailToWriteCommand,
didNotifyEvent,
didReceiveStatusReport,
didReceiveWriteAppData,
didReceiveWriteStdout,
didSendCommand,
didWriteCommand,
eventProtocolError,
sendStartReplCommand,
sendLegacyStartReplCommand,
sendLegacyStartUserProgramCommand,
sendStartUserProgramCommand,
sendStopUserProgramCommand,
sendWriteAppDataCommand,
sendWriteStdinCommand,
sendWriteUserProgramMetaCommand,
sendWriteUserRamCommand,
@@ -33,14 +36,17 @@ import {
import {
EventType,
ProtocolError,
createStartReplCommand,
createLegacyStartReplCommand,
createLegacyStartUserProgramCommand,
createStartUserProgramCommand,
createStopUserProgramCommand,
createWriteAppDataCommand,
createWriteStdinCommand,
createWriteUserProgramMetaCommand,
createWriteUserRamCommand,
getEventType,
parseStatusReport,
parseWriteAppData,
parseWriteStdout,
} from './protocol';
@@ -63,10 +69,14 @@ function* encodeRequest(): Generator {
/* istanbul ignore else: should not be possible to reach */
if (sendStopUserProgramCommand.matches(action)) {
yield* put(writeCommand(action.id, createStopUserProgramCommand()));
} else if (sendLegacyStartUserProgramCommand.matches(action)) {
yield* put(writeCommand(action.id, createLegacyStartUserProgramCommand()));
} else if (sendLegacyStartReplCommand.matches(action)) {
yield* put(writeCommand(action.id, createLegacyStartReplCommand()));
} else if (sendStartUserProgramCommand.matches(action)) {
yield* put(writeCommand(action.id, createStartUserProgramCommand()));
} else if (sendStartReplCommand.matches(action)) {
yield* put(writeCommand(action.id, createStartReplCommand()));
yield* put(
writeCommand(action.id, createStartUserProgramCommand(action.slot)),
);
} else if (sendWriteUserProgramMetaCommand.matches(action)) {
yield* put(
writeCommand(action.id, createWriteUserProgramMetaCommand(action.size)),
@@ -82,6 +92,13 @@ function* encodeRequest(): Generator {
yield* put(
writeCommand(action.id, createWriteStdinCommand(action.payload)),
);
} else if (sendWriteAppDataCommand.matches(action)) {
yield* put(
writeCommand(
action.id,
createWriteAppDataCommand(action.offset, action.payload),
),
);
} else {
console.error(`Unknown Pybricks service command ${action.type}`);
continue;
@@ -108,12 +125,17 @@ function* decodeResponse(action: ReturnType<typeof didNotifyEvent>): Generator {
try {
const responseType = getEventType(action.value);
switch (responseType) {
case EventType.StatusReport:
yield* put(didReceiveStatusReport(parseStatusReport(action.value)));
case EventType.StatusReport: {
const status = parseStatusReport(action.value);
yield* put(didReceiveStatusReport(status.flags, status.slot));
break;
}
case EventType.WriteStdout:
yield* put(didReceiveWriteStdout(parseWriteStdout(action.value)));
break;
case EventType.WriteAppData:
yield* put(didReceiveWriteAppData(parseWriteAppData(action.value)));
break;
default:
throw new ProtocolError(
`unknown pybricks event type: ${hex(responseType, 2)}`,
+3 -3
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
// Copyright (c) 2021-2024 The Pybricks Authors
import { AnyAction } from 'redux';
import {
@@ -130,14 +130,14 @@ test('deviceLowBatteryWarning', () => {
expect(
reducers(
{ deviceLowBatteryWarning: false } as State,
didReceiveStatusReport(statusToFlag(Status.BatteryLowVoltageWarning)),
didReceiveStatusReport(statusToFlag(Status.BatteryLowVoltageWarning), 0),
).deviceLowBatteryWarning,
).toBeTruthy();
expect(
reducers(
{ deviceLowBatteryWarning: true } as State,
didReceiveStatusReport(~statusToFlag(Status.BatteryLowVoltageWarning)),
didReceiveStatusReport(~statusToFlag(Status.BatteryLowVoltageWarning), 0),
).deviceLowBatteryWarning,
).toBeFalsy();
+1 -1
View File
@@ -73,7 +73,7 @@ import {
import { BleConnectionState } from './reducers';
/** The version of the Pybricks Profile version currently implemented by this file. */
export const supportedPybricksProfileVersion = '1.3.0';
export const supportedPybricksProfileVersion = '1.4.0';
const decoder = new TextDecoder();
+28 -7
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2024 The Pybricks Authors
import { createAction } from '../actions';
import { FileFormat } from '../ble-pybricks-service/protocol';
@@ -14,11 +14,25 @@ export const checksum = createAction((checksum: number) => ({
// High-level hub actions.
/**
* Request to download and run a program on the hub.
* @param fileFormat The format of the file to download.
* @param useLegacyDownload Whether to use the legacy NUS download method.
* @param useLegacyStartUserProgram Whether to use the legacy start user program method (changed in protocol v1.4.0).
* @param slot The slot number to download the program to - only valid on hubs/firmware that support multiple slots.
*/
export const downloadAndRun = createAction(
(fileFormat: FileFormat | null, useLegacyDownload: boolean) => ({
(
fileFormat: FileFormat | null,
useLegacyDownload: boolean,
useLegacyStartUserProgram: boolean,
slot: number,
) => ({
type: 'hub.action.downloadAndRun',
fileFormat,
useLegacyDownload,
useLegacyStartUserProgram,
slot,
}),
);
@@ -54,11 +68,18 @@ export const hubDidFailToStopUserProgram = createAction(() => ({
type: 'hub.action.didFailToStopUserProgram',
}));
/** Request to send the start repl command to the hub. */
export const hubStartRepl = createAction((useLegacyDownload: boolean) => ({
type: 'hub.action.startRepl',
useLegacyDownload,
}));
/**
* Request to send the start repl command to the hub.
* @param useLegacyDownload Whether to use the legacy NUS download method.
* @param useLegacyStartUserProgram Whether to use the legacy start user program method (changed in protocol v1.4.0).
*/
export const hubStartRepl = createAction(
(useLegacyDownload: boolean, useLegacyStartUserProgram: boolean) => ({
type: 'hub.action.startRepl',
useLegacyDownload,
useLegacyStartUserProgram,
}),
);
/** Indicates the the start repl command was sent to the hub. */
export const hubDidStartRepl = createAction(() => ({
+33 -10
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2023 The Pybricks Authors
// Copyright (c) 2021-2024 The Pybricks Authors
import { AnyAction } from 'redux';
import {
@@ -47,6 +47,7 @@ test('initial state', () => {
"preferredFileFormat": null,
"runtime": "hub.runtime.disconnected",
"useLegacyDownload": false,
"useLegacyStartUserProgram": false,
"useLegacyStdio": false,
}
`);
@@ -157,7 +158,7 @@ describe('runtime', () => {
expect(
reducers(
{ runtime: HubRuntimeState.Disconnected } as State,
didReceiveStatusReport(statusToFlag(Status.UserProgramRunning)),
didReceiveStatusReport(statusToFlag(Status.UserProgramRunning), 0),
).runtime,
).toBe(HubRuntimeState.Disconnected);
@@ -165,7 +166,7 @@ describe('runtime', () => {
expect(
reducers(
{ runtime: HubRuntimeState.Loading } as State,
didReceiveStatusReport(statusToFlag(Status.UserProgramRunning)),
didReceiveStatusReport(statusToFlag(Status.UserProgramRunning), 0),
).runtime,
).toBe(HubRuntimeState.Loading);
@@ -173,7 +174,7 @@ describe('runtime', () => {
expect(
reducers(
{ runtime: HubRuntimeState.Unknown } as State,
didReceiveStatusReport(statusToFlag(Status.UserProgramRunning)),
didReceiveStatusReport(statusToFlag(Status.UserProgramRunning), 0),
).runtime,
).toBe(HubRuntimeState.Running);
@@ -181,7 +182,7 @@ describe('runtime', () => {
expect(
reducers(
{ runtime: HubRuntimeState.Unknown } as State,
didReceiveStatusReport(0),
didReceiveStatusReport(0, 0),
).runtime,
).toBe(HubRuntimeState.Idle);
@@ -189,7 +190,7 @@ describe('runtime', () => {
expect(
reducers(
{ runtime: HubRuntimeState.Running } as State,
didReceiveStatusReport(0),
didReceiveStatusReport(0, 0),
).runtime,
).toBe(HubRuntimeState.Idle);
@@ -197,7 +198,7 @@ describe('runtime', () => {
expect(
reducers(
{ runtime: HubRuntimeState.StartingRepl } as State,
didReceiveStatusReport(0),
didReceiveStatusReport(0, 0),
).runtime,
).toBe(HubRuntimeState.StartingRepl);
@@ -205,15 +206,17 @@ describe('runtime', () => {
expect(
reducers(
{ runtime: HubRuntimeState.StoppingUserProgram } as State,
didReceiveStatusReport(0),
didReceiveStatusReport(0, 0),
).runtime,
).toBe(HubRuntimeState.StoppingUserProgram);
});
test('hubStartRepl', () => {
expect(
reducers({ runtime: HubRuntimeState.Running } as State, hubStartRepl(false))
.runtime,
reducers(
{ runtime: HubRuntimeState.Running } as State,
hubStartRepl(false, false),
).runtime,
).toBe(HubRuntimeState.StartingRepl);
});
@@ -425,3 +428,23 @@ describe('useLegacyStdio', () => {
).toBeFalsy();
});
});
describe('useLegacyStartUserProgram', () => {
test('Pybricks Profile < v1.4.0', () => {
expect(
reducers(
{ useLegacyStartUserProgram: false } as State,
bleDIServiceDidReceiveSoftwareRevision('1.3.0'),
).useLegacyStartUserProgram,
).toBeTruthy();
});
test('Pybricks Profile >= v1.4.0', () => {
expect(
reducers(
{ useLegacyStartUserProgram: true } as State,
bleDIServiceDidReceiveSoftwareRevision('1.4.0'),
).useLegacyStartUserProgram,
).toBeFalsy();
});
});
+14 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2024 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import * as semver from 'semver';
@@ -263,6 +263,18 @@ const useLegacyStdio: Reducer<boolean> = (state = false, action) => {
return state;
};
/**
* When true, use Legacy StartUserProgram.
*/
const useLegacyStartUserProgram: Reducer<boolean> = (state = false, action) => {
if (bleDIServiceDidReceiveSoftwareRevision.matches(action)) {
// Behavior changed starting with Pybricks Profile v1.4.0.
return !semver.satisfies(action.version, '^1.4.0');
}
return state;
};
export default combineReducers({
runtime,
downloadProgress,
@@ -272,4 +284,5 @@ export default combineReducers({
preferredFileFormat,
useLegacyDownload,
useLegacyStdio,
useLegacyStartUserProgram,
});
+28 -9
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2024 The Pybricks Authors
import { AsyncSaga } from '../../test';
import { alertsShowAlert } from '../alerts/actions';
@@ -7,10 +7,11 @@ import { didWrite, write } from '../ble-nordic-uart-service/actions';
import {
didFailToSendCommand,
didSendCommand,
sendStartReplCommand,
sendLegacyStartReplCommand,
sendStartUserProgramCommand,
sendStopUserProgramCommand,
} from '../ble-pybricks-service/actions';
import { FileFormat } from '../ble-pybricks-service/protocol';
import { BuiltinProgramId, FileFormat } from '../ble-pybricks-service/protocol';
import { editorGetValueRequest, editorGetValueResponse } from '../editor/actions';
import { compile, didCompile } from '../mpy/actions';
import { createCountFunc } from '../utils/iter';
@@ -37,7 +38,7 @@ describe('downloadAndRun', () => {
saga.updateState({ editor: { isReady: true } });
saga.put(downloadAndRun(FileFormat.Mpy5, true));
saga.put(downloadAndRun(FileFormat.Mpy5, true, true, 0));
// first, it gets the value from the current editor
const editorValueAction = await saga.take();
@@ -94,7 +95,7 @@ describe('hubStartRepl', () => {
test('legacy UART download', async () => {
const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() });
saga.put(hubStartRepl(true));
saga.put(hubStartRepl(true, true));
await expect(saga.take()).resolves.toEqual(
write(0, new Uint8Array([32, 32, 32, 32])),
@@ -105,12 +106,28 @@ describe('hubStartRepl', () => {
await saga.end();
});
test('legacy start REPL command', async () => {
const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() });
saga.put(hubStartRepl(false, true));
await expect(saga.take()).resolves.toEqual(sendLegacyStartReplCommand(0));
saga.put(didSendCommand(0));
await expect(saga.take()).resolves.toEqual(hubDidStartRepl());
await saga.end();
});
test('success', async () => {
const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() });
saga.put(hubStartRepl(false));
saga.put(hubStartRepl(false, false));
await expect(saga.take()).resolves.toEqual(sendStartReplCommand(0));
await expect(saga.take()).resolves.toEqual(
sendStartUserProgramCommand(0, BuiltinProgramId.REPL),
);
saga.put(didSendCommand(0));
@@ -122,9 +139,11 @@ describe('hubStartRepl', () => {
test('failure due to disconnect', async () => {
const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() });
saga.put(hubStartRepl(false));
saga.put(hubStartRepl(false, false));
await expect(saga.take()).resolves.toEqual(sendStartReplCommand(0));
await expect(saga.take()).resolves.toEqual(
sendStartUserProgramCommand(0, BuiltinProgramId.REPL),
);
saga.put(didFailToSendCommand(0, new DOMException('test', 'NetworkError')));
+17 -8
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2024 The Pybricks Authors
import {
SagaGenerator,
@@ -19,13 +19,14 @@ import { nordicUartSafeTxCharLength } from '../ble-nordic-uart-service/protocol'
import {
didFailToSendCommand,
didSendCommand,
sendStartReplCommand,
sendLegacyStartReplCommand,
sendLegacyStartUserProgramCommand,
sendStartUserProgramCommand,
sendStopUserProgramCommand,
sendWriteUserProgramMetaCommand,
sendWriteUserRamCommand,
} from '../ble-pybricks-service/actions';
import { FileFormat } from '../ble-pybricks-service/protocol';
import { BuiltinProgramId, FileFormat } from '../ble-pybricks-service/protocol';
import { editorGetValue } from '../editor/sagaLib';
import {
compile,
@@ -311,7 +312,11 @@ function* handleDownloadAndRun(action: ReturnType<typeof downloadAndRun>): Gener
yield* put(didFinishDownload());
const startUserProgramId = nextMessageId();
yield* put(sendStartUserProgramCommand(startUserProgramId));
if (action.useLegacyStartUserProgram) {
yield* put(sendLegacyStartUserProgramCommand(startUserProgramId));
} else {
yield* put(sendStartUserProgramCommand(startUserProgramId, action.slot));
}
const { didFailToStart } = yield* race({
didStart: take(didSendCommand.when((a) => a.id === startUserProgramId)),
@@ -344,22 +349,26 @@ function* handleDownloadAndRun(action: ReturnType<typeof downloadAndRun>): Gener
}
// SPACE, SPACE, SPACE, SPACE
const legacyStartReplCommand = new Uint8Array([0x20, 0x20, 0x20, 0x20]);
const legacyNusStartReplCommand = new Uint8Array([0x20, 0x20, 0x20, 0x20]);
function* handleHubStartRepl(action: ReturnType<typeof hubStartRepl>): Generator {
const nextMessageId = yield* getContext<() => number>('nextMessageId');
if (action.useLegacyDownload) {
yield* put(write(nextMessageId(), legacyStartReplCommand));
yield* put(write(nextMessageId(), legacyNusStartReplCommand));
yield* put(hubDidStartRepl());
return;
}
const id = nextMessageId();
yield* put(sendStartReplCommand(id));
if (action.useLegacyStartUserProgram) {
yield* put(sendLegacyStartReplCommand(id));
} else {
yield* put(sendStartUserProgramCommand(id, BuiltinProgramId.REPL));
}
const { didFailToSend } = yield* race({
didStart: take(didSendCommand.when((a) => a.id === id)),
didSend: take(didSendCommand.when((a) => a.id === id)),
didFailToSend: take(didFailToSendCommand.when((a) => a.id === id)),
});
+23 -13
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
// Copyright (c) 2022-2024 The Pybricks Authors
import { act, cleanup } from '@testing-library/react';
import React from 'react';
@@ -13,17 +13,27 @@ afterEach(() => {
cleanup();
});
it('should dispatch action when clicked', async () => {
const [user, button, dispatch] = testRender(
<FocusScope>
<ReplButton id="test-repl-button" />
</FocusScope>,
{
hub: { hasRepl: true, runtime: HubRuntimeState.Idle },
},
);
test.each([[false, false, false, true, true, true]])(
'should dispatch action when clicked',
async (legacyDownload: boolean, legacyStartUserProgram: boolean) => {
const [user, button, dispatch] = testRender(
<FocusScope>
<ReplButton id="test-repl-button" />
</FocusScope>,
{
hub: {
hasRepl: true,
runtime: HubRuntimeState.Idle,
useLegacyDownload: legacyDownload,
useLegacyStartUserProgram: legacyStartUserProgram,
},
},
);
await act(() => user.click(button.getByRole('button', { name: 'REPL' })));
await act(() => user.click(button.getByRole('button', { name: 'REPL' })));
expect(dispatch).toHaveBeenCalledWith(hubStartRepl(false));
});
expect(dispatch).toHaveBeenCalledWith(
hubStartRepl(legacyDownload, legacyStartUserProgram),
);
},
);
+5 -4
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2024 The Pybricks Authors
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
@@ -14,13 +14,14 @@ import icon from './icon.svg';
type ReplButtonProps = Pick<ActionButtonProps, 'id'>;
const ReplButton: React.FunctionComponent<ReplButtonProps> = ({ id }) => {
const { runtime, useLegacyDownload, hasRepl } = useSelector((s) => s.hub);
const { runtime, useLegacyDownload, useLegacyStartUserProgram, hasRepl } =
useSelector((s) => s.hub);
const i18n = useI18n();
const dispatch = useDispatch();
const action = useCallback(
() => dispatch(hubStartRepl(useLegacyDownload)),
[dispatch, useLegacyDownload],
() => dispatch(hubStartRepl(useLegacyDownload, useLegacyStartUserProgram)),
[dispatch, useLegacyDownload, useLegacyStartUserProgram],
);
const busy = useDebounce(runtime === HubRuntimeState.StartingRepl, 250);
+32 -16
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
// Copyright (c) 2022-2024 The Pybricks Authors
import { act, cleanup } from '@testing-library/react';
import React from 'react';
@@ -14,21 +14,37 @@ afterEach(() => {
cleanup();
});
it('should dispatch action when clicked', async () => {
const [user, button, dispatch] = testRender(
<FocusScope>
<RunButton id="test-run-button" />
</FocusScope>,
{
editor: { activeFileUuid: uuid(0) },
hub: {
runtime: HubRuntimeState.Idle,
preferredFileFormat: FileFormat.MultiMpy6,
test.each([
[false, false],
[false, true],
[true, true],
])(
'should dispatch action when clicked',
async (legacyDownload: boolean, legacyStartUserProgram: boolean) => {
const [user, button, dispatch] = testRender(
<FocusScope>
<RunButton id="test-run-button" />
</FocusScope>,
{
editor: { activeFileUuid: uuid(0) },
hub: {
runtime: HubRuntimeState.Idle,
preferredFileFormat: FileFormat.MultiMpy6,
useLegacyDownload: legacyDownload,
useLegacyStartUserProgram: legacyStartUserProgram,
},
},
},
);
);
await act(() => user.click(button.getByRole('button', { name: 'Run' })));
await act(() => user.click(button.getByRole('button', { name: 'Run' })));
expect(dispatch).toHaveBeenCalledWith(downloadAndRun(FileFormat.MultiMpy6, false));
});
expect(dispatch).toHaveBeenCalledWith(
downloadAndRun(
FileFormat.MultiMpy6,
legacyDownload,
legacyStartUserProgram,
0,
),
);
},
);
+16 -4
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2024 The Pybricks Authors
import React from 'react';
import { useDispatch } from 'react-redux';
@@ -13,8 +13,13 @@ import icon from './icon.svg';
type RunButtonProps = Pick<ActionButtonProps, 'id'>;
const RunButton: React.FunctionComponent<RunButtonProps> = ({ id }) => {
const { downloadProgress, preferredFileFormat, runtime, useLegacyDownload } =
useSelector((s) => s.hub);
const {
downloadProgress,
preferredFileFormat,
runtime,
useLegacyDownload,
useLegacyStartUserProgram,
} = useSelector((s) => s.hub);
const activeFile = useSelector((s) => s.editor.activeFileUuid);
const keyboardShortcut = 'F5';
@@ -38,7 +43,14 @@ const RunButton: React.FunctionComponent<RunButtonProps> = ({ id }) => {
showProgress={runtime === HubRuntimeState.Loading}
progress={downloadProgress === null ? undefined : downloadProgress}
onAction={() =>
dispatch(downloadAndRun(preferredFileFormat, useLegacyDownload))
dispatch(
downloadAndRun(
preferredFileFormat,
useLegacyDownload,
useLegacyStartUserProgram,
0, // No slot UI yet
),
)
}
/>
);