firmware: implement flashing via USB DFU

This commit is contained in:
David Lechner
2022-07-21 18:36:32 -05:00
parent 28f63d5d85
commit 6d07713eb5
16 changed files with 458 additions and 7 deletions
+1
View File
@@ -7,6 +7,7 @@
### 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.
+3 -1
View File
@@ -32,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",
@@ -54,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",
@@ -192,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": [],
+2
View File
@@ -5,6 +5,7 @@ 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 */
@@ -12,6 +13,7 @@ const alertDomains = {
alerts,
ble,
explorer,
firmware,
};
/** Gets the type of available alert domains. */
+3
View File
@@ -35,6 +35,9 @@ 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';
+29
View File
@@ -346,6 +346,35 @@ function didFailToFinishCreator(
*/
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.
*/
+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."
}
}
+182 -6
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,6 +26,7 @@ import {
take,
takeEvery,
} from 'typed-redux-saga/macro';
import { alertsShowAlert } from '../alerts/actions';
import {
fileStorageDidFailToReadFile,
fileStorageDidReadFile,
@@ -55,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,
@@ -66,6 +69,9 @@ import {
didFinish,
didProgress,
didStart,
firmwareDidFailToFlashUsbDfu,
firmwareDidFlashUsbDfu,
firmwareFlashUsbDfu,
firmwareInstallPybricks,
flashFirmware,
} from './actions';
@@ -193,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);
@@ -220,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(
@@ -232,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);
@@ -246,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);
@@ -262,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,
@@ -271,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);
@@ -512,6 +551,139 @@ 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({
@@ -535,10 +707,14 @@ function* handleInstallPybricks(): Generator {
),
);
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);
}
+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,
}
+25
View File
@@ -2338,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
@@ -2362,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
@@ -4682,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"
@@ -7111,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"
@@ -10810,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"