From 14f4716dbc89ffbe97d868eb5812ed7af328ad9e Mon Sep 17 00:00:00 2001 From: Attila Farago Date: Sun, 27 Oct 2024 23:59:04 +0100 Subject: [PATCH] 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 --- src/ble-pybricks-service/actions.ts | 70 ++++++++-- src/ble-pybricks-service/protocol.ts | 139 +++++++++++++++++-- src/ble-pybricks-service/sagas.test.ts | 114 +++++++++++++-- src/ble-pybricks-service/sagas.ts | 38 +++-- src/ble/reducers.test.ts | 6 +- src/ble/sagas.ts | 2 +- src/hub/actions.ts | 35 ++++- src/hub/reducers.test.ts | 43 ++++-- src/hub/reducers.ts | 15 +- src/hub/sagas.test.ts | 37 +++-- src/hub/sagas.ts | 25 ++-- src/toolbar/buttons/repl/ReplButton.test.tsx | 36 +++-- src/toolbar/buttons/repl/ReplButton.tsx | 9 +- src/toolbar/buttons/run/RunButton.test.tsx | 48 ++++--- src/toolbar/buttons/run/RunButton.tsx | 20 ++- 15 files changed, 514 insertions(+), 123 deletions(-) diff --git a/src/ble-pybricks-service/actions.ts b/src/ble-pybricks-service/actions.ts index 3b47aa9fe..cda33a01d 100644 --- a/src/ble-pybricks-service/actions.ts +++ b/src/ble-pybricks-service/actions.ts @@ -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,11 +70,25 @@ 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 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 sendStartReplCommand = createAction((id: number) => ({ - type: 'blePybricksServiceCommand.action.sendStartRepl', +export const sendStartUserProgramCommand = createAction((id: number, slot: number) => ({ + type: 'blePybricksServiceCommand.action.sendStartUserProgram', id, + slot, })); /** @@ -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. diff --git a/src/ble-pybricks-service/protocol.ts b/src/ble-pybricks-service/protocol.ts index cf57f4c6a..773aa25a2 100644 --- a/src/ble-pybricks-service/protocol.ts +++ b/src/ble-pybricks-service/protocol.ts @@ -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. */ diff --git a/src/ble-pybricks-service/sagas.test.ts b/src/ble-pybricks-service/sagas.test.ts index da58587ed..1bee7ce1e 100644 --- a/src/ble-pybricks-service/sagas.test.ts +++ b/src/ble-pybricks-service/sagas.test.ts @@ -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); @@ -149,6 +173,17 @@ describe('command encoder', () => { describe('event decoder', () => { test.each([ + [ + 'legacy status report', + [ + 0x00, // status report event + 0x01, // flags count LSB + 0x00, // . + 0x00, // . + 0x00, // flags count MSB + ], + didReceiveStatusReport(0x00000001, 0), + ], [ 'status report', [ @@ -157,8 +192,9 @@ describe('event decoder', () => { 0x00, // . 0x00, // . 0x00, // flags count MSB + 0x80, // slot ], - didReceiveStatusReport(0x00000001), + 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([ [ diff --git a/src/ble-pybricks-service/sagas.ts b/src/ble-pybricks-service/sagas.ts index f1477bb38..7fffdc5da 100644 --- a/src/ble-pybricks-service/sagas.ts +++ b/src/ble-pybricks-service/sagas.ts @@ -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): 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)}`, diff --git a/src/ble/reducers.test.ts b/src/ble/reducers.test.ts index 2f31c4cd8..716688b73 100644 --- a/src/ble/reducers.test.ts +++ b/src/ble/reducers.test.ts @@ -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(); diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index 00be5fd3a..be3fb191d 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -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(); diff --git a/src/hub/actions.ts b/src/hub/actions.ts index c16fd36e5..422b630eb 100644 --- a/src/hub/actions.ts +++ b/src/hub/actions.ts @@ -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(() => ({ diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index 4102abf16..04a691483 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -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(); + }); +}); diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index 5ddb73287..28a5c672b 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -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 = (state = false, action) => { return state; }; +/** + * When true, use Legacy StartUserProgram. + */ +const useLegacyStartUserProgram: Reducer = (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, }); diff --git a/src/hub/sagas.test.ts b/src/hub/sagas.test.ts index e14232244..552b39187 100644 --- a/src/hub/sagas.test.ts +++ b/src/hub/sagas.test.ts @@ -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'))); diff --git a/src/hub/sagas.ts b/src/hub/sagas.ts index ec6dfb127..2f5fd7b67 100644 --- a/src/hub/sagas.ts +++ b/src/hub/sagas.ts @@ -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): 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): 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): 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)), }); diff --git a/src/toolbar/buttons/repl/ReplButton.test.tsx b/src/toolbar/buttons/repl/ReplButton.test.tsx index 78553921b..3a72c5b0a 100644 --- a/src/toolbar/buttons/repl/ReplButton.test.tsx +++ b/src/toolbar/buttons/repl/ReplButton.test.tsx @@ -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( - - - , - { - 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( + + + , + { + 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), + ); + }, +); diff --git a/src/toolbar/buttons/repl/ReplButton.tsx b/src/toolbar/buttons/repl/ReplButton.tsx index fdb4d56c7..ea1c3e1ba 100644 --- a/src/toolbar/buttons/repl/ReplButton.tsx +++ b/src/toolbar/buttons/repl/ReplButton.tsx @@ -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; const ReplButton: React.FunctionComponent = ({ 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); diff --git a/src/toolbar/buttons/run/RunButton.test.tsx b/src/toolbar/buttons/run/RunButton.test.tsx index 7f82a59ab..02edae82e 100644 --- a/src/toolbar/buttons/run/RunButton.test.tsx +++ b/src/toolbar/buttons/run/RunButton.test.tsx @@ -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( - - - , - { - 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( + + + , + { + 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, + ), + ); + }, +); diff --git a/src/toolbar/buttons/run/RunButton.tsx b/src/toolbar/buttons/run/RunButton.tsx index 72e91ebf4..a7ce63c0f 100644 --- a/src/toolbar/buttons/run/RunButton.tsx +++ b/src/toolbar/buttons/run/RunButton.tsx @@ -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; const RunButton: React.FunctionComponent = ({ 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 = ({ 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 + ), + ) } /> );