ble-pybricks-service: add support for slots

The firmware has added some additional info for hubs that support slots
for user programs. Add these additional parameters accordingly.
This commit is contained in:
David Lechner
2025-07-26 16:14:40 -05:00
parent 1a7663a383
commit 3c4cc20c53
10 changed files with 103 additions and 32 deletions
+8 -2
View File
@@ -183,12 +183,14 @@ 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 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, runningProgId: number) => ({
(statusFlags: number, runningProgId: number, selectedSlot: number) => ({
type: 'blePybricksServiceEvent.action.didReceiveStatusReport',
statusFlags,
runningProgId,
selectedSlot,
}),
);
@@ -226,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,
}),
);
+7 -2
View File
@@ -303,18 +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 program ID 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;
runningProgId: number;
selectedSlot: number;
} {
assert(msg.getUint8(0) === EventType.StatusReport, 'expecting status report event');
return {
flags: msg.getUint32(1, true),
runningProgId: msg.byteLength > 5 ? msg.getUint8(5) : 0,
selectedSlot: msg.byteLength > 6 ? msg.getUint8(6) : 0,
};
}
+16 -3
View File
@@ -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',
@@ -193,8 +205,9 @@ describe('event decoder', () => {
0x00, // .
0x00, // flags count MSB
0x80, // program ID
0x02, // selected slot
],
didReceiveStatusReport(0x00000001, BuiltinProgramId.REPL),
didReceiveStatusReport(0x00000001, BuiltinProgramId.REPL, 2),
],
[
'write stdout',
+7 -1
View File
@@ -127,7 +127,13 @@ function* decodeResponse(action: ReturnType<typeof didNotifyEvent>): Generator {
switch (responseType) {
case EventType.StatusReport: {
const status = parseStatusReport(action.value);
yield* put(didReceiveStatusReport(status.flags, status.runningProgId));
yield* put(
didReceiveStatusReport(
status.flags,
status.runningProgId,
status.selectedSlot,
),
);
break;
}
case EventType.WriteStdout:
+7 -3
View File
@@ -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();
+3 -3
View File
@@ -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(
+11 -2
View File
@@ -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 {
+16 -13
View File
@@ -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();
});
+25 -1
View File
@@ -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<boolean> = (state = false, action) => {
return state;
};
/*
* Returns number of available slots or 0 for slots not supported.
*/
const numOfSlots: Reducer<number> = (state = 0, action) => {
if (blePybricksServiceDidReceiveHubCapabilities.matches(action)) {
return action.numOfSlots;
}
return state;
};
/*
* Returns the currently selected slot on a connected hub.
*/
const selectedSlot: Reducer<number> = (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,
});
+3 -2
View File
@@ -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<RunButtonProps> = ({ 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<RunButtonProps> = ({ id }) => {
preferredFileFormat,
useLegacyDownload,
useLegacyStartUserProgram,
0, // No slot UI yet
selectedSlot,
),
)
}