From 6d07713eb52b4bb8d371924eec1e9e1bae45e322 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 21 Jul 2022 18:35:42 -0500 Subject: [PATCH] firmware: implement flashing via USB DFU --- CHANGELOG.md | 1 + package.json | 4 +- src/alerts.ts | 2 + src/app/constants.ts | 3 + src/firmware/actions.ts | 29 ++++ src/firmware/alerts/FirmwareMismatch.tsx | 21 +++ src/firmware/alerts/NoDfuHub.tsx | 43 ++++++ src/firmware/alerts/NoDfuInterface.tsx | 21 +++ src/firmware/alerts/NoWebUsb.tsx | 26 ++++ src/firmware/alerts/i18n.test.ts | 12 ++ src/firmware/alerts/i18n.ts | 22 +++ src/firmware/alerts/index.ts | 14 ++ src/firmware/alerts/translations/en.json | 21 +++ src/firmware/sagas.ts | 188 ++++++++++++++++++++++- src/usb/index.ts | 33 ++++ yarn.lock | 25 +++ 16 files changed, 458 insertions(+), 7 deletions(-) create mode 100644 src/firmware/alerts/FirmwareMismatch.tsx create mode 100644 src/firmware/alerts/NoDfuHub.tsx create mode 100644 src/firmware/alerts/NoDfuInterface.tsx create mode 100644 src/firmware/alerts/NoWebUsb.tsx create mode 100644 src/firmware/alerts/i18n.test.ts create mode 100644 src/firmware/alerts/i18n.ts create mode 100644 src/firmware/alerts/index.ts create mode 100644 src/firmware/alerts/translations/en.json create mode 100644 src/usb/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92535742..9f1aa2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package.json b/package.json index 5baf750a..8d229669 100644 --- a/package.json +++ b/package.json @@ -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)$)": "/config/jest/fileTransform.mjs" }, "transformIgnorePatterns": [ - "[/\\\\]node_modules[/\\\\](?!(monaco-editor|react-monaco-editor)[/\\\\]).+\\.(js|jsx|mjs|cjs|ts|tsx)$", + "[/\\\\]node_modules[/\\\\](?!(monaco-editor|react-monaco-editor|nanoevents)[/\\\\]).+\\.(js|jsx|mjs|cjs|ts|tsx)$", "^.+\\.module\\.(css|sass|scss)$" ], "modulePaths": [], diff --git a/src/alerts.ts b/src/alerts.ts index 4e372ef0..a203bbff 100644 --- a/src/alerts.ts +++ b/src/alerts.ts @@ -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. */ diff --git a/src/app/constants.ts b/src/app/constants.ts index f145da7d..7c0a22af 100644 --- a/src/app/constants.ts +++ b/src/app/constants.ts @@ -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'; diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index 6526d0fc..ab2343e8 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -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. */ diff --git a/src/firmware/alerts/FirmwareMismatch.tsx b/src/firmware/alerts/FirmwareMismatch.tsx new file mode 100644 index 00000000..ba9522df --- /dev/null +++ b/src/firmware/alerts/FirmwareMismatch.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +const FirmwareMismatch: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return

{i18n.translate(I18nId.FirmwareMismatchMessage)}

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

{i18n.translate(I18nId.NoDfuHubMessage)}

+ + {isWindows() &&

{i18n.translate(I18nId.NoDfuHubSuggestion1Windows)}

} + {isLinux() &&

{i18n.translate(I18nId.NoDfuHubSuggestion1Linux)}

} + +

{i18n.translate(I18nId.NoDfuHubSuggestion2)}

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

{i18n.translate(I18nId.NoDfuInterfaceMessage)}

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

{i18n.translate(I18nId.NoWebUsbMessage)}

+

{i18n.translate(I18nId.NoWebUsbSuggestion)}

+ + ); +}; + +export const noWebUsb: CreateToast = (onAction) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/i18n.test.ts b/src/firmware/alerts/i18n.test.ts new file mode 100644 index 00000000..e706ba28 --- /dev/null +++ b/src/firmware/alerts/i18n.test.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { lookup } from '../../../test'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; + +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { + expect(lookup(en, id)).toBeDefined(); + }); +}); diff --git a/src/firmware/alerts/i18n.ts b/src/firmware/alerts/i18n.ts new file mode 100644 index 00000000..35dfee63 --- /dev/null +++ b/src/firmware/alerts/i18n.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n'; + +export function useI18n(): I18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} + +export enum I18nId { + NoWebUsbMessage = 'noWebUsb.message', + NoWebUsbSuggestion = 'noWebUsb.suggestion', + NoDfuHubMessage = 'noDfuHub.message', + NoDfuHubSuggestion1Windows = 'noDfuHub.suggestion1.windows', + NoDfuHubSuggestion1Linux = 'noDfuHub.suggestion1.linux', + NoDfuHubSuggestion2 = 'noDfuHub.suggestion2', + NoDfuHubTroubleshootButton = 'noDfuHub.troubleshootButton', + NoDfuInterfaceMessage = 'noDfuInterface.message', + FirmwareMismatchMessage = 'firmwareMismatch.message', +} diff --git a/src/firmware/alerts/index.ts b/src/firmware/alerts/index.ts new file mode 100644 index 00000000..82ea36ab --- /dev/null +++ b/src/firmware/alerts/index.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { firmwareMismatch } from './FirmwareMismatch'; +import { noDfuHub } from './NoDfuHub'; +import { noDfuInterface } from './NoDfuInterface'; +import { noWebUsb } from './NoWebUsb'; + +export default { + firmwareMismatch, + noDfuHub, + noDfuInterface, + noWebUsb, +}; diff --git a/src/firmware/alerts/translations/en.json b/src/firmware/alerts/translations/en.json new file mode 100644 index 00000000..df5a2aa4 --- /dev/null +++ b/src/firmware/alerts/translations/en.json @@ -0,0 +1,21 @@ +{ + "noWebUsb": { + "message": "This browser does not support Web USB or Web USB is not enabled.", + "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge." + }, + "noDfuHub": { + "message": "Could not find your hub?", + "suggestion1": { + "windows": "You may need to manually install a USB driver before you can connect to your hub.", + "linux": "You may need to add udev rules before you can connect to your hub." + }, + "suggestion2": "Click the button below for more information.", + "troubleshootButton": "Troubleshooting Tips" + }, + "noDfuInterface": { + "message": "This is very unusual. The USB device did not contain the expected interface." + }, + "firmwareMismatch": { + "message": "Cannot flash firmware. The firmware file is for a different kind of hub." + } +} diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 128314e3..42cfd915 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -10,6 +10,7 @@ import { import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; import moveHubZip from '@pybricks/firmware/build/movehub.zip'; import technicHubZip from '@pybricks/firmware/build/technichub.zip'; +import { WebDFU } from 'dfu'; import { AnyAction } from 'redux'; import { ActionPattern } from 'redux-saga/effects'; import { @@ -25,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): Generat } } +/** Maps USB Product ID to LWP3 hub type ID */ +const productIdMap: ReadonlyMap = new Map([ + [LegoUsbProductId.SpikePrimeBootloader, HubType.PrimeHub], + [LegoUsbProductId.SpikeEssentialBootloader, HubType.EssentialHub], + [LegoUsbProductId.MindstormsRobotInventorBootloader, HubType.PrimeHub], +]); + +// currently all hubs use the same start address +const dfuFirmwareStartAddress = 0x08008000; + +function* handleFlashUsbDfu(action: ReturnType): Generator { + const defer = new Array<() => void>(); + + try { + // not all web browsers support Web USB + if (!navigator.usb) { + yield* put(alertsShowAlert('firmware', 'noWebUsb')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + const device = yield* call(() => + navigator.usb + .requestDevice({ + filters: [ + { + vendorId: legoUsbVendorId, + productId: LegoUsbProductId.SpikePrimeBootloader, + }, + { + vendorId: legoUsbVendorId, + productId: LegoUsbProductId.SpikeEssentialBootloader, + }, + { + vendorId: legoUsbVendorId, + productId: + LegoUsbProductId.MindstormsRobotInventorBootloader, + }, + ], + }) + .catch((err) => { + if ( + err instanceof DOMException && + err.code === DOMException.NOT_FOUND_ERR + ) { + // user clicked cancel button + return undefined; + } + + throw err; + }), + ); + + if (!device) { + yield* put(alertsShowAlert('firmware', 'noDfuHub')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + const dfu = new WebDFU( + device, + // forceInterfacesName is needed to get the flash layout map + { forceInterfacesName: true }, + { + info: console.debug, + warning: console.warn, + progress: (progress, total) => { + // TODO: bind to eventChannel and dispatch progress actions + console.log(progress, total); + }, + }, + ); + + yield* call(() => dfu.init()); + + // we want the interface with alt=0 + const ifaceIndex = dfu.interfaces.findIndex( + (i) => i.alternate.alternateSetting === 0, + ); + + if (ifaceIndex === -1) { + yield* put(alertsShowAlert('firmware', 'noDfuInterface')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + yield* call(() => dfu.connect(ifaceIndex)); + + defer.push(() => dfu.close()); + + const { firmware, deviceId } = yield* loadFirmware( + action.data, + undefined, + action.hubName, + ); + + if (deviceId !== productIdMap.get(device.productId)) { + yield* put(alertsShowAlert('firmware', 'firmwareMismatch')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + dfu.dfuseStartAddress = dfuFirmwareStartAddress; + const writeProc = dfu.write(1024, firmware, true); + + writeProc.events.on('error', console.error); + + // REVISIT: we could possibly race the 'write/end' and 'error' events + // here instead of waiting for disconnect + + // this is a bit of a hack, but the hub resets when flashing is done + // so we get a disconnect event unless there was an error, so the user + // will probably see the timeout error instead of the underlying error + yield* call(() => dfu.waitDisconnected(30000)); + + yield* put(firmwareDidFlashUsbDfu()); + } catch (err) { + if (process.env.NODE_ENV !== 'test') { + console.error(err); + } + + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { error: ensureError(err) }), + ); + + yield* put(firmwareDidFailToFlashUsbDfu()); + } finally { + while (defer.length !== 0) { + defer.pop()?.(); + } + } +} + function* handleInstallPybricks(): Generator { yield* put(firmwareInstallPybricksDialogShow()); const { accepted, canceled } = yield* race({ @@ -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); } diff --git a/src/usb/index.ts b/src/usb/index.ts new file mode 100644 index 00000000..93fb1f4a --- /dev/null +++ b/src/usb/index.ts @@ -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, +} diff --git a/yarn.lock b/yarn.lock index 6d92e8a3..e4e286d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"