Merge pull request #997 from pybricks/dlech

dfu firmware flashing
This commit is contained in:
David Lechner
2022-07-22 10:57:33 -05:00
committed by GitHub
88 changed files with 3228 additions and 965 deletions
@@ -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;
+2
View File
@@ -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.
+9 -4
View File
@@ -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)$)": "<rootDir>/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": [],
+4
View File
@@ -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. */
+2
View File
@@ -34,6 +34,8 @@ function* handleShowAlert(action: ReturnType<typeof alertsShowAlert>): 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 {
+7 -1
View File
@@ -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';
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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;
+5 -5
View File
@@ -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;
+3 -3
View File
@@ -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 {
+22 -71
View File
@@ -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<T extends BleDeviceFailToConnectReasonType> = {
reason: T;
};
export type BleDeviceFailToConnectNoWebBluetoothReason =
Reason<BleDeviceFailToConnectReasonType.NoWebBluetooth>;
export type BleDeviceFailToConnectNoBluetoothReason =
Reason<BleDeviceFailToConnectReasonType.NoBluetooth>;
export type BleDeviceFailToConnectCanceledReason =
Reason<BleDeviceFailToConnectReasonType.Canceled>;
export type BleDeviceFailToConnectNoGattReason =
Reason<BleDeviceFailToConnectReasonType.NoGatt>;
export type BleDeviceFailToConnectNoDeviceInfoServiceReason =
Reason<BleDeviceFailToConnectReasonType.NoDeviceInfoService>;
export type BleDeviceFailToConnectNoPybricksServiceReason =
Reason<BleDeviceFailToConnectReasonType.NoPybricksService>;
export type BleDeviceFailToConnectUnknownReason =
Reason<BleDeviceFailToConnectReasonType.Unknown> & {
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',
}));
/**
+26
View File
@@ -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 (
<>
<p>{i18n.translate(I18nId.BluetoothNotAvailableMessage)}</p>
<p>{i18n.translate(I18nId.BluetoothNotAvailableSuggestion)}</p>
</>
);
};
export const bluetoothNotAvailable: CreateToast = (onAction) => {
return {
message: <BluetoothNotAvailable />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+35
View File
@@ -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<MissingServiceProps> = ({
serviceName,
hubName,
}) => {
const i18n = useI18n();
return (
<>
<p>{i18n.translate(I18nId.MissingServiceMessage, { serviceName })}</p>
<p>{i18n.translate(I18nId.MissingServiceSuggestion1)}</p>
<p>{i18n.translate(I18nId.MissingServiceSuggestion2, { hubName })}</p>
</>
);
};
export const missingService: CreateToast<MissingServiceProps> = (onAction, props) => {
return {
message: <MissingService {...props} />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+21
View File
@@ -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 <p>{i18n.translate(I18nId.NoGattMessage)}</p>;
};
export const noGatt: CreateToast = (onAction) => {
return {
message: <NoGatt />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+58
View File
@@ -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<NoHubProps> = ({ onFlashFirmware }) => {
const i18n = useI18n();
return (
<>
<p>{i18n.translate(I18nId.NoHubMessage)}</p>
<p>
{i18n.translate(I18nId.NoHubSuggestion1, {
appName,
buttonName: (
<strong>
{i18n.translate(I18nId.NoHubFlashFirmwareButton)}
</strong>
),
})}
</p>
<p>{i18n.translate(I18nId.NoHubSuggestion2)}</p>
<div className="pb-ble-alerts-buttons">
<Button icon="download" onClick={onFlashFirmware}>
{i18n.translate(I18nId.NoHubFlashFirmwareButton)}
</Button>
<AnchorButton
icon="help"
href={pybricksBluetoothTroubleshootingUrl}
target="_blank"
>
{i18n.translate(I18nId.NoHubTroubleshootButton)}
<ExternalLinkIcon />
</AnchorButton>
</div>
</>
);
};
export const noHub: CreateToast<never, 'dismiss' | 'flashFirmware'> = (onAction) => {
return {
message: <NoHub onFlashFirmware={() => onAction('flashFirmware')} />,
icon: 'info-sign',
intent: Intent.PRIMARY,
timeout: 15000,
onDismiss: () => onAction('dismiss'),
};
};
+49
View File
@@ -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 (
<>
<p>{i18n.translate(I18nId.NoWebBluetoothMessage)}</p>
{!isLinux() && !isIOS() && (
<p>{i18n.translate(I18nId.NoWebBluetoothSuggestion)}</p>
)}
{isLinux() && (
<>
<p>{i18n.translate(I18nId.NoWebBluetoothLinux)}</p>
<p>
<code>
chrome://flags/#enable-experimental-web-platform-features
</code>
<Button
icon="duplicate"
small={true}
minimal={true}
onClick={() =>
navigator.clipboard.writeText(
'chrome://flags/#enable-experimental-web-platform-features',
)
}
/>
</p>
</>
)}
</>
);
};
export const noWebBluetooth: CreateToast = (onAction) => {
return {
message: <NoWebBluetooth />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import './index.scss';
import { Button, Intent } from '@blueprintjs/core';
import React from 'react';
import { CreateToast } from '../../i18nToaster';
import { I18nId, useI18n } from './i18n';
type OldFirmwareProps = {
onFlashFirmware: () => void;
};
const OldFirmware: React.VoidFunctionComponent<OldFirmwareProps> = ({
onFlashFirmware,
}) => {
const i18n = useI18n();
return (
<>
<p>{i18n.translate(I18nId.OldFirmwareMessage)}</p>
<div className="pb-ble-alerts-buttons">
<Button icon="download" onClick={onFlashFirmware}>
{i18n.translate(I18nId.OldFirmwareFlashFirmwareLabel)}
</Button>
</div>
</>
);
};
export const oldFirmware: CreateToast<never, 'dismiss' | 'flashFirmware'> = (
onAction,
) => {
return {
message: <OldFirmware onFlashFirmware={() => onAction('flashFirmware')} />,
icon: 'info-sign',
intent: Intent.PRIMARY,
onDismiss: () => onAction('dismiss'),
};
};
@@ -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';
+29
View File
@@ -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',
}
+9
View File
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
.pb-ble-alerts {
&-buttons {
display: flex;
gap: 10px;
}
}
+19
View File
@@ -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,
};
+33
View File
@@ -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"
}
}
}
+31 -23
View File
@@ -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();
});
+22 -16
View File
@@ -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<BleConnectionState> = (
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<BleConnectionState> = (
};
const deviceName: Reducer<string> = (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<string> = (state = '', action) => {
};
const deviceType: Reducer<string> = (state = '', action) => {
if (didDisconnect.matches(action)) {
if (bleDidDisconnectPybricks.matches(action)) {
return '';
}
@@ -91,7 +97,7 @@ const deviceType: Reducer<string> = (state = '', action) => {
};
const deviceFirmwareVersion: Reducer<string> = (state = '', action) => {
if (didDisconnect.matches(action)) {
if (bleDidDisconnectPybricks.matches(action)) {
return '';
}
@@ -103,7 +109,7 @@ const deviceFirmwareVersion: Reducer<string> = (state = '', action) => {
};
const deviceLowBatteryWarning: Reducer<boolean> = (state = false, action) => {
if (didDisconnect.matches(action)) {
if (bleDidDisconnectPybricks.matches(action)) {
return false;
}
@@ -117,7 +123,7 @@ const deviceLowBatteryWarning: Reducer<boolean> = (state = false, action) => {
};
const deviceBatteryCharging: Reducer<boolean> = (state = false, action) => {
if (didDisconnect.matches(action)) {
if (bleDidDisconnectPybricks.matches(action)) {
return false;
}
+612
View File
@@ -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<Bluetooth>;
device: MockProxy<BluetoothDevice>;
gatt: MockProxy<BluetoothRemoteGATTServer>;
deviceInfoService: MockProxy<BluetoothRemoteGATTService>;
firmwareRevisionChar: MockProxy<BluetoothRemoteGATTCharacteristic>;
softwareRevisionChar: MockProxy<BluetoothRemoteGATTCharacteristic>;
pnpIdChar: MockProxy<BluetoothRemoteGATTCharacteristic>;
pybricksService: MockProxy<BluetoothRemoteGATTService>;
pybricksChar: MockProxy<BluetoothRemoteGATTCharacteristic>;
uartService: MockProxy<BluetoothRemoteGATTService>;
uartRxChar: MockProxy<BluetoothRemoteGATTCharacteristic>;
uartTxChar: MockProxy<BluetoothRemoteGATTCharacteristic>;
};
/**
* Creates mocks used in connect tests.
*/
function createMocks(): Mocks {
const firmwareRevisionChar = mock<BluetoothRemoteGATTCharacteristic>();
firmwareRevisionChar.readValue.mockResolvedValue(
new DataView(encoder.encode('3.2.0b2').buffer),
);
const softwareRevisionChar = mock<BluetoothRemoteGATTCharacteristic>();
softwareRevisionChar.readValue.mockResolvedValue(
new DataView(encoder.encode('1.1.0').buffer),
);
const pnpIdChar = mock<BluetoothRemoteGATTCharacteristic>();
pnpIdChar.readValue.mockResolvedValue(
new DataView(encodeInfo(HubType.TechnicHub).buffer),
);
const deviceInfoService = mock<BluetoothRemoteGATTService>();
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<BluetoothRemoteGATTCharacteristic>({
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<BluetoothRemoteGATTService>();
pybricksService.getCharacteristic
.calledWith(pybricksControlCharacteristicUUID)
.mockResolvedValue(pybricksChar);
const uartRxChar = mock<BluetoothRemoteGATTCharacteristic>();
const uartTxCharEventTarget = new EventTarget();
const uartTxChar = mock<BluetoothRemoteGATTCharacteristic>({
addEventListener:
uartTxCharEventTarget.addEventListener.bind(uartTxCharEventTarget),
removeEventListener:
uartTxCharEventTarget.removeEventListener.bind(uartTxCharEventTarget),
dispatchEvent: uartTxCharEventTarget.dispatchEvent.bind(uartTxCharEventTarget),
});
const uartService = mock<BluetoothRemoteGATTService>();
uartService.getCharacteristic
.calledWith(nordicUartRxCharUUID)
.mockResolvedValue(uartRxChar);
uartService.getCharacteristic
.calledWith(nordicUartTxCharUUID)
.mockResolvedValue(uartTxChar);
const gatt = mock<BluetoothRemoteGATTServer>();
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<BluetoothDevice>({
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>();
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<void> {
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());
});
});
+313 -277
View File
@@ -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<Task>();
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<typeof alertsDidShowAlert<'ble', 'noHub'>>
>(
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<Event>((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<DataView>((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<typeof alertsDidShowAlert<'ble', 'oldFirmware'>>
>(
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<DataView>((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<Task>();
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<DataView>((emitter) => {
const listener = (): void => {
if (!uartTxChar.value) {
return;
}
emitter(uartTxChar.value);
};
uartTxChar.addEventListener('characteristicvaluechanged', listener);
return (): void =>
uartTxChar.removeEventListener('characteristicvaluechanged', listener);
});
const uartTxChannel = eventChannel<DataView>((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);
}
+30
View File
@@ -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<HubPickerProps> = ({
hubType,
onChange,
}) => {
return (
<RadioGroup
selectedValue={hubType}
onChange={(e) => onChange(e.currentTarget.value as Hub)}
>
<Radio value={Hub.Move}>BOOST Move Hub</Radio>
<Radio value={Hub.City}>City Hub</Radio>
<Radio value={Hub.Technic}>Technic Hub</Radio>
<Radio value={Hub.Prime}>SPIKE Prime Hub</Radio>
<Radio value={Hub.Essential}>SPIKE Essential Hub</Radio>
<Radio value={Hub.Inventor}>MINDSTORMS Robot Inventor Hub</Radio>
</RadioGroup>
);
};
+73
View File
@@ -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';
}
}
+5
View File
@@ -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 {
-25
View File
@@ -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);
-13
View File
@@ -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<typeof bleDeviceDidFailToConnect>,
): void {
if (action.reason === BleDeviceFailToConnectReasonType.Unknown) {
console.error(action.err);
}
}
function handlePybricksEventProtocolError(
action: ReturnType<typeof pybricksEventProtocolError>,
): 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);
@@ -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();
+5 -22
View File
@@ -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}
/>
<FormGroup label={i18n.translate(I18nId.SmartHubLabel)}>
<RadioGroup
selectedValue={hubType}
onChange={(e) => setHubType(e.currentTarget.value as Hub)}
>
<Radio value={Hub.Move}>BOOST Move Hub</Radio>
<Radio value={Hub.City}>City Hub</Radio>
<Radio value={Hub.Technic}>Technic Hub</Radio>
<Radio value={Hub.Prime}>SPIKE Prime</Radio>
<Radio value={Hub.Essential}>SPIKE Essential</Radio>
<Radio value={Hub.Inventor}>
MINDSTORMS Robot Inventor
</Radio>
</RadioGroup>
<HubPicker hubType={hubType} onChange={setHubType} />
</FormGroup>
</div>
<div className={Classes.DIALOG_FOOTER}>
+1 -16
View File
@@ -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.
*/
+1 -1
View File
@@ -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,
+75 -4
View File
@@ -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',
}));
+21
View File
@@ -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 <p>{i18n.translate(I18nId.FirmwareMismatchMessage)}</p>;
};
export const firmwareMismatch: CreateToast = (onAction) => {
return {
message: <FirmwareMismatch />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+43
View File
@@ -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 (
<>
<p>{i18n.translate(I18nId.NoDfuHubMessage)}</p>
{isWindows() && <p>{i18n.translate(I18nId.NoDfuHubSuggestion1Windows)}</p>}
{isLinux() && <p>{i18n.translate(I18nId.NoDfuHubSuggestion1Linux)}</p>}
<p>{i18n.translate(I18nId.NoDfuHubSuggestion2)}</p>
<AnchorButton
icon="help"
href={pybricksUsbDfuTroubleshootingUrl}
target="_blank"
>
{i18n.translate(I18nId.NoDfuHubTroubleshootButton)}
<ExternalLinkIcon />
</AnchorButton>
</>
);
};
export const noDfuHub: CreateToast = (onAction) => {
return {
message: <NoDfuHub />,
icon: 'info-sign',
intent: Intent.PRIMARY,
onDismiss: () => onAction('dismiss'),
};
};
+21
View File
@@ -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 <p>{i18n.translate(I18nId.NoDfuInterfaceMessage)}</p>;
};
export const noDfuInterface: CreateToast = (onAction) => {
return {
message: <NoDfuInterface />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+26
View File
@@ -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 (
<>
<p>{i18n.translate(I18nId.NoWebUsbMessage)}</p>
<p>{i18n.translate(I18nId.NoWebUsbSuggestion)}</p>
</>
);
};
export const noWebUsb: CreateToast = (onAction) => {
return {
message: <NoWebUsb />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+12
View File
@@ -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();
});
});
+22
View File
@@ -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',
}
+14
View File
@@ -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,
};
+21
View File
@@ -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."
}
}
@@ -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<SelectHubPanelProps> = ({
hubType,
onChange,
}) => {
const i18n = useI18n();
return (
<div className={dialogBody}>
<p>{i18n.translate(I18nId.SelectHubPanelMessage)}</p>
<HubPicker hubType={hubType} onChange={onChange} />
<Popover2
popoverClassName={Classes2.POPOVER2_CONTENT_SIZING}
placement="right-end"
content={
<div>
<h3>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsTitle,
)}
</h3>
<ul>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsRcx,
)}
</li>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsNxt,
)}
</li>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsEv3,
)}
</li>
</ul>
<h3>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpTitle,
)}
</h3>
<ul>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpWedo2,
)}
<em>*</em>
</li>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain,
)}
<em>*</em>
</li>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpMario,
)}
</li>
</ul>
<em>
*{' '}
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpFootnote,
)}
</em>
</div>
}
renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => (
<Button
elementRef={ref as IRef<HTMLButtonElement>}
{...targetProps}
>
{i18n.translate(I18nId.SelectHubPanelNotOnListButtonLabel)}
</Button>
)}
/>
</div>
);
};
type AcceptLicensePanelProps = {
hubType: Hub;
licenseAccepted: boolean;
onLicenseAcceptedChanged: (accepted: boolean) => void;
};
const AcceptLicensePanel: React.VoidFunctionComponent<AcceptLicensePanelProps> = ({
hubType,
licenseAccepted,
onLicenseAcceptedChanged,
}) => {
const { data, error } = useFirmware(hubType);
const i18n = useI18n();
return (
<div className={dialogBody}>
<div className="pb-firmware-installPybricksDialog-license">
<div className="pb-firmware-installPybricksDialog-license-text">
{data ? (
<pre>{data.licenseText}</pre>
) : (
<NonIdealState
icon={error ? 'error' : <Spinner />}
description={
error
? i18n.translate(
I18nId.LicensePanelLicenseTextError,
)
: undefined
}
/>
)}
</div>
<Checkbox
label={i18n.translate(I18nId.LicensePanelAcceptCheckboxLabel)}
checked={licenseAccepted}
onChange={(e) => onLicenseAcceptedChanged(e.currentTarget.checked)}
disabled={!data}
/>
</div>
</div>
);
};
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<SelectOptionsPanelProps> = ({
hubType,
hubName,
includeProgram,
selectedIncludeFile,
onChangeHubName,
onChangeIncludeProgram,
onChangeSelectedIncludeFile,
}) => {
const i18n = useI18n();
const isHubNameValid = validateHubName(hubName);
const files = useFileStorageMetadata();
return (
<div className={dialogBody}>
<FormGroup
label={i18n.translate(I18nId.OptionsPanelHubNameLabel)}
labelInfo={i18n.translate(I18nId.OptionsPanelHubNameLabelInfo)}
>
<ControlGroup>
<InputGroup
value={hubName}
onChange={(e) => onChangeHubName(e.currentTarget.value)}
onMouseOver={(e) => e.preventDefault()}
onMouseDown={(e) => e.stopPropagation()}
intent={isHubNameValid ? Intent.NONE : Intent.DANGER}
placeholder="Pybricks Hub"
rightElement={
isHubNameValid ? undefined : (
<Icon
icon="error"
intent={Intent.DANGER}
itemType="div"
/>
)
}
/>
<HelpButton
helpForLabel={i18n.translate(I18nId.OptionsPanelHubNameLabel)}
content={i18n.translate(I18nId.OptionsPanelHubNameHelp)}
/>
</ControlGroup>
</FormGroup>
<FormGroup
label={i18n.translate(I18nId.OptionsPanelCustomMainLabel)}
labelInfo={i18n.translate(I18nId.OptionsPanelCustomMainLabelInfo)}
>
{(hubHasExternalFlash(hubType) && (
<p>
{i18n.translate(
I18nId.OptionsPanelCustomMainNotApplicableMessage,
)}
</p>
)) || (
<ControlGroup>
<Switch
labelElement={i18n.translate(
I18nId.OptionsPanelCustomMainIncludeLabel,
{ main: <code>main.py</code> },
)}
checked={includeProgram}
onChange={(e) =>
onChangeIncludeProgram(
(e.target as HTMLInputElement).checked,
)
}
/>
<Select2
items={files || []}
itemRenderer={(
item,
{ handleClick, handleFocus, modifiers },
) => (
<MenuItem
roleStructure="listoption"
active={modifiers.active}
disabled={modifiers.disabled}
text={item.path}
key={item.uuid}
onClick={handleClick}
onFocus={handleFocus}
/>
)}
noResults={
<MenuItem
roleStructure="listoption"
disabled={true}
text={i18n.translate(
I18nId.OptionsPanelCustomMainIncludeNoFiles,
)}
/>
}
filterable={false}
popoverProps={{ minimal: true }}
disabled={!includeProgram}
onItemSelect={onChangeSelectedIncludeFile}
>
<Button
icon="double-caret-vertical"
text={
selectedIncludeFile?.path ??
i18n.translate(
I18nId.OptionsPanelCustomMainIncludeNoSelection,
)
}
disabled={!includeProgram}
/>
</Select2>
<HelpButton
helpForLabel={i18n.translate(
I18nId.OptionsPanelCustomMainIncludeLabel,
{ main: 'main.py' },
)}
content={i18n.translate(
I18nId.OptionsPanelCustomMainIncludeHelp,
{
appName,
},
)}
/>
</ControlGroup>
)}
</FormGroup>
</div>
);
};
type BootloaderModePanelProps = {
hubType: Hub;
};
const BootloaderModePanel: React.VoidFunctionComponent<BootloaderModePanelProps> = ({
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 (
<div className={dialogBody}>
<p>{i18n.translate(I18nId.BootloaderPanelInstruction1)}</p>
<ol>
{hubHasUSB(hubType) && (
<li>{i18n.translate(I18nId.BootloaderPanelStepDisconnectUsb)}</li>
)}
<li>{i18n.translate(I18nId.BootloaderPanelStepPowerOff)}</li>
{/* City hub has power issues and requires disconnecting motors/sensors */}
{hubType === Hub.City && (
<li>{i18n.translate(I18nId.BootloaderPanelStepDisconnectIo)}</li>
)}
<li>
{i18n.translate(I18nId.BootloaderPanelStepHoldButton, { button })}
</li>
{hubHasUSB(hubType) && (
<li>{i18n.translate(I18nId.BootloaderPanelStepConnectUsb)}</li>
)}
<li>
{i18n.translate(I18nId.BootloaderPanelStepWaitForLight, {
button,
light,
lightPattern,
})}
</li>
<li>
{i18n.translate(
/* hubs with USB will keep the power on, but other hubs won't */
hubHasUSB(hubType)
? I18nId.BootloaderPanelStepReleaseButton
: I18nId.BootloaderPanelStepKeepHolding,
{
button,
},
)}
</li>
</ol>
<p>
{i18n.translate(I18nId.BootloaderPanelInstruction2, {
flashFirmware: (
<strong>
{i18n.translate(I18nId.FlashFirmwareButtonLabel)}
</strong>
),
})}
</p>
</div>
);
};
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<FileMetadata>();
const [licenseAccepted, setLicenseAccepted] = useState(false);
const { data } = useFirmware(hubType);
const i18n = useI18n();
return (
<MultistepDialog
title={i18n.translate(I18nId.Title)}
isOpen={isOpen}
onClose={() => dispatch(firmwareInstallPybricksDialogCancel())}
finalButtonProps={{
text: i18n.translate(I18nId.FlashFirmwareButtonLabel),
onClick: () =>
dispatch(
firmwareInstallPybricksDialogAccept(
hubBootloaderType(hubType),
data?.firmwareZip ?? new ArrayBuffer(0),
selectedIncludeFile?.path,
hubName,
),
),
}}
>
<DialogStep
id="hub"
title={i18n.translate(I18nId.SelectHubPanelTitle)}
panel={<SelectHubPanel hubType={hubType} onChange={setHubType} />}
nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }}
/>
<DialogStep
id="license"
title={i18n.translate(I18nId.LicensePanelTitle)}
panel={
<AcceptLicensePanel
hubType={hubType}
licenseAccepted={licenseAccepted}
onLicenseAcceptedChanged={setLicenseAccepted}
/>
}
backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
nextButtonProps={{
disabled: !licenseAccepted,
text: i18n.translate(I18nId.NextButtonLabel),
}}
/>
<DialogStep
id="options"
title={i18n.translate(I18nId.OptionsPanelTitle)}
panel={
<ConfigureOptionsPanel
hubType={hubType}
hubName={hubName}
includeProgram={includeProgram}
selectedIncludeFile={selectedIncludeFile}
onChangeHubName={setHubName}
onChangeIncludeProgram={setIncludeProgram}
onChangeSelectedIncludeFile={setSelectedIncludeFile}
/>
}
backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }}
/>
<DialogStep
id="bootloader"
title={i18n.translate(I18nId.BootloaderPanelTitle)}
panel={<BootloaderModePanel hubType={hubType} />}
backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
/>
</MultistepDialog>
);
};
@@ -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',
}));
+133
View File
@@ -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, string>([
[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<Cache>({});
// Used to prevent state update if the component is unmounted
const cancelRequest = useRef<boolean>(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;
}
@@ -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();
});
});
@@ -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',
}
@@ -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;
}
@@ -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;
}
}
}
}
@@ -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<boolean> = (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 });
@@ -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"
}
}
+4 -1
View File
@@ -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,
}
`);
+2 -1
View File
@@ -3,6 +3,7 @@
import { Reducer, combineReducers } from 'redux';
import { didFailToFinish, didFinish, didProgress, didStart } from './actions';
import installPybricksDialog from './installPybricksDialog/reducers';
const flashing: Reducer<boolean> = (state = false, action) => {
if (didStart.matches(action)) {
@@ -28,4 +29,4 @@ const progress: Reducer<number | null> = (state = null, action) => {
return state;
};
export default combineReducers({ flashing, progress });
export default combineReducers({ installPybricksDialog, flashing, progress });
+21 -21
View File
@@ -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
+248 -10
View File
@@ -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, string>([
[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<typeof flashFirmware>): 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<typeof flashFirmware>): Generat
}
}
/** Maps USB Product ID to LWP3 hub type ID */
const productIdMap: ReadonlyMap<LegoUsbProductId, HubType> = 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<typeof firmwareFlashUsbDfu>): 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);
}
+4 -3
View File
@@ -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);
});
+3 -3
View File
@@ -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<HubRuntimeState> = (
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;
}
+5 -5
View File
@@ -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<typeof downloadAndRun>): 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);
}
+4
View File
@@ -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;
}
+3 -3
View File
@@ -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.
+26 -8
View File
@@ -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();
-4
View File
@@ -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',
}
+3 -26
View File
@@ -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, <Error>{
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(
-77
View File
@@ -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<typeof bleDeviceDidFailToConnect>,
): 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<typeof bootloaderDidFailToConnect>,
): 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<typeof appDidCheckForUpdate>): Gen
});
}
function* checkVersion(
action: ReturnType<typeof bleDIServiceDidReceiveFirmwareRevision>,
): 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<typeof fileStorageDidFailToInitialize>,
): 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);
-6
View File
@@ -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."
}
}
+1
View File
@@ -3,6 +3,7 @@
/// <reference types="node" />
/// <reference types="react-dom" />
/// <reference types="user-agent-data-types" />
declare namespace NodeJS {
interface ProcessEnv {
+16 -29
View File
@@ -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(<Settings />);
describe('firmware', () => {
it('should dispatch action when install Pybricks firmware button is clicked', async () => {
const [user, settings, dispatch] = testRender(<Settings />);
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(<Settings />);
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(<Settings />);
it('should dispatch action when restore official LEGO firmware button is clicked', async () => {
const [user, settings, dispatch] = testRender(<Settings />);
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());
});
});
+16 -59
View File
@@ -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 = () => {
</ControlGroup>
</FormGroup>
<FormGroup label={i18n.translate(I18nId.FirmwareTitle)}>
<ControlGroup>
<Switch
label={i18n.translate(I18nId.FirmwareCurrentProgramLabel)}
checked={isFlashCurrentProgramEnabled}
onChange={(e) =>
setIsFlashCurrentProgramEnabled(
(e.target as HTMLInputElement).checked,
)
}
/>
<HelpButton
helpForLabel={i18n.translate(
I18nId.FirmwareCurrentProgramLabel,
)}
content={i18n.translate(I18nId.FirmwareCurrentProgramHelp, {
appName,
})}
/>
</ControlGroup>
<Label htmlFor="hub-name-input">
{i18n.translate(I18nId.FirmwareHubNameLabel)}
</Label>
<ControlGroup>
<InputGroup
id="hub-name-input"
value={hubName}
onChange={(e) => setHubName(e.currentTarget.value)}
onMouseOver={(e) => e.preventDefault()}
onMouseDown={(e) => e.stopPropagation()}
intent={isHubNameValid ? Intent.NONE : Intent.DANGER}
placeholder="Pybricks Hub"
rightElement={
isHubNameValid ? undefined : (
<Icon
icon="error"
intent={Intent.DANGER}
itemType="div"
/>
)
}
/>
<HelpButton
helpForLabel={i18n.translate(I18nId.FirmwareHubNameLabel)}
content={i18n.translate(I18nId.FirmwareHubNameHelp)}
/>
</ControlGroup>
<Button
minimal={true}
icon="download"
label={i18n.translate(I18nId.FirmwareFlashPybricksLabel)}
onPress={() => dispatch(firmwareInstallPybricks())}
/>
<InstallPybricksDialog />
<Button
minimal={true}
icon="download"
label={i18n.translate(I18nId.FirmwareFlashLegoLabel)}
onPress={() => dispatch(firmwareRestoreLego())}
/>
</FormGroup>
<FormGroup label={i18n.translate(I18nId.HelpTitle)}>
<ButtonGroup minimal={true} vertical={true} alignText="left">
+2 -50
View File
@@ -1,13 +1,8 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Dispatch, SetStateAction, useCallback } from 'react';
import { useIsFirstRender, useLocalStorage } from 'usehooks-ts';
const encoder = new TextEncoder();
// this is private type from usehooks-ts
type SetValue<T> = Dispatch<SetStateAction<T>>;
import { useCallback } from 'react';
import { useLocalStorage } from 'usehooks-ts';
/** Hook for "showDocs" setting. */
export function useSettingIsShowDocsEnabled(): {
@@ -31,46 +26,3 @@ export function useSettingIsShowDocsEnabled(): {
toggleIsSettingShowDocsEnabled,
};
}
/** Hook for "flashCurrentProgram" setting. */
export function useSettingFlashCurrentProgram(): [boolean, SetValue<boolean>] {
return useLocalStorage<boolean>('setting.flashCurrentProgram', false);
}
/**
* Validates the hub name.
* @param hubName The hub name.
* @returns True if the name if valid, otherwise false.
*/
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;
}
/** Hook for "hubName" setting. */
export function useSettingHubName(): {
hubName: string;
isHubNameValid: boolean;
setHubName: (value: string) => void;
} {
if (useIsFirstRender()) {
// in version 1.x, settings didn't use json format, so we have to migrate
const oldSetting = localStorage.getItem('setting.hubName');
if (oldSetting !== null && !oldSetting.startsWith('"')) {
localStorage.setItem('setting.hubName', JSON.stringify(oldSetting));
}
}
const [hubName, setHubName] = useLocalStorage('setting.hubName', '');
const isHubNameValid = validateHubName(hubName);
return { hubName, isHubNameValid, setHubName };
}
+2 -5
View File
@@ -20,11 +20,8 @@ export enum I18nId {
AppearanceDarkModeHelp = 'appearance.darkMode.help',
AppearanceZoomHelp = 'appearance.zoom.help',
FirmwareTitle = 'firmware.title',
FirmwareCurrentProgramLabel = 'firmware.flashCurrentProgram.label',
FirmwareCurrentProgramHelp = 'firmware.flashCurrentProgram.help',
FirmwareHubNameLabel = 'firmware.hubName.label',
FirmwareHubNameHelp = 'firmware.hubName.help',
FirmwareHubNameError = 'firmware.hubName.error',
FirmwareFlashPybricksLabel = 'firmware.flashPybricksButton.label',
FirmwareFlashLegoLabel = 'firmware.flashLegoButton.label',
HelpTitle = 'help.title',
HelpProjectsLabel = 'help.projects.label',
HelpSupportLabel = 'help.support.label',
+4 -7
View File
@@ -16,14 +16,11 @@
},
"firmware": {
"title": "Firmware",
"flashCurrentProgram": {
"label": "Include current program",
"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}"
"flashPybricksButton": {
"label": "Install Pybricks Firmware"
},
"hubName": {
"label": "Hub name",
"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."
"flashLegoButton": {
"label": "Restore Official LEGO® Firmware"
}
},
"help": {
+4 -4
View File
@@ -19,7 +19,7 @@ import {
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 { checksum } from '../hub/actions';
import { HubRuntimeState } from '../hub/reducers';
import { RootState } from '../reducers';
@@ -56,7 +56,7 @@ function* receiveTerminalData(): Generator {
let value = action.value;
// Try to collect more data so that we aren't sending just one byte at time
while (value.length < SafeTxCharLength) {
while (value.length < nordicUartSafeTxCharLength) {
const { action, timeout } = yield* race({
action: take(channel),
timeout: delay(20),
@@ -72,9 +72,9 @@ function* receiveTerminalData(): Generator {
// stdin gets piped to BLE connection
const data = encoder.encode(value);
for (let i = 0; i < data.length; i += SafeTxCharLength) {
for (let i = 0; i < data.length; i += nordicUartSafeTxCharLength) {
const { id } = yield* put(
write(nextMessageId(), data.slice(i, i + SafeTxCharLength)),
write(nextMessageId(), data.slice(i, i + nordicUartSafeTxCharLength)),
);
yield* take(
-8
View File
@@ -14,14 +14,6 @@ describe('toolbar', () => {
expect(runButton).toBeDefined();
});
it('should have flash button', () => {
const [, toolbar] = testRender(<Toolbar />);
const runButton = toolbar.getByRole('button', { name: 'Flash' });
expect(runButton).toBeDefined();
});
it('should have run button', () => {
const [, toolbar] = testRender(<Toolbar />);
-2
View File
@@ -6,7 +6,6 @@ import React from 'react';
import { useId } from 'react-aria';
import { Toolbar as UtilsToolbar } from '../components/toolbar/Toolbar';
import BluetoothButton from './buttons/bluetooth/BluetoothButton';
import FlashButton from './buttons/flash/FlashButton';
import ReplButton from './buttons/repl/ReplButton';
import RunButton from './buttons/run/RunButton';
import StopButton from './buttons/stop/StopButton';
@@ -23,7 +22,6 @@ const Toolbar: React.VFC = () => {
return (
<UtilsToolbar className="pb-toolbar" firstFocusableItemId={flashButtonId}>
<ButtonGroup className="pb-toolbar-group pb-align-left">
<FlashButton id={flashButtonId} />
<BluetoothButton id={bluetoothButtonId} />
</ButtonGroup>
<ButtonGroup className="pb-toolbar-group pb-align-left">
@@ -1 +1 @@
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient5199" x1="76.89" x2="84.213" y1="96.73" y2="96.73" gradientTransform="matrix(1.2729 0 0 1.2729 -22.91 -26.356)" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/></linearGradient></defs><g transform="translate(0 -270.54)"><g transform="matrix(1.5713 0 0 1.5713 -114.97 131.36)"><path d="m75.463 99.915 7.7607-6.4212-3.7847-3.1468v12.779l3.8059-3.3807-7.8564-6.4637" fill="none" stroke="url(#linearGradient5199)" stroke-width="1.3471"/></g></g></svg>
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="linearGradient5199" x1="76.89" x2="84.213" y1="96.73" y2="96.73" gradientTransform="matrix(1.2729,0,0,1.2729,-22.91,-26.356)" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/></linearGradient></defs><g transform="translate(3.0907 -270.54)"><g transform="matrix(1.5713,0,0,1.5713,-114.97,131.36)"><path d="m75.463 99.915 7.7607-6.4212-3.7847-3.1468v12.779l3.8059-3.3807-7.8564-6.4637" fill="none" stroke="url(#linearGradient5199)" stroke-width="1.3471"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 617 B

@@ -1,20 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { cleanup } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../../../test';
import { flashFirmware } from '../../../firmware/actions';
import FlashButton from './FlashButton';
afterEach(() => {
cleanup();
});
it('should dispatch action when clicked', async () => {
const [user, button, dispatch] = testRender(<FlashButton id="test-flash-button" />);
await user.click(button.getByRole('button', { name: 'Flash' }));
expect(dispatch).toHaveBeenCalledWith(flashFirmware(null, false, ''));
});
-74
View File
@@ -1,74 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import React from 'react';
import { useDispatch } from 'react-redux';
import { BleConnectionState } from '../../../ble/reducers';
import { flashFirmware } from '../../../firmware/actions';
import { BootloaderConnectionState } from '../../../lwp3-bootloader/reducers';
import * as notificationActions from '../../../notifications/actions';
import { useSelector } from '../../../reducers';
import {
useSettingFlashCurrentProgram,
useSettingHubName,
} from '../../../settings/hooks';
import OpenFileButton, { OpenFileButtonProps } from '../../../toolbar/OpenFileButton';
import { I18nId, useI18n } from './i18n';
import icon from './icon.svg';
type FlashButtonProps = Pick<OpenFileButtonProps, 'id'>;
const FlashButton: React.VoidFunctionComponent<FlashButtonProps> = ({ id }) => {
const bootloaderConnection = useSelector((s) => s.bootloader.connection);
const bleConnection = useSelector((s) => s.ble.connection);
const flashing = useSelector((s) => s.firmware.flashing);
const progress = useSelector((s) => s.firmware.progress);
const [isSettingFlashCurrentProgramEnabled] = useSettingFlashCurrentProgram();
const { hubName } = useSettingHubName();
const i18n = useI18n();
const dispatch = useDispatch();
return (
<OpenFileButton
id={id}
label={i18n.translate(I18nId.Label)}
mimeType="application/zip"
fileExtension=".zip"
icon={icon}
tooltip={
progress
? i18n.translate(I18nId.TooltipProgress, {
percent: i18n.formatPercentage(progress),
})
: i18n.translate(I18nId.TooltipAction)
}
enabled={
bootloaderConnection === BootloaderConnectionState.Disconnected &&
bleConnection === BleConnectionState.Disconnected
}
showProgress={flashing}
progress={progress === null ? undefined : progress}
onFile={(data) =>
dispatch(
flashFirmware(data, isSettingFlashCurrentProgramEnabled, hubName),
)
}
onReject={(file) =>
dispatch(
notificationActions.add(
'error',
`'${file.name}' is not a valid firmware file.`,
),
)
}
onClick={() =>
dispatch(
flashFirmware(null, isSettingFlashCurrentProgramEnabled, hubName),
)
}
/>
);
};
export default FlashButton;
-16
View File
@@ -1,16 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-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 {
Label = 'label',
TooltipAction = 'tooltip.action',
TooltipProgress = 'tooltip.progress',
}
-1
View File
@@ -1 +0,0 @@
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="light-circle-fill" gradientTransform="matrix(.041492 0 0 .041492 1.7417 549.61)"><stop stop-color="#fff" offset="0"/></linearGradient><linearGradient id="linearGradient5187" x1="170.49" x2="171.55" y1="91.975" y2="91.975" gradientTransform="matrix(2.2771 0 0 2.2771 -375.89 64.429)" gradientUnits="userSpaceOnUse" xlink:href="#light-circle-fill"/></defs><g transform="translate(0 -270.54)"><path transform="matrix(0 2.2771 -2.2771 0 242.26 191.39)" d="m39.757 100.45-2.9978 1.7308v-3.4615l1.4989 0.86538z" fill="url(#light-circle-fill)"/><path d="m13.537 271.93v3.8731" fill="#4e4e4e" stroke="url(#linearGradient5187)" stroke-width="2.1167"/><g fill="url(#light-circle-fill)"><rect x="1.2749" y="284.17" width="24.017" height="10.586" rx="0" ry="0"/><rect x="3.4315" y="283.18" width="2.7715" height="10.586" rx="0" ry="0"/><rect x="9.0755" y="283.18" width="2.7715" height="10.586" rx="0" ry="0"/><rect x="14.719" y="283.18" width="2.7715" height="10.586" rx="0" ry="0"/><rect x="20.364" y="283.18" width="2.7715" height="10.586" rx="0" ry="0"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,7 +0,0 @@
{
"label": "Flash",
"tooltip": {
"action": "Install Pybricks firmware",
"progress": "Flashing… {percent}"
}
}
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// https://github.com/pybricks/technical-info/blob/master/assigned-numbers.md#usb
/** Official LEGO USB Vendor ID (VID) */
export const legoUsbVendorId = 0x0694;
/** Official LEGO USB Product IDs (PID) */
export enum LegoUsbProductId {
/** MINDSTORMS RCX IR Tower. */
RcxIrTower = 0x0001,
/** MINDSTORMS NXT */
Nxt = 0x0002,
/** WeDo USB hub. */
WedoUsb = 0x0003,
/** MINDSTORMS EV3 */
Ev3 = 0x0005,
/** MINDSTORMS EV3 in firmware update (bootloader) mode. */
Ev3Bootloader = 0x0006,
/** SPIKE Prime hub in DFU (bootloader) mode. */
SpikePrimeBootloader = 0x0008,
/** SPIKE Prime hub. */
SpikePrime = 0x0009,
/** SPIKE Essential hub in DFU (bootloader) mode. */
SpikeEssentialBootloader = 0x000c,
/** SPIKE Essential hub. */
SpikeEssential = 0x000d,
/** MINDSTORMS Robot inventor hub. */
MindstormsRobotInventor = 0x0010,
/** MINDSTORMS Robot inventor hub in DFU (bootloader) mode. */
MindstormsRobotInventorBootloader = 0x0011,
}
+14 -2
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import { fmod, sumComplement32, xor8 } from './math';
import { crc32, fmod, sumComplement32, xor8 } from './math';
describe('fmod', () => {
test('positive numbers', () => {
@@ -22,6 +22,18 @@ describe('sumComplement32', () => {
});
});
describe('crc32', () => {
test('trivial', () => {
expect(crc32([0])).toBe(0);
});
test('trivial2', () => {
expect(crc32([0xffffffff])).toBe(0);
});
test('basic', () => {
expect(crc32([1, 2, 3, 4, 5])).toBe(-2048796416);
});
});
describe('xor8', () => {
test('basic', () => {
expect(xor8([0])).toBe(0xff);
+27 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
/**
* Compute modulo using floored division
@@ -28,6 +28,32 @@ export function sumComplement32(data: Iterable<number>): number {
// checksum is two's complement of total
return ~total + 1;
}
// thanks https://stackoverflow.com/a/33152544/1976323
const crc32Table: ReadonlyArray<number> = [
0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, 0x130476dc, 0x17c56b6b, 0x1a864db2,
0x1e475005, 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, 0x350c9b64, 0x31cd86d3,
0x3c8ea00a, 0x384fbdbd,
];
/**
* Calculates the 32-bit CRC32 checksum.
* @data an iterable of 32-bit integers
* @returns the checksum
*/
export function crc32(data: Iterable<number>): number {
let crc = 0xffffffff;
for (const word of data) {
crc ^= word;
for (let i = 0; i < 8; i++) {
crc = (crc << 4) ^ crc32Table[crc >> 28];
}
}
return crc;
}
/**
* Calculates the 8-bit "xor" checksum
+55 -8
View File
@@ -1,7 +1,28 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import { isAndroid, isMacOS, isWindows } from './os';
import { isAndroid, isIOS, isLinux, isMacOS, isWindows } from './os';
import { defined } from '.';
class TestUserAgentData implements NavigatorUAData {
getHighEntropyValues(_hints: string[]): Promise<UADataValues> {
throw new Error('Method not implemented.');
}
toJSON(): UALowEntropyJSON {
throw new Error('Method not implemented.');
}
get brands(): NavigatorUABrandVersion[] {
throw new Error('Method not implemented.');
}
get mobile(): boolean {
throw new Error('Method not implemented.');
}
get platform(): string {
return 'test-agent';
}
}
Object.defineProperty(navigator, 'userAgentData', { value: new TestUserAgentData() });
afterEach(() => {
jest.resetAllMocks();
@@ -9,33 +30,59 @@ afterEach(() => {
describe('isAndroid', () => {
test('is true', () => {
jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Android');
defined(navigator.userAgentData);
jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue(
'Android',
);
expect(isAndroid()).toBeTruthy();
});
test('is false', () => {
jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Linux');
expect(isAndroid()).toBeFalsy();
});
});
describe('isMacOS', () => {
test('is true', () => {
jest.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel');
defined(navigator.userAgentData);
jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue('macOS');
expect(isMacOS()).toBeTruthy();
});
test('is false', () => {
jest.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32');
expect(isMacOS()).toBeFalsy();
});
});
describe('isWindows', () => {
test('is true', () => {
jest.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32');
defined(navigator.userAgentData);
jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue(
'Windows',
);
expect(isWindows()).toBeTruthy();
});
test('is false', () => {
jest.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel');
expect(isWindows()).toBeFalsy();
});
});
describe('isLinux', () => {
test('is true', () => {
defined(navigator.userAgentData);
jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue('Linux');
expect(isLinux()).toBeTruthy();
});
test('is false', () => {
expect(isLinux()).toBeFalsy();
});
});
describe('isIOS', () => {
test('is true', () => {
defined(navigator.userAgentData);
jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue('iOS');
expect(isIOS()).toBeTruthy();
});
test('is false', () => {
expect(isIOS()).toBeFalsy();
});
});
+20 -7
View File
@@ -1,17 +1,14 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
// Utility functions for dealing with operating systems.
// TODO: replace with navigator.userAgentData when it is more widely available
// https://developer.mozilla.org/en-US/docs/Web/API/User-Agent_Client_Hints_API
/**
* Tests if we are running on Android.
* @returns `true` if running on Android, otherwise `false`.
*/
export function isAndroid(): boolean {
return /android/i.test(navigator.userAgent);
return navigator.userAgentData?.platform === 'Android';
}
/**
@@ -19,7 +16,7 @@ export function isAndroid(): boolean {
* @returns `true` if running on macOS, otherwise `false`.
*/
export function isMacOS(): boolean {
return /mac/i.test(navigator.platform);
return navigator.userAgentData?.platform === 'macOS';
}
/**
@@ -27,5 +24,21 @@ export function isMacOS(): boolean {
* @returns `true` if running on Windows, otherwise `false`.
*/
export function isWindows(): boolean {
return /win/i.test(navigator.platform);
return navigator.userAgentData?.platform === 'Windows';
}
/**
* Tests if we are running on Linux.
* @returns `true` if running on Linux, otherwise `false`.
*/
export function isLinux(): boolean {
return navigator.userAgentData?.platform === 'Linux';
}
/**
* Tests if we are running on iOS.
* @returns `true` if running on iOS, otherwise `false`.
*/
export function isIOS(): boolean {
return navigator.userAgentData?.platform === 'iOS';
}
+69 -8
View File
@@ -1508,7 +1508,7 @@ __metadata:
languageName: node
linkType: hard
"@blueprintjs/core@npm:^4.5.0, @blueprintjs/core@npm:^4.6.1":
"@blueprintjs/core@npm:^4.6.1":
version: 4.6.1
resolution: "@blueprintjs/core@npm:4.6.1"
dependencies:
@@ -1562,6 +1562,21 @@ __metadata:
languageName: node
linkType: hard
"@blueprintjs/select@npm:^4.5.0":
version: 4.5.0
resolution: "@blueprintjs/select@npm:4.5.0"
dependencies:
"@blueprintjs/core": ^4.6.1
"@blueprintjs/popover2": ^1.4.3
classnames: ^2.2
tslib: ~2.3.1
peerDependencies:
react: ^16.8 || 17 || 18
react-dom: ^16.8 || 17 || 18
checksum: 837049e6d7d1063f65aaa9d94d4b9b74a2b0a3a227243ceeaeb4302a743960f4a1c9fbceb51ee20f3216ba71becfd1fa4e405950c0f6d75288ba96f2a8e9be53
languageName: node
linkType: hard
"@csstools/normalize.css@npm:*":
version: 12.0.0
resolution: "@csstools/normalize.css@npm:12.0.0"
@@ -2265,12 +2280,12 @@ __metadata:
languageName: node
linkType: hard
"@pybricks/firmware@npm:4.17.0":
version: 4.17.0
resolution: "@pybricks/firmware@npm:4.17.0"
"@pybricks/firmware@npm:5.0.0":
version: 5.0.0
resolution: "@pybricks/firmware@npm:5.0.0"
dependencies:
jszip: ^3.7.1
checksum: 68d35727d101024f0c58bef0e7a8f245c7c5cbc8b38b2d3c638efc56a1ae84e03e52c596e3bfcdf0d22c1c9add4c2037d8fe4bf54e69b78763ec8c1169cc8517
checksum: 049dd90e988aa574cfa0ead1e62bcb74e6fdfc9b709bc1c40874ddf3abb63cd35555d22806c91184bc2e982912a001e0ef94ef72ef66217eb0319bcaf45a7cb3
languageName: node
linkType: hard
@@ -2300,10 +2315,11 @@ __metadata:
resolution: "@pybricks/pybricks-code@workspace:."
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
@@ -2322,6 +2338,7 @@ __metadata:
"@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
@@ -2346,6 +2363,7 @@ __metadata:
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
eslint: ^8.20.0
@@ -2414,6 +2432,7 @@ __metadata:
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
@@ -4665,6 +4684,13 @@ __metadata:
languageName: node
linkType: hard
"@types/w3c-web-usb@npm:^1.0.6":
version: 1.0.6
resolution: "@types/w3c-web-usb@npm:1.0.6"
checksum: 9f30948cb84174fa290066b08274bdfb034d38c6db0976e9a826508732fba04d81e3300bca41ea23b737f1424c51adec5ae810cdf85d5b5a158d5840914f0417
languageName: node
linkType: hard
"@types/web-bluetooth@npm:^0.0.15":
version: 0.0.15
resolution: "@types/web-bluetooth@npm:0.0.15"
@@ -7094,6 +7120,15 @@ __metadata:
languageName: node
linkType: hard
"dfu@npm:^0.1.5":
version: 0.1.5
resolution: "dfu@npm:0.1.5"
dependencies:
nanoevents: ^6.0.0
checksum: 7fa8aa1578518be4eb8981f67878ecb6f68e64ccf91ed278f35708a6d4e3f6e822e68ab8653c6b3d69e08ddf105c550698b88db363bdab8f8c2a7a582ac96001
languageName: node
linkType: hard
"didyoumean@npm:^1.2.2":
version: 1.2.2
resolution: "didyoumean@npm:1.2.2"
@@ -9685,7 +9720,7 @@ __metadata:
languageName: node
linkType: hard
"jest-mock-extended@npm:^2.0.7":
"jest-mock-extended@npm:2.0.7":
version: 2.0.7
resolution: "jest-mock-extended@npm:2.0.7"
dependencies:
@@ -9697,6 +9732,18 @@ __metadata:
languageName: node
linkType: hard
"jest-mock-extended@patch:jest-mock-extended@npm:2.0.7#.yarn/patches/jest-mock-extended-npm-2.0.7-4cdf066556.patch::locator=%40pybricks%2Fpybricks-code%40workspace%3A.":
version: 2.0.7
resolution: "jest-mock-extended@patch:jest-mock-extended@npm%3A2.0.7#.yarn/patches/jest-mock-extended-npm-2.0.7-4cdf066556.patch::version=2.0.7&hash=b7e4f2&locator=%40pybricks%2Fpybricks-code%40workspace%3A."
dependencies:
ts-essentials: ^7.0.3
peerDependencies:
jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0
typescript: ^3.0.0 || ^4.0.0
checksum: 322b8330e8be6fd3a2a2faf45fc63e0140de88acff00c23d64f5f6b071c1f551129f48a9ba355dd86fe37fcf5b3675704ee7ea2a33d449752e4982de161ddaf2
languageName: node
linkType: hard
"jest-mock@npm:^28.1.3":
version: 28.1.3
resolution: "jest-mock@npm:28.1.3"
@@ -10781,6 +10828,13 @@ __metadata:
languageName: node
linkType: hard
"nanoevents@npm:^6.0.0":
version: 6.0.2
resolution: "nanoevents@npm:6.0.2"
checksum: 73d8c8f584b850bae6705820710a20c19be61145a4a6ad3b157caf1fea52d46f48e0d1f1c1452019c0e84869f226debad2245218f0b0c3f9dffb7afc9b42e663
languageName: node
linkType: hard
"nanoid@npm:^3.3.4":
version: 3.3.4
resolution: "nanoid@npm:3.3.4"
@@ -14728,6 +14782,13 @@ __metadata:
languageName: node
linkType: hard
"user-agent-data-types@npm:^0.3.0":
version: 0.3.0
resolution: "user-agent-data-types@npm:0.3.0"
checksum: 73a61ddfba17e7289a1312fc98c4f1f98d6353e6ace3e3159b36735e6a679b05bcb2afdfb8e602d690a89e6af44669783045ed9059f4bd0890f8e0ee8a772ee5
languageName: node
linkType: hard
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"