mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-14 10:35:11 +00:00
firmware: implement flashing via USB DFU
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
};
|
||||
@@ -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'),
|
||||
};
|
||||
};
|
||||
@@ -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'),
|
||||
};
|
||||
};
|
||||
@@ -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'),
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user