From 5fe23239053b60d67fea44c68901492c188a630c Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 8 Aug 2025 22:56:24 +0000 Subject: [PATCH 1/3] status-bar: make visibility depend on runtime state Change dependency on Bluetooth connection state to runtime state. This way it works for USB connection as well. --- src/status-bar/StatusBar.test.tsx | 5 ++++- src/status-bar/StatusBar.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/status-bar/StatusBar.test.tsx b/src/status-bar/StatusBar.test.tsx index 0ba744399..f5ae875d9 100644 --- a/src/status-bar/StatusBar.test.tsx +++ b/src/status-bar/StatusBar.test.tsx @@ -1,10 +1,11 @@ // 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 () => { @@ -19,6 +20,7 @@ it('should show popover when hub name is clicked', async () => { deviceLowBatteryWarning: false, deviceBatteryCharging: false, }, + hub: { runtime: HubRuntimeState.Idle }, }); await act(() => user.click(statusBar.getByText(testHubName))); @@ -38,6 +40,7 @@ it('should show popover when battery is clicked', async () => { deviceLowBatteryWarning: false, deviceBatteryCharging: false, }, + hub: { runtime: HubRuntimeState.Idle }, }); await act(() => user.click(statusBar.getByTitle('Battery'))); diff --git a/src/status-bar/StatusBar.tsx b/src/status-bar/StatusBar.tsx index f29149967..1756eb7cd 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'; @@ -144,7 +144,7 @@ const BatteryIndicator: React.FunctionComponent = () => { }; 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 && ( <> From c2b80d29ea1953d5eca9d9bb9fecf52fce8b547c Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 8 Aug 2025 22:56:24 +0000 Subject: [PATCH 2/3] hub: move hub info reducers to hub Most of the hub state doesn't depend on the connection type. To make it generic, move it out of ble and into hub so that we can share it with usb. --- src/ble/reducers.test.ts | 97 ------------------------------ src/ble/reducers.ts | 74 +---------------------- src/hub/reducers.test.ts | 99 ++++++++++++++++++++++++++++++- src/hub/reducers.ts | 72 +++++++++++++++++++++- src/status-bar/StatusBar.test.tsx | 11 ++-- src/status-bar/StatusBar.tsx | 10 ++-- 6 files changed, 177 insertions(+), 186 deletions(-) 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..eba4109e1 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, @@ -143,6 +148,66 @@ const runtime: 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; +}; + const downloadProgress: Reducer = (state = null, action) => { if (didStartDownload.matches(action)) { return 0; @@ -299,6 +364,11 @@ const selectedSlot: Reducer = (state = 0, action) => { export default combineReducers({ runtime, + deviceName, + deviceType, + deviceFirmwareVersion, + deviceLowBatteryWarning, + deviceBatteryCharging, downloadProgress, maxBleWriteSize, maxUserProgramSize, diff --git a/src/status-bar/StatusBar.test.tsx b/src/status-bar/StatusBar.test.tsx index f5ae875d9..fee0bcc6f 100644 --- a/src/status-bar/StatusBar.test.tsx +++ b/src/status-bar/StatusBar.test.tsx @@ -4,7 +4,6 @@ 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'; @@ -12,15 +11,14 @@ 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', deviceLowBatteryWarning: false, deviceBatteryCharging: false, }, - hub: { runtime: HubRuntimeState.Idle }, }); await act(() => user.click(statusBar.getByText(testHubName))); @@ -32,15 +30,14 @@ 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', deviceLowBatteryWarning: false, deviceBatteryCharging: false, }, - hub: { runtime: HubRuntimeState.Idle }, }); await act(() => user.click(statusBar.getByTitle('Battery'))); diff --git a/src/status-bar/StatusBar.tsx b/src/status-bar/StatusBar.tsx index 1756eb7cd..c14c8885a 100644 --- a/src/status-bar/StatusBar.tsx +++ b/src/status-bar/StatusBar.tsx @@ -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 ( Date: Sat, 9 Aug 2025 02:21:34 +0000 Subject: [PATCH 3/3] usb: add USB support Initial working USB support. --- src/alerts.ts | 4 +- src/ble-device-info-service/protocol.ts | 10 +- src/hub/reducers.ts | 57 +- src/reducers.ts | 4 +- src/sagas.ts | 4 +- src/toolbar/Toolbar.tsx | 5 +- .../buttons/bluetooth/BluetoothButton.tsx | 21 +- .../buttons/bluetooth/translations/en.json | 3 +- src/toolbar/buttons/usb/UsbButton.test.tsx | 25 + src/toolbar/buttons/usb/UsbButton.tsx | 57 ++ src/toolbar/buttons/usb/connected.svg | 1 + src/toolbar/buttons/usb/disconnected.svg | 1 + src/toolbar/buttons/usb/i18n.ts | 12 + src/toolbar/buttons/usb/translations/en.json | 8 + src/usb/actions.ts | 120 +++ src/usb/alerts/NewPybricksProfile.test.tsx | 19 + src/usb/alerts/NewPybricksProfile.tsx | 51 ++ src/usb/alerts/NoWebUsb.test.tsx | 19 + src/usb/alerts/NoWebUsb.tsx | 26 + src/usb/alerts/OldFimrware.test.tsx | 30 + src/usb/alerts/OldFirmware.tsx | 39 + src/usb/alerts/i18n.ts | 12 + src/usb/alerts/index.scss | 9 + src/usb/alerts/index.ts | 13 + src/usb/alerts/translations/en.json | 18 + src/usb/index.ts | 52 +- src/usb/reducers.test.ts | 68 ++ src/usb/reducers.ts | 74 ++ src/usb/sagas.ts | 718 ++++++++++++++++++ 29 files changed, 1460 insertions(+), 20 deletions(-) create mode 100644 src/toolbar/buttons/usb/UsbButton.test.tsx create mode 100644 src/toolbar/buttons/usb/UsbButton.tsx create mode 100644 src/toolbar/buttons/usb/connected.svg create mode 100644 src/toolbar/buttons/usb/disconnected.svg create mode 100644 src/toolbar/buttons/usb/i18n.ts create mode 100644 src/toolbar/buttons/usb/translations/en.json create mode 100644 src/usb/actions.ts create mode 100644 src/usb/alerts/NewPybricksProfile.test.tsx create mode 100644 src/usb/alerts/NewPybricksProfile.tsx create mode 100644 src/usb/alerts/NoWebUsb.test.tsx create mode 100644 src/usb/alerts/NoWebUsb.tsx create mode 100644 src/usb/alerts/OldFimrware.test.tsx create mode 100644 src/usb/alerts/OldFirmware.tsx create mode 100644 src/usb/alerts/i18n.ts create mode 100644 src/usb/alerts/index.scss create mode 100644 src/usb/alerts/index.ts create mode 100644 src/usb/alerts/translations/en.json create mode 100644 src/usb/reducers.test.ts create mode 100644 src/usb/reducers.ts create mode 100644 src/usb/sagas.ts 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/hub/reducers.ts b/src/hub/reducers.ts index eba4109e1..c4809250b 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -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 = ( // 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 = ( }; const deviceName: Reducer = (state = '', action) => { - if (bleDidDisconnectPybricks.matches(action)) { + if ( + bleDidDisconnectPybricks.matches(action) || + usbDidDisconnectPybricks.matches(action) + ) { return ''; } @@ -157,11 +177,18 @@ const deviceName: Reducer = (state = '', action) => { return action.name; } + if (usbDidReceiveDeviceName.matches(action)) { + return action.deviceName; + } + return state; }; const deviceType: Reducer = (state = '', action) => { - if (bleDidDisconnectPybricks.matches(action)) { + if ( + bleDidDisconnectPybricks.matches(action) || + usbDidDisconnectPybricks.matches(action) + ) { return ''; } @@ -169,11 +196,18 @@ const deviceType: Reducer = (state = '', action) => { return getHubTypeName(action.pnpId); } + if (usbDidConnectPybricks.matches(action)) { + return getHubTypeName(action.pnpId); + } + return state; }; const deviceFirmwareVersion: Reducer = (state = '', action) => { - if (bleDidDisconnectPybricks.matches(action)) { + if ( + bleDidDisconnectPybricks.matches(action) || + usbDidDisconnectPybricks.matches(action) + ) { return ''; } @@ -181,11 +215,18 @@ const deviceFirmwareVersion: Reducer = (state = '', action) => { return action.version; } + if (usbDidReceiveFirmwareRevision.matches(action)) { + return action.version; + } + return state; }; const deviceLowBatteryWarning: Reducer = (state = false, action) => { - if (bleDidDisconnectPybricks.matches(action)) { + if ( + bleDidDisconnectPybricks.matches(action) || + usbDidDisconnectPybricks.matches(action) + ) { return false; } 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/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); +}