diff --git a/.yarn/patches/jest-mock-extended-npm-2.0.7-4cdf066556.patch b/.yarn/patches/jest-mock-extended-npm-2.0.7-4cdf066556.patch new file mode 100644 index 00000000..e77769c0 --- /dev/null +++ b/.yarn/patches/jest-mock-extended-npm-2.0.7-4cdf066556.patch @@ -0,0 +1,13 @@ +diff --git a/lib/CalledWithFn.js b/lib/CalledWithFn.js +index 56674799caceb24951e71ab00f20eb53a52c5b6c..1aec49f8c67eb840dba54e937fe49f66a9c13872 100644 +--- a/lib/CalledWithFn.js ++++ b/lib/CalledWithFn.js +@@ -30,7 +30,7 @@ const calledWithFn = () => { + fn.mockImplementation((...args) => checkCalledWith(calledWithStack, args)); + calledWithStack = []; + } +- calledWithStack.push({ args, calledWithFn }); ++ calledWithStack.unshift({ args, calledWithFn }); + return calledWithFn; + }; + return fn; diff --git a/CHANGELOG.md b/CHANGELOG.md index 149020ba..9f1aa2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ### Added - Added better error message when no files to backup ([support#681]). +- Added multi-step firmware flashing dialog. +- Added support for flashing firmware via USB DFU. ### Fixed - Fixed deleting files that are not open in the editor. diff --git a/package.json b/package.json index adfab2f6..09b9448f 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ }, "dependencies": { "@babel/core": "^7.18.9", - "@blueprintjs/core": "^4.5.0", + "@blueprintjs/core": "^4.6.1", "@blueprintjs/popover2": "^1.4.3", + "@blueprintjs/select": "^4.5.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", - "@pybricks/firmware": "4.17.0", + "@pybricks/firmware": "5.0.0", "@pybricks/ide-docs": "2.2.0", "@pybricks/mpy-cross-v5": "^2.0.0", "@pybricks/mpy-cross-v6": "^2.0.0", @@ -31,6 +32,7 @@ "@types/react-splitter-layout": "^3.0.2", "@types/redux-logger": "^3.0.9", "@types/semver": "^7.3.10", + "@types/w3c-web-usb": "^1.0.6", "@types/web-bluetooth": "^0.0.15", "@types/web-locks-api": "^0.0.2", "@types/wicg-file-system-access": "^2020.9.5", @@ -53,6 +55,7 @@ "dexie": "^3.2.2", "dexie-observable": "^4.0.0-beta.13", "dexie-react-hooks": "^1.1.1", + "dfu": "^0.1.5", "dotenv": "^16.0.1", "dotenv-expand": "^8.0.3", "fake-indexeddb": "^4.0.0", @@ -110,6 +113,7 @@ "typed-redux-saga": "^1.5.0", "typescript": "~4.7.4", "usehooks-ts": "^2.6.0", + "user-agent-data-types": "^0.3.0", "web-vitals": "^2.1.4", "webpack": "^5.73.0", "webpack-dev-server": "^4.9.3", @@ -162,7 +166,8 @@ "resolutions": { "mq-polyfill@1.1.8": "patch:mq-polyfill@npm:1.1.8#.yarn/patches/mq-polyfill-npm-1.1.8-62fe162439.patch", "react-error-overlay": "6.0.9", - "react-dev-utils@^12.0.1": "patch:react-dev-utils@npm:12.0.1#.yarn/patches/react-dev-utils-npm-12.0.1-83ba06e3ee.patch" + "react-dev-utils@^12.0.1": "patch:react-dev-utils@npm:12.0.1#.yarn/patches/react-dev-utils-npm-12.0.1-83ba06e3ee.patch", + "jest-mock-extended@^2.0.7": "patch:jest-mock-extended@npm:2.0.7#.yarn/patches/jest-mock-extended-npm-2.0.7-4cdf066556.patch" }, "jest": { "roots": [ @@ -189,7 +194,7 @@ "^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": "/config/jest/fileTransform.mjs" }, "transformIgnorePatterns": [ - "[/\\\\]node_modules[/\\\\](?!(monaco-editor|react-monaco-editor)[/\\\\]).+\\.(js|jsx|mjs|cjs|ts|tsx)$", + "[/\\\\]node_modules[/\\\\](?!(monaco-editor|react-monaco-editor|nanoevents)[/\\\\]).+\\.(js|jsx|mjs|cjs|ts|tsx)$", "^.+\\.module\\.(css|sass|scss)$" ], "modulePaths": [], diff --git a/src/alerts.ts b/src/alerts.ts index f05a8134..a203bbff 100644 --- a/src/alerts.ts +++ b/src/alerts.ts @@ -3,13 +3,17 @@ import { IToastProps } from '@blueprintjs/core'; import alerts from './alerts/alerts'; +import ble from './ble/alerts'; import explorer from './explorer/alerts'; +import firmware from './firmware/alerts'; import { CreateToast } from './i18nToaster'; /** This collects alerts from all of the subsystems of the app */ const alertDomains = { alerts, + ble, explorer, + firmware, }; /** Gets the type of available alert domains. */ diff --git a/src/alerts/sagas.ts b/src/alerts/sagas.ts index f6421dc0..f12a29c9 100644 --- a/src/alerts/sagas.ts +++ b/src/alerts/sagas.ts @@ -34,6 +34,8 @@ function* handleShowAlert(action: ReturnType): Generator try { const alertAction = yield* take(chan); + // the dismiss actions will have called this already, but other actions don't + toaster.dismiss(key); yield* put(alertsDidShowAlert(action.domain, action.specific, alertAction)); } finally { diff --git a/src/app/constants.ts b/src/app/constants.ts index d4e99b8e..7c0a22af 100644 --- a/src/app/constants.ts +++ b/src/app/constants.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors // Definitions for compile-time UI settings. @@ -32,6 +32,12 @@ export const pybricksBugReportsUrl = 'https://github.com/pybricks/support/issues /** URL for Pybricks community chat on Gitter */ export const pybricksGitterUrl = 'https://gitter.im/pybricks/community'; +export const pybricksBluetoothTroubleshootingUrl = + 'https://github.com/pybricks/support/discussions/270'; + +export const pybricksUsbDfuTroubleshootingUrl = + 'https://github.com/pybricks/support/discussions/688'; + /** Pybricks copyright statement. */ export const pybricksCopyright = 'Copyright (c) 2020-2022 The Pybricks Authors'; diff --git a/src/ble-device-info-service/protocol.test.ts b/src/ble-device-info-service/protocol.test.ts index 33237599..8b82309b 100644 --- a/src/ble-device-info-service/protocol.test.ts +++ b/src/ble-device-info-service/protocol.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { HubType, @@ -8,7 +8,7 @@ import { } from '../ble-lwp3-service/protocol'; import { decodePnpId, getHubTypeName } from './protocol'; -function encodeInfo(id: HubType, variant?: number) { +export function encodeInfo(id: HubType, variant?: number) { return new DataView( new Uint8Array([ 1, // Bluetooth SIG diff --git a/src/ble-device-info-service/protocol.ts b/src/ble-device-info-service/protocol.ts index 191e7d9e..585f76e0 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 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors // // Pybricks uses the standard Device Info service. // Refer to Device Information Service (DIS) at https://www.bluetooth.com/specifications/specs/ @@ -13,7 +13,7 @@ import { } from '../ble-lwp3-service/protocol'; /** Device Information service UUID. */ -export const serviceUUID = 0x180a; +export const deviceInformationServiceUUID = 0x180a; /** Firmware Revision String characteristic UUID. */ export const firmwareRevisionStringUUID = 0x2a26; diff --git a/src/ble-nordic-uart-service/protocol.ts b/src/ble-nordic-uart-service/protocol.ts index a6a961f3..392a2681 100644 --- a/src/ble-nordic-uart-service/protocol.ts +++ b/src/ble-nordic-uart-service/protocol.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Definitions related to the nRF UART Bluetooth low energy GATT service. // @@ -8,16 +8,16 @@ // https://infocenter.nordicsemi.com/topic/sdk_nrf5_v16.0.0/ble_sdk_app_nus_eval.html /** nRF UART Service UUID. */ -export const ServiceUUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; +export const nordicUartServiceUUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; /** nRF UART RX Characteristic UUID. Supports Write or Write without response. */ -export const RxCharUUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; +export const nordicUartRxCharUUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; /** nRF UART TX Characteristic UUID. Supports Notifications. */ -export const TxCharUUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; +export const nordicUartTxCharUUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; /** * This is the largest data size for the TX characteristic that is safe to use * when the negotiated MTU is unknown. */ -export const SafeTxCharLength = 20; +export const nordicUartSafeTxCharLength = 20; diff --git a/src/ble-pybricks-service/protocol.ts b/src/ble-pybricks-service/protocol.ts index 48b73424..e53f39ce 100644 --- a/src/ble-pybricks-service/protocol.ts +++ b/src/ble-pybricks-service/protocol.ts @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Definitions related to the Pybricks Bluetooth low energy GATT service. import { assert } from '../utils'; /** Pybricks service UUID. */ -export const ServiceUUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef'; +export const pybricksServiceUUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef'; /** Pybricks control characteristic UUID. */ -export const ControlCharacteristicUUID = 'c5f50002-8280-46da-89f4-6d8051e4aeef'; +export const pybricksControlCharacteristicUUID = 'c5f50002-8280-46da-89f4-6d8051e4aeef'; /** Commands are instructions sent to the hub. */ export enum CommandType { diff --git a/src/ble/actions.ts b/src/ble/actions.ts index 0c85d711..71c7204a 100644 --- a/src/ble/actions.ts +++ b/src/ble/actions.ts @@ -5,96 +5,47 @@ import { createAction } from '../actions'; /** - * Creates an action that indicates connecting has been requested. + * Creates an action that initiates a connection to a hub running Pybricks firmware. */ -export const connect = createAction(() => ({ - type: 'ble.device.action.connect', +export const bleConnectPybricks = createAction(() => ({ + type: 'ble.action.connectPybricks', })); /** - * Creates an action that indicates a device was connected. + * Response that indicates {@link bleConnectPybricks} succeeded. */ -export const didConnect = createAction((id: string, name: string) => ({ - type: 'ble.device.action.didConnect', +export const bleDidConnectPybricks = createAction((id: string, name: string) => ({ + type: 'ble.device.action.didConnectPybricks', id, name, })); -export enum BleDeviceFailToConnectReasonType { - NoWebBluetooth = 'ble.device.didFailToConnect.noWebBluetooth', - NoBluetooth = 'ble.device.didFailToConnect.noBluetooth', - Canceled = 'ble.device.didFailToConnect.canceled', - NoGatt = 'ble.device.didFailToConnect.noGatt', - NoDeviceInfoService = 'ble.device.didFailToConnect.noDeviceInfoService', - NoPybricksService = 'ble.device.didFailToConnect.noPybricksService', - Unknown = 'ble.device.didFailToConnect.unknown', -} - -type Reason = { - reason: T; -}; - -export type BleDeviceFailToConnectNoWebBluetoothReason = - Reason; - -export type BleDeviceFailToConnectNoBluetoothReason = - Reason; - -export type BleDeviceFailToConnectCanceledReason = - Reason; - -export type BleDeviceFailToConnectNoGattReason = - Reason; - -export type BleDeviceFailToConnectNoDeviceInfoServiceReason = - Reason; - -export type BleDeviceFailToConnectNoPybricksServiceReason = - Reason; - -export type BleDeviceFailToConnectUnknownReason = - Reason & { - err: Error; - }; - -export type BleDeviceDidFailToConnectReason = - | BleDeviceFailToConnectNoWebBluetoothReason - | BleDeviceFailToConnectNoBluetoothReason - | BleDeviceFailToConnectCanceledReason - | BleDeviceFailToConnectNoGattReason - | BleDeviceFailToConnectNoDeviceInfoServiceReason - | BleDeviceFailToConnectNoPybricksServiceReason - | BleDeviceFailToConnectUnknownReason; - /** - * Creates an action that indicates a device failed to connect. + * Response that indicates {@link bleConnectPybricks} failed. */ -export const didFailToConnect = createAction( - (reason: BleDeviceDidFailToConnectReason) => ({ - type: 'ble.device.action.didFailToConnect', - ...reason, - }), -); - -/** - * Creates an action that indicates disconnecting was requested. - */ -export const disconnect = createAction(() => ({ - type: 'ble.device.action.disconnect', +export const bleDidFailToConnectPybricks = createAction(() => ({ + type: 'ble.action.didFailToConnectPybricks', })); /** - * Creates an action that indicates a device was disconnected. + * Creates an action to request disconnecting a hub running Pybricks firmware. */ -export const didDisconnect = createAction(() => ({ - type: 'ble.device.action.didDisconnect', +export const bleDisconnectPybricks = createAction(() => ({ + type: 'ble.action.disconnectPybricks', })); /** - * Creates an action that indicates a device failed to disconnect. + * Creates an action that indicates that {@link bleDisconnectPybricks} succeeded. */ -export const didFailToDisconnect = createAction(() => ({ - type: 'ble.device.action.didFailToDisconnect', +export const bleDidDisconnectPybricks = createAction(() => ({ + type: 'ble.action.didDisconnectPybricks', +})); + +/** + * Creates an action that indicates that {@link bleDisconnectPybricks} failed. + */ +export const bleDidFailToDisconnectPybricks = createAction(() => ({ + type: 'ble.action.didFailToDisconnectPybricks', })); /** diff --git a/src/ble/alerts/BluetoothNotAvailable.tsx b/src/ble/alerts/BluetoothNotAvailable.tsx new file mode 100644 index 00000000..91db9822 --- /dev/null +++ b/src/ble/alerts/BluetoothNotAvailable.tsx @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +const BluetoothNotAvailable: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return ( + <> +

{i18n.translate(I18nId.BluetoothNotAvailableMessage)}

+

{i18n.translate(I18nId.BluetoothNotAvailableSuggestion)}

+ + ); +}; + +export const bluetoothNotAvailable: CreateToast = (onAction) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/ble/alerts/MissingService.tsx b/src/ble/alerts/MissingService.tsx new file mode 100644 index 00000000..caef2390 --- /dev/null +++ b/src/ble/alerts/MissingService.tsx @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +type MissingServiceProps = { + serviceName: string; + hubName: string; +}; + +const MissingService: React.VoidFunctionComponent = ({ + serviceName, + hubName, +}) => { + const i18n = useI18n(); + return ( + <> +

{i18n.translate(I18nId.MissingServiceMessage, { serviceName })}

+

{i18n.translate(I18nId.MissingServiceSuggestion1)}

+

{i18n.translate(I18nId.MissingServiceSuggestion2, { hubName })}

+ + ); +}; + +export const missingService: CreateToast = (onAction, props) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/ble/alerts/NoGatt.tsx b/src/ble/alerts/NoGatt.tsx new file mode 100644 index 00000000..78649013 --- /dev/null +++ b/src/ble/alerts/NoGatt.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +const NoGatt: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return

{i18n.translate(I18nId.NoGattMessage)}

; +}; + +export const noGatt: CreateToast = (onAction) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/ble/alerts/NoHub.tsx b/src/ble/alerts/NoHub.tsx new file mode 100644 index 00000000..61fc0728 --- /dev/null +++ b/src/ble/alerts/NoHub.tsx @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import './index.scss'; +import { AnchorButton, Button, Intent } from '@blueprintjs/core'; +import React from 'react'; +import { appName, pybricksBluetoothTroubleshootingUrl } from '../../app/constants'; +import { CreateToast } from '../../i18nToaster'; +import ExternalLinkIcon from '../../utils/ExternalLinkIcon'; +import { I18nId, useI18n } from './i18n'; + +type NoHubProps = { + onFlashFirmware: () => void; +}; + +const NoHub: React.VoidFunctionComponent = ({ onFlashFirmware }) => { + const i18n = useI18n(); + + return ( + <> +

{i18n.translate(I18nId.NoHubMessage)}

+

+ {i18n.translate(I18nId.NoHubSuggestion1, { + appName, + buttonName: ( + + {i18n.translate(I18nId.NoHubFlashFirmwareButton)} + + ), + })} +

+

{i18n.translate(I18nId.NoHubSuggestion2)}

+
+ + + {i18n.translate(I18nId.NoHubTroubleshootButton)} + + +
+ + ); +}; + +export const noHub: CreateToast = (onAction) => { + return { + message: onAction('flashFirmware')} />, + icon: 'info-sign', + intent: Intent.PRIMARY, + timeout: 15000, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/ble/alerts/NoWebBluetooth.tsx b/src/ble/alerts/NoWebBluetooth.tsx new file mode 100644 index 00000000..175cd784 --- /dev/null +++ b/src/ble/alerts/NoWebBluetooth.tsx @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Button, Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { isIOS, isLinux } from '../../utils/os'; +import { I18nId, useI18n } from './i18n'; + +const NoWebBluetooth: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return ( + <> +

{i18n.translate(I18nId.NoWebBluetoothMessage)}

+ {!isLinux() && !isIOS() && ( +

{i18n.translate(I18nId.NoWebBluetoothSuggestion)}

+ )} + {isLinux() && ( + <> +

{i18n.translate(I18nId.NoWebBluetoothLinux)}

+

+ + chrome://flags/#enable-experimental-web-platform-features + + + + + ); +}; + +export const oldFirmware: CreateToast = ( + onAction, +) => { + return { + message: onAction('flashFirmware')} />, + icon: 'info-sign', + intent: Intent.PRIMARY, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/toolbar/buttons/flash/i18n.test.ts b/src/ble/alerts/i18n.test.ts similarity index 75% rename from src/toolbar/buttons/flash/i18n.test.ts rename to src/ble/alerts/i18n.test.ts index b8f901e0..e706ba28 100644 --- a/src/toolbar/buttons/flash/i18n.test.ts +++ b/src/ble/alerts/i18n.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2022 The Pybricks Authors -import { lookup } from '../../../../test'; +import { lookup } from '../../../test'; import { I18nId } from './i18n'; import en from './translations/en.json'; diff --git a/src/ble/alerts/i18n.ts b/src/ble/alerts/i18n.ts new file mode 100644 index 00000000..20fca0a1 --- /dev/null +++ b/src/ble/alerts/i18n.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n'; + +export function useI18n(): I18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} + +export enum I18nId { + NoWebBluetoothMessage = 'noWebBluetooth.message', + NoWebBluetoothSuggestion = 'noWebBluetooth.suggestion', + NoWebBluetoothLinux = 'noWebBluetooth.linux', + BluetoothNotAvailableMessage = 'bluetoothNotAvailable.message', + BluetoothNotAvailableSuggestion = 'bluetoothNotAvailable.suggestion', + NoGattMessage = 'noGatt.message', + MissingServiceMessage = 'missingService.message', + MissingServiceSuggestion1 = 'missingService.suggestion1', + MissingServiceSuggestion2 = 'missingService.suggestion2', + NoHubMessage = 'noHub.message', + NoHubSuggestion1 = 'noHub.suggestion1', + NoHubSuggestion2 = 'noHub.suggestion2', + NoHubFlashFirmwareButton = 'noHub.flashFirmwareButton', + NoHubTroubleshootButton = 'noHub.troubleshootButton', + OldFirmwareMessage = 'oldFirmware.message', + OldFirmwareFlashFirmwareLabel = 'oldFirmware.flashFirmware.label', +} diff --git a/src/ble/alerts/index.scss b/src/ble/alerts/index.scss new file mode 100644 index 00000000..7771c733 --- /dev/null +++ b/src/ble/alerts/index.scss @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +.pb-ble-alerts { + &-buttons { + display: flex; + gap: 10px; + } +} diff --git a/src/ble/alerts/index.ts b/src/ble/alerts/index.ts new file mode 100644 index 00000000..22b7ffc0 --- /dev/null +++ b/src/ble/alerts/index.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { bluetoothNotAvailable } from './BluetoothNotAvailable'; +import { missingService } from './MissingService'; +import { noGatt } from './NoGatt'; +import { noHub } from './NoHub'; +import { noWebBluetooth } from './NoWebBluetooth'; +import { oldFirmware } from './OldFirmware'; + +// gathers all of the alert creation functions for passing up to the top level +export default { + bluetoothNotAvailable, + missingService, + noGatt, + noHub, + noWebBluetooth, + oldFirmware, +}; diff --git a/src/ble/alerts/translations/en.json b/src/ble/alerts/translations/en.json new file mode 100644 index 00000000..fe9bfd18 --- /dev/null +++ b/src/ble/alerts/translations/en.json @@ -0,0 +1,33 @@ +{ + "noWebBluetooth": { + "message": "This browser does not support Web Bluetooth or it is not enabled.", + "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge.", + "linux": "Web Bluetooth is experimental on Linux and must be manually enabled. Copy the link below and paste it in the address bar.", + "action": "More Info" + }, + "bluetoothNotAvailable": { + "message": "No Bluetooth adapter could be found.", + "suggestion": "Please connect or enable a Bluetooth Low Energy adapter and restart the browser." + }, + "noGatt": { + "message": "The web browser did not give permission to use Bluetooth Low Energy." + }, + "missingService": { + "message": "Connected to hub but failed to get {serviceName} service.", + "suggestion1": "Ensure that you are using the most recent firmware.", + "suggestion2": "If the problem persists, try removing the \"{hubName}\" device in your OS Bluetooth settings, then try connecting again." + }, + "noHub": { + "message": "Could not find your hub?", + "suggestion1": "{appName} requires custom firmware to be flashed to your hub. If you have not done this already, click the {buttonName} button below to do so now.", + "suggestion2": "If you have flashed the Pybricks firmware to the hub already and you are still having problems connecting, please visit the troubleshooting guide.", + "flashFirmwareButton": "Flash Firmware", + "troubleshootButton": "Troubleshooting Tips" + }, + "oldFirmware": { + "message": "A new firmware version is available for this hub. Please install the latest version to use all new features.", + "flashFirmware": { + "label": "Flash firmware now" + } + } +} diff --git a/src/ble/reducers.test.ts b/src/ble/reducers.test.ts index 742c5ed3..3fc80dbb 100644 --- a/src/ble/reducers.test.ts +++ b/src/ble/reducers.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { AnyAction } from 'redux'; import { @@ -11,13 +11,12 @@ import { HubType, LegoCompanyId } from '../ble-lwp3-service/protocol'; import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; import { - BleDeviceDidFailToConnectReason, - connect, - didConnect, - didDisconnect, - didFailToConnect, - didFailToDisconnect, - disconnect, + bleConnectPybricks, + bleDidConnectPybricks, + bleDidDisconnectPybricks, + bleDidFailToConnectPybricks, + bleDidFailToDisconnectPybricks, + bleDisconnectPybricks, } from './actions'; import reducers, { BleConnectionState } from './reducers'; @@ -38,35 +37,39 @@ test('initial state', () => { test('connection', () => { expect( - reducers({ connection: BleConnectionState.Disconnected } as State, connect()) - .connection, + reducers( + { connection: BleConnectionState.Disconnected } as State, + bleConnectPybricks(), + ).connection, ).toBe(BleConnectionState.Connecting); expect( reducers( { connection: BleConnectionState.Connecting } as State, - didConnect('test-id', 'Test Name'), + bleDidConnectPybricks('test-id', 'Test Name'), ).connection, ).toBe(BleConnectionState.Connected); expect( reducers( { connection: BleConnectionState.Connecting } as State, - didFailToConnect({} as BleDeviceDidFailToConnectReason), + bleDidFailToConnectPybricks(), ).connection, ).toBe(BleConnectionState.Disconnected); expect( - reducers({ connection: BleConnectionState.Connected } as State, disconnect()) - .connection, + reducers( + { connection: BleConnectionState.Connected } as State, + bleDisconnectPybricks(), + ).connection, ).toBe(BleConnectionState.Disconnecting); expect( reducers( { connection: BleConnectionState.Disconnecting } as State, - didDisconnect(), + bleDidDisconnectPybricks(), ).connection, ).toBe(BleConnectionState.Disconnected); expect( reducers( { connection: BleConnectionState.Disconnecting } as State, - didFailToDisconnect(), + bleDidFailToDisconnectPybricks(), ).connection, ).toBe(BleConnectionState.Connected); }); @@ -76,11 +79,13 @@ test('deviceName', () => { const testName = 'Test Name'; expect( - reducers({ deviceName: '' } as State, didConnect(testId, testName)).deviceName, + reducers({ deviceName: '' } as State, bleDidConnectPybricks(testId, testName)) + .deviceName, ).toBe(testName); expect( - reducers({ deviceName: testName } as State, didDisconnect()).deviceName, + reducers({ deviceName: testName } as State, bleDidDisconnectPybricks()) + .deviceName, ).toBe(''); }); @@ -98,7 +103,8 @@ test('deviceType', () => { ).toBe('Move hub'); expect( - reducers({ deviceType: 'Move hub' } as State, didDisconnect()).deviceType, + reducers({ deviceType: 'Move hub' } as State, bleDidDisconnectPybricks()) + .deviceType, ).toBe(''); }); @@ -113,8 +119,10 @@ test('deviceFirmwareVersion', () => { ).toBe(testVersion); expect( - reducers({ deviceFirmwareVersion: testVersion } as State, didDisconnect()) - .deviceFirmwareVersion, + reducers( + { deviceFirmwareVersion: testVersion } as State, + bleDidDisconnectPybricks(), + ).deviceFirmwareVersion, ).toBe(''); }); @@ -134,14 +142,14 @@ test('deviceLowBatteryWarning', () => { ).toBeFalsy(); expect( - reducers({ deviceLowBatteryWarning: true } as State, didDisconnect()) + reducers({ deviceLowBatteryWarning: true } as State, bleDidDisconnectPybricks()) .deviceLowBatteryWarning, ).toBeFalsy(); }); test('deviceBatteryCharging', () => { expect( - reducers({ deviceBatteryCharging: true } as State, didDisconnect()) + reducers({ deviceBatteryCharging: true } as State, bleDidDisconnectPybricks()) .deviceBatteryCharging, ).toBeFalsy(); }); diff --git a/src/ble/reducers.ts b/src/ble/reducers.ts index f69d9eaf..808a42fa 100644 --- a/src/ble/reducers.ts +++ b/src/ble/reducers.ts @@ -13,12 +13,12 @@ import { getHubTypeName } from '../ble-device-info-service/protocol'; import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; import { - connect, - didConnect, - didDisconnect, - didFailToConnect, - didFailToDisconnect, - disconnect, + bleConnectPybricks, + bleDidConnectPybricks, + bleDidDisconnectPybricks, + bleDidFailToConnectPybricks, + bleDidFailToDisconnectPybricks, + bleDisconnectPybricks, } from './actions'; /** @@ -47,19 +47,25 @@ const connection: Reducer = ( state = BleConnectionState.Disconnected, action, ) => { - if (connect.matches(action)) { + if (bleConnectPybricks.matches(action)) { return BleConnectionState.Connecting; } - if (didConnect.matches(action) || didFailToDisconnect.matches(action)) { + if ( + bleDidConnectPybricks.matches(action) || + bleDidFailToDisconnectPybricks.matches(action) + ) { return BleConnectionState.Connected; } - if (disconnect.matches(action)) { + if (bleDisconnectPybricks.matches(action)) { return BleConnectionState.Disconnecting; } - if (didFailToConnect.matches(action) || didDisconnect.matches(action)) { + if ( + bleDidFailToConnectPybricks.matches(action) || + bleDidDisconnectPybricks.matches(action) + ) { return BleConnectionState.Disconnected; } @@ -67,11 +73,11 @@ const connection: Reducer = ( }; const deviceName: Reducer = (state = '', action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return ''; } - if (didConnect.matches(action)) { + if (bleDidConnectPybricks.matches(action)) { return action.name; } @@ -79,7 +85,7 @@ const deviceName: Reducer = (state = '', action) => { }; const deviceType: Reducer = (state = '', action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return ''; } @@ -91,7 +97,7 @@ const deviceType: Reducer = (state = '', action) => { }; const deviceFirmwareVersion: Reducer = (state = '', action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return ''; } @@ -103,7 +109,7 @@ const deviceFirmwareVersion: Reducer = (state = '', action) => { }; const deviceLowBatteryWarning: Reducer = (state = false, action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return false; } @@ -117,7 +123,7 @@ const deviceLowBatteryWarning: Reducer = (state = false, action) => { }; const deviceBatteryCharging: Reducer = (state = false, action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return false; } diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts new file mode 100644 index 00000000..5b973691 --- /dev/null +++ b/src/ble/sagas.test.ts @@ -0,0 +1,612 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { MockProxy, mock } from 'jest-mock-extended'; +import { AsyncSaga } from '../../test'; +import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions'; +import { + bleDIServiceDidReceiveFirmwareRevision, + bleDIServiceDidReceivePnPId, + bleDIServiceDidReceiveSoftwareRevision, +} from '../ble-device-info-service/actions'; +import { + deviceInformationServiceUUID, + firmwareRevisionStringUUID, + pnpIdUUID, + softwareRevisionStringUUID, +} from '../ble-device-info-service/protocol'; +import { encodeInfo } from '../ble-device-info-service/protocol.test'; +import { HubType } from '../ble-lwp3-service/protocol'; +import { + nordicUartRxCharUUID, + nordicUartServiceUUID, + nordicUartTxCharUUID, +} from '../ble-nordic-uart-service/protocol'; +import { + pybricksControlCharacteristicUUID, + pybricksServiceUUID, +} from '../ble-pybricks-service/protocol'; +import { firmwareInstallPybricks } from '../firmware/actions'; +import { + bleConnectPybricks, + bleDidConnectPybricks, + bleDidDisconnectPybricks, + bleDidFailToConnectPybricks, + bleDisconnectPybricks, + toggleBluetooth, +} from './actions'; +import { BleConnectionState } from './reducers'; +import ble from './sagas'; + +const encoder = new TextEncoder(); + +afterEach(() => { + jest.clearAllMocks(); +}); + +type Mocks = { + bluetooth: MockProxy; + device: MockProxy; + gatt: MockProxy; + deviceInfoService: MockProxy; + firmwareRevisionChar: MockProxy; + softwareRevisionChar: MockProxy; + pnpIdChar: MockProxy; + pybricksService: MockProxy; + pybricksChar: MockProxy; + uartService: MockProxy; + uartRxChar: MockProxy; + uartTxChar: MockProxy; +}; + +/** + * Creates mocks used in connect tests. + */ +function createMocks(): Mocks { + const firmwareRevisionChar = mock(); + firmwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('3.2.0b2').buffer), + ); + + const softwareRevisionChar = mock(); + softwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('1.1.0').buffer), + ); + + const pnpIdChar = mock(); + pnpIdChar.readValue.mockResolvedValue( + new DataView(encodeInfo(HubType.TechnicHub).buffer), + ); + + const deviceInfoService = mock(); + deviceInfoService.getCharacteristic + .calledWith(firmwareRevisionStringUUID) + .mockResolvedValue(firmwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(softwareRevisionStringUUID) + .mockResolvedValue(softwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(pnpIdUUID) + .mockResolvedValue(pnpIdChar); + + const pybricksCharEventTarget = new EventTarget(); + const pybricksChar = mock({ + addEventListener: pybricksCharEventTarget.addEventListener.bind( + pybricksCharEventTarget, + ), + removeEventListener: pybricksCharEventTarget.removeEventListener.bind( + pybricksCharEventTarget, + ), + dispatchEvent: pybricksCharEventTarget.dispatchEvent.bind( + pybricksCharEventTarget, + ), + }); + pybricksChar.startNotifications.mockResolvedValue(pybricksChar); + pybricksChar.stopNotifications.mockResolvedValue(pybricksChar); + + const pybricksService = mock(); + pybricksService.getCharacteristic + .calledWith(pybricksControlCharacteristicUUID) + .mockResolvedValue(pybricksChar); + + const uartRxChar = mock(); + + const uartTxCharEventTarget = new EventTarget(); + const uartTxChar = mock({ + addEventListener: + uartTxCharEventTarget.addEventListener.bind(uartTxCharEventTarget), + removeEventListener: + uartTxCharEventTarget.removeEventListener.bind(uartTxCharEventTarget), + dispatchEvent: uartTxCharEventTarget.dispatchEvent.bind(uartTxCharEventTarget), + }); + + const uartService = mock(); + uartService.getCharacteristic + .calledWith(nordicUartRxCharUUID) + .mockResolvedValue(uartRxChar); + uartService.getCharacteristic + .calledWith(nordicUartTxCharUUID) + .mockResolvedValue(uartTxChar); + + const gatt = mock(); + gatt.connect.mockResolvedValue(gatt); + gatt.disconnect.mockImplementation(() => { + setTimeout(() => { + device.dispatchEvent(new Event('gattserverdisconnected')); + }, 10); + }); + gatt.getPrimaryService + .calledWith(deviceInformationServiceUUID) + .mockResolvedValue(deviceInfoService); + gatt.getPrimaryService + .calledWith(pybricksServiceUUID) + .mockResolvedValue(pybricksService); + gatt.getPrimaryService + .calledWith(nordicUartServiceUUID) + .mockResolvedValue(uartService); + + const deviceEvents = new EventTarget(); + const device = mock({ + id: 'test-id', + name: 'test name', + gatt, + addEventListener: deviceEvents.addEventListener.bind( + deviceEvents, + ) as BluetoothDevice['addEventListener'], + removeEventListener: deviceEvents.removeEventListener.bind(deviceEvents), + dispatchEvent: deviceEvents.dispatchEvent.bind(deviceEvents), + }); + + const bluetooth = mock(); + bluetooth.getAvailability.mockResolvedValue(true); + bluetooth.requestDevice.mockResolvedValue(device); + + return { + bluetooth, + device, + gatt, + deviceInfoService, + firmwareRevisionChar, + softwareRevisionChar, + pnpIdChar, + pybricksService, + pybricksChar, + uartService, + uartRxChar, + uartTxChar, + }; +} + +enum ConnectRunPoint { + Connect, + DidReceiveFirmwareRevision, + DidReceiveSoftwareRevision, + DidReceivePnpId, + DidConnect, +} + +/** + * Run the "success" path of the connect saga until a given point. + * + * This helps avoid duplicate code in tests. + * + * @param saga The saga. + * @param point The point at which to stop running. + */ +async function runConnectUntil(saga: AsyncSaga, point: ConnectRunPoint): Promise { + saga.put(bleConnectPybricks()); + + if (point === ConnectRunPoint.Connect) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveFirmwareRevision('3.2.0b2'), + ); + + await expect(saga.take()).resolves.toEqual(alertsShowAlert('ble', 'oldFirmware')); + + if (point === ConnectRunPoint.DidReceiveFirmwareRevision) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveSoftwareRevision('1.1.0'), + ); + + if (point === ConnectRunPoint.DidReceiveSoftwareRevision) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceivePnPId({ + productId: 0x80, + productVersion: 0, + vendorId: 919, + vendorIdSource: 1, + }), + ); + + if (point === ConnectRunPoint.DidReceivePnpId) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDidConnectPybricks('test-id', 'test name'), + ); +} + +describe('connect action is dispatched', () => { + let saga: AsyncSaga; + + beforeEach(() => { + saga = new AsyncSaga(ble); + }); + + it('should fail if no web bluetooth', async () => { + await runConnectUntil(saga, ConnectRunPoint.Connect); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('ble', 'noWebBluetooth'), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + }); + + describe('has web bluetooth', () => { + let mocks: Mocks; + beforeEach(() => { + mocks = createMocks(); + navigator.bluetooth = mocks.bluetooth; + }); + + it('should fail if bluetooth is not available', async () => { + jest.spyOn(navigator.bluetooth, 'getAvailability').mockResolvedValue(false); + + await runConnectUntil(saga, ConnectRunPoint.Connect); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('ble', 'bluetoothNotAvailable'), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + }); + + it('should fail if user canceled requestDevice', async () => { + jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( + new DOMException('test error', 'NotFoundError'), + ); + + await runConnectUntil(saga, ConnectRunPoint.Connect); + + await expect(saga.take()).resolves.toEqual(alertsShowAlert('ble', 'noHub')); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + saga.put(alertsDidShowAlert('ble', 'noHub', 'flashFirmware')); + await expect(saga.take()).resolves.toEqual(firmwareInstallPybricks()); + }); + + it('should fail on other exception in requestDevice', async () => { + const testError = new DOMException('test error', 'SecurityError'); + jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( + testError, + ); + + await runConnectUntil(saga, ConnectRunPoint.Connect); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + }); + + it('should fail if device has no gatt property', async () => { + Object.defineProperty(mocks.device, 'gatt', { value: undefined }); + + await runConnectUntil(saga, ConnectRunPoint.Connect); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('ble', 'noGatt'), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + }); + + it('should fail if gatt connect fails', async () => { + const testError = new DOMException('test error', 'NetworkError'); + mocks.gatt.connect.mockRejectedValueOnce(testError); + + await runConnectUntil(saga, ConnectRunPoint.Connect); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + }); + + it('should fail if device does not have device info service', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.gatt.getPrimaryService + .calledWith(deviceInformationServiceUUID) + .mockRejectedValueOnce(testError); + + await runConnectUntil(saga, ConnectRunPoint.Connect); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('ble', 'missingService', { + serviceName: 'Device Information', + hubName: 'test name', + }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if getting firmware revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.deviceInfoService.getCharacteristic + .calledWith(firmwareRevisionStringUUID) + .mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.Connect); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if reading firmware revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.firmwareRevisionChar.readValue.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.Connect); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if getting software revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.deviceInfoService.getCharacteristic + .calledWith(softwareRevisionStringUUID) + .mockRejectedValueOnce(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceiveFirmwareRevision); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if reading software revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.softwareRevisionChar.readValue.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceiveFirmwareRevision); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should skip bleDIServiceDidReceivePnPId action if getting pnp id characteristic fails', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.deviceInfoService.getCharacteristic + .calledWith(pnpIdUUID) + .mockRejectedValueOnce(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceiveSoftwareRevision); + + await expect(saga.take()).resolves.toEqual( + bleDidConnectPybricks('test-id', 'test name'), + ); + }); + + it('should fail if reading pnp id characteristic fails', async () => { + const testError = new Error('test error'); + mocks.pnpIdChar.readValue.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceiveSoftwareRevision); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if device does not have pybricks service', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.gatt.getPrimaryService + .calledWith(pybricksServiceUUID) + .mockRejectedValueOnce(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('ble', 'missingService', { + serviceName: 'Pybricks', + hubName: 'test name', + }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if getting pybricks characteristic fails', async () => { + const testError = new Error('test error'); + mocks.pybricksService.getCharacteristic + .calledWith(pybricksControlCharacteristicUUID) + .mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if stopping pybricks characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.pybricksChar.stopNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if starting pybricks characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.pybricksChar.startNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if device does not have nordic uart service', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.gatt.getPrimaryService + .calledWith(nordicUartServiceUUID) + .mockRejectedValueOnce(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('ble', 'missingService', { + serviceName: 'Nordic UART', + hubName: 'test name', + }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if getting nordic uart rx characteristic fails', async () => { + const testError = new Error('test error'); + mocks.uartService.getCharacteristic + .calledWith(nordicUartRxCharUUID) + .mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if getting nordic uart tx characteristic fails', async () => { + const testError = new Error('test error'); + mocks.uartService.getCharacteristic + .calledWith(nordicUartTxCharUUID) + .mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if stopping nordic uart tx characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.uartTxChar.stopNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if starting nordic uart tx characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.uartTxChar.startNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('alerts', 'unexpectedError', { error: testError }), + ); + await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should put didConnection action', async () => { + await runConnectUntil(saga, ConnectRunPoint.DidConnect); + }); + + it('should handle disconnect', async () => { + await runConnectUntil(saga, ConnectRunPoint.DidConnect); + + saga.put(bleDisconnectPybricks()); + + await expect(saga.take()).resolves.toEqual(bleDidDisconnectPybricks()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + }); + + afterEach(async () => { + await saga.end(); + }); +}); + +describe('toggleBluetooth action', () => { + it('should connect when disconnected', async () => { + const saga = new AsyncSaga(ble); + + saga.updateState({ ble: { connection: BleConnectionState.Disconnected } }); + + saga.put(toggleBluetooth()); + + await expect(saga.take()).resolves.toEqual(bleConnectPybricks()); + }); + + it('should disconnect when connected', async () => { + const saga = new AsyncSaga(ble); + + saga.updateState({ ble: { connection: BleConnectionState.Connected } }); + + saga.put(toggleBluetooth()); + + await expect(saga.take()).resolves.toEqual(bleDisconnectPybricks()); + }); +}); diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index 44a5d09d..f291af2a 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -6,15 +6,21 @@ // TODO: this file needs to be combined with the firmware BLE connection management // to reduce duplicated code -import { END, Task, eventChannel } from 'redux-saga'; +import { firmwareVersion } from '@pybricks/firmware'; +import { Task, buffers, eventChannel } from 'redux-saga'; +import { satisfies } from 'semver'; import { call, cancel, + delay, + fork, put, select, + spawn, + take, takeEvery, - takeMaybe, } from 'typed-redux-saga/macro'; +import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions'; import { bleDIServiceDidReceiveFirmwareRevision, bleDIServiceDidReceivePnPId, @@ -22,7 +28,7 @@ import { } from '../ble-device-info-service/actions'; import { decodePnpId, - serviceUUID as deviceInfoServiceUUID, + deviceInformationServiceUUID, firmwareRevisionStringUUID, pnpIdUUID, softwareRevisionStringUUID, @@ -34,9 +40,9 @@ import { write as writeUart, } from '../ble-nordic-uart-service/actions'; import { - RxCharUUID as uartRxCharUUID, - ServiceUUID as uartServiceUUID, - TxCharUUID as uartTxCharUUID, + nordicUartRxCharUUID, + nordicUartServiceUUID, + nordicUartTxCharUUID, } from '../ble-nordic-uart-service/protocol'; import { didFailToWriteCommand, @@ -45,28 +51,25 @@ import { writeCommand, } from '../ble-pybricks-service/actions'; import { - ControlCharacteristicUUID as pybricksCommandCharacteristicUUID, - ServiceUUID as pybricksServiceUUID, + pybricksControlCharacteristicUUID, + pybricksServiceUUID, } from '../ble-pybricks-service/protocol'; +import { firmwareInstallPybricks } from '../firmware/actions'; import { RootState } from '../reducers'; import { ensureError } from '../utils'; +import { pythonVersionToSemver } from '../utils/version'; import { - BleDeviceFailToConnectReasonType as Reason, - connect, - didConnect, - didDisconnect, - didFailToConnect, - disconnect, + bleConnectPybricks as bleConnectPybricks, + bleDidConnectPybricks, + bleDidDisconnectPybricks, + bleDidFailToConnectPybricks, + bleDisconnectPybricks, toggleBluetooth, } from './actions'; import { BleConnectionState } from './reducers'; const decoder = new TextDecoder(); -function handleDisconnect(server: BluetoothRemoteGATTServer): void { - server.disconnect(); -} - function* handlePybricksControlValueChanged(data: DataView): Generator { yield* put(didNotifyEvent(data)); } @@ -99,312 +102,345 @@ function* handleWriteUart( } } -function* handleConnect(): Generator { +function* handleBleConnectPybricks(): Generator { if (navigator.bluetooth === undefined) { - yield* put(didFailToConnect({ reason: Reason.NoWebBluetooth })); + yield* put(alertsShowAlert('ble', 'noWebBluetooth')); + yield* put(bleDidFailToConnectPybricks()); return; } const available = yield* call(() => navigator.bluetooth.getAvailability()); if (!available) { - yield* put(didFailToConnect({ reason: Reason.NoBluetooth })); + yield* put(alertsShowAlert('ble', 'bluetoothNotAvailable')); + yield* put(bleDidFailToConnectPybricks()); return; } - let device: BluetoothDevice; + // spawned tasks that will need to be canceled later + const tasks = new Array(); + + const defer = new Array<() => void>(); + try { - device = yield* call(() => - navigator.bluetooth.requestDevice({ - filters: [{ services: [pybricksServiceUUID] }], - optionalServices: [ - pybricksServiceUUID, - deviceInfoServiceUUID, - uartServiceUUID, - ], - }), + const device = yield* call(() => + navigator.bluetooth + .requestDevice({ + filters: [{ services: [pybricksServiceUUID] }], + optionalServices: [ + pybricksServiceUUID, + deviceInformationServiceUUID, + nordicUartServiceUUID, + ], + }) + .catch((err) => { + if ( + err instanceof DOMException && + err.code === DOMException.NOT_FOUND_ERR + ) { + // this means the user clicked the cancel button in the scan dialog + return undefined; + } + + throw err; + }), ); - } catch (err) { - if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - // this can happen if the use cancels the dialog - yield* put(didFailToConnect({ reason: Reason.Canceled })); - } else { - yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), + + if (!device) { + yield* put(alertsShowAlert('ble', 'noHub')); + yield* put(bleDidFailToConnectPybricks()); + + const { action } = yield* take< + ReturnType> + >( + alertsDidShowAlert.when( + (a) => a.domain === 'ble' && a.specific === 'noHub', + ), ); - } - return; - } - if (device.gatt === undefined) { - yield* put(didFailToConnect({ reason: Reason.NoGatt })); - return; - } + if (action === 'flashFirmware') { + yield* put(firmwareInstallPybricks()); + } - const disconnectChannel = eventChannel((emitter) => { - const listener = (): void => emitter(END); - device.addEventListener('gattserverdisconnected', listener); - return (): void => - device.removeEventListener('gattserverdisconnected', listener); - }); - - let server: BluetoothRemoteGATTServer; - try { - server = yield* call([device.gatt, 'connect']); - } catch (err) { - disconnectChannel.close(); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; - } - - yield* takeEvery(disconnect, handleDisconnect, server); - - let deviceInfoService: BluetoothRemoteGATTService; - try { - deviceInfoService = yield* call( - [server, 'getPrimaryService'], - deviceInfoServiceUUID, - ); - } catch (err) { - server.disconnect(); - yield* takeMaybe(disconnectChannel); - if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - yield* put(didFailToConnect({ reason: Reason.NoDeviceInfoService })); - } else { - yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), - ); - } - return; - } - - let firmwareVersionChar: BluetoothRemoteGATTCharacteristic; - try { - firmwareVersionChar = yield* call( - [deviceInfoService, 'getCharacteristic'], - firmwareRevisionStringUUID, - ); - } catch (err) { - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; - } - - try { - const version = decoder.decode(yield* call([firmwareVersionChar, 'readValue'])); - yield* put(bleDIServiceDidReceiveFirmwareRevision(version)); - } catch (err) { - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; - } - - let softwareVersionChar: BluetoothRemoteGATTCharacteristic; - try { - softwareVersionChar = yield* call( - [deviceInfoService, 'getCharacteristic'], - softwareRevisionStringUUID, - ); - } catch (err) { - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; - } - - try { - const version = decoder.decode(yield* call([softwareVersionChar, 'readValue'])); - yield* put(bleDIServiceDidReceiveSoftwareRevision(version)); - } catch (err) { - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; - } - - let pnpIdChar: BluetoothRemoteGATTCharacteristic | undefined = undefined; - try { - pnpIdChar = yield* call([deviceInfoService, 'getCharacteristic'], pnpIdUUID); - } catch (err) { - console.warn( - 'PnP ID characteristic requires Pybricks firmware v3.1.0a1 or later', - ); - } - - if (pnpIdChar) { - try { - const pnpId = decodePnpId(yield* call([pnpIdChar, 'readValue'])); - yield* put(bleDIServiceDidReceivePnPId(pnpId)); - } catch (err) { - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), - ); return; } - } - let pybricksService: BluetoothRemoteGATTService; - try { - pybricksService = yield* call( - [server, 'getPrimaryService'], - pybricksServiceUUID, - ); - } catch (err) { - server.disconnect(); - yield* takeMaybe(disconnectChannel); - if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - yield* put(didFailToConnect({ reason: Reason.NoPybricksService })); - } else { - yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), - ); + const gatt = device.gatt; + + if (!gatt) { + yield* put(alertsShowAlert('ble', 'noGatt')); + yield* put(bleDidFailToConnectPybricks()); + return; } - return; - } - let pybricksControlChar: BluetoothRemoteGATTCharacteristic; - try { - pybricksControlChar = yield* call( - [pybricksService, 'getCharacteristic'], - pybricksCommandCharacteristicUUID, + const disconnectChannel = eventChannel((emit) => { + device.addEventListener('gattserverdisconnected', emit); + return (): void => + device.removeEventListener('gattserverdisconnected', emit); + }, buffers.sliding(1)); + + defer.push(() => disconnectChannel.close()); + + const server = yield* call(() => gatt.connect()); + + defer.push(() => server.disconnect()); + + // istanbul ignore if + if (process.env.NODE_ENV !== 'test') { + // give OS Bluetooth stack some time to settle + yield* delay(1000); + } + + const deviceInfoService = yield* call(() => + server.getPrimaryService(deviceInformationServiceUUID).catch((err) => { + if ( + err instanceof DOMException && + err.code === DOMException.NOT_FOUND_ERR + ) { + return undefined; + } + + throw err; + }), ); - } catch (err) { - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; - } - const pybricksControlChannel = eventChannel((emitter) => { - const listener = (): void => { - if (!pybricksControlChar.value) { - return; - } - emitter(pybricksControlChar.value); - }; - pybricksControlChar.addEventListener('characteristicvaluechanged', listener); - return (): void => - pybricksControlChar.removeEventListener( + if (!deviceInfoService) { + yield* put( + alertsShowAlert('ble', 'missingService', { + serviceName: 'Device Information', + hubName: device.name || 'Pybricks Hub', + }), + ); + yield* put(bleDidFailToConnectPybricks()); + return; + } + + const firmwareVersionChar = yield* call(() => + deviceInfoService.getCharacteristic(firmwareRevisionStringUUID), + ); + + const firmwareRevision = decoder.decode( + yield* call(() => firmwareVersionChar.readValue()), + ); + yield* put(bleDIServiceDidReceiveFirmwareRevision(firmwareRevision)); + + // notify user if old firmware + if ( + satisfies( + pythonVersionToSemver(firmwareRevision), + `<${pythonVersionToSemver(firmwareVersion)}`, + ) + ) { + yield* put(alertsShowAlert('ble', 'oldFirmware')); + + // initiate flashing firmware if user requested + const flashIfRequested = function* () { + const { action } = yield* take< + ReturnType> + >( + alertsDidShowAlert.when( + (a) => a.domain === 'ble' && 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 softwareVersionChar = yield* call(() => + deviceInfoService.getCharacteristic(softwareRevisionStringUUID), + ); + + const softwareRevision = decoder.decode( + yield* call(() => softwareVersionChar.readValue()), + ); + yield* put(bleDIServiceDidReceiveSoftwareRevision(softwareRevision)); + + const pnpIdChar = yield* call(() => + deviceInfoService.getCharacteristic(pnpIdUUID).catch((err) => { + if ( + err instanceof DOMException && + err.code === DOMException.NOT_FOUND_ERR + ) { + // istanbul ignore if + if (process.env.NODE_ENV !== 'test') { + console.warn( + 'PnP ID characteristic requires Pybricks firmware v3.1.0a1 or later', + ); + } + + return undefined; + } + + throw err; + }), + ); + + if (pnpIdChar) { + const pnpId = decodePnpId(yield* call(() => pnpIdChar.readValue())); + yield* put(bleDIServiceDidReceivePnPId(pnpId)); + } + + const pybricksService = yield* call(() => + server.getPrimaryService(pybricksServiceUUID).catch((err) => { + if ( + err instanceof DOMException && + err.code === DOMException.NOT_FOUND_ERR + ) { + return undefined; + } + + throw err; + }), + ); + + if (!pybricksService) { + yield* put( + alertsShowAlert('ble', 'missingService', { + serviceName: 'Pybricks', + hubName: device.name || 'Pybricks Hub', + }), + ); + yield* put(bleDidFailToConnectPybricks()); + return; + } + + const pybricksControlChar = yield* call(() => + pybricksService.getCharacteristic(pybricksControlCharacteristicUUID), + ); + + const pybricksControlChannel = eventChannel((emit) => { + const listener = (): void => { + if (!pybricksControlChar.value) { + return; + } + emit(pybricksControlChar.value); + }; + + pybricksControlChar.addEventListener( 'characteristicvaluechanged', listener, ); - }); - // forked tasks that will need to be canceled later - const tasks = new Array(); + return (): void => + pybricksControlChar.removeEventListener( + 'characteristicvaluechanged', + listener, + ); + }); - tasks.push( - yield* takeEvery(pybricksControlChannel, handlePybricksControlValueChanged), - ); + defer.push(() => pybricksControlChannel.close()); + tasks.push( + yield* takeEvery(pybricksControlChannel, handlePybricksControlValueChanged), + ); - try { // REVISIT: possible Pybricks firmware bug (or chromium bug on Linux) // where 'characteristicvaluechanged' is not called after disconnecting // and reconnecting unless we stop notifications before we start them // again. Wireshark shows that no enable notification descriptor write // is performed but notifications are received. - yield* call([pybricksControlChar, 'stopNotifications']); - yield* call([pybricksControlChar, 'startNotifications']); - } catch (err) { - yield* cancel(tasks); - pybricksControlChannel.close(); - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; - } + yield* call(() => pybricksControlChar.stopNotifications()); + yield* call(() => pybricksControlChar.startNotifications()); - tasks.push(yield* takeEvery(writeCommand, handleWriteCommand, pybricksControlChar)); + tasks.push( + yield* takeEvery(writeCommand, handleWriteCommand, pybricksControlChar), + ); - let uartService: BluetoothRemoteGATTService; - try { - uartService = yield* call([server, 'getPrimaryService'], uartServiceUUID); - } catch (err) { - yield* cancel(tasks); - pybricksControlChannel.close(); - server.disconnect(); - yield* takeMaybe(disconnectChannel); - if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - yield* put(didFailToConnect({ reason: Reason.NoPybricksService })); - } else { + const uartService = yield* call(() => + server.getPrimaryService(nordicUartServiceUUID).catch((err) => { + if ( + err instanceof DOMException && + err.code === DOMException.NOT_FOUND_ERR + ) { + return undefined; + } + + throw err; + }), + ); + + if (!uartService) { yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), + alertsShowAlert('ble', 'missingService', { + serviceName: 'Nordic UART', + hubName: device.name || 'Pybricks Hub', + }), ); + yield* put(bleDidFailToConnectPybricks()); + return; } - return; - } - let uartRxChar: BluetoothRemoteGATTCharacteristic; - try { - uartRxChar = yield* call([uartService, 'getCharacteristic'], uartRxCharUUID); - } catch (err) { - yield* cancel(tasks); - pybricksControlChannel.close(); - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; - } + const uartRxChar = yield* call(() => + uartService.getCharacteristic(nordicUartRxCharUUID), + ); - let uartTxChar: BluetoothRemoteGATTCharacteristic; - try { - uartTxChar = yield* call([uartService, 'getCharacteristic'], uartTxCharUUID); - } catch (err) { - yield* cancel(tasks); - pybricksControlChannel.close(); - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; - } + const uartTxChar = yield* call(() => + uartService.getCharacteristic(nordicUartTxCharUUID), + ); - const uartTxChannel = eventChannel((emitter) => { - const listener = (): void => { - if (!uartTxChar.value) { - return; - } - emitter(uartTxChar.value); - }; - uartTxChar.addEventListener('characteristicvaluechanged', listener); - return (): void => - uartTxChar.removeEventListener('characteristicvaluechanged', listener); - }); + const uartTxChannel = eventChannel((emitter) => { + const listener = (): void => { + if (!uartTxChar.value) { + return; + } + emitter(uartTxChar.value); + }; + uartTxChar.addEventListener('characteristicvaluechanged', listener); + return (): void => + uartTxChar.removeEventListener('characteristicvaluechanged', listener); + }); - tasks.push(yield* takeEvery(uartTxChannel, handleUartValueChanged)); + defer.push(() => uartTxChannel.close()); + tasks.push(yield* takeEvery(uartTxChannel, handleUartValueChanged)); - try { // REVISIT: possible Pybricks firmware bug (or chromium bug on Linux) // where 'characteristicvaluechanged' is not called after disconnecting // and reconnecting unless we stop notifications before we start them // again. Wireshark shows that no enable notification descriptor write // is performed but notifications are received. - yield* call([uartTxChar, 'stopNotifications']); - yield* call([uartTxChar, 'startNotifications']); + yield* call(() => uartTxChar.stopNotifications()); + yield* call(() => uartTxChar.startNotifications()); + + tasks.push(yield* takeEvery(writeUart, handleWriteUart, uartRxChar)); + + yield* put(bleDidConnectPybricks(device.id, device.name || '')); + + const handleDisconnectRequest = function* (): Generator { + yield* take(bleDisconnectPybricks); + server.disconnect(); + }; + + yield* fork(handleDisconnectRequest); + + // wait for disconnection + yield* take(disconnectChannel); + + yield* put(bleDidDisconnectPybricks()); } catch (err) { + // istanbul ignore if + if (process.env.NODE_ENV !== 'test') { + // log error so it can still be copied even if alert is closed + console.error(err); + } + + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: ensureError(err), + }), + ); + yield* put(bleDidFailToConnectPybricks()); + } finally { yield* cancel(tasks); - uartTxChannel.close(); - pybricksControlChannel.close(); - server.disconnect(); - yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); - return; + + while (defer.length > 0) { + defer.pop()?.(); + } } - - tasks.push(yield* takeEvery(writeUart, handleWriteUart, uartRxChar)); - - yield* put(didConnect(device.id, device.name || '')); - - // wait for disconnection - yield* takeMaybe(disconnectChannel); - - yield* cancel(tasks); - uartTxChannel.close(); - pybricksControlChannel.close(); - - yield* put(didDisconnect()); } function* handleToggleBluetooth(): Generator { @@ -414,15 +450,15 @@ function* handleToggleBluetooth(): Generator { switch (connectionState) { case BleConnectionState.Connected: - yield* put(disconnect()); + yield* put(bleDisconnectPybricks()); break; case BleConnectionState.Disconnected: - yield* put(connect()); + yield* put(bleConnectPybricks()); break; } } export default function* (): Generator { - yield* takeEvery(connect, handleConnect); + yield* takeEvery(bleConnectPybricks, handleBleConnectPybricks); yield* takeEvery(toggleBluetooth, handleToggleBluetooth); } diff --git a/src/components/hubPicker/HubPicker.tsx b/src/components/hubPicker/HubPicker.tsx new file mode 100644 index 00000000..13889395 --- /dev/null +++ b/src/components/hubPicker/HubPicker.tsx @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Radio, RadioGroup } from '@blueprintjs/core'; +import React from 'react'; +import { Hub } from '.'; + +type HubPickerProps = { + hubType: Hub; + onChange: (hubType: Hub) => void; +}; + +export const HubPicker: React.VoidFunctionComponent = ({ + hubType, + onChange, +}) => { + return ( + onChange(e.currentTarget.value as Hub)} + > + BOOST Move Hub + City Hub + Technic Hub + SPIKE Prime Hub + SPIKE Essential Hub + MINDSTORMS Robot Inventor Hub + + ); +}; diff --git a/src/components/hubPicker/index.ts b/src/components/hubPicker/index.ts new file mode 100644 index 00000000..a4200da3 --- /dev/null +++ b/src/components/hubPicker/index.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +/** Supported hub types. */ +export enum Hub { + /** BOOST Move hub */ + Move = 'movehub', + /** City hub */ + City = 'cityhub', + /** Technic hub */ + Technic = 'technichub', + /** MINDSTORMS Robot Inventor hub */ + Inventor = 'inventorhub', + /** SPIKE Prime hub */ + Prime = 'primehub', + /** SPIKE Essential hub */ + Essential = 'essentialhub', +} + +/** + * Tests if hub has a USB port. + */ +export function hubHasUSB(hub: Hub): boolean { + switch (hub) { + case Hub.Prime: + case Hub.Essential: + case Hub.Inventor: + return true; + default: + return false; + } +} + +/** + * Tests if hub has a Bluetooth button. + */ +export function hubHasBluetoothButton(hub: Hub): boolean { + switch (hub) { + case Hub.Prime: + case Hub.Inventor: + return true; + default: + return false; + } +} + +/** + * Tests if hub has external flash memory. + */ +export function hubHasExternalFlash(hub: Hub): boolean { + switch (hub) { + case Hub.Prime: + case Hub.Essential: + case Hub.Inventor: + return true; + default: + return false; + } +} + +/** Gets the bootloader type for the hub. */ +export function hubBootloaderType(hub: Hub) { + switch (hub) { + case Hub.Prime: + case Hub.Essential: + case Hub.Inventor: + return 'usb-lego-dfu'; + case Hub.Move: + case Hub.City: + case Hub.Technic: + return 'ble-lwp3-bootloader'; + } +} diff --git a/src/editor/sagas.ts b/src/editor/sagas.ts index 7a8b4aae..519bc7e4 100644 --- a/src/editor/sagas.ts +++ b/src/editor/sagas.ts @@ -394,6 +394,11 @@ function* monitorEditors(): Generator { * Runs a web worker with Pyodide so that we can use Jedi for intellisense. */ function* runJedi(): Generator { + // TODO: web workers are not implemented in test environment + if (process.env.NODE_ENV === 'test') { + return; + } + const defer = new Array<() => void>(); try { diff --git a/src/error-log/sagas.test.ts b/src/error-log/sagas.test.ts index 52711661..58701f83 100644 --- a/src/error-log/sagas.test.ts +++ b/src/error-log/sagas.test.ts @@ -4,10 +4,6 @@ import { AsyncSaga } from '../../test'; import { didFailToWrite } from '../ble-nordic-uart-service/actions'; import { eventProtocolError } from '../ble-pybricks-service/actions'; -import { - BleDeviceFailToConnectReasonType, - didFailToConnect as bleDidFailToConnect, -} from '../ble/actions'; import { BootloaderConnectionFailureReason, didError, @@ -15,27 +11,6 @@ import { } from '../lwp3-bootloader/actions'; import errorLog from './sagas'; -test('bleDeviceDidFailToConnect', async () => { - const saga = new AsyncSaga(errorLog); - - console.error = jest.fn(); - - saga.put( - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Canceled }), - ); - expect(console.error).toHaveBeenCalledTimes(0); - - saga.put( - bleDidFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Unknown, - err: new Error('test error'), - }), - ); - expect(console.error).toHaveBeenCalledTimes(1); - - await saga.end(); -}); - test('bleDataDidFailToWrite', async () => { const saga = new AsyncSaga(errorLog); diff --git a/src/error-log/sagas.ts b/src/error-log/sagas.ts index aca7848a..642f12c3 100644 --- a/src/error-log/sagas.ts +++ b/src/error-log/sagas.ts @@ -4,10 +4,6 @@ import { takeEvery } from 'typed-redux-saga/macro'; import { didFailToWrite as bleUartDidFailToWrite } from '../ble-nordic-uart-service/actions'; import { eventProtocolError as pybricksEventProtocolError } from '../ble-pybricks-service/actions'; -import { - BleDeviceFailToConnectReasonType, - didFailToConnect as bleDeviceDidFailToConnect, -} from '../ble/actions'; import { fileStorageDidFailToStoreTextFileValue } from '../fileStorage/actions'; import { BootloaderConnectionFailureReason, @@ -15,14 +11,6 @@ import { didFailToConnect as bootloaderDidFailToConnect, } from '../lwp3-bootloader/actions'; -function handleBleDeviceDidFailToConnect( - action: ReturnType, -): void { - if (action.reason === BleDeviceFailToConnectReasonType.Unknown) { - console.error(action.err); - } -} - function handlePybricksEventProtocolError( action: ReturnType, ): void { @@ -54,7 +42,6 @@ function handleFileStorageDidFailToStoreTextFileValue( } export default function* (): Generator { - yield* takeEvery(bleDeviceDidFailToConnect, handleBleDeviceDidFailToConnect); yield* takeEvery(pybricksEventProtocolError, handlePybricksEventProtocolError); yield* takeEvery(bleUartDidFailToWrite, handleBleUartDidFailToWrite); yield* takeEvery(bootloaderDidFailToConnect, handleBootloaderDidFailToConnect); diff --git a/src/explorer/newFileWizard/NewFileWizard.test.tsx b/src/explorer/newFileWizard/NewFileWizard.test.tsx index c2755314..5b1ba4ac 100644 --- a/src/explorer/newFileWizard/NewFileWizard.test.tsx +++ b/src/explorer/newFileWizard/NewFileWizard.test.tsx @@ -5,8 +5,9 @@ import { fireEvent, waitFor } from '@testing-library/dom'; import { cleanup } from '@testing-library/react'; import React from 'react'; import { testRender } from '../../../test'; +import { Hub } from '../../components/hubPicker'; import NewFileWizard from './NewFileWizard'; -import { Hub, newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; +import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; afterEach(() => { cleanup(); diff --git a/src/explorer/newFileWizard/NewFileWizard.tsx b/src/explorer/newFileWizard/NewFileWizard.tsx index 37947720..54981736 100644 --- a/src/explorer/newFileWizard/NewFileWizard.tsx +++ b/src/explorer/newFileWizard/NewFileWizard.tsx @@ -1,17 +1,12 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -import { - Button, - Classes, - Dialog, - FormGroup, - Radio, - RadioGroup, -} from '@blueprintjs/core'; +import { Button, Classes, Dialog, FormGroup } from '@blueprintjs/core'; import React, { useCallback, useRef, useState } from 'react'; import { useId } from 'react-aria'; import { useDispatch } from 'react-redux'; +import { Hub } from '../../components/hubPicker'; +import { HubPicker } from '../../components/hubPicker/HubPicker'; import { useFileStorageMetadata } from '../../fileStorage/hooks'; import { FileNameValidationResult, @@ -20,7 +15,7 @@ import { } from '../../pybricksMicropython/lib'; import { useSelector } from '../../reducers'; import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup'; -import { Hub, newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; +import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; import { I18nId, useI18n } from './i18n'; // This should be set to the most commonly used hub. @@ -75,19 +70,7 @@ const NewFileWizard: React.VoidFunctionComponent = () => { onChange={setFileName} /> - setHubType(e.currentTarget.value as Hub)} - > - BOOST Move Hub - City Hub - Technic Hub - SPIKE Prime - SPIKE Essential - - MINDSTORMS Robot Inventor - - +

diff --git a/src/explorer/newFileWizard/actions.ts b/src/explorer/newFileWizard/actions.ts index 2ce71e67..b8d9ab20 100644 --- a/src/explorer/newFileWizard/actions.ts +++ b/src/explorer/newFileWizard/actions.ts @@ -2,28 +2,13 @@ // Copyright (c) 2022 The Pybricks Authors import { createAction } from '../../actions'; +import { Hub } from '../../components/hubPicker'; import { pythonFileExtension } from '../../pybricksMicropython/lib'; /** Supported file extensions. */ type SupportedFileExtension = typeof pythonFileExtension; -/** Supported hub types. */ -export enum Hub { - /** BOOST Move hub */ - Move = 'movehub', - /** City hub */ - City = 'cityhub', - /** Technic hub */ - Technic = 'technichub', - /** MINDSTORMS Robot Inventor hub */ - Inventor = 'inventorhub', - /** SPIKE Prime hub */ - Prime = 'primehub', - /** SPIKE Essential hub */ - Essential = 'essentialhub', -} - /** * Requests to show the new file wizard dialog. */ diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts index 2cfdf7f7..528618d7 100644 --- a/src/explorer/sagas.test.ts +++ b/src/explorer/sagas.test.ts @@ -6,6 +6,7 @@ import { FileWithHandle } from 'browser-fs-access'; import { mock } from 'jest-mock-extended'; import { AsyncSaga, uuid } from '../../test'; import { alertsShowAlert } from '../alerts/actions'; +import { Hub } from '../components/hubPicker'; import { editorActivateFile, editorCloseFile, @@ -72,7 +73,6 @@ import { } from './duplicateFileDialog/actions'; import { ExplorerError, ExplorerErrorName } from './error'; import { - Hub, newFileWizardDidAccept, newFileWizardDidCancel, newFileWizardShow, diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index e441c44c..ab2343e8 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -120,15 +120,15 @@ export type FailToFinishReason = /** * Creates a new action to flash firmware to a hub. * @param data The firmware zip file data or `null` to get firmware later. - * @param flashCurrentProgram If true, flash the current program from the editor, - * otherwise use the program from firmware.zip. + * @param customProgram If defined, flash the path of a program from file storage, + * otherwise use the main.py program from firmware.zip. * @param hubName A custom hub name or an empty string to use the default name. */ export const flashFirmware = createAction( - (data: ArrayBuffer | null, flashCurrentProgram: boolean, hubName: string) => ({ + (data: ArrayBuffer | null, customProgram: string | undefined, hubName: string) => ({ type: 'flashFirmware.action.flashFirmware', data, - flashCurrentProgram, + customProgram, hubName, }), ); @@ -345,3 +345,74 @@ function didFailToFinishCreator( * @param total The total number of bytes to be flashed. */ export const didFailToFinish = createAction(didFailToFinishCreator); + +/** + * Low-level action to flash firmware using LEGO's DFU over USB. + * @param data The firmware zip file data. + * @param hubName A custom hub name or an empty string to use the default name. + */ +export const firmwareFlashUsbDfu = createAction( + (data: ArrayBuffer, hubName: string) => ({ + type: 'firmware.action.flashUsbDfu', + data, + hubName, + }), +); + +/** + * Low-level action that indicates {@link firmwareFlashUsbDfu} succeeded. + */ +export const firmwareDidFlashUsbDfu = createAction(() => ({ + type: 'firmware.action.didFlashUsbDfu', +})); + +/** + * Low-level action that indicates {@link firmwareFlashUsbDfu} failed. + */ +export const firmwareDidFailToFlashUsbDfu = createAction(() => ({ + type: 'firmware.action.didFailToFlashUsbDfu', +})); + +// High-level actions + +/** + * Action that triggers the install Pybricks firmware saga. + */ +export const firmwareInstallPybricks = createAction(() => ({ + type: 'firmware.action.installPybricks', +})); + +/** + * Action that indicates {@link firmwareInstallPybricks} succeeded. + */ +export const firmwareDidInstallPybricks = createAction(() => ({ + type: 'firmware.action.didInstallPybricks', +})); + +/** + * Action that indicates {@link firmwareInstallPybricks} failed. + */ +export const firmwareDidFailToInstallPybricks = createAction(() => ({ + type: 'firmware.action.didFailToInstallPybricks', +})); + +/** + * Action that triggers the restore LEGO firmware saga. + */ +export const firmwareRestoreLego = createAction(() => ({ + type: 'firmware.action.restoreLego', +})); + +/** + * Action that indicates {@link firmwareRestoreLego} succeeded. + */ +export const firmwareDidRestoreLego = createAction(() => ({ + type: 'firmware.action.didRestoreLego', +})); + +/** + * Action that indicates {@link firmwareRestoreLego} failed. + */ +export const firmwareDidFailToRestoreLego = createAction(() => ({ + type: 'firmware.action.didFailToRestoreLego', +})); diff --git a/src/firmware/alerts/FirmwareMismatch.tsx b/src/firmware/alerts/FirmwareMismatch.tsx new file mode 100644 index 00000000..ba9522df --- /dev/null +++ b/src/firmware/alerts/FirmwareMismatch.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +const FirmwareMismatch: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return

{i18n.translate(I18nId.FirmwareMismatchMessage)}

; +}; + +export const firmwareMismatch: CreateToast = (onAction) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/NoDfuHub.tsx b/src/firmware/alerts/NoDfuHub.tsx new file mode 100644 index 00000000..a065da10 --- /dev/null +++ b/src/firmware/alerts/NoDfuHub.tsx @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { AnchorButton, Intent } from '@blueprintjs/core'; +import React from 'react'; +import { pybricksUsbDfuTroubleshootingUrl } from '../../app/constants'; +import { CreateToast } from '../../i18nToaster'; +import ExternalLinkIcon from '../../utils/ExternalLinkIcon'; +import { isLinux, isWindows } from '../../utils/os'; +import { I18nId, useI18n } from './i18n'; + +const NoDfuHub: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + + return ( + <> +

{i18n.translate(I18nId.NoDfuHubMessage)}

+ + {isWindows() &&

{i18n.translate(I18nId.NoDfuHubSuggestion1Windows)}

} + {isLinux() &&

{i18n.translate(I18nId.NoDfuHubSuggestion1Linux)}

} + +

{i18n.translate(I18nId.NoDfuHubSuggestion2)}

+ + + {i18n.translate(I18nId.NoDfuHubTroubleshootButton)} + + + + ); +}; + +export const noDfuHub: CreateToast = (onAction) => { + return { + message: , + icon: 'info-sign', + intent: Intent.PRIMARY, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/NoDfuInterface.tsx b/src/firmware/alerts/NoDfuInterface.tsx new file mode 100644 index 00000000..9751bbdb --- /dev/null +++ b/src/firmware/alerts/NoDfuInterface.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +const NoDfuInterface: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return

{i18n.translate(I18nId.NoDfuInterfaceMessage)}

; +}; + +export const noDfuInterface: CreateToast = (onAction) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/NoWebUsb.tsx b/src/firmware/alerts/NoWebUsb.tsx new file mode 100644 index 00000000..3303c023 --- /dev/null +++ b/src/firmware/alerts/NoWebUsb.tsx @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +const NoWebUsb: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return ( + <> +

{i18n.translate(I18nId.NoWebUsbMessage)}

+

{i18n.translate(I18nId.NoWebUsbSuggestion)}

+ + ); +}; + +export const noWebUsb: CreateToast = (onAction) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/i18n.test.ts b/src/firmware/alerts/i18n.test.ts new file mode 100644 index 00000000..e706ba28 --- /dev/null +++ b/src/firmware/alerts/i18n.test.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { lookup } from '../../../test'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; + +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { + expect(lookup(en, id)).toBeDefined(); + }); +}); diff --git a/src/firmware/alerts/i18n.ts b/src/firmware/alerts/i18n.ts new file mode 100644 index 00000000..35dfee63 --- /dev/null +++ b/src/firmware/alerts/i18n.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n'; + +export function useI18n(): I18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} + +export enum I18nId { + NoWebUsbMessage = 'noWebUsb.message', + NoWebUsbSuggestion = 'noWebUsb.suggestion', + NoDfuHubMessage = 'noDfuHub.message', + NoDfuHubSuggestion1Windows = 'noDfuHub.suggestion1.windows', + NoDfuHubSuggestion1Linux = 'noDfuHub.suggestion1.linux', + NoDfuHubSuggestion2 = 'noDfuHub.suggestion2', + NoDfuHubTroubleshootButton = 'noDfuHub.troubleshootButton', + NoDfuInterfaceMessage = 'noDfuInterface.message', + FirmwareMismatchMessage = 'firmwareMismatch.message', +} diff --git a/src/firmware/alerts/index.ts b/src/firmware/alerts/index.ts new file mode 100644 index 00000000..82ea36ab --- /dev/null +++ b/src/firmware/alerts/index.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { firmwareMismatch } from './FirmwareMismatch'; +import { noDfuHub } from './NoDfuHub'; +import { noDfuInterface } from './NoDfuInterface'; +import { noWebUsb } from './NoWebUsb'; + +export default { + firmwareMismatch, + noDfuHub, + noDfuInterface, + noWebUsb, +}; diff --git a/src/firmware/alerts/translations/en.json b/src/firmware/alerts/translations/en.json new file mode 100644 index 00000000..df5a2aa4 --- /dev/null +++ b/src/firmware/alerts/translations/en.json @@ -0,0 +1,21 @@ +{ + "noWebUsb": { + "message": "This browser does not support Web USB or Web USB is not enabled.", + "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge." + }, + "noDfuHub": { + "message": "Could not find your hub?", + "suggestion1": { + "windows": "You may need to manually install a USB driver before you can connect to your hub.", + "linux": "You may need to add udev rules before you can connect to your hub." + }, + "suggestion2": "Click the button below for more information.", + "troubleshootButton": "Troubleshooting Tips" + }, + "noDfuInterface": { + "message": "This is very unusual. The USB device did not contain the expected interface." + }, + "firmwareMismatch": { + "message": "Cannot flash firmware. The firmware file is for a different kind of hub." + } +} diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx new file mode 100644 index 00000000..3236c55e --- /dev/null +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import './installPybricksDialog.scss'; +import { + Button, + Checkbox, + Classes, + ControlGroup, + DialogStep, + FormGroup, + IRef, + Icon, + InputGroup, + Intent, + MenuItem, + MultistepDialog, + NonIdealState, + Spinner, + Switch, +} from '@blueprintjs/core'; +import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2'; +import { Select2 } from '@blueprintjs/select'; +import classNames from 'classnames'; +import React, { useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { appName } from '../../app/constants'; +import HelpButton from '../../components/HelpButton'; +import { + Hub, + hubBootloaderType, + hubHasBluetoothButton, + hubHasExternalFlash, + hubHasUSB, +} from '../../components/hubPicker'; +import { HubPicker } from '../../components/hubPicker/HubPicker'; +import { FileMetadata } from '../../fileStorage'; +import { useFileStorageMetadata } from '../../fileStorage/hooks'; +import { useSelector } from '../../reducers'; +import { + firmwareInstallPybricksDialogAccept, + firmwareInstallPybricksDialogCancel, +} from './actions'; +import { useFirmware } from './hooks'; +import { I18nId, useI18n } from './i18n'; +import { validateHubName } from '.'; + +const dialogBody = classNames( + Classes.DIALOG_BODY, + 'pb-firmware-installPybricksDialog-body', +); + +type SelectHubPanelProps = { + hubType: Hub; + onChange: (hubType: Hub) => void; +}; + +const SelectHubPanel: React.VoidFunctionComponent = ({ + hubType, + onChange, +}) => { + const i18n = useI18n(); + + return ( +
+

{i18n.translate(I18nId.SelectHubPanelMessage)}

+ + +

+ {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsTitle, + )} +

+
    +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsRcx, + )} +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsNxt, + )} +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsEv3, + )} +
  • +
+

+ {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpTitle, + )} +

+
    +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpWedo2, + )} + * +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain, + )} + * +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpMario, + )} +
  • +
+ + + *{' '} + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpFootnote, + )} + +
+ } + renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => ( + + )} + /> +
+ ); +}; + +type AcceptLicensePanelProps = { + hubType: Hub; + licenseAccepted: boolean; + onLicenseAcceptedChanged: (accepted: boolean) => void; +}; + +const AcceptLicensePanel: React.VoidFunctionComponent = ({ + hubType, + licenseAccepted, + onLicenseAcceptedChanged, +}) => { + const { data, error } = useFirmware(hubType); + const i18n = useI18n(); + + return ( +
+
+
+ {data ? ( +
{data.licenseText}
+ ) : ( + } + description={ + error + ? i18n.translate( + I18nId.LicensePanelLicenseTextError, + ) + : undefined + } + /> + )} +
+ onLicenseAcceptedChanged(e.currentTarget.checked)} + disabled={!data} + /> +
+
+ ); +}; + +type SelectOptionsPanelProps = { + hubType: Hub; + hubName: string; + includeProgram: boolean; + selectedIncludeFile: FileMetadata | undefined; + onChangeHubName(hubName: string): void; + onChangeIncludeProgram(includeProgram: boolean): void; + onChangeSelectedIncludeFile(selectedIncludeFile: FileMetadata | undefined): void; +}; + +const ConfigureOptionsPanel: React.VoidFunctionComponent = ({ + hubType, + hubName, + includeProgram, + selectedIncludeFile, + onChangeHubName, + onChangeIncludeProgram, + onChangeSelectedIncludeFile, +}) => { + const i18n = useI18n(); + const isHubNameValid = validateHubName(hubName); + const files = useFileStorageMetadata(); + + return ( +
+ + + onChangeHubName(e.currentTarget.value)} + onMouseOver={(e) => e.preventDefault()} + onMouseDown={(e) => e.stopPropagation()} + intent={isHubNameValid ? Intent.NONE : Intent.DANGER} + placeholder="Pybricks Hub" + rightElement={ + isHubNameValid ? undefined : ( + + ) + } + /> + + + + + {(hubHasExternalFlash(hubType) && ( +

+ {i18n.translate( + I18nId.OptionsPanelCustomMainNotApplicableMessage, + )} +

+ )) || ( + + main.py }, + )} + checked={includeProgram} + onChange={(e) => + onChangeIncludeProgram( + (e.target as HTMLInputElement).checked, + ) + } + /> + ( + + )} + noResults={ + + } + filterable={false} + popoverProps={{ minimal: true }} + disabled={!includeProgram} + onItemSelect={onChangeSelectedIncludeFile} + > +
+ ); +}; + +type BootloaderModePanelProps = { + hubType: Hub; +}; + +const BootloaderModePanel: React.VoidFunctionComponent = ({ + hubType, +}) => { + const i18n = useI18n(); + + const { button, light, lightPattern } = useMemo(() => { + return { + button: i18n.translate( + hubHasBluetoothButton(hubType) + ? I18nId.BootloaderPanelButtonBluetooth + : I18nId.BootloaderPanelButtonPower, + ), + light: i18n.translate( + hubHasBluetoothButton(hubType) + ? I18nId.BootloaderPanelLightBluetooth + : I18nId.BootloaderPanelLightStatus, + ), + lightPattern: i18n.translate( + hubHasBluetoothButton(hubType) + ? I18nId.BootloaderPanelLightPatternBluetooth + : I18nId.BootloaderPanelLightPatternStatus, + ), + }; + }, [i18n, hubType]); + + return ( +
+

{i18n.translate(I18nId.BootloaderPanelInstruction1)}

+
    + {hubHasUSB(hubType) && ( +
  1. {i18n.translate(I18nId.BootloaderPanelStepDisconnectUsb)}
  2. + )} + +
  3. {i18n.translate(I18nId.BootloaderPanelStepPowerOff)}
  4. + + {/* City hub has power issues and requires disconnecting motors/sensors */} + {hubType === Hub.City && ( +
  5. {i18n.translate(I18nId.BootloaderPanelStepDisconnectIo)}
  6. + )} + +
  7. + {i18n.translate(I18nId.BootloaderPanelStepHoldButton, { button })} +
  8. + + {hubHasUSB(hubType) && ( +
  9. {i18n.translate(I18nId.BootloaderPanelStepConnectUsb)}
  10. + )} + +
  11. + {i18n.translate(I18nId.BootloaderPanelStepWaitForLight, { + button, + light, + lightPattern, + })} +
  12. + +
  13. + {i18n.translate( + /* hubs with USB will keep the power on, but other hubs won't */ + hubHasUSB(hubType) + ? I18nId.BootloaderPanelStepReleaseButton + : I18nId.BootloaderPanelStepKeepHolding, + { + button, + }, + )} +
  14. +
+

+ {i18n.translate(I18nId.BootloaderPanelInstruction2, { + flashFirmware: ( + + {i18n.translate(I18nId.FlashFirmwareButtonLabel)} + + ), + })} +

+
+ ); +}; + +const defaultHubType = Hub.Technic; + +export const InstallPybricksDialog: React.VoidFunctionComponent = () => { + const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog); + const dispatch = useDispatch(); + const [hubType, setHubType] = useState(defaultHubType); + const [hubName, setHubName] = useState(''); + const [includeProgram, setIncludeProgram] = useState(false); + const [selectedIncludeFile, setSelectedIncludeFile] = useState(); + const [licenseAccepted, setLicenseAccepted] = useState(false); + const { data } = useFirmware(hubType); + const i18n = useI18n(); + + return ( + dispatch(firmwareInstallPybricksDialogCancel())} + finalButtonProps={{ + text: i18n.translate(I18nId.FlashFirmwareButtonLabel), + onClick: () => + dispatch( + firmwareInstallPybricksDialogAccept( + hubBootloaderType(hubType), + data?.firmwareZip ?? new ArrayBuffer(0), + selectedIncludeFile?.path, + hubName, + ), + ), + }} + > + } + nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }} + /> + + } + backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }} + nextButtonProps={{ + disabled: !licenseAccepted, + text: i18n.translate(I18nId.NextButtonLabel), + }} + /> + + } + backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }} + nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }} + /> + } + backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }} + /> + + ); +}; diff --git a/src/firmware/installPybricksDialog/actions.ts b/src/firmware/installPybricksDialog/actions.ts new file mode 100644 index 00000000..6653de68 --- /dev/null +++ b/src/firmware/installPybricksDialog/actions.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { createAction } from '../../actions'; + +/** Actions that request the install Pybricks firmware dialog to be shown. */ +export const firmwareInstallPybricksDialogShow = createAction(() => ({ + type: 'firmware.installPybricksDialog.action.show', +})); + +type FlashMethod = 'ble-lwp3-bootloader' | 'usb-lego-dfu'; + +/** + * Action that indicates the user accepted the install Pybricks firmware dialog. + * @param flashMethod The connection method and protocol used for flashing. + * @param firmwareZip The firmware.zip raw data. + * @param customProgram Optional path of custom program to include when flashing firmware. + * @param hubName The hub name to use when flashing firmware. + */ +export const firmwareInstallPybricksDialogAccept = createAction( + ( + flashMethod: FlashMethod, + firmwareZip: ArrayBuffer, + customProgram: string | undefined, + hubName: string, + ) => ({ + type: 'firmware.installPybricksDialog.action.accept', + flashMethod, + firmwareZip, + customProgram, + hubName, + }), +); + +/** Actions that indicates the user canceled the install Pybricks firmware dialog. */ +export const firmwareInstallPybricksDialogCancel = createAction(() => ({ + type: 'firmware.installPybricksDialog.action.cancel', +})); diff --git a/src/firmware/installPybricksDialog/hooks.ts b/src/firmware/installPybricksDialog/hooks.ts new file mode 100644 index 00000000..42535940 --- /dev/null +++ b/src/firmware/installPybricksDialog/hooks.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors +// based on https://usehooks-ts.com/react-hook/use-fetch + +import { FirmwareReader } from '@pybricks/firmware'; +import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; +import essentialHubZip from '@pybricks/firmware/build/essentialhub.zip'; +import moveHubZip from '@pybricks/firmware/build/movehub.zip'; +import primeHubZip from '@pybricks/firmware/build/primehub.zip'; +import technicHubZip from '@pybricks/firmware/build/technichub.zip'; +import { useEffect, useReducer, useRef } from 'react'; +import { Hub } from '../../components/hubPicker'; + +type FirmwareData = { + firmwareZip: ArrayBuffer; + licenseText: string; +}; + +interface State { + /** The firmware.zip data or undefined if `fetch()` is not complete or on error. */ + data?: FirmwareData; + /** Undefined `fetch()` is not complete yet or was successful, otherwise the error. */ + error?: Error; +} + +type Cache = { [url: string]: FirmwareData }; + +// discriminated union type +type Action = + | { type: 'loading' } + | { type: 'fetched'; payload: FirmwareData } + | { type: 'error'; payload: Error }; + +const firmwareZipMap = new Map([ + [Hub.Move, moveHubZip], + [Hub.City, cityHubZip], + [Hub.Technic, technicHubZip], + [Hub.Prime, primeHubZip], + [Hub.Essential, essentialHubZip], + [Hub.Inventor, primeHubZip], +]); + +/** + * Gets Pybricks firmware .zip file for the specified hub type. + * @param hubType The hub type. + * @returns The current state. + */ +export function useFirmware(hubType: Hub): State { + const url = firmwareZipMap.get(hubType); + const cache = useRef({}); + + // Used to prevent state update if the component is unmounted + const cancelRequest = useRef(false); + + const initialState: State = { + error: undefined, + data: undefined, + }; + + // Keep state logic separated + const fetchReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'loading': + return { ...initialState }; + case 'fetched': + return { ...initialState, data: action.payload }; + case 'error': + return { ...initialState, error: action.payload }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(fetchReducer, initialState); + + useEffect(() => { + // Do nothing if the url is not given + if (!url) { + return; + } + + cancelRequest.current = false; + + const fetchData = async () => { + dispatch({ type: 'loading' }); + + // If a cache exists for this url, return it + if (cache.current[url]) { + dispatch({ type: 'fetched', payload: cache.current[url] }); + return; + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.statusText); + } + + const firmwareZip = await response.arrayBuffer(); + const reader = await FirmwareReader.load(firmwareZip); + const licenseText = await reader.readReadMeOss(); + const data = { firmwareZip, licenseText }; + + cache.current[url] = data; + if (cancelRequest.current) { + return; + } + + dispatch({ type: 'fetched', payload: data }); + } catch (error) { + if (process.env.NODE_ENV !== 'test') { + console.error(error); + } + + if (cancelRequest.current) { + return; + } + + dispatch({ type: 'error', payload: error as Error }); + } + }; + + void fetchData(); + + // Use the cleanup function for avoiding a possible + // state update after the component was unmounted + return () => { + cancelRequest.current = true; + }; + }, [url]); + + return state; +} diff --git a/src/firmware/installPybricksDialog/i18n.en.test.ts b/src/firmware/installPybricksDialog/i18n.en.test.ts new file mode 100644 index 00000000..3b098b20 --- /dev/null +++ b/src/firmware/installPybricksDialog/i18n.en.test.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021-2022 The Pybricks Authors + +import { lookup } from '../../../test'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; + +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { + expect(lookup(en, id)).toBeDefined(); + }); +}); diff --git a/src/firmware/installPybricksDialog/i18n.ts b/src/firmware/installPybricksDialog/i18n.ts new file mode 100644 index 00000000..d7a31471 --- /dev/null +++ b/src/firmware/installPybricksDialog/i18n.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021-2022 The Pybricks Authors +// +// Settings translation keys. + +import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n'; + +export function useI18n(): I18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} + +export enum I18nId { + Title = 'title', + SelectHubPanelTitle = 'selectHubPanel.title', + SelectHubPanelMessage = 'selectHubPanel.message', + SelectHubPanelNotOnListButtonLabel = 'selectHubPanel.notOnListButton.label', + SelectHubPanelNotOnListButtonInfoMindstormsTitle = 'selectHubPanel.notOnListButton.info.mindstorms.title', + SelectHubPanelNotOnListButtonInfoMindstormsRcx = 'selectHubPanel.notOnListButton.info.mindstorms.rcx', + SelectHubPanelNotOnListButtonInfoMindstormsNxt = 'selectHubPanel.notOnListButton.info.mindstorms.nxt', + SelectHubPanelNotOnListButtonInfoMindstormsEv3 = 'selectHubPanel.notOnListButton.info.mindstorms.ev3', + SelectHubPanelNotOnListButtonInfoPoweredUpTitle = 'selectHubPanel.notOnListButton.info.poweredUp.title', + SelectHubPanelNotOnListButtonInfoPoweredUpWedo2 = 'selectHubPanel.notOnListButton.info.poweredUp.wedo2', + SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain = 'selectHubPanel.notOnListButton.info.poweredUp.duploTrain', + SelectHubPanelNotOnListButtonInfoPoweredUpMario = 'selectHubPanel.notOnListButton.info.poweredUp.mario', + SelectHubPanelNotOnListButtonInfoPoweredUpFootnote = 'selectHubPanel.notOnListButton.info.poweredUp.footnote', + LicensePanelTitle = 'licensePanel.title', + LicensePanelLicenseTextError = 'licensePanel.licenseText.error', + LicensePanelAcceptCheckboxLabel = 'licensePanel.acceptCheckbox.label', + OptionsPanelTitle = 'optionsPanel.title', + OptionsPanelHubNameLabel = 'optionsPanel.hubName.label', + OptionsPanelHubNameLabelInfo = 'optionsPanel.hubName.labelInfo', + OptionsPanelHubNameHelp = 'optionsPanel.hubName.help', + OptionsPanelHubNameError = 'optionsPanel.hubName.error', + OptionsPanelCustomMainLabel = 'optionsPanel.customMain.label', + OptionsPanelCustomMainLabelInfo = 'optionsPanel.customMain.labelInfo', + OptionsPanelCustomMainNotApplicableMessage = 'optionsPanel.customMain.notApplicable.message', + OptionsPanelCustomMainIncludeLabel = 'optionsPanel.customMain.include.label', + OptionsPanelCustomMainIncludeNoSelection = 'optionsPanel.customMain.include.noSelection', + OptionsPanelCustomMainIncludeNoFiles = 'optionsPanel.customMain.include.noFiles', + OptionsPanelCustomMainIncludeHelp = 'optionsPanel.customMain.include.help', + BootloaderPanelTitle = 'bootloaderPanel.title', + BootloaderPanelInstruction1 = 'bootloaderPanel.instruction1', + BootloaderPanelButtonBluetooth = 'bootloaderPanel.button.bluetooth', + BootloaderPanelButtonPower = 'bootloaderPanel.button.power', + BootloaderPanelLightBluetooth = 'bootloaderPanel.light.bluetooth', + BootloaderPanelLightStatus = 'bootloaderPanel.light.status', + BootloaderPanelLightPatternBluetooth = 'bootloaderPanel.lightPattern.bluetooth', + BootloaderPanelLightPatternStatus = 'bootloaderPanel.lightPattern.status', + BootloaderPanelStepDisconnectUsb = 'bootloaderPanel.step.disconnectUsb', + BootloaderPanelStepPowerOff = 'bootloaderPanel.step.powerOff', + BootloaderPanelStepDisconnectIo = 'bootloaderPanel.step.disconnectIo', + BootloaderPanelStepHoldButton = 'bootloaderPanel.step.holdButton', + BootloaderPanelStepConnectUsb = 'bootloaderPanel.step.connectUsb', + BootloaderPanelStepWaitForLight = 'bootloaderPanel.step.waitForLight', + BootloaderPanelStepReleaseButton = 'bootloaderPanel.step.releaseButton', + BootloaderPanelStepKeepHolding = 'bootloaderPanel.step.keepHolding', + BootloaderPanelInstruction2 = 'bootloaderPanel.instruction2', + NextButtonLabel = 'nextButton.label', + BackButtonLabel = 'backButton.label', + FlashFirmwareButtonLabel = 'flashFirmwareButton.label', +} diff --git a/src/firmware/installPybricksDialog/index.ts b/src/firmware/installPybricksDialog/index.ts new file mode 100644 index 00000000..2323fb34 --- /dev/null +++ b/src/firmware/installPybricksDialog/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +const encoder = new TextEncoder(); + +/** + * Validates the hub name. + * @param hubName The hub name. + * @returns True if the name if valid, otherwise false. + */ +export function validateHubName(hubName: string): boolean { + const encoded = encoder.encode(hubName); + + // Technically, the max hub name size is determined by each individual + // firmware file, so we can't check until the firmware has been selected. + // However all firmware currently have 16 bytes allocated (including zero- + // termination), so we can hard code the check here to allow notifying the + // user earlier for better UX. + return encoded.length < 16; +} diff --git a/src/firmware/installPybricksDialog/installPybricksDialog.scss b/src/firmware/installPybricksDialog/installPybricksDialog.scss new file mode 100644 index 00000000..a031effc --- /dev/null +++ b/src/firmware/installPybricksDialog/installPybricksDialog.scss @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +@use '@blueprintjs/core/lib/scss/variables' as bp; + +.pb-firmware-installPybricksDialog { + &-body { + min-height: bp.$pt-grid-size * 25; + } + + &-license { + display: flex; + flex-direction: column; + gap: bp.$pt-grid-size; + min-height: inherit; + + &-text { + flex-grow: 1; + min-height: 0; + max-height: bp.$pt-grid-size * 20; + overflow: auto; + + & .#{bp.$ns}-non-ideal-state { + min-height: bp.$pt-grid-size * 20; + } + } + } +} diff --git a/src/firmware/installPybricksDialog/reducers.ts b/src/firmware/installPybricksDialog/reducers.ts new file mode 100644 index 00000000..afa2694a --- /dev/null +++ b/src/firmware/installPybricksDialog/reducers.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Reducer, combineReducers } from '@reduxjs/toolkit'; +import { + firmwareInstallPybricksDialogAccept, + firmwareInstallPybricksDialogCancel, + firmwareInstallPybricksDialogShow, +} from './actions'; + +/** Controls the flash Pybricks firmware dialog open state. */ +const isOpen: Reducer = (state = false, action) => { + if (firmwareInstallPybricksDialogShow.matches(action)) { + return true; + } + + if (firmwareInstallPybricksDialogAccept.matches(action)) { + return false; + } + + if (firmwareInstallPybricksDialogCancel.matches(action)) { + return false; + } + + return state; +}; + +export default combineReducers({ isOpen }); diff --git a/src/firmware/installPybricksDialog/translations/en.json b/src/firmware/installPybricksDialog/translations/en.json new file mode 100644 index 00000000..4da3c19d --- /dev/null +++ b/src/firmware/installPybricksDialog/translations/en.json @@ -0,0 +1,92 @@ +{ + "title": "Install Pybricks Firmware", + "selectHubPanel": { + "title": "Select hub type", + "message": "Which kind of hub do you want to use?", + "notOnListButton": { + "label": "My hub is not in the list.", + "info": { + "mindstorms": { + "title": "MINDSTORMS Programmable Bricks", + "rcx": "RCX - not enough memory to run Pybricks", + "nxt": "NXT - maybe some day", + "ev3": "EV3 - supported using VS Code instead of Pybricks Code" + }, + "poweredUp": { + "title": "Unsupported Powered Up Hubs", + "wedo2": "WeDo 2.0 Smart hub", + "duploTrain": "Duplo Train hub", + "mario": "Mario/Luigi/Peach", + "footnote": "firmware cannot be updated" + } + } + } + }, + "licensePanel": { + "title": "Accept licenses", + "licenseText": { + "error": "There was a problem while getting the firmware file." + }, + "acceptCheckbox": { + "label": "I have read and agree to the license terms and conditions." + } + }, + "optionsPanel": { + "title": "Configure options", + "hubName": { + "label": "Hub name", + "labelInfo": "(optional)", + "help": "Enter a name here to customize the hub name when flashing the firmware. This name will be used in the Bluetooth advertising data and can be used to identify the hub when connecting.", + "error": "The name is too long." + }, + "customMain": { + "label": "Include custom program", + "labelInfo": "(optional)", + "notApplicable": { + "message": "This hub has external flash memory so including a custom program when flashing firmware is not needed." + }, + "include": { + "label": "Include selected program as {main}", + "noSelection": "(no selection)", + "noFiles": "(no files)", + "help": "Enable to include your program when flashing the firmware or disable to use the default program. Flashing your program along with the firmware will allow you to run your program without being connected to {appName}" + } + } + }, + "bootloaderPanel": { + "title": "Place hub in bootloader mode", + "instruction1": "To flash the firmware, the hub must be placed in bootloader mode. Follow the steps below to do this:", + "button": { + "bluetooth": "Bluetooth button", + "power": "power button" + }, + "light": { + "bluetooth": "Bluetooth light", + "status": "hub status light" + }, + "lightPattern": { + "bluetooth": "pink-green-blue-off", + "status": "light purple" + }, + "step": { + "disconnectUsb": "Disconnect the USB cable from the hub.", + "powerOff": "Turn off the hub.", + "disconnectIo": "Disconnect all motors and sensors from the I/O ports on the hub.", + "holdButton": "Press and hold the {button} on the hub.", + "connectUsb": "Connect the USB cable.", + "waitForLight": "Keep holding the {button} and wait for the {light} to start flashing {lightPattern}. This takes about 5 seconds.", + "releaseButton": "Release the {button}", + "keepHolding": "Keep holding the {button}." + }, + "instruction2": "Then click the {flashFirmware} button below to connect to the hub and flash the firmware." + }, + "backButton": { + "label": "Back" + }, + "nextButton": { + "label": "Next" + }, + "flashFirmwareButton": { + "label": "Flash Firmware" + } +} diff --git a/src/firmware/reducers.test.ts b/src/firmware/reducers.test.ts index a910f8e0..c264aa26 100644 --- a/src/firmware/reducers.test.ts +++ b/src/firmware/reducers.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { AnyAction } from 'redux'; import { @@ -17,6 +17,9 @@ test('initial state', () => { expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "flashing": false, + "installPybricksDialog": Object { + "isOpen": false, + }, "progress": null, } `); diff --git a/src/firmware/reducers.ts b/src/firmware/reducers.ts index 97bd6c12..19ad63c1 100644 --- a/src/firmware/reducers.ts +++ b/src/firmware/reducers.ts @@ -3,6 +3,7 @@ import { Reducer, combineReducers } from 'redux'; import { didFailToFinish, didFinish, didProgress, didStart } from './actions'; +import installPybricksDialog from './installPybricksDialog/reducers'; const flashing: Reducer = (state = false, action) => { if (didStart.matches(action)) { @@ -28,4 +29,4 @@ const progress: Reducer = (state = null, action) => { return state; }; -export default combineReducers({ flashing, progress }); +export default combineReducers({ installPybricksDialog, flashing, progress }); diff --git a/src/firmware/sagas.test.ts b/src/firmware/sagas.test.ts index 0c55fa8e..5d0a4ba6 100644 --- a/src/firmware/sagas.test.ts +++ b/src/firmware/sagas.test.ts @@ -82,7 +82,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, 'test name')); + saga.put(flashFirmwareAction(null, undefined, 'test name')); // first step is to connect to the hub bootloader @@ -228,7 +228,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -277,7 +277,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -344,7 +344,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -407,7 +407,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -473,7 +473,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -532,7 +532,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -597,7 +597,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -679,7 +679,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -744,7 +744,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -839,7 +839,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -943,7 +943,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -1087,7 +1087,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader @@ -1232,7 +1232,7 @@ describe('flashFirmware', () => { saga.put( flashFirmwareAction( await zip.generateAsync({ type: 'arraybuffer' }), - false, + undefined, '', ), ); @@ -1381,7 +1381,7 @@ describe('flashFirmware', () => { saga.put( flashFirmwareAction( await zip.generateAsync({ type: 'arraybuffer' }), - false, + undefined, '', ), ); @@ -1428,7 +1428,7 @@ describe('flashFirmware', () => { saga.put( flashFirmwareAction( await zip.generateAsync({ type: 'arraybuffer' }), - false, + undefined, '', ), ); @@ -1474,7 +1474,7 @@ describe('flashFirmware', () => { saga.put( flashFirmwareAction( await zip.generateAsync({ type: 'arraybuffer' }), - false, + undefined, '', ), ); @@ -1534,7 +1534,7 @@ describe('flashFirmware', () => { saga.put( flashFirmwareAction( await zip.generateAsync({ type: 'arraybuffer' }), - false, + undefined, '', ), ); @@ -1595,7 +1595,7 @@ describe('flashFirmware', () => { saga.put( flashFirmwareAction( await zip.generateAsync({ type: 'arraybuffer' }), - false, + undefined, '', ), ); @@ -1659,7 +1659,7 @@ describe('flashFirmware', () => { saga.put( flashFirmwareAction( await zip.generateAsync({ type: 'arraybuffer' }), - false, + undefined, '', ), ); @@ -1745,7 +1745,7 @@ describe('flashFirmware', () => { // saga is triggered by this action - saga.put(flashFirmwareAction(null, false, '')); + saga.put(flashFirmwareAction(null, undefined, '')); // first step is to connect to the hub bootloader diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 878e047c..42cfd915 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -10,6 +10,7 @@ import { import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; import moveHubZip from '@pybricks/firmware/build/movehub.zip'; import technicHubZip from '@pybricks/firmware/build/technichub.zip'; +import { WebDFU } from 'dfu'; import { AnyAction } from 'redux'; import { ActionPattern } from 'redux-saga/effects'; import { @@ -25,7 +26,12 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; -import { editorGetValue } from '../editor/sagas'; +import { alertsShowAlert } from '../alerts/actions'; +import { + fileStorageDidFailToReadFile, + fileStorageDidReadFile, + fileStorageReadFile, +} from '../fileStorage/actions'; import { checksumRequest, checksumResponse, @@ -51,8 +57,9 @@ import { MaxProgramFlashSize, Result } from '../lwp3-bootloader/protocol'; import { BootloaderConnectionState } from '../lwp3-bootloader/reducers'; import { compile, didCompile, didFailToCompile } from '../mpy/actions'; import { RootState } from '../reducers'; +import { LegoUsbProductId, legoUsbVendorId } from '../usb'; import { defined, ensureError, hex, maybe } from '../utils'; -import { fmod, sumComplement32 } from '../utils/math'; +import { crc32, fmod, sumComplement32 } from '../utils/math'; import { isAndroid } from '../utils/os'; import { FailToFinishReasonType, @@ -62,8 +69,17 @@ import { didFinish, didProgress, didStart, + firmwareDidFailToFlashUsbDfu, + firmwareDidFlashUsbDfu, + firmwareFlashUsbDfu, + firmwareInstallPybricks, flashFirmware, } from './actions'; +import { + firmwareInstallPybricksDialogAccept, + firmwareInstallPybricksDialogCancel, + firmwareInstallPybricksDialogShow, +} from './installPybricksDialog/actions'; const firmwareZipMap = new Map([ [HubType.CityHub, cityHubZip], @@ -183,7 +199,12 @@ function* loadFirmware( } else { yield* put(didFailToFinish(FailToFinishReasonType.Unknown, readerErr)); } + + // FIXME: we should return error/throw instead yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); } defined(reader); @@ -191,11 +212,17 @@ function* loadFirmware( const firmwareBase = yield* call(() => reader.readFirmwareBase()); const metadata = yield* call(() => reader.readMetadata()); - // if a user program was not given, then use main.py from the frimware.zip + // if a user program was not given, then use main.py from the firmware.zip if (program === undefined) { program = yield* call(() => reader.readMainPy()); } + // REVISIT: the firmware may eventually be changed to allow no main.py + // for now, ensure there is a program even if it does nothing + if (!program) { + program = ''; + } + if (![5, 6].includes(metadata['mpy-abi-version'])) { yield* put( didFailToFinish( @@ -204,7 +231,12 @@ function* loadFirmware( MetadataProblem.NotSupported, ), ); + + // FIXME: we should return error/throw instead yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); } yield* put( @@ -216,8 +248,12 @@ function* loadFirmware( }); if (mpyFail) { + // FIXME: we should return error/throw instead yield* put(didFailToFinish(FailToFinishReasonType.FailedToCompile)); yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); } defined(mpy); @@ -230,8 +266,12 @@ function* loadFirmware( const firmwareView = new DataView(firmware.buffer); if (firmware.length > metadata['max-firmware-size']) { + // FIXME: we should return error/throw instead yield* put(didFailToFinish(FailToFinishReasonType.FirmwareSize)); yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); } firmware.set(firmwareBase); @@ -246,7 +286,23 @@ function* loadFirmware( } } - if (metadata['checksum-type'] !== 'sum') { + const checksum = (function () { + switch (metadata['checksum-type']) { + case 'sum': + return sumComplement32( + firmwareIterator(firmwareView, metadata['max-firmware-size']), + ); + case 'crc32': + return crc32( + firmwareIterator(firmwareView, metadata['max-firmware-size']), + ); + default: + return undefined; + } + })(); + + if (!checksum) { + // FIXME: we should return error/throw instead yield* put( didFailToFinish( FailToFinishReasonType.BadMetadata, @@ -255,11 +311,10 @@ function* loadFirmware( ), ); yield* disconnectAndCancel(); - } - const checksum = sumComplement32( - firmwareIterator(firmwareView, metadata['max-firmware-size']), - ); + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); + } firmwareView.setUint32(checksumOffset, checksum, true); @@ -277,8 +332,27 @@ function* handleFlashFirmware(action: ReturnType): Generat let program: string | undefined = undefined; - if (action.flashCurrentProgram) { - program = yield* editorGetValue(); + if (action.customProgram) { + yield* put(fileStorageReadFile(action.customProgram)); + + const { didRead, didFailToRead } = yield* race({ + didRead: take( + fileStorageDidReadFile.when((a) => a.path === action.customProgram), + ), + didFailToRead: take( + fileStorageDidFailToReadFile.when( + (a) => a.path === action.customProgram, + ), + ), + }); + + if (didFailToRead) { + throw didFailToRead.error; + } + + defined(didRead); + + program = didRead.contents; } if (action.data !== null) { @@ -477,6 +551,170 @@ function* handleFlashFirmware(action: ReturnType): Generat } } +/** Maps USB Product ID to LWP3 hub type ID */ +const productIdMap: ReadonlyMap = new Map([ + [LegoUsbProductId.SpikePrimeBootloader, HubType.PrimeHub], + [LegoUsbProductId.SpikeEssentialBootloader, HubType.EssentialHub], + [LegoUsbProductId.MindstormsRobotInventorBootloader, HubType.PrimeHub], +]); + +// currently all hubs use the same start address +const dfuFirmwareStartAddress = 0x08008000; + +function* handleFlashUsbDfu(action: ReturnType): Generator { + const defer = new Array<() => void>(); + + try { + // not all web browsers support Web USB + if (!navigator.usb) { + yield* put(alertsShowAlert('firmware', 'noWebUsb')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + const device = yield* call(() => + navigator.usb + .requestDevice({ + filters: [ + { + vendorId: legoUsbVendorId, + productId: LegoUsbProductId.SpikePrimeBootloader, + }, + { + vendorId: legoUsbVendorId, + productId: LegoUsbProductId.SpikeEssentialBootloader, + }, + { + vendorId: legoUsbVendorId, + productId: + LegoUsbProductId.MindstormsRobotInventorBootloader, + }, + ], + }) + .catch((err) => { + if ( + err instanceof DOMException && + err.code === DOMException.NOT_FOUND_ERR + ) { + // user clicked cancel button + return undefined; + } + + throw err; + }), + ); + + if (!device) { + yield* put(alertsShowAlert('firmware', 'noDfuHub')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + const dfu = new WebDFU( + device, + // forceInterfacesName is needed to get the flash layout map + { forceInterfacesName: true }, + { + info: console.debug, + warning: console.warn, + progress: (progress, total) => { + // TODO: bind to eventChannel and dispatch progress actions + console.log(progress, total); + }, + }, + ); + + yield* call(() => dfu.init()); + + // we want the interface with alt=0 + const ifaceIndex = dfu.interfaces.findIndex( + (i) => i.alternate.alternateSetting === 0, + ); + + if (ifaceIndex === -1) { + yield* put(alertsShowAlert('firmware', 'noDfuInterface')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + yield* call(() => dfu.connect(ifaceIndex)); + + defer.push(() => dfu.close()); + + const { firmware, deviceId } = yield* loadFirmware( + action.data, + undefined, + action.hubName, + ); + + if (deviceId !== productIdMap.get(device.productId)) { + yield* put(alertsShowAlert('firmware', 'firmwareMismatch')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + dfu.dfuseStartAddress = dfuFirmwareStartAddress; + const writeProc = dfu.write(1024, firmware, true); + + writeProc.events.on('error', console.error); + + // REVISIT: we could possibly race the 'write/end' and 'error' events + // here instead of waiting for disconnect + + // this is a bit of a hack, but the hub resets when flashing is done + // so we get a disconnect event unless there was an error, so the user + // will probably see the timeout error instead of the underlying error + yield* call(() => dfu.waitDisconnected(30000)); + + yield* put(firmwareDidFlashUsbDfu()); + } catch (err) { + if (process.env.NODE_ENV !== 'test') { + console.error(err); + } + + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { error: ensureError(err) }), + ); + + yield* put(firmwareDidFailToFlashUsbDfu()); + } finally { + while (defer.length !== 0) { + defer.pop()?.(); + } + } +} + +function* handleInstallPybricks(): Generator { + yield* put(firmwareInstallPybricksDialogShow()); + const { accepted, canceled } = yield* race({ + accepted: take(firmwareInstallPybricksDialogAccept), + canceled: take(firmwareInstallPybricksDialogCancel), + }); + + if (canceled) { + return; + } + + defined(accepted); + + switch (accepted.flashMethod) { + case 'ble-lwp3-bootloader': + yield* put( + flashFirmware( + accepted.firmwareZip, + accepted.customProgram, + accepted.hubName, + ), + ); + break; + case 'usb-lego-dfu': + yield* put(firmwareFlashUsbDfu(accepted.firmwareZip, accepted.hubName)); + break; + } +} + export default function* (): Generator { yield* takeEvery(flashFirmware, handleFlashFirmware); + yield* takeEvery(firmwareFlashUsbDfu, handleFlashUsbDfu); + yield* takeEvery(firmwareInstallPybricks, handleInstallPybricks); } diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index 21526eb8..d590b1b6 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -4,7 +4,7 @@ import { AnyAction } from 'redux'; import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; -import { didConnect, didDisconnect } from '../ble/actions'; +import { bleDidConnectPybricks, bleDidDisconnectPybricks } from '../ble/actions'; import { didFailToFinishDownload, didFinishDownload, @@ -30,7 +30,7 @@ describe('runtime', () => { expect( reducers( { runtime: HubRuntimeState.Disconnected } as State, - didConnect('test-id', 'Test Name'), + bleDidConnectPybricks('test-id', 'Test Name'), ).runtime, ).toBe(HubRuntimeState.Unknown); }); @@ -38,7 +38,8 @@ describe('runtime', () => { test.each(Object.values(HubRuntimeState))('didDisconnect', (startingState) => { // all states are overridden by disconnect expect( - reducers({ runtime: startingState } as State, didDisconnect()).runtime, + reducers({ runtime: startingState } as State, bleDidDisconnectPybricks()) + .runtime, ).toBe(HubRuntimeState.Disconnected); }); diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index c0b4ef52..16986c61 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -6,7 +6,7 @@ import * as semver from 'semver'; import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; -import { didConnect, didDisconnect } from '../ble/actions'; +import { bleDidConnectPybricks, bleDidDisconnectPybricks } from '../ble/actions'; import { pythonVersionToSemver } from '../utils/version'; import { didFailToFinishDownload, @@ -49,11 +49,11 @@ const runtime: Reducer = ( state = HubRuntimeState.Disconnected, action, ) => { - if (didConnect.matches(action)) { + if (bleDidConnectPybricks.matches(action)) { return HubRuntimeState.Unknown; } - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return HubRuntimeState.Disconnected; } diff --git a/src/hub/sagas.ts b/src/hub/sagas.ts index f2013b10..0cb471d8 100644 --- a/src/hub/sagas.ts +++ b/src/hub/sagas.ts @@ -12,13 +12,13 @@ import { takeEvery, } from 'typed-redux-saga/macro'; import { didFailToWrite, didWrite, write } from '../ble-nordic-uart-service/actions'; -import { SafeTxCharLength } from '../ble-nordic-uart-service/protocol'; +import { nordicUartSafeTxCharLength } from '../ble-nordic-uart-service/protocol'; import { didFailToSendCommand, didSendCommand, sendStopUserProgramCommand, } from '../ble-pybricks-service/actions'; -import { didConnect } from '../ble/actions'; +import { bleDidConnectPybricks } from '../ble/actions'; import { editorGetValue } from '../editor/sagas'; import { compile, didCompile, didFailToCompile } from '../mpy/actions'; import { defined } from '../utils'; @@ -121,10 +121,10 @@ function* handleDownloadAndRun(action: ReturnType): Gener const chunk = mpy.data.slice(i, i + downloadChunkSize); // we can actually only write 20 bytes at a time - for (let j = 0; j < chunk.length; j += SafeTxCharLength) { + for (let j = 0; j < chunk.length; j += nordicUartSafeTxCharLength) { yield* put(didProgressDownload((i + j) / mpy.data.byteLength)); const writeAction = yield* put( - write(nextMessageId(), chunk.slice(j, j + SafeTxCharLength)), + write(nextMessageId(), chunk.slice(j, j + nordicUartSafeTxCharLength)), ); const { didFailToWrite } = yield* waitForWrite(writeAction.id); @@ -190,5 +190,5 @@ export default function* (): Generator { yield* takeEvery(repl, handleRepl); yield* takeEvery(stop, handleStop); // calling stop right after connecting should get the hub into a known state - yield* takeEvery(didConnect, handleStop); + yield* takeEvery(bleDidConnectPybricks, handleStop); } diff --git a/src/index.scss b/src/index.scss index c5db83a0..b7953d70 100644 --- a/src/index.scss +++ b/src/index.scss @@ -118,6 +118,10 @@ a.#{bp.$ns}-button { } } +.#{bp.$ns}-control-group { + gap: bp.$pt-grid-size * 0.5; +} + .#{bp.$ns}-form-group > .#{bp.$ns}-label { font-weight: bolder; } diff --git a/src/lwp3-bootloader/protocol.ts b/src/lwp3-bootloader/protocol.ts index 107bc47e..431377d9 100644 --- a/src/lwp3-bootloader/protocol.ts +++ b/src/lwp3-bootloader/protocol.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // Ref: https://lego.github.io/lego-ble-wireless-protocol-docs/index.html#lego-hub-boot-loader-service @@ -8,12 +8,12 @@ import { assert, hex } from '../utils'; /** * LEGO Powered Up Bootloader Service UUID. */ -export const ServiceUUID = '00001625-1212-efde-1623-785feabcd123'; +export const lwp3BootloaderServiceUUID = '00001625-1212-efde-1623-785feabcd123'; /** * LEGO Powered Up Bootloader Characteristic UUID. */ -export const CharacteristicUUID = '00001626-1212-efde-1623-785feabcd123'; +export const lwp3BootloaderCharacteristicUUID = '00001626-1212-efde-1623-785feabcd123'; /** * The maximum message size that can be sent or received. diff --git a/src/lwp3-bootloader/sagas-ble.ts b/src/lwp3-bootloader/sagas-ble.ts index 8b384736..6760ebe5 100644 --- a/src/lwp3-bootloader/sagas-ble.ts +++ b/src/lwp3-bootloader/sagas-ble.ts @@ -4,7 +4,16 @@ // Handles Bluetooth Low Energy connection to LEGO Wireless Protocol v3 Bootloader service. import { END, eventChannel } from 'redux-saga'; -import { call, cancel, put, spawn, takeEvery, takeMaybe } from 'typed-redux-saga/macro'; +import { + call, + cancel, + delay, + put, + spawn, + takeEvery, + takeMaybe, +} from 'typed-redux-saga/macro'; +import { alertsShowAlert } from '../alerts/actions'; import { ensureError } from '../utils'; import { BootloaderConnectionFailureReason as Reason, @@ -18,7 +27,10 @@ import { disconnect, send, } from './actions'; -import { CharacteristicUUID, ServiceUUID } from './protocol'; +import { + lwp3BootloaderCharacteristicUUID, + lwp3BootloaderServiceUUID, +} from './protocol'; function* handleNotify(data: DataView): Generator { yield* put(didReceive(data)); @@ -42,12 +54,14 @@ function* write( function* handleConnect(): Generator { if (navigator.bluetooth === undefined) { + yield* put(alertsShowAlert('ble', 'noWebBluetooth')); yield* put(didFailToConnect(Reason.NoWebBluetooth)); return; } const available = yield* call(() => navigator.bluetooth.getAvailability()); if (!available) { + yield* put(alertsShowAlert('ble', 'bluetoothNotAvailable')); yield* put(didFailToConnect(Reason.NoBluetooth)); return; } @@ -56,8 +70,8 @@ function* handleConnect(): Generator { try { device = yield* call(() => navigator.bluetooth.requestDevice({ - filters: [{ services: [ServiceUUID] }], - optionalServices: [ServiceUUID], + filters: [{ services: [lwp3BootloaderServiceUUID] }], + optionalServices: [lwp3BootloaderServiceUUID], }), ); } catch (err) { @@ -93,15 +107,19 @@ function* handleConnect(): Generator { return; } + // istanbul ignore if + if (process.env.NODE_ENV !== 'test') { + // give OS Bluetooth stack some time to settle + yield* delay(1000); + } + let service: BluetoothRemoteGATTService; try { - service = yield* call([server, 'getPrimaryService'], ServiceUUID); + service = yield* call([server, 'getPrimaryService'], lwp3BootloaderServiceUUID); } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - // Possibly/probably caused by Chrome BlueZ back-end bug - // https://chromium-review.googlesource.com/c/chromium/src/+/2214098 yield* put(didFailToConnect(Reason.GattServiceNotFound)); } else { yield* put(didFailToConnect(Reason.Unknown, ensureError(err))); @@ -113,7 +131,7 @@ function* handleConnect(): Generator { try { characteristic = yield* call( [service, 'getCharacteristic'], - CharacteristicUUID, + lwp3BootloaderCharacteristicUUID, ); } catch (err) { server.disconnect(); diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index d7dea7f7..eceeb4b2 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -14,10 +14,7 @@ export function useI18n(): I18n { export enum I18nId { AppNoUpdateFound = 'app.noUpdateFound', BleUnexpectedError = 'ble.unexpectedError', - BleGattPermission = 'ble.gattPermission', BleGattServiceNotFound = 'ble.gattServiceNotFound', - BleNoWebBluetooth = 'ble.noWebBluetooth', - BleNoBluetooth = 'ble.noBluetooth', EditorFailedToOpenFile = 'editor.failedToOpenFile', EditorFailedToSaveFile = 'editor.failedToSaveFile', ExplorerFailedToImportFiles = 'explorer.failedToImportFiles', @@ -42,5 +39,4 @@ export enum I18nId { ServiceWorkerUpdateMessage = 'serviceWorker.update.message', ServiceWorkerUpdateAction = 'serviceWorker.update.action', MpyError = 'mpy.error', - CheckFirmwareTooOld = 'check.firmwareTooOld', } diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index c3d08c18..4ad88de6 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -2,20 +2,11 @@ // Copyright (c) 2021-2022 The Pybricks Authors import { IToaster } from '@blueprintjs/core'; -import { - FirmwareReaderError, - FirmwareReaderErrorCode, - firmwareVersion, -} from '@pybricks/firmware'; +import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware'; import { I18nManager } from '@shopify/react-i18n'; import { AnyAction } from 'redux'; import { AsyncSaga, uuid } from '../../test'; import { appDidCheckForUpdate } from '../app/actions'; -import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; -import { - BleDeviceFailToConnectReasonType, - didFailToConnect as bleDidFailToConnect, -} from '../ble/actions'; import { editorDidFailToOpenFile } from '../editor/actions'; import { EditorError } from '../editor/error'; import { @@ -61,22 +52,9 @@ function createTestToasterSaga(): { toaster: IToaster; saga: AsyncSaga } { } test.each([ - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoWebBluetooth }), - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoBluetooth }), - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoGatt }), - bleDidFailToConnect({ - reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, - }), - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoPybricksService }), - bleDidFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Unknown, - err: { name: 'test', message: 'unknown' }, - }), bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Unknown, { message: 'test', }), - bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoWebBluetooth), - bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoBluetooth), bootloaderDidFailToConnect(BootloaderConnectionFailureReason.GattServiceNotFound), didFailToCompile(['reason']), add('warning', 'message'), @@ -108,7 +86,6 @@ test.each([ didFailToFinish(FailToFinishReasonType.FirmwareSize), didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')), appDidCheckForUpdate(false), - bleDIServiceDidReceiveFirmwareRevision('3.0.0'), fileStorageDidFailToInitialize(new Error('test error')), explorerDidFailToImportFiles(new Error('test error')), explorerDidFailToCreateNewFile(new Error('test error')), @@ -129,12 +106,12 @@ test.each([ }); test.each([ - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Canceled }), + bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoWebBluetooth), + bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoBluetooth), bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Canceled), didFailToFinish(FailToFinishReasonType.FailedToConnect), serviceWorkerDidSucceed(), appDidCheckForUpdate(true), - bleDIServiceDidReceiveFirmwareRevision(firmwareVersion), explorerDidFailToImportFiles(new DOMException('test message', 'AbortError')), explorerDidFailToCreateNewFile(new DOMException('test message', 'AbortError')), explorerDidFailToDuplicateFile( diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 5b4470f1..89a84665 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -4,20 +4,13 @@ // Saga for managing notifications (toasts) import { ActionProps, IToaster, IconName, Intent, LinkProps } from '@blueprintjs/core'; -import { firmwareVersion } from '@pybricks/firmware'; import { Replacements } from '@shopify/react-i18n'; import React from 'react'; import { channel } from 'redux-saga'; -import * as semver from 'semver'; import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro'; import { getAlertProps } from '../alerts'; import { appDidCheckForUpdate, appReload } from '../app/actions'; import { appName } from '../app/constants'; -import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; -import { - BleDeviceFailToConnectReasonType, - didFailToConnect as bleDeviceDidFailToConnect, -} from '../ble/actions'; import { editorDidFailToOpenFile } from '../editor/actions'; import { EditorError } from '../editor/error'; import { @@ -35,7 +28,6 @@ import { } from '../lwp3-bootloader/actions'; import { didCompile, didFailToCompile } from '../mpy/actions'; import { serviceWorkerDidUpdate } from '../service-worker/actions'; -import { pythonVersionToSemver } from '../utils/version'; import NotificationAction from './NotificationAction'; import NotificationMessage from './NotificationMessage'; import { add as addNotification } from './actions'; @@ -170,45 +162,6 @@ function* showUnexpectedError(messageId: I18nId, error: Error): Generator { ); } -function* showBleDeviceDidFailToConnectError( - action: ReturnType, -): Generator { - switch (action.reason) { - case BleDeviceFailToConnectReasonType.NoGatt: - yield* showSingleton(Level.Error, I18nId.BleGattPermission); - break; - - case BleDeviceFailToConnectReasonType.NoPybricksService: - yield* showSingleton(Level.Error, I18nId.BleGattServiceNotFound, { - serviceName: 'Pybricks', - hubName: 'Pybricks Hub', - }); - break; - case BleDeviceFailToConnectReasonType.NoDeviceInfoService: - yield* showSingleton(Level.Error, I18nId.BleGattServiceNotFound, { - serviceName: 'Device Information', - hubName: 'Pybricks Hub', - }); - break; - case BleDeviceFailToConnectReasonType.NoBluetooth: - yield* showSingleton(Level.Error, I18nId.BleNoBluetooth); - break; - case BleDeviceFailToConnectReasonType.NoWebBluetooth: - yield* showSingleton( - Level.Error, - I18nId.BleNoWebBluetooth, - undefined, - helpAction( - 'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md', - ), - ); - break; - case BleDeviceFailToConnectReasonType.Unknown: - yield* showUnexpectedError(I18nId.BleUnexpectedError, action.err); - break; - } -} - function* showBootloaderDidFailToConnectError( action: ReturnType, ): Generator { @@ -219,19 +172,6 @@ function* showBootloaderDidFailToConnectError( hubName: 'LEGO Bootloader', }); break; - case BootloaderConnectionFailureReason.NoWebBluetooth: - yield* showSingleton( - Level.Error, - I18nId.BleNoWebBluetooth, - undefined, - helpAction( - 'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md', - ), - ); - break; - case BootloaderConnectionFailureReason.NoBluetooth: - yield* showSingleton(Level.Error, I18nId.BleNoBluetooth); - break; case BootloaderConnectionFailureReason.Unknown: yield* showUnexpectedError(I18nId.BleUnexpectedError, action.err); break; @@ -365,21 +305,6 @@ function* showNoUpdateInfo(action: ReturnType): Gen }); } -function* checkVersion( - action: ReturnType, -): Generator { - // ensure the actual hub firmware version is the same as the shipped - // firmware version or newer - if ( - !semver.satisfies( - pythonVersionToSemver(action.version), - `>=${pythonVersionToSemver(firmwareVersion)}`, - ) - ) { - yield* showSingleton(Level.Error, I18nId.CheckFirmwareTooOld); - } -} - function* showFileStorageFailToInitialize( action: ReturnType, ): Generator { @@ -454,7 +379,6 @@ function* showExplorerFailToDelete( } export default function* (): Generator { - yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError); yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError); yield* takeEvery(didFailToFinish, showFlashFirmwareError); yield* takeEvery(didCompile, dismissCompilerError); @@ -462,7 +386,6 @@ export default function* (): Generator { yield* takeEvery(addNotification, handleAddNotification); yield* takeEvery(serviceWorkerDidUpdate, showServiceWorkerUpdate); yield* takeEvery(appDidCheckForUpdate, showNoUpdateInfo); - yield* takeEvery(bleDIServiceDidReceiveFirmwareRevision, checkVersion); yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize); yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles); yield* takeEvery(explorerDidFailToCreateNewFile, showExplorerFailToCreateFile); diff --git a/src/notifications/translations/en.json b/src/notifications/translations/en.json index 15ed39f6..5e5de875 100644 --- a/src/notifications/translations/en.json +++ b/src/notifications/translations/en.json @@ -3,10 +3,7 @@ "noUpdateFound": "{appName} is already up to date." }, "ble": { - "gattPermission": "The web browser did not give permission to use Bluetooth Low Energy", "gattServiceNotFound": "Connected to hub but failed to get {serviceName} service.\nEnsure that you are using the most recent firmware.\nIf the problem persists, try removing the \"{hubName}\" device in your OS Bluetooth settings, then try connecting again.", - "noWebBluetooth": "This web browser does not support Web Bluetooth or it is not enabled.", - "noBluetooth": "No Bluetooth adapter could be found. Please connect or enable a Bluetooth Low Energy adapter and restart the browser.", "unexpectedError": "Unexpected error while trying to connect: {errorMessage}" }, "editor": { @@ -46,8 +43,5 @@ "message": "A new version of {appName} is available. Click {action} to start using the new version.", "action": "Restart" } - }, - "check": { - "firmwareTooOld": "A new firmware version is available for this hub. Please install the latest version to use all new features." } } diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 222dc10e..3366c193 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -3,6 +3,7 @@ /// /// +/// declare namespace NodeJS { interface ProcessEnv { diff --git a/src/settings/Settings.test.tsx b/src/settings/Settings.test.tsx index 59e2f381..5f19301d 100644 --- a/src/settings/Settings.test.tsx +++ b/src/settings/Settings.test.tsx @@ -4,6 +4,7 @@ import { cleanup, getByLabelText, waitFor } from '@testing-library/react'; import React from 'react'; import { testRender } from '../../test'; +import { firmwareInstallPybricks, firmwareRestoreLego } from '../firmware/actions'; import Settings from './Settings'; afterEach(() => { @@ -41,41 +42,27 @@ describe('darkMode setting switch', () => { }); }); -describe('flashCurrentProgram setting switch', () => { - it('should toggle the setting', async () => { - const [user, settings] = testRender(); +describe('firmware', () => { + it('should dispatch action when install Pybricks firmware button is clicked', async () => { + const [user, settings, dispatch] = testRender(); - expect(localStorage.getItem('setting.flashCurrentProgram')).toBe(null); + const button = settings.getByRole('button', { + name: 'Install Pybricks Firmware', + }); + await user.click(button); - await user.click(settings.getByLabelText('Include current program')); - expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('true'); - - await user.click(settings.getByLabelText('Include current program')); - expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('false'); - }); -}); - -describe('hubName setting', () => { - it('should migrate old settings', () => { - // old settings did not use json format, so lack quotes - localStorage.setItem('setting.hubName', 'old name'); - - const [, settings] = testRender(); - - const textBox = settings.getByLabelText('Hub name'); - - expect(textBox).toHaveValue('old name'); + expect(dispatch).toHaveBeenCalledWith(firmwareInstallPybricks()); }); - it('should update the setting', async () => { - const [user, settings] = testRender(); + it('should dispatch action when restore official LEGO firmware button is clicked', async () => { + const [user, settings, dispatch] = testRender(); - expect(localStorage.getItem('setting.hubName')).toBe(null); + const button = settings.getByRole('button', { + name: 'Restore Official LEGO® Firmware', + }); + await user.click(button); - const textBox = settings.getByLabelText('Hub name'); - await user.type(textBox, 'test name'); - - expect(localStorage.getItem('setting.hubName')).toBe('"test name"'); + expect(dispatch).toHaveBeenCalledWith(firmwareRestoreLego()); }); }); diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index ccd28ac9..67c5aca2 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -6,10 +6,6 @@ import { ButtonGroup, ControlGroup, FormGroup, - Icon, - InputGroup, - Intent, - Label, Switch, } from '@blueprintjs/core'; import React, { useState } from 'react'; @@ -18,7 +14,6 @@ import { useTernaryDarkMode } from 'usehooks-ts'; import AboutDialog from '../about/AboutDialog'; import { appCheckForUpdate, appReload, appShowInstallPrompt } from '../app/actions'; import { - appName, pybricksBugReportsUrl, pybricksGitterUrl, pybricksProjectsUrl, @@ -26,15 +21,13 @@ import { } from '../app/constants'; import { Button } from '../components/Button'; import HelpButton from '../components/HelpButton'; +import { firmwareInstallPybricks, firmwareRestoreLego } from '../firmware/actions'; +import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog'; import { pseudolocalize } from '../i18n'; import { useSelector } from '../reducers'; import ExternalLinkIcon from '../utils/ExternalLinkIcon'; import { isMacOS } from '../utils/os'; -import { - useSettingFlashCurrentProgram, - useSettingHubName, - useSettingIsShowDocsEnabled, -} from './hooks'; +import { useSettingIsShowDocsEnabled } from './hooks'; import { I18nId, useI18n } from './i18n'; import './settings.scss'; @@ -44,8 +37,6 @@ const Settings: React.VoidFunctionComponent = () => { const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false); const { isDarkMode, setTernaryDarkMode } = useTernaryDarkMode(); - const [isFlashCurrentProgramEnabled, setIsFlashCurrentProgramEnabled] = - useSettingFlashCurrentProgram(); const isServiceWorkerRegistered = useSelector( (s) => s.app.isServiceWorkerRegistered, ); @@ -56,7 +47,6 @@ const Settings: React.VoidFunctionComponent = () => { ); const promptingInstall = useSelector((s) => s.app.promptingInstall); const readyForOfflineUse = useSelector((s) => s.app.readyForOfflineUse); - const { hubName, isHubNameValid, setHubName } = useSettingHubName(); const dispatch = useDispatch(); @@ -107,52 +97,19 @@ const Settings: React.VoidFunctionComponent = () => { - - - setIsFlashCurrentProgramEnabled( - (e.target as HTMLInputElement).checked, - ) - } - /> - - - - - setHubName(e.currentTarget.value)} - onMouseOver={(e) => e.preventDefault()} - onMouseDown={(e) => e.stopPropagation()} - intent={isHubNameValid ? Intent.NONE : Intent.DANGER} - placeholder="Pybricks Hub" - rightElement={ - isHubNameValid ? undefined : ( - - ) - } - /> - - +