Skip to content

Commit a546109

Browse files
committed
usb: add USB support
Initial working USB support.
1 parent 38db0d0 commit a546109

29 files changed

+1460
-20
lines changed

src/alerts.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2022 The Pybricks Authors
2+
// Copyright (c) 2022-2025 The Pybricks Authors
33

44
import { ToastProps } from '@blueprintjs/core';
55
import alerts from './alerts/alerts';
@@ -11,6 +11,7 @@ import hub from './hub/alerts';
1111
import mpy from './mpy/alerts';
1212
import sponsor from './sponsor/alerts';
1313
import type { CreateToast } from './toasterTypes';
14+
import usb from './usb/alerts';
1415

1516
/** This collects alerts from all of the subsystems of the app */
1617
const alertDomains = {
@@ -22,6 +23,7 @@ const alertDomains = {
2223
hub,
2324
mpy,
2425
sponsor,
26+
usb,
2527
};
2628

2729
/** Gets the type of available alert domains. */

src/ble-device-info-service/protocol.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2021-2022 The Pybricks Authors
2+
// Copyright (c) 2021-2025 The Pybricks Authors
33
//
44
// Pybricks uses the standard Device Info service.
55
// Refer to Device Information Service (DIS) at https://www.bluetooth.com/specifications/specs/
@@ -15,6 +15,14 @@ import {
1515
/** Device Information service UUID. */
1616
export const deviceInformationServiceUUID = 0x180a;
1717

18+
/**
19+
* Device Name characteristic UUID.
20+
*
21+
* NB: Some OSes block this, so we just get the device name from the BLE
22+
* advertisement data instead. But we need this UUID for USB support.
23+
*/
24+
export const deviceNameUUID = 0x2a00;
25+
1826
/** Firmware Revision String characteristic UUID. */
1927
export const firmwareRevisionStringUUID = 0x2a26;
2028

src/hub/reducers.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ import {
2626
Status,
2727
statusToFlag,
2828
} from '../ble-pybricks-service/protocol';
29+
import {
30+
usbDidConnectPybricks,
31+
usbDidDisconnectPybricks,
32+
usbDidReceiveDeviceName,
33+
usbDidReceiveFirmwareRevision,
34+
usbDisconnectPybricks,
35+
} from '../usb/actions';
2936
import { pythonVersionToSemver } from '../utils/version';
3037
import {
3138
didFailToFinishDownload,
@@ -68,21 +75,31 @@ const runtime: Reducer<HubRuntimeState> = (
6875
// can't possibly be in any other state until we get a connect event.
6976
if (
7077
state === HubRuntimeState.Disconnected &&
71-
!bleDidConnectPybricks.matches(action)
78+
!bleDidConnectPybricks.matches(action) &&
79+
!usbDidConnectPybricks.matches(action)
7280
) {
7381
return state;
7482
}
7583

76-
if (bleDidConnectPybricks.matches(action)) {
84+
if (
85+
bleDidConnectPybricks.matches(action) ||
86+
usbDidConnectPybricks.matches(action)
87+
) {
7788
return HubRuntimeState.Unknown;
7889
}
7990

80-
if (bleDisconnectPybricks.matches(action)) {
91+
if (
92+
bleDisconnectPybricks.matches(action) ||
93+
usbDisconnectPybricks.matches(action)
94+
) {
8195
// disconnecting
8296
return HubRuntimeState.Unknown;
8397
}
8498

85-
if (bleDidDisconnectPybricks.matches(action)) {
99+
if (
100+
bleDidDisconnectPybricks.matches(action) ||
101+
usbDidDisconnectPybricks.matches(action)
102+
) {
86103
return HubRuntimeState.Disconnected;
87104
}
88105

@@ -149,43 +166,67 @@ const runtime: Reducer<HubRuntimeState> = (
149166
};
150167

151168
const deviceName: Reducer<string> = (state = '', action) => {
152-
if (bleDidDisconnectPybricks.matches(action)) {
169+
if (
170+
bleDidDisconnectPybricks.matches(action) ||
171+
usbDidDisconnectPybricks.matches(action)
172+
) {
153173
return '';
154174
}
155175

156176
if (bleDidConnectPybricks.matches(action)) {
157177
return action.name;
158178
}
159179

180+
if (usbDidReceiveDeviceName.matches(action)) {
181+
return action.deviceName;
182+
}
183+
160184
return state;
161185
};
162186

163187
const deviceType: Reducer<string> = (state = '', action) => {
164-
if (bleDidDisconnectPybricks.matches(action)) {
188+
if (
189+
bleDidDisconnectPybricks.matches(action) ||
190+
usbDidDisconnectPybricks.matches(action)
191+
) {
165192
return '';
166193
}
167194

168195
if (bleDIServiceDidReceivePnPId.matches(action)) {
169196
return getHubTypeName(action.pnpId);
170197
}
171198

199+
if (usbDidConnectPybricks.matches(action)) {
200+
return getHubTypeName(action.pnpId);
201+
}
202+
172203
return state;
173204
};
174205

175206
const deviceFirmwareVersion: Reducer<string> = (state = '', action) => {
176-
if (bleDidDisconnectPybricks.matches(action)) {
207+
if (
208+
bleDidDisconnectPybricks.matches(action) ||
209+
usbDidDisconnectPybricks.matches(action)
210+
) {
177211
return '';
178212
}
179213

180214
if (bleDIServiceDidReceiveFirmwareRevision.matches(action)) {
181215
return action.version;
182216
}
183217

218+
if (usbDidReceiveFirmwareRevision.matches(action)) {
219+
return action.version;
220+
}
221+
184222
return state;
185223
};
186224

187225
const deviceLowBatteryWarning: Reducer<boolean> = (state = false, action) => {
188-
if (bleDidDisconnectPybricks.matches(action)) {
226+
if (
227+
bleDidDisconnectPybricks.matches(action) ||
228+
usbDidDisconnectPybricks.matches(action)
229+
) {
189230
return false;
190231
}
191232

src/reducers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2020-2022 The Pybricks Authors
2+
// Copyright (c) 2020-2025 The Pybricks Authors
33

44
import { TypedUseSelectorHook, useSelector as useReduxSelector } from 'react-redux';
55
import { Reducer, combineReducers } from 'redux';
@@ -13,6 +13,7 @@ import hub from './hub/reducers';
1313
import bootloader from './lwp3-bootloader/reducers';
1414
import sponsor from './sponsor/reducers';
1515
import tour from './tour/reducers';
16+
import usb from './usb/reducers';
1617

1718
/**
1819
* Root reducer for redux store.
@@ -28,6 +29,7 @@ export const rootReducer = combineReducers({
2829
hub,
2930
tour,
3031
sponsor,
32+
usb,
3133
});
3234

3335
/**

src/sagas.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2020-2022 The Pybricks Authors
2+
// Copyright (c) 2020-2025 The Pybricks Authors
33

44
import { eventChannel } from 'redux-saga';
55
import { all, spawn, take } from 'typed-redux-saga/macro';
@@ -17,6 +17,7 @@ import lwp3BootloaderBle from './lwp3-bootloader/sagas-ble';
1717
import mpy from './mpy/sagas';
1818
import notifications from './notifications/sagas';
1919
import type { TerminalSagaContext } from './terminal/sagas';
20+
import usb from './usb/sagas';
2021

2122
/**
2223
* Listens to the 'pb-lazy-saga' event to spawn sagas from a React.lazy() initializer.
@@ -53,6 +54,7 @@ export default function* (): Generator {
5354
hub(),
5455
mpy(),
5556
notifications(),
57+
usb(),
5658
lazySagas(),
5759
]);
5860
}

src/toolbar/Toolbar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2020-2023 The Pybricks Authors
2+
// Copyright (c) 2020-2025 The Pybricks Authors
33

44
import { ButtonGroup } from '@blueprintjs/core';
55
import React from 'react';
@@ -10,11 +10,13 @@ import ReplButton from './buttons/repl/ReplButton';
1010
import RunButton from './buttons/run/RunButton';
1111
import SponsorButton from './buttons/sponsor/SponsorButton';
1212
import StopButton from './buttons/stop/StopButton';
13+
import UsbButton from './buttons/usb/UsbButton';
1314
import { useI18n } from './i18n';
1415

1516
import './toolbar.scss';
1617

1718
// matches ID in tour component
19+
const usbButtonId = 'pb-toolbar-usb-button';
1820
const bluetoothButtonId = 'pb-toolbar-bluetooth-button';
1921
const runButtonId = 'pb-toolbar-run-button';
2022
const sponsorButtonId = 'pb-toolbar-sponsor-button';
@@ -31,6 +33,7 @@ const Toolbar: React.FunctionComponent = () => {
3133
firstFocusableItemId={bluetoothButtonId}
3234
>
3335
<ButtonGroup className="pb-toolbar-group pb-align-left">
36+
<UsbButton id={usbButtonId} />
3437
<BluetoothButton id={bluetoothButtonId} />
3538
</ButtonGroup>
3639
<ButtonGroup className="pb-toolbar-group pb-align-left">

src/toolbar/buttons/bluetooth/BluetoothButton.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2020-2023 The Pybricks Authors
2+
// Copyright (c) 2020-2025 The Pybricks Authors
33

44
import React from 'react';
55
import { useDispatch } from 'react-redux';
66
import { toggleBluetooth } from '../../../ble/actions';
77
import { BleConnectionState } from '../../../ble/reducers';
88
import { BootloaderConnectionState } from '../../../lwp3-bootloader/reducers';
99
import { useSelector } from '../../../reducers';
10+
import { UsbConnectionState } from '../../../usb/reducers';
1011
import ActionButton, { ActionButtonProps } from '../../ActionButton';
1112
import connectedIcon from './connected.svg';
1213
import disconnectedIcon from './disconnected.svg';
@@ -17,10 +18,13 @@ type BluetoothButtonProps = Pick<ActionButtonProps, 'id'>;
1718
const BluetoothButton: React.FunctionComponent<BluetoothButtonProps> = ({ id }) => {
1819
const bootloaderConnection = useSelector((s) => s.bootloader.connection);
1920
const bleConnection = useSelector((s) => s.ble.connection);
21+
const usbConnection = useSelector((s) => s.usb.connection);
2022

21-
const isDisconnected =
23+
const isBluetoothDisconnected =
2224
bootloaderConnection === BootloaderConnectionState.Disconnected &&
2325
bleConnection === BleConnectionState.Disconnected;
26+
const isEverythingDisconnected =
27+
isBluetoothDisconnected && usbConnection === UsbConnectionState.Disconnected;
2428

2529
const i18n = useI18n();
2630
const dispatch = useDispatch();
@@ -30,10 +34,17 @@ const BluetoothButton: React.FunctionComponent<BluetoothButtonProps> = ({ id })
3034
id={id}
3135
label={i18n.translate('label')}
3236
tooltip={i18n.translate(
33-
isDisconnected ? 'tooltip.connect' : 'tooltip.disconnect',
37+
usbConnection !== UsbConnectionState.Disconnected
38+
? 'tooltip.usbConnected'
39+
: isBluetoothDisconnected
40+
? 'tooltip.connect'
41+
: 'tooltip.disconnect',
3442
)}
35-
icon={isDisconnected ? disconnectedIcon : connectedIcon}
36-
enabled={isDisconnected || bleConnection === BleConnectionState.Connected}
43+
icon={isBluetoothDisconnected ? disconnectedIcon : connectedIcon}
44+
enabled={
45+
isEverythingDisconnected ||
46+
bleConnection === BleConnectionState.Connected
47+
}
3748
showProgress={
3849
bleConnection === BleConnectionState.Connecting ||
3950
bleConnection === BleConnectionState.Disconnecting

src/toolbar/buttons/bluetooth/translations/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"label": "Bluetooth",
33
"tooltip": {
44
"connect": "Connect using Bluetooth",
5-
"disconnect": "Disconnect Bluetooth"
5+
"disconnect": "Disconnect Bluetooth",
6+
"usbConnected": "USB is connected, disconnect to use Bluetooth"
67
}
78
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2025 The Pybricks Authors
3+
4+
import { act, cleanup } from '@testing-library/react';
5+
import React from 'react';
6+
import { FocusScope } from 'react-aria';
7+
import { testRender } from '../../../../test';
8+
import { usbToggle } from '../../../usb/actions';
9+
import UsbButton from './UsbButton';
10+
11+
afterEach(() => {
12+
cleanup();
13+
});
14+
15+
it('should dispatch action when clicked', async () => {
16+
const [user, button, dispatch] = testRender(
17+
<FocusScope>
18+
<UsbButton id="test-usb-button" />
19+
</FocusScope>,
20+
);
21+
22+
await act(() => user.click(button.getByRole('button', { name: 'USB' })));
23+
24+
expect(dispatch).toHaveBeenCalledWith(usbToggle());
25+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2025 The Pybricks Authors
3+
4+
import React from 'react';
5+
import { useDispatch } from 'react-redux';
6+
import { BleConnectionState } from '../../../ble/reducers';
7+
import { BootloaderConnectionState } from '../../../lwp3-bootloader/reducers';
8+
import { useSelector } from '../../../reducers';
9+
import { usbToggle } from '../../../usb/actions';
10+
import { UsbConnectionState } from '../../../usb/reducers';
11+
import ActionButton, { ActionButtonProps } from '../../ActionButton';
12+
import connectedIcon from './connected.svg';
13+
import disconnectedIcon from './disconnected.svg';
14+
import { useI18n } from './i18n';
15+
16+
type UsbButtonProps = Pick<ActionButtonProps, 'id'>;
17+
18+
const UsbButton: React.FunctionComponent<UsbButtonProps> = ({ id }) => {
19+
const bootloaderConnection = useSelector((s) => s.bootloader.connection);
20+
const bleConnection = useSelector((s) => s.ble.connection);
21+
const usbConnection = useSelector((s) => s.usb.connection);
22+
23+
const isUsbDisconnected = usbConnection === UsbConnectionState.Disconnected;
24+
const isEverythingDisconnected =
25+
isUsbDisconnected &&
26+
bootloaderConnection === BootloaderConnectionState.Disconnected &&
27+
bleConnection === BleConnectionState.Disconnected;
28+
29+
const i18n = useI18n();
30+
const dispatch = useDispatch();
31+
32+
return (
33+
<ActionButton
34+
id={id}
35+
label={i18n.translate('label')}
36+
tooltip={i18n.translate(
37+
bleConnection !== BleConnectionState.Disconnected
38+
? 'tooltip.bluetoothConnected'
39+
: isUsbDisconnected
40+
? 'tooltip.connect'
41+
: 'tooltip.disconnect',
42+
)}
43+
icon={isUsbDisconnected ? disconnectedIcon : connectedIcon}
44+
enabled={
45+
isEverythingDisconnected ||
46+
usbConnection === UsbConnectionState.Connected
47+
}
48+
showProgress={
49+
usbConnection === UsbConnectionState.Connecting ||
50+
usbConnection === UsbConnectionState.Disconnecting
51+
}
52+
onAction={() => dispatch(usbToggle())}
53+
/>
54+
);
55+
};
56+
57+
export default UsbButton;

0 commit comments

Comments
 (0)