diff --git a/package.json b/package.json
index fff12528..58f24173 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts
index f0d9f090..af64e80f 100644
--- a/src/firmware/actions.ts
+++ b/src/firmware/actions.ts
@@ -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,
+ }),
+);
diff --git a/src/firmware/alerts/NoWebHid.tsx b/src/firmware/alerts/NoWebHid.tsx
new file mode 100644
index 00000000..d590f434
--- /dev/null
+++ b/src/firmware/alerts/NoWebHid.tsx
@@ -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 (
+ <>
+
{i18n.translate('noWebHid.message')}
+ {i18n.translate('noWebHid.suggestion')}
+ >
+ );
+};
+
+export const noWebHid: CreateToast = (onAction) => ({
+ message: ,
+ icon: ,
+ intent: Intent.DANGER,
+ onDismiss: () => onAction('dismiss'),
+});
diff --git a/src/firmware/alerts/index.ts b/src/firmware/alerts/index.ts
index d25d162c..a6187418 100644
--- a/src/firmware/alerts/index.ts
+++ b/src/firmware/alerts/index.ts
@@ -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,
};
diff --git a/src/firmware/alerts/translations/en.json b/src/firmware/alerts/translations/en.json
index 51e4b974..d43aa55d 100644
--- a/src/firmware/alerts/translations/en.json
+++ b/src/firmware/alerts/translations/en.json
@@ -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": {
diff --git a/src/firmware/assets/EV3_Firmware_V1.09H.bin b/src/firmware/assets/EV3_Firmware_V1.09H.bin
new file mode 100644
index 00000000..17d8f2d6
Binary files /dev/null and b/src/firmware/assets/EV3_Firmware_V1.09H.bin differ
diff --git a/src/firmware/assets/ev3-image-1.10e.bin b/src/firmware/assets/ev3-image-1.10e.bin
new file mode 100644
index 00000000..8673dfd1
Binary files /dev/null and b/src/firmware/assets/ev3-image-1.10e.bin differ
diff --git a/src/firmware/assets/ev3_firmware_v1.09e.bin b/src/firmware/assets/ev3_firmware_v1.09e.bin
new file mode 100644
index 00000000..a0736064
Binary files /dev/null and b/src/firmware/assets/ev3_firmware_v1.09e.bin differ
diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx
index 8e424659..d4725a9b 100644
--- a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx
+++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx
@@ -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('');
diff --git a/src/firmware/reducers.test.ts b/src/firmware/reducers.test.ts
index 1e93e6c3..2a3ae568 100644
--- a/src/firmware/reducers.test.ts
+++ b/src/firmware/reducers.test.ts
@@ -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,
diff --git a/src/firmware/reducers.ts b/src/firmware/reducers.ts
index fd73fb65..552a6c4e 100644
--- a/src/firmware/reducers.ts
+++ b/src/firmware/reducers.ts
@@ -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 = (
return state;
};
+const isFirmwareFlashEV3InProgress: Reducer = (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,
});
diff --git a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx
index 88d2339c..60a0b1ae 100644
--- a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx
+++ b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx
@@ -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(
diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts
index 2b5cf30d..b3833e3a 100644
--- a/src/firmware/sagas.ts
+++ b/src/firmware/sagas.ts
@@ -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): 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> = [];
+ 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((emit) => {
+ hidDevice.addEventListener('inputreport', emit);
+ return () => hidDevice.removeEventListener('inputreport', emit);
+ });
+ exitStack.push(async () => inputChannel.close());
+
+ function* readInputReports(): SagaGenerator {
+ 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,
): 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);
}
diff --git a/src/redux.ts b/src/redux.ts
index 2a6991d2..adf96108 100644
--- a/src/redux.ts
+++ b/src/redux.ts
@@ -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',
diff --git a/yarn.lock b/yarn.lock
index 1777f174..906304ac 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"