mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-15 02:54:07 +00:00
usb: add USB support
Initial working USB support.
This commit is contained in:
+3
-1
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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,9 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2025 The Pybricks Authors
|
||||
|
||||
.pb-usb-alerts {
|
||||
&-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user