diff --git a/src/ble-pybricks-service/actions.ts b/src/ble-pybricks-service/actions.ts index cda33a01d..9961bd3df 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-2024 The Pybricks Authors +// Copyright (c) 2021-2025 The Pybricks Authors // // Actions for Bluetooth Low Energy Pybricks service @@ -81,15 +81,17 @@ export const sendLegacyStartReplCommand = createAction((id: number) => ({ /** * 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. + * @param progId The program ID 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, -})); +export const sendStartUserProgramCommand = createAction( + (id: number, progId: number) => ({ + type: 'blePybricksServiceCommand.action.sendStartUserProgram', + id, + progId, + }), +); /** * Action that requests to write user program metadata. @@ -180,13 +182,15 @@ 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. + * @param progId The ID number of the user program that is running. + * @param selectedSlot The currently selected slot on the hub. */ export const didReceiveStatusReport = createAction( - (statusFlags: number, slot: number) => ({ + (statusFlags: number, runningProgId: number, selectedSlot: number) => ({ type: 'blePybricksServiceEvent.action.didReceiveStatusReport', statusFlags, - slot, + runningProgId, + selectedSlot, }), ); @@ -224,13 +228,17 @@ export const eventProtocolError = createAction((error: Error) => ({ /** * Action that is called when the Pybricks Hub Capbailities characteristic * is read. + * + * @since Pybricks Profile v1.2.0 + * @changed numOfSlots added in v.1.5.0 */ export const blePybricksServiceDidReceiveHubCapabilities = createAction( - (maxWriteSize: number, flags: number, maxUserProgramSize: number) => ({ + (maxWriteSize: number, flags: number, maxUserProgramSize: number, numOfSlots) => ({ type: 'blePybricksServiceEvent.action.didReceiveHubCapabilities', maxWriteSize, flags, maxUserProgramSize, + numOfSlots, }), ); diff --git a/src/ble-pybricks-service/protocol.ts b/src/ble-pybricks-service/protocol.ts index 773aa25a2..63334d872 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-2024 The Pybricks Authors +// Copyright (c) 2020-2025 The Pybricks Authors // // Definitions related to the Pybricks Bluetooth low energy GATT service. @@ -112,17 +112,17 @@ export function createStopUserProgramCommand(): Uint8Array { * Creates a {@link CommandType.StartUserProgram} message. * * Parameters: - * - slot: Program identifier (one byte). Slots 0--127 are reserved for + * - progId: 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( - slot: number | BuiltinProgramId, + progId: number | BuiltinProgramId, ): Uint8Array { const msg = new Uint8Array(2); msg[0] = CommandType.StartUserProgram; - msg[1] = slot; + msg[1] = progId; return msg; } @@ -303,15 +303,23 @@ 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 and the slot number of the running program. + * @returns The status as bit flags, the program ID number of the running program, + * and the currently selected slot. * - * @since Pybricks Profile v1.0.0 - changed in v1.4.0 + * @since Pybricks Profile v1.0.0 + * @changed runningProgId added in v1.4.0 + * @changed selectedSlot added in v1.5.0 */ -export function parseStatusReport(msg: DataView): { flags: number; slot: number } { +export function parseStatusReport(msg: DataView): { + flags: number; + runningProgId: number; + selectedSlot: number; +} { assert(msg.getUint8(0) === EventType.StatusReport, 'expecting status report event'); return { flags: msg.getUint32(1, true), - slot: msg.byteLength > 5 ? msg.getUint8(5) : 0, + runningProgId: msg.byteLength > 5 ? msg.getUint8(5) : 0, + selectedSlot: msg.byteLength > 6 ? msg.getUint8(6) : 0, }; } diff --git a/src/ble-pybricks-service/sagas.test.ts b/src/ble-pybricks-service/sagas.test.ts index 698bd8ae5..69562e43b 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-2024 The Pybricks Authors +// Copyright (c) 2021-2025 The Pybricks Authors import { AsyncSaga } from '../../test'; import { @@ -35,11 +35,11 @@ describe('command encoder', () => { ], ], [ - 'start user program with slot', + 'start user program with program id', sendStartUserProgramCommand(0, 0x2a), [ 0x01, // start user program command - 0x2a, // program slot + 0x2a, // program ID ], ], [ @@ -174,7 +174,7 @@ describe('command encoder', () => { describe('event decoder', () => { test.each([ [ - 'legacy status report', + 'v1.3 status report', [ 0x00, // status report event 0x01, // flags count LSB @@ -182,7 +182,19 @@ describe('event decoder', () => { 0x00, // . 0x00, // flags count MSB ], - didReceiveStatusReport(0x00000001, 0), + didReceiveStatusReport(0x00000001, 0, 0), + ], + [ + 'v1.4 status report', + [ + 0x00, // status report event + 0x01, // flags count LSB + 0x00, // . + 0x00, // . + 0x00, // flags count MSB + 0x80, // program ID + ], + didReceiveStatusReport(0x00000001, BuiltinProgramId.REPL, 0), ], [ 'status report', @@ -192,9 +204,10 @@ describe('event decoder', () => { 0x00, // . 0x00, // . 0x00, // flags count MSB - 0x80, // slot + 0x80, // program ID + 0x02, // selected slot ], - didReceiveStatusReport(0x00000001, BuiltinProgramId.REPL), + didReceiveStatusReport(0x00000001, BuiltinProgramId.REPL, 2), ], [ 'write stdout', diff --git a/src/ble-pybricks-service/sagas.ts b/src/ble-pybricks-service/sagas.ts index 7fffdc5da..8ddc3b808 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-2024 The Pybricks Authors +// Copyright (c) 2021-2025 The Pybricks Authors // // Handles Pybricks protocol. @@ -75,7 +75,7 @@ function* encodeRequest(): Generator { yield* put(writeCommand(action.id, createLegacyStartReplCommand())); } else if (sendStartUserProgramCommand.matches(action)) { yield* put( - writeCommand(action.id, createStartUserProgramCommand(action.slot)), + writeCommand(action.id, createStartUserProgramCommand(action.progId)), ); } else if (sendWriteUserProgramMetaCommand.matches(action)) { yield* put( @@ -127,7 +127,13 @@ function* decodeResponse(action: ReturnType): Generator { switch (responseType) { case EventType.StatusReport: { const status = parseStatusReport(action.value); - yield* put(didReceiveStatusReport(status.flags, status.slot)); + yield* put( + didReceiveStatusReport( + status.flags, + status.runningProgId, + status.selectedSlot, + ), + ); break; } case EventType.WriteStdout: diff --git a/src/ble/reducers.test.ts b/src/ble/reducers.test.ts index 716688b73..deba2a9a1 100644 --- a/src/ble/reducers.test.ts +++ b/src/ble/reducers.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021-2024 The Pybricks Authors +// Copyright (c) 2021-2025 The Pybricks Authors import { AnyAction } from 'redux'; import { @@ -130,14 +130,18 @@ test('deviceLowBatteryWarning', () => { expect( reducers( { deviceLowBatteryWarning: false } as State, - didReceiveStatusReport(statusToFlag(Status.BatteryLowVoltageWarning), 0), + didReceiveStatusReport(statusToFlag(Status.BatteryLowVoltageWarning), 0, 0), ).deviceLowBatteryWarning, ).toBeTruthy(); expect( reducers( { deviceLowBatteryWarning: true } as State, - didReceiveStatusReport(~statusToFlag(Status.BatteryLowVoltageWarning), 0), + didReceiveStatusReport( + ~statusToFlag(Status.BatteryLowVoltageWarning), + 0, + 0, + ), ).deviceLowBatteryWarning, ).toBeFalsy(); diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts index 1062e4bc7..25c497437 100644 --- a/src/ble/sagas.test.ts +++ b/src/ble/sagas.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors import { MockProxy, mock } from 'jest-mock-extended'; import { AsyncSaga } from '../../test'; @@ -114,7 +114,7 @@ function createMocks(): Mocks { hubCapabilitiesChar.readValue.mockResolvedValue( new DataView( new Uint8Array([ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]).buffer, ), ); @@ -459,7 +459,7 @@ describe('connect action is dispatched', () => { ); await expect(saga.take()).resolves.toEqual( - blePybricksServiceDidReceiveHubCapabilities(0, 0, 0), + blePybricksServiceDidReceiveHubCapabilities(0, 0, 0, 0), ); await expect(saga.take()).resolves.toEqual( diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index be3fb191d..3c5ae49d1 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2025 The Pybricks Authors // // Manages connection to a Bluetooth Low Energy device running Pybricks firmware. @@ -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.4.0'; +export const supportedPybricksProfileVersion = '1.5.0'; const decoder = new TextDecoder(); @@ -378,11 +378,20 @@ function* handleBleConnectPybricks(): Generator { const flags = hubCapabilitiesValue.getUint32(2, true); const maxUserProgramSize = hubCapabilitiesValue.getUint32(6, true); + const numOfSlots = (() => { + if (semver.satisfies(softwareRevision, '^1.5.0')) { + return hubCapabilitiesValue.getUint8(10); + } + + return 0; + })(); + yield* put( blePybricksServiceDidReceiveHubCapabilities( maxWriteSize, flags, maxUserProgramSize, + numOfSlots, ), ); } else { diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index 04a691483..7e7bbf837 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021-2024 The Pybricks Authors +// Copyright (c) 2021-2025 The Pybricks Authors import { AnyAction } from 'redux'; import { @@ -44,8 +44,10 @@ test('initial state', () => { "hasRepl": false, "maxBleWriteSize": 0, "maxUserProgramSize": 0, + "numOfSlots": 0, "preferredFileFormat": null, "runtime": "hub.runtime.disconnected", + "selectedSlot": 0, "useLegacyDownload": false, "useLegacyStartUserProgram": false, "useLegacyStdio": false, @@ -158,7 +160,7 @@ describe('runtime', () => { expect( reducers( { runtime: HubRuntimeState.Disconnected } as State, - didReceiveStatusReport(statusToFlag(Status.UserProgramRunning), 0), + didReceiveStatusReport(statusToFlag(Status.UserProgramRunning), 0, 0), ).runtime, ).toBe(HubRuntimeState.Disconnected); @@ -166,7 +168,7 @@ describe('runtime', () => { expect( reducers( { runtime: HubRuntimeState.Loading } as State, - didReceiveStatusReport(statusToFlag(Status.UserProgramRunning), 0), + didReceiveStatusReport(statusToFlag(Status.UserProgramRunning), 0, 0), ).runtime, ).toBe(HubRuntimeState.Loading); @@ -174,7 +176,7 @@ describe('runtime', () => { expect( reducers( { runtime: HubRuntimeState.Unknown } as State, - didReceiveStatusReport(statusToFlag(Status.UserProgramRunning), 0), + didReceiveStatusReport(statusToFlag(Status.UserProgramRunning), 0, 0), ).runtime, ).toBe(HubRuntimeState.Running); @@ -182,7 +184,7 @@ describe('runtime', () => { expect( reducers( { runtime: HubRuntimeState.Unknown } as State, - didReceiveStatusReport(0, 0), + didReceiveStatusReport(0, 0, 0), ).runtime, ).toBe(HubRuntimeState.Idle); @@ -190,7 +192,7 @@ describe('runtime', () => { expect( reducers( { runtime: HubRuntimeState.Running } as State, - didReceiveStatusReport(0, 0), + didReceiveStatusReport(0, 0, 0), ).runtime, ).toBe(HubRuntimeState.Idle); @@ -198,7 +200,7 @@ describe('runtime', () => { expect( reducers( { runtime: HubRuntimeState.StartingRepl } as State, - didReceiveStatusReport(0, 0), + didReceiveStatusReport(0, 0, 0), ).runtime, ).toBe(HubRuntimeState.StartingRepl); @@ -206,7 +208,7 @@ describe('runtime', () => { expect( reducers( { runtime: HubRuntimeState.StoppingUserProgram } as State, - didReceiveStatusReport(0, 0), + didReceiveStatusReport(0, 0, 0), ).runtime, ).toBe(HubRuntimeState.StoppingUserProgram); }); @@ -301,7 +303,7 @@ describe('maxBleWriteSize', () => { expect( reducers( { maxBleWriteSize: 0 } as State, - blePybricksServiceDidReceiveHubCapabilities(size, 0, 100), + blePybricksServiceDidReceiveHubCapabilities(size, 0, 100, 0), ).maxBleWriteSize, ).toBe(size); }); @@ -312,7 +314,7 @@ describe('maxUserProgramSize', () => { expect( reducers( { maxUserProgramSize: 0 } as State, - blePybricksServiceDidReceiveHubCapabilities(23, 0, size), + blePybricksServiceDidReceiveHubCapabilities(23, 0, size, 0), ).maxUserProgramSize, ).toBe(size); }); @@ -340,7 +342,7 @@ describe('hasRepl', () => { expect( reducers( { hasRepl: true } as State, - blePybricksServiceDidReceiveHubCapabilities(23, flag, 100), + blePybricksServiceDidReceiveHubCapabilities(23, flag, 100, 0), ).hasRepl, ).toBe(Boolean(flag & HubCapabilityFlag.HasRepl)); }, @@ -374,6 +376,7 @@ describe('preferredFileFormat', () => { 23, HubCapabilityFlag.UserProgramMultiMpy6, 100, + 0, ), ).preferredFileFormat, ).toBe(FileFormat.MultiMpy6); @@ -383,7 +386,7 @@ describe('preferredFileFormat', () => { expect( reducers( { preferredFileFormat: FileFormat.MultiMpy6 } as State, - blePybricksServiceDidReceiveHubCapabilities(23, 0, 100), + blePybricksServiceDidReceiveHubCapabilities(23, 0, 100, 0), ).preferredFileFormat, ).toBeNull(); }); @@ -403,7 +406,7 @@ describe('useLegacyDownload', () => { expect( reducers( { useLegacyDownload: true } as State, - blePybricksServiceDidReceiveHubCapabilities(23, 0, 100), + blePybricksServiceDidReceiveHubCapabilities(23, 0, 100, 0), ).useLegacyDownload, ).toBeFalsy(); }); diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index 28a5c672b..73824d238 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2024 The Pybricks Authors +// Copyright (c) 2020-2025 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; import * as semver from 'semver'; @@ -275,6 +275,28 @@ const useLegacyStartUserProgram: Reducer = (state = false, action) => { return state; }; +/* + * Returns number of available slots or 0 for slots not supported. + */ +const numOfSlots: Reducer = (state = 0, action) => { + if (blePybricksServiceDidReceiveHubCapabilities.matches(action)) { + return action.numOfSlots; + } + + return state; +}; + +/* + * Returns the currently selected slot on a connected hub. + */ +const selectedSlot: Reducer = (state = 0, action) => { + if (didReceiveStatusReport.matches(action)) { + return action.selectedSlot; + } + + return state; +}; + export default combineReducers({ runtime, downloadProgress, @@ -285,4 +307,6 @@ export default combineReducers({ useLegacyDownload, useLegacyStdio, useLegacyStartUserProgram, + numOfSlots, + selectedSlot, }); diff --git a/src/toolbar/buttons/run/RunButton.tsx b/src/toolbar/buttons/run/RunButton.tsx index a7ce63c0f..226e1d579 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-2024 The Pybricks Authors +// Copyright (c) 2020-2025 The Pybricks Authors import React from 'react'; import { useDispatch } from 'react-redux'; @@ -19,6 +19,7 @@ const RunButton: React.FunctionComponent = ({ id }) => { runtime, useLegacyDownload, useLegacyStartUserProgram, + selectedSlot, } = useSelector((s) => s.hub); const activeFile = useSelector((s) => s.editor.activeFileUuid); const keyboardShortcut = 'F5'; @@ -48,7 +49,7 @@ const RunButton: React.FunctionComponent = ({ id }) => { preferredFileFormat, useLegacyDownload, useLegacyStartUserProgram, - 0, // No slot UI yet + selectedSlot, ), ) }