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"