diff --git a/src/alerts.ts b/src/alerts.ts index 7675f42ea..acd152903 100644 --- a/src/alerts.ts +++ b/src/alerts.ts @@ -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. */ diff --git a/src/ble-device-info-service/protocol.ts b/src/ble-device-info-service/protocol.ts index 585f76e01..93a284406 100644 --- a/src/ble-device-info-service/protocol.ts +++ b/src/ble-device-info-service/protocol.ts @@ -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; diff --git a/src/ble/reducers.test.ts b/src/ble/reducers.test.ts index deba2a9a1..b9e89ff19 100644 --- a/src/ble/reducers.test.ts +++ b/src/ble/reducers.test.ts @@ -2,14 +2,6 @@ // Copyright (c) 2021-2025 The Pybricks Authors import { AnyAction } from 'redux'; -import { - bleDIServiceDidReceiveFirmwareRevision, - bleDIServiceDidReceivePnPId, -} from '../ble-device-info-service/actions'; -import { PnpIdVendorIdSource } from '../ble-device-info-service/protocol'; -import { HubType, LegoCompanyId } from '../ble-lwp3-service/protocol'; -import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; -import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; import { bleConnectPybricks, bleDidConnectPybricks, @@ -26,11 +18,6 @@ test('initial state', () => { expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` { "connection": "ble.connection.state.disconnected", - "deviceBatteryCharging": false, - "deviceFirmwareVersion": "", - "deviceLowBatteryWarning": false, - "deviceName": "", - "deviceType": "", } `); }); @@ -73,87 +60,3 @@ test('connection', () => { ).connection, ).toBe(BleConnectionState.Connected); }); - -test('deviceName', () => { - const testId = 'test-id'; - const testName = 'Test Name'; - - expect( - reducers({ deviceName: '' } as State, bleDidConnectPybricks(testId, testName)) - .deviceName, - ).toBe(testName); - - expect( - reducers({ deviceName: testName } as State, bleDidDisconnectPybricks()) - .deviceName, - ).toBe(''); -}); - -test('deviceType', () => { - expect( - reducers( - { deviceType: '' } as State, - bleDIServiceDidReceivePnPId({ - vendorIdSource: PnpIdVendorIdSource.BluetoothSig, - vendorId: LegoCompanyId, - productId: HubType.MoveHub, - productVersion: 0, - }), - ).deviceType, - ).toBe('Move hub'); - - expect( - reducers({ deviceType: 'Move hub' } as State, bleDidDisconnectPybricks()) - .deviceType, - ).toBe(''); -}); - -test('deviceFirmwareVersion', () => { - const testVersion = '3.0.0'; - - expect( - reducers( - { deviceFirmwareVersion: '' } as State, - bleDIServiceDidReceiveFirmwareRevision(testVersion), - ).deviceFirmwareVersion, - ).toBe(testVersion); - - expect( - reducers( - { deviceFirmwareVersion: testVersion } as State, - bleDidDisconnectPybricks(), - ).deviceFirmwareVersion, - ).toBe(''); -}); - -test('deviceLowBatteryWarning', () => { - expect( - reducers( - { deviceLowBatteryWarning: false } as State, - didReceiveStatusReport(statusToFlag(Status.BatteryLowVoltageWarning), 0, 0), - ).deviceLowBatteryWarning, - ).toBeTruthy(); - - expect( - reducers( - { deviceLowBatteryWarning: true } as State, - didReceiveStatusReport( - ~statusToFlag(Status.BatteryLowVoltageWarning), - 0, - 0, - ), - ).deviceLowBatteryWarning, - ).toBeFalsy(); - - expect( - reducers({ deviceLowBatteryWarning: true } as State, bleDidDisconnectPybricks()) - .deviceLowBatteryWarning, - ).toBeFalsy(); -}); - -test('deviceBatteryCharging', () => { - expect( - reducers({ deviceBatteryCharging: true } as State, bleDidDisconnectPybricks()) - .deviceBatteryCharging, - ).toBeFalsy(); -}); diff --git a/src/ble/reducers.ts b/src/ble/reducers.ts index 808a42fa6..753b3180e 100644 --- a/src/ble/reducers.ts +++ b/src/ble/reducers.ts @@ -1,17 +1,10 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2020-2025 The Pybricks Authors // // Manages state for the Bluetooth Low Energy connection. // This assumes that there is only one global connection to a single device. import { Reducer, combineReducers } from 'redux'; -import { - bleDIServiceDidReceiveFirmwareRevision, - bleDIServiceDidReceivePnPId, -} from '../ble-device-info-service/actions'; -import { getHubTypeName } from '../ble-device-info-service/protocol'; -import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; -import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; import { bleConnectPybricks, bleDidConnectPybricks, @@ -72,71 +65,6 @@ const connection: Reducer = ( return state; }; -const deviceName: Reducer = (state = '', action) => { - if (bleDidDisconnectPybricks.matches(action)) { - return ''; - } - - if (bleDidConnectPybricks.matches(action)) { - return action.name; - } - - return state; -}; - -const deviceType: Reducer = (state = '', action) => { - if (bleDidDisconnectPybricks.matches(action)) { - return ''; - } - - if (bleDIServiceDidReceivePnPId.matches(action)) { - return getHubTypeName(action.pnpId); - } - - return state; -}; - -const deviceFirmwareVersion: Reducer = (state = '', action) => { - if (bleDidDisconnectPybricks.matches(action)) { - return ''; - } - - if (bleDIServiceDidReceiveFirmwareRevision.matches(action)) { - return action.version; - } - - return state; -}; - -const deviceLowBatteryWarning: Reducer = (state = false, action) => { - if (bleDidDisconnectPybricks.matches(action)) { - return false; - } - - if (didReceiveStatusReport.matches(action)) { - return Boolean( - action.statusFlags & statusToFlag(Status.BatteryLowVoltageWarning), - ); - } - - return state; -}; - -const deviceBatteryCharging: Reducer = (state = false, action) => { - if (bleDidDisconnectPybricks.matches(action)) { - return false; - } - - // TODO: hub does not currently have a status flag for this - - return state; -}; - export default combineReducers({ connection, - deviceName, - deviceType, - deviceFirmwareVersion, - deviceLowBatteryWarning, - deviceBatteryCharging, }); diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index 7e7bbf837..87b081a24 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -7,9 +7,13 @@ import { bleDidDisconnectPybricks, bleDisconnectPybricks, } from '../ble/actions'; -import { bleDIServiceDidReceiveSoftwareRevision } from '../ble-device-info-service/actions'; -import { PnpId } from '../ble-device-info-service/protocol'; -import { HubType } from '../ble-lwp3-service/protocol'; +import { + bleDIServiceDidReceiveFirmwareRevision, + bleDIServiceDidReceivePnPId, + bleDIServiceDidReceiveSoftwareRevision, +} from '../ble-device-info-service/actions'; +import { PnpId, PnpIdVendorIdSource } from '../ble-device-info-service/protocol'; +import { HubType, LegoCompanyId } from '../ble-lwp3-service/protocol'; import { blePybricksServiceDidNotReceiveHubCapabilities, blePybricksServiceDidReceiveHubCapabilities, @@ -40,6 +44,11 @@ type State = ReturnType; test('initial state', () => { expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` { + "deviceBatteryCharging": false, + "deviceFirmwareVersion": "", + "deviceLowBatteryWarning": false, + "deviceName": "", + "deviceType": "", "downloadProgress": null, "hasRepl": false, "maxBleWriteSize": 0, @@ -298,6 +307,90 @@ describe('runtime', () => { }); }); +test('deviceName', () => { + const testId = 'test-id'; + const testName = 'Test Name'; + + expect( + reducers({ deviceName: '' } as State, bleDidConnectPybricks(testId, testName)) + .deviceName, + ).toBe(testName); + + expect( + reducers({ deviceName: testName } as State, bleDidDisconnectPybricks()) + .deviceName, + ).toBe(''); +}); + +test('deviceType', () => { + expect( + reducers( + { deviceType: '' } as State, + bleDIServiceDidReceivePnPId({ + vendorIdSource: PnpIdVendorIdSource.BluetoothSig, + vendorId: LegoCompanyId, + productId: HubType.MoveHub, + productVersion: 0, + }), + ).deviceType, + ).toBe('Move hub'); + + expect( + reducers({ deviceType: 'Move hub' } as State, bleDidDisconnectPybricks()) + .deviceType, + ).toBe(''); +}); + +test('deviceFirmwareVersion', () => { + const testVersion = '3.0.0'; + + expect( + reducers( + { deviceFirmwareVersion: '' } as State, + bleDIServiceDidReceiveFirmwareRevision(testVersion), + ).deviceFirmwareVersion, + ).toBe(testVersion); + + expect( + reducers( + { deviceFirmwareVersion: testVersion } as State, + bleDidDisconnectPybricks(), + ).deviceFirmwareVersion, + ).toBe(''); +}); + +test('deviceLowBatteryWarning', () => { + expect( + reducers( + { deviceLowBatteryWarning: false } as State, + didReceiveStatusReport(statusToFlag(Status.BatteryLowVoltageWarning), 0, 0), + ).deviceLowBatteryWarning, + ).toBeTruthy(); + + expect( + reducers( + { deviceLowBatteryWarning: true } as State, + didReceiveStatusReport( + ~statusToFlag(Status.BatteryLowVoltageWarning), + 0, + 0, + ), + ).deviceLowBatteryWarning, + ).toBeFalsy(); + + expect( + reducers({ deviceLowBatteryWarning: true } as State, bleDidDisconnectPybricks()) + .deviceLowBatteryWarning, + ).toBeFalsy(); +}); + +test('deviceBatteryCharging', () => { + expect( + reducers({ deviceBatteryCharging: true } as State, bleDidDisconnectPybricks()) + .deviceBatteryCharging, + ).toBeFalsy(); +}); + describe('maxBleWriteSize', () => { test.each([100, 1000])('Pybricks Profile >= v1.2.0: %s', (size) => { expect( diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index 73824d238..c4809250b 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -8,7 +8,12 @@ import { bleDidDisconnectPybricks, bleDisconnectPybricks, } from '../ble/actions'; -import { bleDIServiceDidReceiveSoftwareRevision } from '../ble-device-info-service/actions'; +import { + bleDIServiceDidReceiveFirmwareRevision, + bleDIServiceDidReceivePnPId, + bleDIServiceDidReceiveSoftwareRevision, +} from '../ble-device-info-service/actions'; +import { getHubTypeName } from '../ble-device-info-service/protocol'; import { HubType } from '../ble-lwp3-service/protocol'; import { blePybricksServiceDidNotReceiveHubCapabilities, @@ -21,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, @@ -63,21 +75,31 @@ const runtime: Reducer = ( // 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; } @@ -143,6 +165,90 @@ const runtime: Reducer = ( return state; }; +const deviceName: Reducer = (state = '', action) => { + if ( + bleDidDisconnectPybricks.matches(action) || + usbDidDisconnectPybricks.matches(action) + ) { + return ''; + } + + if (bleDidConnectPybricks.matches(action)) { + return action.name; + } + + if (usbDidReceiveDeviceName.matches(action)) { + return action.deviceName; + } + + return state; +}; + +const deviceType: Reducer = (state = '', action) => { + if ( + bleDidDisconnectPybricks.matches(action) || + usbDidDisconnectPybricks.matches(action) + ) { + return ''; + } + + if (bleDIServiceDidReceivePnPId.matches(action)) { + return getHubTypeName(action.pnpId); + } + + if (usbDidConnectPybricks.matches(action)) { + return getHubTypeName(action.pnpId); + } + + return state; +}; + +const deviceFirmwareVersion: Reducer = (state = '', action) => { + if ( + bleDidDisconnectPybricks.matches(action) || + usbDidDisconnectPybricks.matches(action) + ) { + return ''; + } + + if (bleDIServiceDidReceiveFirmwareRevision.matches(action)) { + return action.version; + } + + if (usbDidReceiveFirmwareRevision.matches(action)) { + return action.version; + } + + return state; +}; + +const deviceLowBatteryWarning: Reducer = (state = false, action) => { + if ( + bleDidDisconnectPybricks.matches(action) || + usbDidDisconnectPybricks.matches(action) + ) { + return false; + } + + if (didReceiveStatusReport.matches(action)) { + return Boolean( + action.statusFlags & statusToFlag(Status.BatteryLowVoltageWarning), + ); + } + + return state; +}; + +const deviceBatteryCharging: Reducer = (state = false, action) => { + if (bleDidDisconnectPybricks.matches(action)) { + return false; + } + + // TODO: hub does not currently have a status flag for this + + return state; +}; + const downloadProgress: Reducer = (state = null, action) => { if (didStartDownload.matches(action)) { return 0; @@ -299,6 +405,11 @@ const selectedSlot: Reducer = (state = 0, action) => { export default combineReducers({ runtime, + deviceName, + deviceType, + deviceFirmwareVersion, + deviceLowBatteryWarning, + deviceBatteryCharging, downloadProgress, maxBleWriteSize, maxUserProgramSize, diff --git a/src/reducers.ts b/src/reducers.ts index f76e7b2b7..06ca417e8 100644 --- a/src/reducers.ts +++ b/src/reducers.ts @@ -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, }); /** diff --git a/src/sagas.ts b/src/sagas.ts index a30080117..8456c55be 100644 --- a/src/sagas.ts +++ b/src/sagas.ts @@ -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(), ]); } diff --git a/src/status-bar/StatusBar.test.tsx b/src/status-bar/StatusBar.test.tsx index 0ba744399..fee0bcc6f 100644 --- a/src/status-bar/StatusBar.test.tsx +++ b/src/status-bar/StatusBar.test.tsx @@ -1,18 +1,18 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021-2023 The Pybricks Authors +// Copyright (c) 2021-2025 The Pybricks Authors import { act, waitFor } from '@testing-library/react'; import React from 'react'; import { testRender } from '../../test'; -import { BleConnectionState } from '../ble/reducers'; +import { HubRuntimeState } from '../hub/reducers'; import StatusBar from './StatusBar'; it('should show popover when hub name is clicked', async () => { const testHubName = 'Test hub'; const [user, statusBar] = testRender(, { - ble: { - connection: BleConnectionState.Connected, + hub: { + runtime: HubRuntimeState.Idle, deviceName: testHubName, deviceType: 'hub type', deviceFirmwareVersion: 'v0.0.0', @@ -30,8 +30,8 @@ it('should show popover when battery is clicked', async () => { const testHubName = 'Test hub'; const [user, statusBar] = testRender(, { - ble: { - connection: BleConnectionState.Connected, + hub: { + runtime: HubRuntimeState.Idle, deviceName: testHubName, deviceType: 'hub type', deviceFirmwareVersion: 'v0.0.0', diff --git a/src/status-bar/StatusBar.tsx b/src/status-bar/StatusBar.tsx index f29149967..c14c8885a 100644 --- a/src/status-bar/StatusBar.tsx +++ b/src/status-bar/StatusBar.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2025 The Pybricks Authors import './status-bar.scss'; import { @@ -16,8 +16,8 @@ import { import { Disable, Error, TickCircle } from '@blueprintjs/icons'; import classNames from 'classnames'; import React, { useMemo } from 'react'; -import { BleConnectionState } from '../ble/reducers'; import { CompletionEngineStatus } from '../editor/redux/codeCompletion'; +import { HubRuntimeState } from '../hub/reducers'; import { useSelector } from '../reducers'; import { useI18n } from './i18n'; @@ -72,9 +72,9 @@ const CompletionEngineIndicator: React.FunctionComponent = () => { const HubInfoButton: React.FunctionComponent = () => { const i18n = useI18n(); - const deviceName = useSelector((s) => s.ble.deviceName); - const deviceType = useSelector((s) => s.ble.deviceType); - const deviceFirmwareVersion = useSelector((s) => s.ble.deviceFirmwareVersion); + const deviceName = useSelector((s) => s.hub.deviceName); + const deviceType = useSelector((s) => s.hub.deviceType); + const deviceFirmwareVersion = useSelector((s) => s.hub.deviceFirmwareVersion); return ( { const BatteryIndicator: React.FunctionComponent = () => { const i18n = useI18n(); - const charging = useSelector((s) => s.ble.deviceBatteryCharging); - const lowBatteryWarning = useSelector((s) => s.ble.deviceLowBatteryWarning); + const charging = useSelector((s) => s.hub.deviceBatteryCharging); + const lowBatteryWarning = useSelector((s) => s.hub.deviceLowBatteryWarning); return ( { }; const StatusBar: React.FunctionComponent = () => { - const connection = useSelector((s) => s.ble.connection); + const runtime = useSelector((s) => s.hub.runtime); return (
{
- {connection === BleConnectionState.Connected && ( + {runtime !== HubRuntimeState.Disconnected && ( <> diff --git a/src/toolbar/Toolbar.tsx b/src/toolbar/Toolbar.tsx index efe8bb6ff..67400c265 100644 --- a/src/toolbar/Toolbar.tsx +++ b/src/toolbar/Toolbar.tsx @@ -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} > + diff --git a/src/toolbar/buttons/bluetooth/BluetoothButton.tsx b/src/toolbar/buttons/bluetooth/BluetoothButton.tsx index c33bd50f2..c56d06880 100644 --- a/src/toolbar/buttons/bluetooth/BluetoothButton.tsx +++ b/src/toolbar/buttons/bluetooth/BluetoothButton.tsx @@ -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; const BluetoothButton: React.FunctionComponent = ({ 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 = ({ 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 diff --git a/src/toolbar/buttons/bluetooth/translations/en.json b/src/toolbar/buttons/bluetooth/translations/en.json index e1508bed6..4735eecd2 100644 --- a/src/toolbar/buttons/bluetooth/translations/en.json +++ b/src/toolbar/buttons/bluetooth/translations/en.json @@ -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" } } diff --git a/src/toolbar/buttons/usb/UsbButton.test.tsx b/src/toolbar/buttons/usb/UsbButton.test.tsx new file mode 100644 index 000000000..991950195 --- /dev/null +++ b/src/toolbar/buttons/usb/UsbButton.test.tsx @@ -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( + + + , + ); + + await act(() => user.click(button.getByRole('button', { name: 'USB' }))); + + expect(dispatch).toHaveBeenCalledWith(usbToggle()); +}); diff --git a/src/toolbar/buttons/usb/UsbButton.tsx b/src/toolbar/buttons/usb/UsbButton.tsx new file mode 100644 index 000000000..e04f044f7 --- /dev/null +++ b/src/toolbar/buttons/usb/UsbButton.tsx @@ -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; + +const UsbButton: React.FunctionComponent = ({ 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 ( + dispatch(usbToggle())} + /> + ); +}; + +export default UsbButton; diff --git a/src/toolbar/buttons/usb/connected.svg b/src/toolbar/buttons/usb/connected.svg new file mode 100644 index 000000000..d478eafb7 --- /dev/null +++ b/src/toolbar/buttons/usb/connected.svg @@ -0,0 +1 @@ + diff --git a/src/toolbar/buttons/usb/disconnected.svg b/src/toolbar/buttons/usb/disconnected.svg new file mode 100644 index 000000000..d478eafb7 --- /dev/null +++ b/src/toolbar/buttons/usb/disconnected.svg @@ -0,0 +1 @@ + diff --git a/src/toolbar/buttons/usb/i18n.ts b/src/toolbar/buttons/usb/i18n.ts new file mode 100644 index 000000000..b19de94d1 --- /dev/null +++ b/src/toolbar/buttons/usb/i18n.ts @@ -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 { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} diff --git a/src/toolbar/buttons/usb/translations/en.json b/src/toolbar/buttons/usb/translations/en.json new file mode 100644 index 000000000..64cff58c8 --- /dev/null +++ b/src/toolbar/buttons/usb/translations/en.json @@ -0,0 +1,8 @@ +{ + "label": "USB", + "tooltip": { + "connect": "Connect using USB", + "disconnect": "Disconnect USB", + "bluetoothConnected": "Bluetooth is connected, disconnect to use USB" + } +} diff --git a/src/usb/actions.ts b/src/usb/actions.ts new file mode 100644 index 000000000..f4d9d199b --- /dev/null +++ b/src/usb/actions.ts @@ -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', +})); diff --git a/src/usb/alerts/NewPybricksProfile.test.tsx b/src/usb/alerts/NewPybricksProfile.test.tsx new file mode 100644 index 000000000..9d910d45a --- /dev/null +++ b/src/usb/alerts/NewPybricksProfile.test.tsx @@ -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(); + + await act(() => user.click(message.getByRole('button', { name: /close/i }))); + + expect(callback).toHaveBeenCalledWith('dismiss'); +}); diff --git a/src/usb/alerts/NewPybricksProfile.tsx b/src/usb/alerts/NewPybricksProfile.tsx new file mode 100644 index 000000000..c367267b4 --- /dev/null +++ b/src/usb/alerts/NewPybricksProfile.tsx @@ -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 = ({ + hubVersion, + supportedVersion, +}) => { + const i18n = useI18n(); + return ( + <> +

{i18n.translate('newPybricksProfile.message')}

+

+ {i18n.translate('newPybricksProfile.versions', { + hubVersion: `v${hubVersion}`, + app: appName, + appVersion: `v${supportedVersion}`, + })} +

+

+ {i18n.translate('newPybricksProfile.suggestion', { + app: appName, + })} +

+ + ); +}; + +export const newPybricksProfile: CreateToast = ( + onAction, + props, +) => ({ + message: , + icon: , + intent: Intent.WARNING, + timeout: 15000, // long message, need more time to read + onDismiss: () => onAction('dismiss'), +}); diff --git a/src/usb/alerts/NoWebUsb.test.tsx b/src/usb/alerts/NoWebUsb.test.tsx new file mode 100644 index 000000000..d1cf4d277 --- /dev/null +++ b/src/usb/alerts/NoWebUsb.test.tsx @@ -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(); + + await act(() => user.click(message.getByRole('button', { name: /close/i }))); + + expect(callback).toHaveBeenCalledWith('dismiss'); +}); diff --git a/src/usb/alerts/NoWebUsb.tsx b/src/usb/alerts/NoWebUsb.tsx new file mode 100644 index 000000000..28a44f575 --- /dev/null +++ b/src/usb/alerts/NoWebUsb.tsx @@ -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 ( + <> +

{i18n.translate('noWebUsb.message')}

+ {!isIOS() &&

{i18n.translate('noWebUsb.suggestion')}

} + + ); +}; + +export const noWebUsb: CreateToast = (onAction) => ({ + message: , + icon: , + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), +}); diff --git a/src/usb/alerts/OldFimrware.test.tsx b/src/usb/alerts/OldFimrware.test.tsx new file mode 100644 index 000000000..a31e0f9f2 --- /dev/null +++ b/src/usb/alerts/OldFimrware.test.tsx @@ -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(); + + 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(); + + await act(() => user.click(message.getByRole('button', { name: /firmware/i }))); + + expect(callback).toHaveBeenCalledWith('flashFirmware'); +}); diff --git a/src/usb/alerts/OldFirmware.tsx b/src/usb/alerts/OldFirmware.tsx new file mode 100644 index 000000000..f6c0d5767 --- /dev/null +++ b/src/usb/alerts/OldFirmware.tsx @@ -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 = ({ + onFlashFirmware, +}) => { + const i18n = useI18n(); + + return ( + <> +

{i18n.translate('oldFirmware.message')}

+
+ +
+ + ); +}; + +export const oldFirmware: CreateToast = ( + onAction, +) => ({ + message: onAction('flashFirmware')} />, + icon: , + intent: Intent.PRIMARY, + onDismiss: () => onAction('dismiss'), +}); diff --git a/src/usb/alerts/i18n.ts b/src/usb/alerts/i18n.ts new file mode 100644 index 000000000..1fce2da95 --- /dev/null +++ b/src/usb/alerts/i18n.ts @@ -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 { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} diff --git a/src/usb/alerts/index.scss b/src/usb/alerts/index.scss new file mode 100644 index 000000000..55f25d6e8 --- /dev/null +++ b/src/usb/alerts/index.scss @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 The Pybricks Authors + +.pb-usb-alerts { + &-buttons { + display: flex; + gap: 10px; + } +} diff --git a/src/usb/alerts/index.ts b/src/usb/alerts/index.ts new file mode 100644 index 000000000..c8f30b510 --- /dev/null +++ b/src/usb/alerts/index.ts @@ -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, +}; diff --git a/src/usb/alerts/translations/en.json b/src/usb/alerts/translations/en.json new file mode 100644 index 000000000..58484b627 --- /dev/null +++ b/src/usb/alerts/translations/en.json @@ -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." + } +} diff --git a/src/usb/index.ts b/src/usb/index.ts index 93fb1f4a8..1ee8c4288 100644 --- a/src/usb/index.ts +++ b/src/usb/index.ts @@ -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, +} diff --git a/src/usb/reducers.test.ts b/src/usb/reducers.test.ts new file mode 100644 index 000000000..35877a3a8 --- /dev/null +++ b/src/usb/reducers.test.ts @@ -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; + +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); +}); diff --git a/src/usb/reducers.ts b/src/usb/reducers.ts new file mode 100644 index 000000000..9ee2650d9 --- /dev/null +++ b/src/usb/reducers.ts @@ -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 = ( + 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, +}); diff --git a/src/usb/sagas.ts b/src/usb/sagas.ts new file mode 100644 index 000000000..0eea6f0d2 --- /dev/null +++ b/src/usb/sagas.ts @@ -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> = []; + function* cleanup() { + for (const func of exitStack.reverse()) { + yield* call(() => func()); + } + } + + const disconnectChannel = eventChannel((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> + >( + 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( + (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((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((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); +}