usb: add USB support

Initial working USB support.
This commit is contained in:
David Lechner
2025-08-08 21:36:18 -05:00
parent 38db0d016c
commit a546109273
29 changed files with 1460 additions and 20 deletions
+3 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// Copyright (c) 2022-2025 The Pybricks Authors
import { ToastProps } from '@blueprintjs/core';
import alerts from './alerts/alerts';
@@ -11,6 +11,7 @@ import hub from './hub/alerts';
import mpy from './mpy/alerts';
import sponsor from './sponsor/alerts';
import type { CreateToast } from './toasterTypes';
import usb from './usb/alerts';
/** This collects alerts from all of the subsystems of the app */
const alertDomains = {
@@ -22,6 +23,7 @@ const alertDomains = {
hub,
mpy,
sponsor,
usb,
};
/** Gets the type of available alert domains. */
+9 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
// Copyright (c) 2021-2025 The Pybricks Authors
//
// Pybricks uses the standard Device Info service.
// Refer to Device Information Service (DIS) at https://www.bluetooth.com/specifications/specs/
@@ -15,6 +15,14 @@ import {
/** Device Information service UUID. */
export const deviceInformationServiceUUID = 0x180a;
/**
* Device Name characteristic UUID.
*
* NB: Some OSes block this, so we just get the device name from the BLE
* advertisement data instead. But we need this UUID for USB support.
*/
export const deviceNameUUID = 0x2a00;
/** Firmware Revision String characteristic UUID. */
export const firmwareRevisionStringUUID = 0x2a26;
+49 -8
View File
@@ -26,6 +26,13 @@ import {
Status,
statusToFlag,
} from '../ble-pybricks-service/protocol';
import {
usbDidConnectPybricks,
usbDidDisconnectPybricks,
usbDidReceiveDeviceName,
usbDidReceiveFirmwareRevision,
usbDisconnectPybricks,
} from '../usb/actions';
import { pythonVersionToSemver } from '../utils/version';
import {
didFailToFinishDownload,
@@ -68,21 +75,31 @@ const runtime: Reducer<HubRuntimeState> = (
// can't possibly be in any other state until we get a connect event.
if (
state === HubRuntimeState.Disconnected &&
!bleDidConnectPybricks.matches(action)
!bleDidConnectPybricks.matches(action) &&
!usbDidConnectPybricks.matches(action)
) {
return state;
}
if (bleDidConnectPybricks.matches(action)) {
if (
bleDidConnectPybricks.matches(action) ||
usbDidConnectPybricks.matches(action)
) {
return HubRuntimeState.Unknown;
}
if (bleDisconnectPybricks.matches(action)) {
if (
bleDisconnectPybricks.matches(action) ||
usbDisconnectPybricks.matches(action)
) {
// disconnecting
return HubRuntimeState.Unknown;
}
if (bleDidDisconnectPybricks.matches(action)) {
if (
bleDidDisconnectPybricks.matches(action) ||
usbDidDisconnectPybricks.matches(action)
) {
return HubRuntimeState.Disconnected;
}
@@ -149,7 +166,10 @@ const runtime: Reducer<HubRuntimeState> = (
};
const deviceName: Reducer<string> = (state = '', action) => {
if (bleDidDisconnectPybricks.matches(action)) {
if (
bleDidDisconnectPybricks.matches(action) ||
usbDidDisconnectPybricks.matches(action)
) {
return '';
}
@@ -157,11 +177,18 @@ const deviceName: Reducer<string> = (state = '', action) => {
return action.name;
}
if (usbDidReceiveDeviceName.matches(action)) {
return action.deviceName;
}
return state;
};
const deviceType: Reducer<string> = (state = '', action) => {
if (bleDidDisconnectPybricks.matches(action)) {
if (
bleDidDisconnectPybricks.matches(action) ||
usbDidDisconnectPybricks.matches(action)
) {
return '';
}
@@ -169,11 +196,18 @@ const deviceType: Reducer<string> = (state = '', action) => {
return getHubTypeName(action.pnpId);
}
if (usbDidConnectPybricks.matches(action)) {
return getHubTypeName(action.pnpId);
}
return state;
};
const deviceFirmwareVersion: Reducer<string> = (state = '', action) => {
if (bleDidDisconnectPybricks.matches(action)) {
if (
bleDidDisconnectPybricks.matches(action) ||
usbDidDisconnectPybricks.matches(action)
) {
return '';
}
@@ -181,11 +215,18 @@ const deviceFirmwareVersion: Reducer<string> = (state = '', action) => {
return action.version;
}
if (usbDidReceiveFirmwareRevision.matches(action)) {
return action.version;
}
return state;
};
const deviceLowBatteryWarning: Reducer<boolean> = (state = false, action) => {
if (bleDidDisconnectPybricks.matches(action)) {
if (
bleDidDisconnectPybricks.matches(action) ||
usbDidDisconnectPybricks.matches(action)
) {
return false;
}
+3 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
// Copyright (c) 2020-2025 The Pybricks Authors
import { TypedUseSelectorHook, useSelector as useReduxSelector } from 'react-redux';
import { Reducer, combineReducers } from 'redux';
@@ -13,6 +13,7 @@ import hub from './hub/reducers';
import bootloader from './lwp3-bootloader/reducers';
import sponsor from './sponsor/reducers';
import tour from './tour/reducers';
import usb from './usb/reducers';
/**
* Root reducer for redux store.
@@ -28,6 +29,7 @@ export const rootReducer = combineReducers({
hub,
tour,
sponsor,
usb,
});
/**
+3 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
// Copyright (c) 2020-2025 The Pybricks Authors
import { eventChannel } from 'redux-saga';
import { all, spawn, take } from 'typed-redux-saga/macro';
@@ -17,6 +17,7 @@ import lwp3BootloaderBle from './lwp3-bootloader/sagas-ble';
import mpy from './mpy/sagas';
import notifications from './notifications/sagas';
import type { TerminalSagaContext } from './terminal/sagas';
import usb from './usb/sagas';
/**
* Listens to the 'pb-lazy-saga' event to spawn sagas from a React.lazy() initializer.
@@ -53,6 +54,7 @@ export default function* (): Generator {
hub(),
mpy(),
notifications(),
usb(),
lazySagas(),
]);
}
+4 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2025 The Pybricks Authors
import { ButtonGroup } from '@blueprintjs/core';
import React from 'react';
@@ -10,11 +10,13 @@ import ReplButton from './buttons/repl/ReplButton';
import RunButton from './buttons/run/RunButton';
import SponsorButton from './buttons/sponsor/SponsorButton';
import StopButton from './buttons/stop/StopButton';
import UsbButton from './buttons/usb/UsbButton';
import { useI18n } from './i18n';
import './toolbar.scss';
// matches ID in tour component
const usbButtonId = 'pb-toolbar-usb-button';
const bluetoothButtonId = 'pb-toolbar-bluetooth-button';
const runButtonId = 'pb-toolbar-run-button';
const sponsorButtonId = 'pb-toolbar-sponsor-button';
@@ -31,6 +33,7 @@ const Toolbar: React.FunctionComponent = () => {
firstFocusableItemId={bluetoothButtonId}
>
<ButtonGroup className="pb-toolbar-group pb-align-left">
<UsbButton id={usbButtonId} />
<BluetoothButton id={bluetoothButtonId} />
</ButtonGroup>
<ButtonGroup className="pb-toolbar-group pb-align-left">
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2025 The Pybricks Authors
import React from 'react';
import { useDispatch } from 'react-redux';
@@ -7,6 +7,7 @@ import { toggleBluetooth } from '../../../ble/actions';
import { BleConnectionState } from '../../../ble/reducers';
import { BootloaderConnectionState } from '../../../lwp3-bootloader/reducers';
import { useSelector } from '../../../reducers';
import { UsbConnectionState } from '../../../usb/reducers';
import ActionButton, { ActionButtonProps } from '../../ActionButton';
import connectedIcon from './connected.svg';
import disconnectedIcon from './disconnected.svg';
@@ -17,10 +18,13 @@ type BluetoothButtonProps = Pick<ActionButtonProps, 'id'>;
const BluetoothButton: React.FunctionComponent<BluetoothButtonProps> = ({ id }) => {
const bootloaderConnection = useSelector((s) => s.bootloader.connection);
const bleConnection = useSelector((s) => s.ble.connection);
const usbConnection = useSelector((s) => s.usb.connection);
const isDisconnected =
const isBluetoothDisconnected =
bootloaderConnection === BootloaderConnectionState.Disconnected &&
bleConnection === BleConnectionState.Disconnected;
const isEverythingDisconnected =
isBluetoothDisconnected && usbConnection === UsbConnectionState.Disconnected;
const i18n = useI18n();
const dispatch = useDispatch();
@@ -30,10 +34,17 @@ const BluetoothButton: React.FunctionComponent<BluetoothButtonProps> = ({ id })
id={id}
label={i18n.translate('label')}
tooltip={i18n.translate(
isDisconnected ? 'tooltip.connect' : 'tooltip.disconnect',
usbConnection !== UsbConnectionState.Disconnected
? 'tooltip.usbConnected'
: isBluetoothDisconnected
? 'tooltip.connect'
: 'tooltip.disconnect',
)}
icon={isDisconnected ? disconnectedIcon : connectedIcon}
enabled={isDisconnected || bleConnection === BleConnectionState.Connected}
icon={isBluetoothDisconnected ? disconnectedIcon : connectedIcon}
enabled={
isEverythingDisconnected ||
bleConnection === BleConnectionState.Connected
}
showProgress={
bleConnection === BleConnectionState.Connecting ||
bleConnection === BleConnectionState.Disconnecting
@@ -2,6 +2,7 @@
"label": "Bluetooth",
"tooltip": {
"connect": "Connect using Bluetooth",
"disconnect": "Disconnect Bluetooth"
"disconnect": "Disconnect Bluetooth",
"usbConnected": "USB is connected, disconnect to use Bluetooth"
}
}
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { act, cleanup } from '@testing-library/react';
import React from 'react';
import { FocusScope } from 'react-aria';
import { testRender } from '../../../../test';
import { usbToggle } from '../../../usb/actions';
import UsbButton from './UsbButton';
afterEach(() => {
cleanup();
});
it('should dispatch action when clicked', async () => {
const [user, button, dispatch] = testRender(
<FocusScope>
<UsbButton id="test-usb-button" />
</FocusScope>,
);
await act(() => user.click(button.getByRole('button', { name: 'USB' })));
expect(dispatch).toHaveBeenCalledWith(usbToggle());
});
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import React from 'react';
import { useDispatch } from 'react-redux';
import { BleConnectionState } from '../../../ble/reducers';
import { BootloaderConnectionState } from '../../../lwp3-bootloader/reducers';
import { useSelector } from '../../../reducers';
import { usbToggle } from '../../../usb/actions';
import { UsbConnectionState } from '../../../usb/reducers';
import ActionButton, { ActionButtonProps } from '../../ActionButton';
import connectedIcon from './connected.svg';
import disconnectedIcon from './disconnected.svg';
import { useI18n } from './i18n';
type UsbButtonProps = Pick<ActionButtonProps, 'id'>;
const UsbButton: React.FunctionComponent<UsbButtonProps> = ({ id }) => {
const bootloaderConnection = useSelector((s) => s.bootloader.connection);
const bleConnection = useSelector((s) => s.ble.connection);
const usbConnection = useSelector((s) => s.usb.connection);
const isUsbDisconnected = usbConnection === UsbConnectionState.Disconnected;
const isEverythingDisconnected =
isUsbDisconnected &&
bootloaderConnection === BootloaderConnectionState.Disconnected &&
bleConnection === BleConnectionState.Disconnected;
const i18n = useI18n();
const dispatch = useDispatch();
return (
<ActionButton
id={id}
label={i18n.translate('label')}
tooltip={i18n.translate(
bleConnection !== BleConnectionState.Disconnected
? 'tooltip.bluetoothConnected'
: isUsbDisconnected
? 'tooltip.connect'
: 'tooltip.disconnect',
)}
icon={isUsbDisconnected ? disconnectedIcon : connectedIcon}
enabled={
isEverythingDisconnected ||
usbConnection === UsbConnectionState.Connected
}
showProgress={
usbConnection === UsbConnectionState.Connecting ||
usbConnection === UsbConnectionState.Disconnecting
}
onAction={() => dispatch(usbToggle())}
/>
);
};
export default UsbButton;
+1
View File
@@ -0,0 +1 @@
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.2631 0 0 1 -3.2708 -.26595)"><g fill="#fff"><circle cx="13.046" cy="22.827" r="2.341"/><circle cx="9.2957" cy="9.9073" r="1.5278"/><rect x="15.21" y="6.5057" width="3.1483" height="3.0696" rx=".060605" ry=".060605"/><rect x="12.345" y="4.68" width="1.4073" height="18.44" rx=".027091" ry=".36408"/><path transform="matrix(.96747 0 0 .96747 .78192 .83494)" d="m14.724 4.5411h-4.0658l2.0329-3.5211z"/></g><path d="m16.745 8.9797v2.8863l-3.6079 3.6079-0.03436-0.05727" fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="1.3229"/><path d="m9.2838 10.691v4.2837l3.6079 3.6079 0.03436-0.05727" fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="1.3229"/></g></svg>

After

Width:  |  Height:  |  Size: 813 B

+1
View File
@@ -0,0 +1 @@
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.2631 0 0 1 -3.2708 -.26595)"><g fill="#fff"><circle cx="13.046" cy="22.827" r="2.341"/><circle cx="9.2957" cy="9.9073" r="1.5278"/><rect x="15.21" y="6.5057" width="3.1483" height="3.0696" rx=".060605" ry=".060605"/><rect x="12.345" y="4.68" width="1.4073" height="18.44" rx=".027091" ry=".36408"/><path transform="matrix(.96747 0 0 .96747 .78192 .83494)" d="m14.724 4.5411h-4.0658l2.0329-3.5211z"/></g><path d="m16.745 8.9797v2.8863l-3.6079 3.6079-0.03436-0.05727" fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="1.3229"/><path d="m9.2838 10.691v4.2837l3.6079 3.6079 0.03436-0.05727" fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="1.3229"/></g></svg>

After

Width:  |  Height:  |  Size: 813 B

+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2025 The Pybricks Authors
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
import type { TypedI18n } from '../../../i18n';
import type translations from './translations/en.json';
export function useI18n(): TypedI18n<typeof translations> {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
@@ -0,0 +1,8 @@
{
"label": "USB",
"tooltip": {
"connect": "Connect using USB",
"disconnect": "Disconnect USB",
"bluetoothConnected": "Bluetooth is connected, disconnect to use USB"
}
}
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { createAction } from '../actions';
import { PnpId } from '../ble-device-info-service/protocol';
/**
* Creates an action that initiates a connection to a hub running Pybricks firmware.
*/
export const usbConnectPybricks = createAction(() => ({
type: 'usb.action.connectPybricks',
}));
/**
* Creates an action that indicates that a usb connection was started due to a
* hot plug event.
*/
export const usbHotPlugConnectPybricks = createAction(() => ({
type: 'usb.action.hotPlugConnectPybricks',
}));
/**
* Response that indicates {@link usbConnectPybricks} or {@link usbHotPlugConnectPybricks} succeeded.
*/
export const usbDidConnectPybricks = createAction((pnpId: PnpId) => ({
type: 'usb.device.action.didConnectPybricks',
pnpId,
}));
/**
* Response that indicates {@link usbConnectPybricks} or {@link usbHotPlugConnectPybricks} failed.
*/
export const usbDidFailToConnectPybricks = createAction(() => ({
type: 'usb.action.didFailToConnectPybricks',
}));
/**
* Creates an action to request disconnecting a hub running Pybricks firmware.
*/
export const usbDisconnectPybricks = createAction(() => ({
type: 'usb.action.disconnectPybricks',
}));
/**
* Creates an action that indicates that {@link usbDisconnectPybricks} succeeded.
*/
export const usbDidDisconnectPybricks = createAction(() => ({
type: 'usb.action.didDisconnectPybricks',
}));
/**
* Creates an action that indicates that {@link usbDisconnectPybricks} failed.
*/
export const usbDidFailToDisconnectPybricks = createAction(() => ({
type: 'usb.action.didFailToDisconnectPybricks',
}));
/**
* Indicates that a response to a Pybricks message was received.
* @statusCode The status code of the response.
*/
export const usbDidReceivePybricksMessageResponse = createAction(
(statusCode: number) => ({
type: 'usb.action.didReceivePybricksMessageResponse',
statusCode,
}),
);
/** Action that indicates the device name characteristic was read. */
export const usbDidReceiveDeviceName = createAction((deviceName: string) => ({
type: 'action.usb.didReceiveDeviceName',
deviceName,
}));
/** Action that indicates the software revision characteristic was read. */
export const usbDidReceiveSoftwareRevision = createAction((version: string) => ({
type: 'action.usb.didReceiveSoftwareRevision',
version,
}));
/** Action that indicates the firmware revision characteristic was read. */
export const usbDidReceiveFirmwareRevision = createAction((version: string) => ({
type: 'action.usb.didReceiveFirmwareRevision',
version,
}));
/**
* Subscribe to Pybricks events (equivalent of enable notifications on the
* Pybricks control/events BLE characteristic)
*/
export const usbPybricksSubscribe = createAction(() => ({
type: 'usb.action.pybricksSubscribe',
}));
export const usbPybricksDidSubscribe = createAction(() => ({
type: 'usb.action.pybricksDidSubscribe',
}));
export const usbPybricksDidFailToSubscribe = createAction(() => ({
type: 'usb.action.pybricksDidFailToSubscribe',
}));
export const usbPybricksUnsubscribe = createAction(() => ({
type: 'usb.action.pybricksUnsubscribe',
}));
export const usbPybricksDidUnsubscribe = createAction(() => ({
type: 'usb.action.pybricksDidUnsubscribe',
}));
export const usbPybricksDidFailToUnsubscribe = createAction(() => ({
type: 'usb.action.pybricksDidFailToUnsubscribe',
}));
/**
* High-level BLE actions.
*/
export const usbToggle = createAction(() => ({
type: 'usb.action.toggle',
}));
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
import { Toast } from '@blueprintjs/core';
import { act } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../../test';
import { newPybricksProfile } from './NewPybricksProfile';
it('should dismiss when close is clicked', async () => {
const callback = jest.fn();
const toast = newPybricksProfile(callback, undefined as never);
const [user, message] = testRender(<Toast {...toast} />);
await act(() => user.click(message.getByRole('button', { name: /close/i })));
expect(callback).toHaveBeenCalledWith('dismiss');
});
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2023 The Pybricks Authors
import { Intent } from '@blueprintjs/core';
import { WarningSign } from '@blueprintjs/icons';
import React from 'react';
import { appName } from '../../app/constants';
import type { CreateToast } from '../../toasterTypes';
import { useI18n } from './i18n';
type NewPybricksProfileProps = {
/** The Pybricks Profile version reported by the hub. */
hubVersion: string;
/** The supported Pybricks Profile version. */
supportedVersion: string;
};
const NewPybricksProfile: React.FunctionComponent<NewPybricksProfileProps> = ({
hubVersion,
supportedVersion,
}) => {
const i18n = useI18n();
return (
<>
<p>{i18n.translate('newPybricksProfile.message')}</p>
<p>
{i18n.translate('newPybricksProfile.versions', {
hubVersion: `v${hubVersion}`,
app: appName,
appVersion: `v${supportedVersion}`,
})}
</p>
<p>
{i18n.translate('newPybricksProfile.suggestion', {
app: appName,
})}
</p>
</>
);
};
export const newPybricksProfile: CreateToast<NewPybricksProfileProps> = (
onAction,
props,
) => ({
message: <NewPybricksProfile {...props} />,
icon: <WarningSign />,
intent: Intent.WARNING,
timeout: 15000, // long message, need more time to read
onDismiss: () => onAction('dismiss'),
});
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { Toast } from '@blueprintjs/core';
import { act } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../../test';
import { noWebUsb } from './NoWebUsb';
it('should dismiss when close is clicked', async () => {
const callback = jest.fn();
const toast = noWebUsb(callback, undefined as never);
const [user, message] = testRender(<Toast {...toast} />);
await act(() => user.click(message.getByRole('button', { name: /close/i })));
expect(callback).toHaveBeenCalledWith('dismiss');
});
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { Intent } from '@blueprintjs/core';
import { Error } from '@blueprintjs/icons';
import React from 'react';
import type { CreateToast } from '../../toasterTypes';
import { isIOS } from '../../utils/os';
import { useI18n } from './i18n';
const NoWebUsb: React.FunctionComponent = () => {
const i18n = useI18n();
return (
<>
<p>{i18n.translate('noWebUsb.message')}</p>
{!isIOS() && <p>{i18n.translate('noWebUsb.suggestion')}</p>}
</>
);
};
export const noWebUsb: CreateToast = (onAction) => ({
message: <NoWebUsb />,
icon: <Error />,
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
});
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
import { Toast } from '@blueprintjs/core';
import { act } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../../test';
import { oldFirmware } from './OldFirmware';
it('should dismiss when close is clicked', async () => {
const callback = jest.fn();
const toast = oldFirmware(callback, undefined as never);
const [user, message] = testRender(<Toast {...toast} />);
await act(() => user.click(message.getByRole('button', { name: /close/i })));
expect(callback).toHaveBeenCalledWith('dismiss');
});
it('should flash firmware when button is clicked', async () => {
const callback = jest.fn();
const toast = oldFirmware(callback, undefined as never);
const [user, message] = testRender(<Toast {...toast} />);
await act(() => user.click(message.getByRole('button', { name: /firmware/i })));
expect(callback).toHaveBeenCalledWith('flashFirmware');
});
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2025 The Pybricks Authors
import './index.scss';
import { Button, Intent } from '@blueprintjs/core';
import { Download, InfoSign } from '@blueprintjs/icons';
import React from 'react';
import type { CreateToast } from '../../toasterTypes';
import { useI18n } from './i18n';
type OldFirmwareProps = {
onFlashFirmware: () => void;
};
const OldFirmware: React.FunctionComponent<OldFirmwareProps> = ({
onFlashFirmware,
}) => {
const i18n = useI18n();
return (
<>
<p>{i18n.translate('oldFirmware.message')}</p>
<div className="pb-usb-alerts-buttons">
<Button icon={<Download />} onClick={onFlashFirmware}>
{i18n.translate('oldFirmware.flashFirmware.label')}
</Button>
</div>
</>
);
};
export const oldFirmware: CreateToast<never, 'dismiss' | 'flashFirmware'> = (
onAction,
) => ({
message: <OldFirmware onFlashFirmware={() => onAction('flashFirmware')} />,
icon: <InfoSign />,
intent: Intent.PRIMARY,
onDismiss: () => onAction('dismiss'),
});
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2025 The Pybricks Authors
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
import type { TypedI18n } from '../../i18n';
import type translations from './translations/en.json';
export function useI18n(): TypedI18n<typeof translations> {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
+9
View File
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
.pb-usb-alerts {
&-buttons {
display: flex;
gap: 10px;
}
}
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { newPybricksProfile } from './NewPybricksProfile';
import { noWebUsb } from './NoWebUsb';
import { oldFirmware } from './OldFirmware';
// gathers all of the alert creation functions for passing up to the top level
export default {
newPybricksProfile,
noWebUsb,
oldFirmware,
};
+18
View File
@@ -0,0 +1,18 @@
{
"noWebUsb": {
"message": "This browser does not support Web USB or it is not enabled.",
"suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge.",
"action": "More Info"
},
"oldFirmware": {
"message": "A new firmware version is available for this hub. Please install the latest version to use all new features.",
"flashFirmware": {
"label": "Update Pybricks firmware"
}
},
"newPybricksProfile": {
"message": "The connected hub uses a newer Pybricks communication profile version. Some features may not work correctly.",
"versions": "The hub has {hubVersion} while {app} supports {appVersion}.",
"suggestion": "Downgrade the hub firmware or upgrade {app} to avoid problems."
}
}
+51 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// Copyright (c) 2022-2025 The Pybricks Authors
// https://github.com/pybricks/technical-info/blob/master/assigned-numbers.md#usb
@@ -31,3 +31,53 @@ export enum LegoUsbProductId {
/** MINDSTORMS Robot inventor hub in DFU (bootloader) mode. */
MindstormsRobotInventorBootloader = 0x0011,
}
/** USB bDeviceClass for Pybricks hubs */
export const pybricksUsbClass = 0xff;
/** USB bDeviceSubClass for Pybricks hubs */
export const pybricksUsbSubclass = 0xc5;
/** USB bDeviceProtocol for Pybricks hubs */
export const pybricksUsbProtocol = 0xf5;
/** Maximum data length for {@link PybricksUsbInterfaceRequest}s */
export const pybricksUsbRequestMaxLength = 20;
/**
* bRequest values for Pybricks USB interface control requests.
*/
export enum PybricksUsbInterfaceRequest {
/** Analogous to standard BLE GATT attributes. */
Gatt = 0x01,
/** Analogous to Pybricks BLE characteristics. */
Pybricks = 0x02,
}
/**
* Extracts a 16-bit UUID from a 128-bit UUID string.
* @param uuid A 128-bit UUID string in the format "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".
* @returns The extracted 16-bit UUID as a number.
*/
export function uuid16(uuid: string): number {
// Convert a 128-bit UUID string to a 16-bit UUID number.
const hex = uuid.slice(4, 8);
return parseInt(hex, 16);
}
/** Hub to host messages via the Pybricks interface IN endpoint. */
export enum PybricksUsbInEndpointMessageType {
/**
* Analog of BLE status response. Emitted in response to every OUT message
* received.
*/
Response = 1,
/**Analog to BLE notification. Only emitted if subscribed. */
Event = 2,
}
/** Host to hub messages via the Pybricks USB interface OUT endpoint. */
export enum PybricksUsbOutEndpointMessageType {
/** Analog of BLE Client Characteristic Configuration Descriptor (CCCD). */
Subscribe = 1,
/** Analog of BLE Client Characteristic Write with response. */
Command = 2,
}
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { AnyAction } from 'redux';
import { PnpIdVendorIdSource } from '../ble-device-info-service/protocol';
import {
usbConnectPybricks,
usbDidConnectPybricks,
usbDidDisconnectPybricks,
usbDidFailToConnectPybricks,
usbDidFailToDisconnectPybricks,
usbDisconnectPybricks,
} from './actions';
import reducers, { UsbConnectionState } from './reducers';
type State = ReturnType<typeof reducers>;
test('initial state', () => {
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
{
"connection": "usb.connection.state.disconnected",
}
`);
});
test('connection', () => {
expect(
reducers(
{ connection: UsbConnectionState.Disconnected } as State,
usbConnectPybricks(),
).connection,
).toBe(UsbConnectionState.Connecting);
expect(
reducers(
{ connection: UsbConnectionState.Connecting } as State,
usbDidConnectPybricks({
vendorIdSource: PnpIdVendorIdSource.UsbImpForum,
vendorId: 0x1234,
productId: 0x5678,
productVersion: 1,
}),
).connection,
).toBe(UsbConnectionState.Connected);
expect(
reducers(
{ connection: UsbConnectionState.Connecting } as State,
usbDidFailToConnectPybricks(),
).connection,
).toBe(UsbConnectionState.Disconnected);
expect(
reducers(
{ connection: UsbConnectionState.Connected } as State,
usbDisconnectPybricks(),
).connection,
).toBe(UsbConnectionState.Disconnecting);
expect(
reducers(
{ connection: UsbConnectionState.Disconnecting } as State,
usbDidDisconnectPybricks(),
).connection,
).toBe(UsbConnectionState.Disconnected);
expect(
reducers(
{ connection: UsbConnectionState.Disconnecting } as State,
usbDidFailToDisconnectPybricks(),
).connection,
).toBe(UsbConnectionState.Connected);
});
+74
View File
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
//
// Manages state for the USB connection.
// This assumes that there is only one global connection to a single device.
import { Reducer, combineReducers } from 'redux';
import {
usbConnectPybricks,
usbDidConnectPybricks,
usbDidDisconnectPybricks,
usbDidFailToConnectPybricks,
usbDidFailToDisconnectPybricks,
usbDisconnectPybricks,
usbHotPlugConnectPybricks,
} from './actions';
/**
* Describes the state of the USB connection.
*/
export enum UsbConnectionState {
/**
* No device is connected.
*/
Disconnected = 'usb.connection.state.disconnected',
/**
* Connecting to a device.
*/
Connecting = 'usb.connection.state.connecting',
/**
* Connected to a device.
*/
Connected = 'usb.connection.state.connected',
/**
* Disconnecting from a device.
*/
Disconnecting = 'usb.connection.state.disconnecting',
}
const connection: Reducer<UsbConnectionState> = (
state = UsbConnectionState.Disconnected,
action,
) => {
if (
usbConnectPybricks.matches(action) ||
usbHotPlugConnectPybricks.matches(action)
) {
return UsbConnectionState.Connecting;
}
if (
usbDidConnectPybricks.matches(action) ||
usbDidFailToDisconnectPybricks.matches(action)
) {
return UsbConnectionState.Connected;
}
if (usbDisconnectPybricks.matches(action)) {
return UsbConnectionState.Disconnecting;
}
if (
usbDidFailToConnectPybricks.matches(action) ||
usbDidDisconnectPybricks.matches(action)
) {
return UsbConnectionState.Disconnected;
}
return state;
};
export default combineReducers({
connection,
});
+718
View File
@@ -0,0 +1,718 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { firmwareVersion } from '@pybricks/firmware';
import { AnyAction } from 'redux';
import { eventChannel } from 'redux-saga';
import semver from 'semver';
import {
actionChannel,
call,
delay,
fork,
put,
race,
select,
spawn,
take,
takeEvery,
} from 'typed-redux-saga/macro';
import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions';
import { BleConnectionState } from '../ble/reducers';
import { supportedPybricksProfileVersion } from '../ble/sagas';
import {
PnpIdVendorIdSource,
deviceNameUUID,
firmwareRevisionStringUUID,
softwareRevisionStringUUID,
} from '../ble-device-info-service/protocol';
import {
blePybricksServiceDidReceiveHubCapabilities,
didFailToWriteCommand,
didNotifyEvent,
didWriteCommand,
writeCommand,
} from '../ble-pybricks-service/actions';
import { pybricksHubCapabilitiesCharacteristicUUID } from '../ble-pybricks-service/protocol';
import { firmwareInstallPybricks } from '../firmware/actions';
import { RootState } from '../reducers';
import { assert, defined, ensureError, maybe } from '../utils';
import { pythonVersionToSemver } from '../utils/version';
import {
usbConnectPybricks,
usbDidConnectPybricks,
usbDidDisconnectPybricks,
usbDidFailToConnectPybricks,
usbDidReceiveDeviceName,
usbDidReceiveFirmwareRevision,
usbDidReceivePybricksMessageResponse,
usbDidReceiveSoftwareRevision,
usbDisconnectPybricks,
usbHotPlugConnectPybricks,
usbPybricksDidFailToSubscribe,
usbPybricksDidFailToUnsubscribe,
usbPybricksDidSubscribe,
usbPybricksDidUnsubscribe,
usbPybricksSubscribe,
usbPybricksUnsubscribe,
usbToggle,
} from './actions';
import { UsbConnectionState } from './reducers';
import {
PybricksUsbInEndpointMessageType,
PybricksUsbInterfaceRequest,
PybricksUsbOutEndpointMessageType,
pybricksUsbClass,
pybricksUsbProtocol,
pybricksUsbRequestMaxLength,
pybricksUsbSubclass,
uuid16,
} from '.';
const textDecoder = new TextDecoder('utf-8');
function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator {
// Normally, this would be triggered by usbConnectPybricks(), but on hotplug
// events, this doens't happen, so we need a different action to trigger
// so that reducers still work correctly.
if (hotPlugDevice !== undefined) {
yield* put(usbHotPlugConnectPybricks());
}
if (navigator.usb === undefined) {
yield* put(alertsShowAlert('usb', 'noWebUsb'));
yield* put(usbDidFailToConnectPybricks());
return;
}
const exitStack: Array<() => Promise<void>> = [];
function* cleanup() {
for (const func of exitStack.reverse()) {
yield* call(() => func());
}
}
const disconnectChannel = eventChannel<USBConnectionEvent>((emitter) => {
navigator.usb.addEventListener('disconnect', emitter);
return () => {
navigator.usb.removeEventListener('disconnect', emitter);
};
});
exitStack.push(async () => disconnectChannel.close());
let usbDevice: USBDevice;
// if we are not responding to a hotplug event, we need to request the device
if (hotPlugDevice === undefined) {
const [reqDevice, reqDeviceErr] = yield* call(() =>
maybe(
navigator.usb.requestDevice({
filters: [
{
classCode: pybricksUsbClass,
subclassCode: pybricksUsbSubclass,
protocolCode: pybricksUsbProtocol,
},
],
}),
),
);
if (reqDeviceErr) {
if (reqDeviceErr.name === 'NotFoundError') {
// This means the user canceled the device selection dialog.
// REVISIT: should we show noHub message like BLE?
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
console.error('Failed to request USB device:', reqDeviceErr);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
defined(reqDevice);
usbDevice = reqDevice;
} else {
usbDevice = hotPlugDevice;
}
const [, openErr] = yield* call(() => maybe(usbDevice.open()));
if (openErr) {
// TODO: show error message to user here
console.error('Failed to open USB device:', openErr);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
exitStack.push(() => usbDevice.close().catch(console.debug));
// Always reset the USB device to ensure it is in a known state.
const [, resetErr] = yield* call(() => maybe(usbDevice.reset()));
if (resetErr) {
// TODO: show error message to user here
console.error('Failed to reset USB device:', resetErr);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
// REVISIT: For now, we are making the assumption that there is only one
// configuration and it is already selected and that it contains a Pybricks
// interface.
assert(
usbDevice.configuration !== undefined,
'USB device configuration is undefined',
);
assert(
usbDevice.configuration.interfaces.length > 0,
'USB device has no interfaces',
);
const iface = usbDevice.configuration.interfaces.find(
(iface) =>
iface.alternate.interfaceClass === pybricksUsbClass &&
iface.alternate.interfaceSubclass === pybricksUsbSubclass &&
iface.alternate.interfaceProtocol === pybricksUsbProtocol,
);
assert(iface !== undefined, 'USB device does not have a Pybricks interface');
const inEndpoint = iface.alternate.endpoints.find(
(ep) => ep.direction === 'in' && ep.type === 'bulk',
);
assert(
inEndpoint !== undefined,
'USB device does not have a bulk IN endpoint for Pybricks interface',
);
const outEndpoint = iface.alternate.endpoints.find(
(ep) => ep.direction === 'out' && ep.type === 'bulk',
);
assert(
outEndpoint !== undefined,
'USB device does not have a bulk OUT endpoint for Pybricks interface',
);
const [, claimErr] = yield* call(() =>
maybe(usbDevice.claimInterface(iface.interfaceNumber)),
);
if (claimErr) {
// TODO: show error message to user here
console.error('Failed to claim USB interface:', claimErr);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
exitStack.push(() => usbDevice.releaseInterface(0).catch(console.debug));
const [fwVerResult, fwVerError] = yield* call(() =>
maybe(
usbDevice.controlTransferIn(
{
requestType: 'class',
recipient: 'interface',
request: PybricksUsbInterfaceRequest.Gatt,
value: firmwareRevisionStringUUID,
index: 0x00,
},
pybricksUsbRequestMaxLength,
),
),
);
if (fwVerError || fwVerResult?.status !== 'ok') {
// TODO: show error message to user here
console.error('Failed to get firmware version:', fwVerError);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
defined(fwVerResult);
const firmwareRevision = textDecoder.decode(fwVerResult.data);
yield* put(usbDidReceiveFirmwareRevision(firmwareRevision));
// notify user if old firmware
if (
semver.lt(
pythonVersionToSemver(firmwareRevision),
pythonVersionToSemver(firmwareVersion),
)
) {
yield* put(alertsShowAlert('usb', 'oldFirmware'));
// initiate flashing firmware if user requested
const flashIfRequested = function* () {
const { action } = yield* take<
ReturnType<typeof alertsDidShowAlert<'usb', 'oldFirmware'>>
>(
alertsDidShowAlert.when(
(a) => a.domain === 'usb' && a.specific === 'oldFirmware',
),
);
if (action === 'flashFirmware') {
yield* put(firmwareInstallPybricks());
}
};
// have to spawn so that we don't block the task and it still works
// if parent task ends
yield* spawn(flashIfRequested);
}
const [nameResult, nameError] = yield* call(() =>
maybe(
usbDevice.controlTransferIn(
{
requestType: 'class',
recipient: 'interface',
request: PybricksUsbInterfaceRequest.Gatt,
value: deviceNameUUID,
index: 0x00,
},
pybricksUsbRequestMaxLength,
),
),
);
if (nameError || nameResult?.status !== 'ok') {
// TODO: show error message to user here
console.error('Failed to get device name:', nameError);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
defined(nameResult);
const deviceName = textDecoder.decode(nameResult.data);
yield* put(usbDidReceiveDeviceName(deviceName));
const [swVerResult, swVerError] = yield* call(() =>
maybe(
usbDevice.controlTransferIn(
{
requestType: 'class',
recipient: 'interface',
request: PybricksUsbInterfaceRequest.Gatt,
value: softwareRevisionStringUUID,
index: 0x00,
},
pybricksUsbRequestMaxLength,
),
),
);
if (swVerError || swVerResult?.status !== 'ok') {
// TODO: show error message to user here
console.error('Failed to get software version:', swVerError);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
defined(swVerResult);
const softwareRevision = textDecoder.decode(swVerResult.data);
yield* put(usbDidReceiveSoftwareRevision(softwareRevision));
// notify user if newer Pybricks Profile on hub
if (
semver.gte(
softwareRevision,
new semver.SemVer(supportedPybricksProfileVersion).inc('minor'),
)
) {
yield* put(
alertsShowAlert('ble', 'newPybricksProfile', {
hubVersion: softwareRevision,
supportedVersion: supportedPybricksProfileVersion,
}),
);
}
const [hubCapResult, hubCapErr] = yield* call(() =>
maybe(
usbDevice.controlTransferIn(
{
requestType: 'class',
recipient: 'interface',
request: PybricksUsbInterfaceRequest.Pybricks,
value: uuid16(pybricksHubCapabilitiesCharacteristicUUID),
index: 0x00,
},
pybricksUsbRequestMaxLength,
),
),
);
if (hubCapErr || hubCapResult?.status !== 'ok') {
// TODO: show error message to user here
console.error('Failed to get hub capabilities:', hubCapErr);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
defined(hubCapResult);
assert(hubCapResult.data !== undefined, 'Hub capabilities data is undefined');
const hubCapabilitiesValue = new DataView(hubCapResult.data.buffer);
const maxWriteSize = hubCapabilitiesValue.getUint16(0, true);
const flags = hubCapabilitiesValue.getUint32(2, true);
const maxUserProgramSize = hubCapabilitiesValue.getUint32(6, true);
const numOfSlots = hubCapabilitiesValue.getUint8(10);
yield* put(
blePybricksServiceDidReceiveHubCapabilities(
maxWriteSize,
flags,
maxUserProgramSize,
numOfSlots,
),
);
// This services the Pybricks interface IN endpoint and pipes messages
// to the correct place depending on the message type. We need to get this
// up and running before subscribing to events or sending commands so that
// we can receive responses.
function* receiveMessages(): Generator {
defined(usbDevice);
defined(inEndpoint);
defined(outEndpoint);
for (;;) {
const [result, err] = yield* call(() =>
maybe(
usbDevice.transferIn(
inEndpoint.endpointNumber,
inEndpoint.packetSize,
),
),
);
if (err) {
// TODO: notify user that USB is broken (if not disconnected)
console.error('Failed to receive USB message:', err);
return;
}
if (result?.status !== 'ok') {
console.warn('USB message transfer failed:', result);
continue;
}
assert(result.data !== undefined, 'USB message data is undefined');
if (result.data.byteLength < 1) {
// empty messages are normal, just ignore them
continue;
}
console.debug('Received USB message:', result.data);
switch (result.data.getInt8(0)) {
case PybricksUsbInEndpointMessageType.Response:
yield* put(
usbDidReceivePybricksMessageResponse(
result.data.getUint32(1, true),
),
);
break;
case PybricksUsbInEndpointMessageType.Event:
yield* put(
didNotifyEvent(new DataView(result.data.buffer.slice(1))),
);
break;
default:
console.warn('Unknown USB message type:', result.data.getInt8(0));
break;
}
}
}
const receiveMessagesTask = yield* fork(receiveMessages);
exitStack.push(async () => receiveMessagesTask.cancel());
// This is used to serialize requests to the Pybricks interface OUT endpoint.
// It makes sure that we wait for a response for each command before sending
// the next one.
function* sendMessages(): Generator {
defined(usbDevice);
defined(outEndpoint);
const chan = yield* actionChannel<AnyAction>(
(a: AnyAction) =>
usbPybricksSubscribe.matches(a) ||
usbPybricksUnsubscribe.matches(a) ||
writeCommand.matches(a),
);
for (;;) {
const action = yield* take(chan);
console.debug('Processing USB action:', action);
if (usbPybricksSubscribe.matches(action)) {
const message = new DataView(new ArrayBuffer(2));
message.setUint8(0, PybricksUsbOutEndpointMessageType.Subscribe);
message.setUint8(1, 1); // subscribe to events
const [result, err] = yield* call(() =>
maybe(usbDevice.transferOut(outEndpoint.endpointNumber, message)),
);
if (err || result?.status !== 'ok') {
yield* put(usbPybricksDidFailToSubscribe());
console.error('Failed to send USB subscribe message:', err, result);
continue;
}
yield* put(usbPybricksDidSubscribe());
continue;
}
if (usbPybricksUnsubscribe.matches(action)) {
const message = new DataView(new ArrayBuffer(2));
message.setUint8(0, PybricksUsbOutEndpointMessageType.Subscribe);
message.setUint8(1, 0); // unsubscribe from events
const [result, err] = yield* call(() =>
maybe(usbDevice.transferOut(outEndpoint.endpointNumber, message)),
);
if (err || result?.status !== 'ok') {
yield* put(usbPybricksDidFailToUnsubscribe());
console.error(
'Failed to send USB unsubscribe message:',
err,
result,
);
continue;
}
yield* put(usbPybricksDidUnsubscribe());
continue;
}
if (writeCommand.matches(action)) {
const payload = new Uint8Array(1 + action.value.length);
payload[0] = PybricksUsbOutEndpointMessageType.Command;
payload.set(action.value, 1);
const message = new DataView(payload.buffer);
const [result, err] = yield* call(() =>
maybe(usbDevice.transferOut(outEndpoint.endpointNumber, message)),
);
if (err) {
yield* put(didFailToWriteCommand(action.id, ensureError(err)));
console.error('Failed to send USB command:', err, result);
continue;
}
if (result?.status !== 'ok') {
yield* put(
didFailToWriteCommand(action.id, ensureError(result?.status)),
);
console.error('Failed to send USB command:', result);
continue;
}
const { response, timeout } = yield* race({
response: take(usbDidReceivePybricksMessageResponse),
timeout: delay(1000),
});
if (timeout) {
yield* put(
didFailToWriteCommand(
action.id,
new Error('Timed out waiting for response'),
),
);
console.error('Timed out waiting for USB command response');
continue;
}
defined(response);
if (response.statusCode !== 0) {
yield* put(
didFailToWriteCommand(
action.id,
new Error(
`USB command failed with status code ${response.statusCode}`,
),
),
);
console.error('USB command failed:', response);
continue;
}
yield* put(didWriteCommand(action.id));
continue;
}
console.error(`Unknown USB action type: ${action.type}`);
}
}
const sendMessagesTask = yield* fork(sendMessages);
exitStack.push(async () => sendMessagesTask.cancel());
yield* put(usbPybricksSubscribe());
const { didFailToSub } = yield* race({
didSub: take(usbPybricksDidSubscribe),
didFailToSub: take(usbPybricksDidFailToSubscribe),
});
if (didFailToSub) {
console.error('Failed to subscribe to USB Pybricks messages:', didFailToSub);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();
return;
}
// TODO: how to push put(usbPybricksUnsubscribe()) on exit stack?
yield* put(
usbDidConnectPybricks({
vendorIdSource: PnpIdVendorIdSource.UsbImpForum,
vendorId: usbDevice.vendorId,
productId: usbDevice.productId,
productVersion: 0,
}),
);
// wait for the user to request disconnecting the USB device or for the
// USB device to be physically disconnected
for (;;) {
const { disconnectRequest, disconnectEvent } = yield* race({
disconnectRequest: take(usbDisconnectPybricks),
disconnectEvent: take(disconnectChannel),
});
console.debug('USB disconnect request or event received:', {
disconnectRequest,
disconnectEvent,
});
if (disconnectRequest || disconnectEvent?.device === usbDevice) {
break;
}
}
yield* put(usbPybricksUnsubscribe());
yield* race({
didUnsub: take(usbPybricksDidUnsubscribe),
didFailToUnsub: take(usbPybricksDidFailToUnsubscribe),
});
yield* cleanup();
yield* put(usbDidDisconnectPybricks());
}
function* handleUsbToggle(): Generator {
const connectionState = (yield select(
(s: RootState) => s.usb.connection,
)) as UsbConnectionState;
console.debug('Handling USB toggle action, current state:', connectionState);
switch (connectionState) {
case UsbConnectionState.Connected:
yield* put(usbDisconnectPybricks());
break;
case UsbConnectionState.Disconnected:
yield* put(usbConnectPybricks());
break;
}
}
function* handleUsbConnectEvent(): Generator {
if (navigator.usb === undefined) {
return;
}
const [devices, devicesError] = yield* call(() =>
maybe(navigator.usb.getDevices()),
);
if (devicesError) {
console.error('Failed to get USB devices:', devicesError);
} else {
defined(devices);
const pybricksDevices = devices.filter(
(d) =>
d.deviceClass === pybricksUsbClass &&
d.deviceSubclass === pybricksUsbSubclass &&
d.deviceProtocol === pybricksUsbProtocol,
);
// if there is exactly one Pybricks device connected, we can connect to
// it, otherwise we should let the user choose which one to connect to
if (pybricksDevices.length === 1) {
yield* spawn(handleUsbConnectPybricks, pybricksDevices[0]);
}
}
const channel = eventChannel<USBConnectionEvent>((emitter) => {
navigator.usb.addEventListener('connect', emitter);
return () => {
navigator.usb.removeEventListener('connect', emitter);
};
});
for (;;) {
const event = yield* take(channel);
console.log('USB device connected:', event);
if (
event.device.deviceClass !== pybricksUsbClass ||
event.device.deviceSubclass !== pybricksUsbSubclass ||
event.device.deviceProtocol !== pybricksUsbProtocol
) {
continue;
}
const state = yield* select((s: RootState) => s);
// If we are not already connected, we can connect to the hub that
// was just connected.
if (
state.ble.connection === BleConnectionState.Disconnected &&
state.usb.connection === UsbConnectionState.Disconnected
) {
yield* spawn(handleUsbConnectPybricks, event.device);
}
}
}
function* handleUsbDisconnectEvent(): Generator {
if (navigator.usb === undefined) {
return;
}
const channel = eventChannel<USBConnectionEvent>((emitter) => {
navigator.usb.addEventListener('disconnect', emitter);
return () => {
navigator.usb.removeEventListener('disconnect', emitter);
};
});
for (;;) {
const event = yield* take(channel);
console.log('USB device disconnected:', event);
}
}
export default function* (): Generator {
yield* takeEvery(usbConnectPybricks, handleUsbConnectPybricks, undefined);
yield* takeEvery(usbToggle, handleUsbToggle);
yield* spawn(handleUsbConnectEvent);
yield* spawn(handleUsbDisconnectEvent);
}