firmware: add UI for EV3 firmware flashing

This is just the UI bits (new file template, Pybricks firmware install,
official firmware restore).

The Pybricks firmware flashing hangs because we don't have a EV3 firmware
yet in @pybricks/firmware. Official firmware restore has an alert()
placeholder so at least it does something.
This commit is contained in:
David Lechner
2025-08-09 21:51:47 -05:00
parent 8b5cab4760
commit 007640c7b6
14 changed files with 284 additions and 74 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@pybricks/firmware": "7.22.0",
"@pybricks/ide-docs": "2.20.0",
"@pybricks/images": "^1.3.0",
"@pybricks/images": "^1.4.0",
"@pybricks/jedi": "1.17.0",
"@pybricks/mpy-cross-v5": "^2.0.0",
"@pybricks/mpy-cross-v6": "^2.0.0",
+7 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
// Copyright (c) 2022-2025 The Pybricks Authors
import './hubPicker.scss';
import { Radio, RadioGroup } from '@blueprintjs/core';
@@ -66,6 +66,12 @@ export const HubPicker: React.FunctionComponent<HubPickerProps> = ({ disabled })
label="MINDSTORMS Robot Inventor Hub"
/>
</Radio>
<Radio value={Hub.EV3}>
<HubIcon
url={new URL('@pybricks/images/hub-ev3.png', import.meta.url)}
label="MINDSTORMS EV3"
/>
</Radio>
</RadioGroup>
);
};
+7 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// Copyright (c) 2022-2025 The Pybricks Authors
/** Supported hub types. */
export enum Hub {
@@ -15,6 +15,8 @@ export enum Hub {
Prime = 'primehub',
/** SPIKE Essential hub */
Essential = 'essentialhub',
/** MINDSTORMS EV3 hub */
EV3 = 'ev3',
}
/**
@@ -25,6 +27,7 @@ export function hubHasUSB(hub: Hub): boolean {
case Hub.Prime:
case Hub.Essential:
case Hub.Inventor:
case Hub.EV3:
return true;
default:
return false;
@@ -52,6 +55,7 @@ export function hubHasExternalFlash(hub: Hub): boolean {
case Hub.Prime:
case Hub.Essential:
case Hub.Inventor:
case Hub.EV3:
return true;
default:
return false;
@@ -69,5 +73,7 @@ export function hubBootloaderType(hub: Hub) {
case Hub.City:
case Hub.Technic:
return 'ble-lwp3-bootloader';
case Hub.EV3:
return 'usb-ev3';
}
}
+17 -2
View File
@@ -309,7 +309,9 @@ export const language = <monaco.languages.IMonarchLanguage>{
*/
function createTemplate(hubClassName: string, deviceClassNames: string[]): string {
return `from pybricks.hubs import ${hubClassName}
from pybricks.pupdevices import ${deviceClassNames.join(', ')}
from pybricks.${
hubClassName === 'EV3Brick' ? 'ev3devices' : 'pupdevices'
} import ${deviceClassNames.join(', ')}
from pybricks.parameters import Button, Color, Direction, Port, Side, Stop
from pybricks.robotics import DriveBase
from pybricks.tools import wait, StopWatch
@@ -325,7 +327,8 @@ type HubLabel =
| 'technichub'
| 'inventorhub'
| 'primehub'
| 'essentialhub';
| 'essentialhub'
| 'ev3';
const templateSnippets: Array<
Required<
@@ -375,6 +378,18 @@ const templateSnippets: Array<
'ColorLightMatrix',
]),
},
{
label: 'ev3',
documentation: 'Template for MINDSTORMS EV3 program.',
insertText: createTemplate('EV3Brick', [
'Motor',
'ColorSensor',
'GyroSensor',
'InfraredSensor',
'TouchSensor',
'UltrasonicSensor',
]),
},
];
/**
+37 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
// Copyright (c) 2020-2025 The Pybricks Authors
import { FirmwareReaderError, HubType } from '@pybricks/firmware';
import { createAction } from '../actions';
@@ -429,3 +429,39 @@ export const firmwareDidRestoreOfficialDfu = createAction(() => ({
export const firmwareDidFailToRestoreOfficialDfu = createAction(() => ({
type: 'firmware.action.didFailToRestoreOfficialDfu',
}));
/**
* Versions of official LEGO EV3 firmware available for restore.
*/
export enum EV3OfficialFirmwareVersion {
/** Official LEGO EV3 firmware version 1.09H (Home edition). */
home = '1.09H',
/** Official LEGO EV3 firmware version 1.09E (Education edition). */
education = '1.09E',
/** Official LEGO EV3 firmware version 1.10E (only useful for Microsoft MakeCode). */
makecode = '1.10E',
}
/**
* Action that triggers the restore official EV3 firmware saga.
*/
export const firmwareRestoreOfficialEV3 = createAction(
(version: EV3OfficialFirmwareVersion) => ({
type: 'firmware.action.restoreOfficialEV3',
version,
}),
);
/**
* Action that indicates {@link firmwareRestoreOfficialEV3} succeeded.
*/
export const firmwareDidRestoreOfficialEV3 = createAction(() => ({
type: 'firmware.action.didRestoreOfficialEV3',
}));
/**
* Action that indicates {@link firmwareRestoreOfficialEV3} failed.
*/
export const firmwareDidFailToRestoreOfficialEV3 = createAction(() => ({
type: 'firmware.action.didFailToRestoreOfficialEV3',
}));
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
// Copyright (c) 2022-2025 The Pybricks Authors
import './bootloaderInstructions.scss';
import { Callout, Intent } from '@blueprintjs/core';
@@ -117,7 +117,11 @@ const BootloaderInstructions: React.FunctionComponent<BootloaderInstructionsProp
const { button, light, lightPattern } = useMemo(() => {
return {
button: i18n.translate(
hubHasBluetoothButton(hubType) ? 'button.bluetooth' : 'button.power',
hubType === Hub.EV3
? 'button.right'
: hubHasBluetoothButton(hubType)
? 'button.bluetooth'
: 'button.power',
),
light: i18n.translate(
hubHasBluetoothButton(hubType) ? 'light.bluetooth' : 'light.status',
@@ -163,13 +167,16 @@ const BootloaderInstructions: React.FunctionComponent<BootloaderInstructionsProp
const prepareSteps = useMemo(
() => (
<>
<li>
{i18n.translate(
hubHasUSB(hubType)
? 'instructionGroup.prepare.usb'
: 'instructionGroup.prepare.batteries',
)}
</li>
{hubType !== Hub.EV3 && (
<li>
{i18n.translate(
hubHasUSB(hubType)
? 'instructionGroup.prepare.usb'
: 'instructionGroup.prepare.batteries',
)}
</li>
)}
<li>{i18n.translate('instructionGroup.prepare.turnOff')}</li>
{/* For non-usb recovery, show step about official app */}
{recovery && !hubHasUSB(hubType) && (
@@ -179,6 +186,12 @@ const BootloaderInstructions: React.FunctionComponent<BootloaderInstructionsProp
})}
</li>
)}
{hubType === Hub.EV3 && (
<li>
{i18n.translate('instructionGroup.bootloaderMode.connectUsb')}
</li>
)}
</>
),
[i18n, recovery, hubType],
@@ -210,7 +223,37 @@ const BootloaderInstructions: React.FunctionComponent<BootloaderInstructionsProp
{/* not strictly necessary, but order is swapped in the video,
so we match it here. */}
{hubType !== Hub.Essential && hubHasUSB(hubType) && (
{(hubType === Hub.Prime || hubType === Hub.Inventor) &&
hubHasUSB(hubType) && (
<li
className={classNames(
activeStep === 'connect-usb' && 'pb-active-step',
)}
>
{i18n.translate(
'instructionGroup.bootloaderMode.connectUsb',
)}
</li>
)}
{hubType !== Hub.EV3 && (
<li
className={classNames(
activeStep === 'wait-for-light' && 'pb-active-step',
)}
>
{i18n.translate(
'instructionGroup.bootloaderMode.waitForLight',
{
button,
light,
lightPattern,
},
)}
</li>
)}
{hubType === Hub.Essential && hubHasUSB(hubType) && (
<li
className={classNames(
activeStep === 'connect-usb' && 'pb-active-step',
@@ -220,25 +263,15 @@ const BootloaderInstructions: React.FunctionComponent<BootloaderInstructionsProp
</li>
)}
<li
className={classNames(
activeStep === 'wait-for-light' && 'pb-active-step',
)}
>
{i18n.translate('instructionGroup.bootloaderMode.waitForLight', {
button,
light,
lightPattern,
})}
</li>
{hubType === Hub.Essential && hubHasUSB(hubType) && (
{hubType === Hub.EV3 && (
<li
className={classNames(
activeStep === 'connect-usb' && 'pb-active-step',
activeStep === 'press-power-button' && 'pb-active-step',
)}
>
{i18n.translate('instructionGroup.bootloaderMode.connectUsb')}
{i18n.translate(
'instructionGroup.bootloaderMode.pressPowerButtonEV3',
)}
</li>
)}
@@ -262,7 +295,9 @@ const BootloaderInstructions: React.FunctionComponent<BootloaderInstructionsProp
)}
>
{i18n.translate(
'instructionGroup.bootloaderMode.releaseButton',
hubType === Hub.EV3
? 'instructionGroup.bootloaderMode.releaseButtonsEV3'
: 'instructionGroup.bootloaderMode.releaseButton',
{
button,
},
@@ -342,6 +377,13 @@ const BootloaderInstructions: React.FunctionComponent<BootloaderInstructionsProp
1
}
>
{hubType === Hub.EV3 && (
<li>
{i18n.translate(
'instructionGroup.connect.selectEV3FirmwareType',
)}
</li>
)}
<li>
{i18n.translate(
'instructionGroup.connect.clickConnectAndFlash',
@@ -389,7 +431,7 @@ const BootloaderInstructions: React.FunctionComponent<BootloaderInstructionsProp
</Callout>
)}
{hubHasUSB(hubType) && isWindows() && (
{hubHasUSB(hubType) && hubType !== Hub.EV3 && isWindows() && (
<Callout intent={Intent.WARNING} icon={<WarningSign />}>
{i18n.translate('warning.windows.message', {
instructions: (
@@ -11,7 +11,8 @@
},
"button": {
"bluetooth": "Bluetooth button",
"power": "button"
"power": "button",
"right": "right button"
},
"light": {
"bluetooth": "Bluetooth light",
@@ -37,10 +38,13 @@
"waitForLight": "Wait for the {light} to start blinking {lightPattern}.",
"waitAppConnect": "The app will automatically connect and start restoring the firmware.",
"releaseButton": "Release the {button}.",
"keepHolding": "Keep holding the {button} in the next steps. We'll tell you when to let go."
"keepHolding": "Keep holding the {button} in the next steps. We'll tell you when to let go.",
"pressPowerButtonEV3": "Press the center button to turn on the EV3.",
"releaseButtonsEV3": "When the screen says \"Updating...\", release both buttons."
},
"connect": {
"title": "Install:",
"selectEV3FirmwareType": "Select the version of firmware to restore below.",
"clickConnectAndFlash": "Click the {flashButton} button below.",
"selectDevice": "In the pop-up dialog, select {deviceName} and click {connectButton}.",
"connectButton": "Connect"
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
// Copyright (c) 2022-2025 The Pybricks Authors
import './installPybricksDialog.scss';
import {
@@ -142,11 +142,6 @@ const UnsupportedHubs: React.FunctionComponent = () => {
'selectHubPanel.notOnListButton.info.mindstorms.nxt',
)}
</li>
<li>
{i18n.translate(
'selectHubPanel.notOnListButton.info.mindstorms.ev3',
)}
</li>
</ul>
<h4>
{i18n.translate('selectHubPanel.notOnListButton.info.poweredUp.title')}
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// Copyright (c) 2022-2025 The Pybricks Authors
import { createAction } from '../../actions';
@@ -8,7 +8,7 @@ export const firmwareInstallPybricksDialogShow = createAction(() => ({
type: 'firmware.installPybricksDialog.action.show',
}));
type FlashMethod = 'ble-lwp3-bootloader' | 'usb-lego-dfu';
type FlashMethod = 'ble-lwp3-bootloader' | 'usb-lego-dfu' | 'usb-ev3';
/**
* Action that indicates the user accepted the install Pybricks firmware dialog.
@@ -14,8 +14,7 @@
"sponsor": "sponsor"
},
"rcx": "RCX: Might work in streaming mode.",
"nxt": "NXT: We have tested this. It could work!",
"ev3": "EV3: Supported using Visual Studio Code."
"nxt": "NXT: We have tested this. It could work!"
},
"poweredUp": {
"title": "Other hubs",
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
// Copyright (c) 2022-2025 The Pybricks Authors
import {
Button,
@@ -7,10 +7,13 @@ import {
DialogStep,
Intent,
MultistepDialog,
Radio,
RadioGroup,
} from '@blueprintjs/core';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useLocalStorage } from 'usehooks-ts';
import {
legoEducationSpikeRegisteredTrademark,
legoMindstormsRegisteredTrademark,
@@ -20,7 +23,11 @@ import { Hub, hubHasUSB } from '../../components/hubPicker';
import { HubPicker } from '../../components/hubPicker/HubPicker';
import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks';
import { useSelector } from '../../reducers';
import { firmwareRestoreOfficialDfu } from '../actions';
import {
EV3OfficialFirmwareVersion,
firmwareRestoreOfficialDfu,
firmwareRestoreOfficialEV3,
} from '../actions';
import BootloaderInstructions from '../bootloaderInstructions/BootloaderInstructions';
import { firmwareRestoreOfficialDialogHide } from './actions';
import { useI18n } from './i18n';
@@ -51,11 +58,20 @@ const RestoreFirmwarePanel: React.FunctionComponent = () => {
s.firmware.isFirmwareFlashUsbDfuInProgress ||
s.firmware.isFirmwareRestoreOfficialDfuInProgress,
);
const [ev3OfficialFirmwareVersion, setEv3OfficialFirmwareVersion] =
useLocalStorage<EV3OfficialFirmwareVersion>(
'ev3OfficialFirmwareVersion',
EV3OfficialFirmwareVersion.home,
);
const handleRestoreButtonClick = useCallback(() => {
const handleRestoreDfuButtonClick = useCallback(() => {
dispatch(firmwareRestoreOfficialDfu(hubType));
}, [dispatch, hubType]);
const handleRestoreEV3ButtonClick = useCallback(() => {
dispatch(firmwareRestoreOfficialEV3(ev3OfficialFirmwareVersion));
}, [dispatch, ev3OfficialFirmwareVersion]);
return (
<div className={classNames(Classes.DIALOG_BODY, Classes.RUNNING_TEXT)}>
<BootloaderInstructions
@@ -63,32 +79,103 @@ const RestoreFirmwarePanel: React.FunctionComponent = () => {
recovery
flashButtonText={i18n.translate('restoreFirmwarePanel.flashButton')}
/>
{hubHasUSB(hubType) ? (
<>
<p>
{i18n.translate('restoreFirmwarePanel.instruction2.updateApp', {
app:
hubType === Hub.Inventor
? legoMindstormsRegisteredTrademark
: legoEducationSpikeRegisteredTrademark,
})}{' '}
{hubType !== Hub.Inventor
? i18n.translate(
'restoreFirmwarePanel.instruction2.updateAppVersion',
)
: ''}
</p>
<div className="pb-spacer" />
{hubType === Hub.EV3 ? (
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-evenly',
height: '100%',
}}
>
<Button
intent={Intent.PRIMARY}
disabled={inProgress}
onClick={handleRestoreButtonClick}
onClick={handleRestoreEV3ButtonClick}
>
{i18n.translate('restoreFirmwarePanel.flashButton')}
</Button>
</>
<RadioGroup
selectedValue={ev3OfficialFirmwareVersion}
onChange={(event) =>
setEv3OfficialFirmwareVersion(
event.currentTarget.value as EV3OfficialFirmwareVersion,
)
}
>
<Radio
value={EV3OfficialFirmwareVersion.home}
checked={
ev3OfficialFirmwareVersion ===
EV3OfficialFirmwareVersion.home
}
>
{i18n.translate(
'restoreFirmwarePanel.ev3FirmwareType.home',
)}
</Radio>
<Radio
value={EV3OfficialFirmwareVersion.education}
checked={
ev3OfficialFirmwareVersion ===
EV3OfficialFirmwareVersion.education
}
>
{i18n.translate(
'restoreFirmwarePanel.ev3FirmwareType.education',
)}
</Radio>
<Radio
value={EV3OfficialFirmwareVersion.makecode}
checked={
ev3OfficialFirmwareVersion ===
EV3OfficialFirmwareVersion.makecode
}
>
{i18n.translate(
'restoreFirmwarePanel.ev3FirmwareType.makecode',
)}
</Radio>
</RadioGroup>
</div>
) : (
<p>{i18n.translate('restoreFirmwarePanel.instruction2.ble.message')}</p>
<>
{hubHasUSB(hubType) ? (
<>
<p>
{i18n.translate(
'restoreFirmwarePanel.instruction2.updateApp',
{
app:
hubType === Hub.Inventor
? legoMindstormsRegisteredTrademark
: legoEducationSpikeRegisteredTrademark,
},
)}{' '}
{hubType !== Hub.Inventor
? i18n.translate(
'restoreFirmwarePanel.instruction2.updateAppVersion',
)
: ''}
</p>
<div className="pb-spacer" />
<Button
intent={Intent.PRIMARY}
disabled={inProgress}
onClick={handleRestoreDfuButtonClick}
>
{i18n.translate('restoreFirmwarePanel.flashButton')}
</Button>
</>
) : (
<p>
{i18n.translate(
'restoreFirmwarePanel.instruction2.ble.message',
)}
</p>
)}
</>
)}
</div>
);
@@ -22,6 +22,11 @@
"updateApp": "Once the recovery is complete, use the {app} app to get the latest official firmware.",
"updateAppVersion": "You can use version 2 (legacy) or version 3 to do so."
},
"flashButton": "Restore"
"flashButton": "Restore",
"ev3FirmwareType": {
"home": "Home edition (v1.09H)",
"education": "Education edition (v1.09E)",
"makecode": "Microsoft MakeCode (v1.10E)"
}
}
}
+16 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2024 The Pybricks Authors
// Copyright (c) 2020-2025 The Pybricks Authors
import {
FirmwareReader,
@@ -73,11 +73,13 @@ import {
didStart,
firmwareDidFailToFlashUsbDfu,
firmwareDidFailToRestoreOfficialDfu,
firmwareDidFailToRestoreOfficialEV3,
firmwareDidFlashUsbDfu,
firmwareDidRestoreOfficialDfu,
firmwareFlashUsbDfu,
firmwareInstallPybricks,
firmwareRestoreOfficialDfu,
firmwareRestoreOfficialEV3,
flashFirmware,
} from './actions';
import { firmwareDfuWindowsDriverInstallDialogDialogShow } from './dfuWindowsDriverInstallDialog/actions';
@@ -912,6 +914,10 @@ function* handleInstallPybricks(): Generator {
);
}
break;
case 'usb-ev3':
// TODO: implement flashing via EV3 USB
console.error('Flashing via EV3 USB is not implemented yet');
break;
}
}
@@ -988,9 +994,18 @@ function* handleRestoreOfficialDfu(
}
}
function* handleRestoreOfficialEV3(
action: ReturnType<typeof firmwareRestoreOfficialEV3>,
): Generator {
action;
alert('Flashing via EV3 USB is not implemented yet');
yield* put(firmwareDidFailToRestoreOfficialEV3());
}
export default function* (): Generator {
yield* takeEvery(flashFirmware, handleFlashFirmware);
yield* takeEvery(firmwareFlashUsbDfu, handleFlashUsbDfu);
yield* takeEvery(firmwareInstallPybricks, handleInstallPybricks);
yield* takeEvery(firmwareRestoreOfficialDfu, handleRestoreOfficialDfu);
yield* takeEvery(firmwareRestoreOfficialEV3, handleRestoreOfficialEV3);
}
+5 -5
View File
@@ -2939,10 +2939,10 @@ __metadata:
languageName: node
linkType: hard
"@pybricks/images@npm:^1.3.0":
version: 1.3.0
resolution: "@pybricks/images@npm:1.3.0"
checksum: aad203588d967cd2dc3859226a4349992fec45f443b74a6b8e55f714c895fa25ee147c2e906b604bc48f93c48cd75526ec487a254ca142426d61ba29b7101d14
"@pybricks/images@npm:^1.4.0":
version: 1.4.0
resolution: "@pybricks/images@npm:1.4.0"
checksum: 644be26497cb648bebab6be5f7cc5dae28fbe210f1cb5435316d10411a9a4a016abc37fe6fb52c2dd06c2e45bcdb5c38db0a6fbb31690ee4ea6bfdacbc67c25c
languageName: node
linkType: hard
@@ -2976,7 +2976,7 @@ __metadata:
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.11
"@pybricks/firmware": 7.22.0
"@pybricks/ide-docs": 2.20.0
"@pybricks/images": ^1.3.0
"@pybricks/images": ^1.4.0
"@pybricks/jedi": 1.17.0
"@pybricks/mpy-cross-v5": ^2.0.0
"@pybricks/mpy-cross-v6": ^2.0.0