firmware: implement EV3 official firmware restore

Basic support for restoring the official EV3 firmware. Is missing a video
and needs a bunch of helpful error messages, but basic functionality
is working.
This commit is contained in:
David Lechner
2025-08-09 21:51:47 -05:00
parent 007640c7b6
commit 70bedecb83
15 changed files with 446 additions and 10 deletions
+1
View File
@@ -35,6 +35,7 @@
"@types/react-transition-group": "^4.4.10",
"@types/redux-logger": "^3.0.12",
"@types/semver": "^7.5.6",
"@types/w3c-web-hid": "^1.0.6",
"@types/w3c-web-usb": "^1.0.10",
"@types/web-bluetooth": "^0.0.20",
"@types/web-locks-api": "^0.0.5",
+42
View File
@@ -465,3 +465,45 @@ export const firmwareDidRestoreOfficialEV3 = createAction(() => ({
export const firmwareDidFailToRestoreOfficialEV3 = createAction(() => ({
type: 'firmware.action.didFailToRestoreOfficialEV3',
}));
/**
* Low-level action to flash firmware to an EV3 hub.
* @param firmware The firmware binary blob.
*/
export const firmwareFlashEV3 = createAction((firmware: ArrayBuffer) => ({
type: 'firmware.action.flashEV3',
firmware,
}));
/**
* Low-level action that indicates {@link firmwareFlashEV3} succeeded.
*/
export const firmwareDidFlashEV3 = createAction(() => ({
type: 'firmware.action.didFlashEV3',
}));
/**
* Low-level action that indicates {@link firmwareFlashEV3} failed.
*/
export const firmwareDidFailToFlashEV3 = createAction(() => ({
type: 'firmware.action.didFailToFlashEV3',
}));
export const firmwareDidReceiveEV3Reply = createAction(
(
length: number,
replyNumber: number,
messageType: number,
replyCommand: number,
status: number,
payload: ArrayBufferLike,
) => ({
type: 'firmware.action.didReceiveEV3Reply',
length,
replyNumber,
messageType,
replyCommand,
status,
payload,
}),
);
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { Intent } from '@blueprintjs/core';
import { Error } from '@blueprintjs/icons';
import React from 'react';
import type { CreateToast } from '../../toasterTypes';
import { useI18n } from './i18n';
const NoWebHid: React.FunctionComponent = () => {
const i18n = useI18n();
return (
<>
<p>{i18n.translate('noWebHid.message')}</p>
<p>{i18n.translate('noWebHid.suggestion')}</p>
</>
);
};
export const noWebHid: CreateToast = (onAction) => ({
message: <NoWebHid />,
icon: <Error />,
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
});
+3 -1
View File
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// Copyright (c) 2022-2025 The Pybricks Authors
import { dfuError } from './DfuError';
import { flashProgress } from './FlashProgress';
import { noDfuHub } from './NoDfuHub';
import { noDfuInterface } from './NoDfuInterface';
import { noWebHid } from './NoWebHid';
import { noWebUsb } from './NoWebUsb';
import { releaseButton } from './ReleaseButton';
@@ -13,6 +14,7 @@ export default {
flashProgress,
noDfuHub,
noDfuInterface,
noWebHid,
noWebUsb,
releaseButton,
};
+4
View File
@@ -8,6 +8,10 @@
"message": "This browser does not support WebUSB or WebUSB is not enabled.",
"suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge."
},
"noWebHid": {
"message": "This browser does not support WebHID or WebHID is not enabled.",
"suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge."
},
"noDfuHub": {
"message": "Could not find your hub?",
"suggestion1": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -453,7 +453,8 @@ export const InstallPybricksDialog: React.FunctionComponent = () => {
const inProgress = useSelector(
(s) =>
s.firmware.isFirmwareFlashUsbDfuInProgress ||
s.firmware.isFirmwareRestoreOfficialDfuInProgress,
s.firmware.isFirmwareRestoreOfficialDfuInProgress ||
s.firmware.isFirmwareFlashEV3InProgress,
);
const dispatch = useDispatch();
const [hubName, setHubName] = useState('');
+2 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2023 The Pybricks Authors
// Copyright (c) 2021-2025 The Pybricks Authors
import { AnyAction } from 'redux';
import {
@@ -23,6 +23,7 @@ test('initial state', () => {
"installPybricksDialog": {
"isOpen": false,
},
"isFirmwareFlashEV3InProgress": false,
"isFirmwareFlashUsbDfuInProgress": false,
"isFirmwareRestoreOfficialDfuInProgress": false,
"progress": null,
+20 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2023 The Pybricks Authors
// Copyright (c) 2021-2025 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import {
@@ -9,10 +9,13 @@ import {
didStart,
firmwareDidFailToFlashUsbDfu,
firmwareDidFailToRestoreOfficialDfu,
firmwareDidFailToRestoreOfficialEV3,
firmwareDidFlashUsbDfu,
firmwareDidRestoreOfficialDfu,
firmwareDidRestoreOfficialEV3,
firmwareFlashUsbDfu,
firmwareRestoreOfficialDfu,
firmwareRestoreOfficialEV3,
} from './actions';
import dfuWindowsDriverInstallDialog from './dfuWindowsDriverInstallDialog/reducers';
import installPybricksDialog from './installPybricksDialog/reducers';
@@ -77,6 +80,21 @@ const isFirmwareRestoreOfficialDfuInProgress: Reducer<boolean> = (
return state;
};
const isFirmwareFlashEV3InProgress: Reducer<boolean> = (state = false, action) => {
if (firmwareRestoreOfficialEV3.matches(action)) {
return true;
}
if (firmwareDidRestoreOfficialEV3.matches(action)) {
return false;
}
if (firmwareDidFailToRestoreOfficialEV3.matches(action)) {
return false;
}
return state;
};
export default combineReducers({
dfuWindowsDriverInstallDialog,
installPybricksDialog,
@@ -85,4 +103,5 @@ export default combineReducers({
progress,
isFirmwareFlashUsbDfuInProgress,
isFirmwareRestoreOfficialDfuInProgress,
isFirmwareFlashEV3InProgress,
});
@@ -56,7 +56,8 @@ const RestoreFirmwarePanel: React.FunctionComponent = () => {
const inProgress = useSelector(
(s) =>
s.firmware.isFirmwareFlashUsbDfuInProgress ||
s.firmware.isFirmwareRestoreOfficialDfuInProgress,
s.firmware.isFirmwareRestoreOfficialDfuInProgress ||
s.firmware.isFirmwareFlashEV3InProgress,
);
const [ev3OfficialFirmwareVersion, setEv3OfficialFirmwareVersion] =
useLocalStorage<EV3OfficialFirmwareVersion>(
+335 -4
View File
@@ -22,6 +22,7 @@ import {
call,
cancel,
delay,
fork,
getContext,
put,
race,
@@ -61,9 +62,10 @@ 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 { assert, defined, ensureError, hex, maybe } from '../utils';
import { crc32, fmod, sumComplement32 } from '../utils/math';
import {
EV3OfficialFirmwareVersion,
FailToFinishReasonType,
HubError,
MetadataProblem,
@@ -71,11 +73,16 @@ import {
didFinish,
didProgress,
didStart,
firmwareDidFailToFlashEV3,
firmwareDidFailToFlashUsbDfu,
firmwareDidFailToRestoreOfficialDfu,
firmwareDidFailToRestoreOfficialEV3,
firmwareDidFlashEV3,
firmwareDidFlashUsbDfu,
firmwareDidReceiveEV3Reply,
firmwareDidRestoreOfficialDfu,
firmwareDidRestoreOfficialEV3,
firmwareFlashEV3,
firmwareFlashUsbDfu,
firmwareInstallPybricks,
firmwareRestoreOfficialDfu,
@@ -994,12 +1001,335 @@ function* handleRestoreOfficialDfu(
}
}
function* handleFlashEV3(action: ReturnType<typeof firmwareFlashEV3>): Generator {
if (navigator.hid === undefined) {
yield* put(alertsShowAlert('firmware', 'noWebHid'));
yield* put(firmwareDidFailToFlashEV3());
return;
}
const [hidDevices, hidDevicesError] = yield* call(() =>
maybe(
navigator.hid.requestDevice({
filters: [
{
vendorId: legoUsbVendorId,
productId: LegoUsbProductId.Ev3Bootloader,
},
],
}),
),
);
if (hidDevicesError) {
// TODO: show info message with tips on how to get EV3 into bootloader mode
console.error(hidDevicesError);
yield* put(firmwareDidFailToFlashEV3());
return;
}
defined(hidDevices);
if (hidDevices.length === 0) {
yield* put(
alertsShowAlert('alerts', 'unexpectedError', {
error: new Error('no EV3 HID devices found'),
}),
);
yield* put(firmwareDidFailToFlashEV3());
return;
}
// Only flash one device.
const hidDevice = hidDevices[0];
const exitStack: Array<() => Promise<void>> = [];
function* cleanup() {
for (const func of exitStack.reverse()) {
yield* call(() => func());
}
}
const [, openError] = yield* call(() => maybe(hidDevice.open()));
if (openError) {
console.error(openError);
yield* put(alertsShowAlert('alerts', 'unexpectedError', { error: openError }));
yield* put(firmwareDidFailToFlashEV3());
yield* cleanup();
return;
}
exitStack.push(() => hidDevice.close());
const inputChannel = eventChannel<HIDInputReportEvent>((emit) => {
hidDevice.addEventListener('inputreport', emit);
return () => hidDevice.removeEventListener('inputreport', emit);
});
exitStack.push(async () => inputChannel.close());
function* readInputReports(): SagaGenerator<void> {
for (;;) {
const event = yield* take(inputChannel);
if (event.data.byteLength === 0) {
continue; // ignore empty reports
}
const length = event.data.getInt16(0, true);
const replyNumber = event.data.getInt16(2, true);
const messageType = event.data.getUint8(4);
const replyCommand = event.data.getUint8(5);
const status = event.data.getUint8(6);
const payload = event.data.buffer.slice(7, 7 + length + 2);
console.debug(
`EV3 reply: length=${length}, replyNumber=${replyNumber}, messageType=${messageType}, replyCommand=${replyCommand}, status=${status}, payload=${payload}`,
);
yield* put(
firmwareDidReceiveEV3Reply(
length,
replyNumber,
messageType,
replyCommand,
status,
payload,
),
);
}
}
const readInputReportsTask = yield* fork(readInputReports);
exitStack.push(async () => readInputReportsTask.cancel());
function* sendCommand(
command: number,
payload?: Uint8Array,
): SagaGenerator<[DataView | undefined, Error | undefined]> {
console.debug(`EV3 send: command=${command}, payload=${payload}`);
const dataBuffer = new Uint8Array((payload?.byteLength ?? 0) + 6);
const data = new DataView(dataBuffer.buffer);
data.setInt16(0, (payload?.byteLength ?? 0) + 4, true);
data.setInt16(2, 0, true); // TODO: reply number
data.setUint8(4, 0x01); // system command w/ reply
data.setUint8(5, command);
if (payload) {
dataBuffer.set(payload, 6);
}
const [, sendError] = yield* call(() => maybe(hidDevice.sendReport(0, data)));
if (sendError) {
return [undefined, sendError];
}
const { reply, timeout } = yield* race({
reply: take(firmwareDidReceiveEV3Reply),
timeout: delay(5000),
});
if (timeout) {
return [undefined, new Error('Timeout waiting for EV3 reply')];
}
defined(reply);
if (reply.replyCommand !== command) {
return [
undefined,
new Error(
`EV3 reply command mismatch: expected ${command}, got ${reply.replyCommand}`,
),
];
}
if (reply.status !== 0) {
return [
undefined,
new Error(
`EV3 reply status error: ${reply.status} for command ${command}`,
),
];
}
return [new DataView(reply.payload), undefined];
}
const [version, versionError] = yield* sendCommand(0xf6); // get version
if (versionError) {
yield* put(
alertsShowAlert('alerts', 'unexpectedError', {
error: ensureError(versionError),
}),
);
yield* put(firmwareDidFailToFlashEV3());
yield* cleanup();
return;
}
defined(version);
console.debug(
`EV3 bootloader version: ${version.getUint32(
0,
true,
)}, HW version: ${version.getUint32(4, true)}`,
);
// FIXME: should be called much earlier.
yield* put(didStart());
const sectorSize = 64 * 1024; // flash memory sector size
const maxPayloadSize = 1018; // maximum payload size for EV3 commands
for (let i = 0; i < action.firmware.byteLength; i += sectorSize) {
const sectorData = action.firmware.slice(i, i + sectorSize);
assert(sectorData.byteLength <= sectorSize, 'sector data too large');
const erasePayload = new DataView(new ArrayBuffer(8));
erasePayload.setUint32(0, i, true);
erasePayload.setUint32(4, sectorData.byteLength, true);
const [, eraseError] = yield* sendCommand(
0xf0,
new Uint8Array(erasePayload.buffer),
);
if (eraseError) {
yield* put(
alertsShowAlert('alerts', 'unexpectedError', {
error: eraseError,
}),
);
// FIXME: should have a better error reason
yield* put(didFailToFinish(FailToFinishReasonType.Unknown));
yield* put(firmwareDidFailToFlashEV3());
yield* cleanup();
return;
}
for (let j = 0; j < sectorData.byteLength; j += maxPayloadSize) {
const payload = sectorData.slice(j, j + maxPayloadSize);
const [, sendError] = yield* sendCommand(0xf2, new Uint8Array(payload));
if (sendError) {
yield* put(
alertsShowAlert('alerts', 'unexpectedError', {
error: sendError,
}),
);
// FIXME: should have a better error reason
yield* put(didFailToFinish(FailToFinishReasonType.Unknown));
yield* put(firmwareDidFailToFlashEV3());
yield* cleanup();
return;
}
}
yield* put(
didProgress((i + sectorData.byteLength) / action.firmware.byteLength),
);
yield* put(
alertsShowAlert(
'firmware',
'flashProgress',
{
action: 'flash',
progress: (i + sectorData.byteLength) / action.firmware.byteLength,
},
firmwareBleProgressToastId,
true,
),
);
}
yield* put(
alertsShowAlert(
'firmware',
'flashProgress',
{
action: 'flash',
progress: 1,
},
firmwareBleProgressToastId,
true,
),
);
const [, rebootError] = yield* sendCommand(0xf4); // start app
if (rebootError) {
// FIXME: should have a better error reason
yield* put(didFailToFinish(FailToFinishReasonType.Unknown));
yield* put(firmwareDidFailToFlashEV3());
yield* cleanup();
return;
}
yield* put(didFinish());
yield* cleanup();
yield* put(firmwareDidFlashEV3());
}
function getUrlForEV3FirmwareVersion(version: EV3OfficialFirmwareVersion): URL {
switch (version) {
case EV3OfficialFirmwareVersion.home:
return new URL('./assets/EV3_Firmware_V1.09H.bin', import.meta.url);
case EV3OfficialFirmwareVersion.education:
return new URL('./assets/ev3_firmware_v1.09e.bin', import.meta.url);
case EV3OfficialFirmwareVersion.makecode:
return new URL('./assets/ev3-image-1.10e.bin', import.meta.url);
default:
throw new Error(`unsupported EV3 firmware version: ${version}`);
}
}
function* handleRestoreOfficialEV3(
action: ReturnType<typeof firmwareRestoreOfficialEV3>,
): Generator {
action;
alert('Flashing via EV3 USB is not implemented yet');
yield* put(firmwareDidFailToRestoreOfficialEV3());
try {
const url = getUrlForEV3FirmwareVersion(action.version);
const response = yield* call(() => fetch(url));
if (!response.ok) {
// TODO: replace with proper alert
// istanbul ignore if
if (process.env.NODE_ENV !== 'test') {
console.error(response);
}
throw new Error('failed to fetch');
}
const firmwareBlob = yield* call(() => response.blob());
const firmware = yield* call(() => firmwareBlob.arrayBuffer());
yield* put(firmwareFlashEV3(firmware));
const { didFailToFlash } = yield* race({
didFlash: take(firmwareDidFlashEV3),
didFailToFlash: take(firmwareDidFailToFlashEV3),
});
if (didFailToFlash) {
yield* put(firmwareDidFailToRestoreOfficialEV3());
return;
}
yield* put(firmwareDidRestoreOfficialEV3());
} catch (err) {
// istanbul ignore if
if (process.env.NODE_ENV !== 'test') {
console.error(err);
}
yield* put(
alertsShowAlert('alerts', 'unexpectedError', { error: ensureError(err) }),
);
yield* put(firmwareDidFailToRestoreOfficialEV3());
}
}
export default function* (): Generator {
@@ -1007,5 +1337,6 @@ export default function* (): Generator {
yield* takeEvery(firmwareFlashUsbDfu, handleFlashUsbDfu);
yield* takeEvery(firmwareInstallPybricks, handleInstallPybricks);
yield* takeEvery(firmwareRestoreOfficialDfu, handleRestoreOfficialDfu);
yield* takeEvery(firmwareFlashEV3, handleFlashEV3);
yield* takeEvery(firmwareRestoreOfficialEV3, handleRestoreOfficialEV3);
}
+2 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2023 The Pybricks Authors
// Copyright (c) 2023-2025 The Pybricks Authors
import type { SerializableStateInvariantMiddlewareOptions } from '@reduxjs/toolkit';
@@ -13,6 +13,7 @@ export const serializableCheck: SerializableStateInvariantMiddlewareOptions = {
// contain ArrayBuffer, Blob or DataView
'data',
'file',
'firmware',
'firmwareZip',
'payload',
'value',
+8
View File
@@ -2998,6 +2998,7 @@ __metadata:
"@types/react-transition-group": ^4.4.10
"@types/redux-logger": ^3.0.12
"@types/semver": ^7.5.6
"@types/w3c-web-hid": ^1.0.6
"@types/w3c-web-usb": ^1.0.10
"@types/web-bluetooth": ^0.0.20
"@types/web-locks-api": ^0.0.5
@@ -5464,6 +5465,13 @@ __metadata:
languageName: node
linkType: hard
"@types/w3c-web-hid@npm:^1.0.6":
version: 1.0.6
resolution: "@types/w3c-web-hid@npm:1.0.6"
checksum: 14773befa9c458b3459cdb530a8269937e623e6b72c6bd2d7f88b42f8d47c02d8a64ddc98f79c81c930b6eadf1dc1c94917b553ead72acc13c8406f65310c85d
languageName: node
linkType: hard
"@types/w3c-web-usb@npm:^1.0.10":
version: 1.0.10
resolution: "@types/w3c-web-usb@npm:1.0.10"