From 8db98a89f91f0d7026a6b10072c387fa73d549fb Mon Sep 17 00:00:00 2001 From: James Aguilar Date: Sun, 4 Jan 2026 02:31:39 +0000 Subject: [PATCH 1/4] ev3: Don't request hardware version. This feature doesn't seem to work when it is issued from a USB3 bus. See https://github.com/pybricks/support/issues/2515. Since we don't use the version, removing the command is harmless. --- src/firmware/sagas.ts | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index a25e5b26..482957ad 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2025 The Pybricks Authors +// Copyright (c) 2020-2026 The Pybricks Authors import { FirmwareReader, @@ -1204,28 +1204,6 @@ function* handleFlashEV3(action: ReturnType): Generator 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()); From a33c5800160d4e9bede60f55d1e3820427b9c172 Mon Sep 17 00:00:00 2001 From: James Aguilar Date: Tue, 23 Dec 2025 23:27:13 +0000 Subject: [PATCH 2/4] firmware: Don't add checksums when not needed. This fixes an issue where the EV3 firmware had checksums appended to it that made its size not align to the sector size, causing flashing to fail. --- src/firmware/sagas.ts | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 482957ad..bff25c32 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -351,32 +351,20 @@ function* loadFirmware( 'Expected metadata to be v2.x', ); - const firmware = new Uint8Array(firmwareBase.length + 4); - const firmwareView = new DataView(firmware.buffer); - - firmware.set(firmwareBase); - - // empty string means use default name (don't write over firmware) - if (hubName) { - firmware.set(encodeHubName(hubName, metadata), metadata['hub-name-offset']); - } - - const checksum = (function () { + const [checksumFunc, checksumExtraLength] = (() => { switch (metadata['checksum-type']) { case 'sum': - return sumComplement32( - firmwareIterator(firmwareView, metadata['checksum-size']), - ); + return [sumComplement32, 4]; case 'crc32': - return crc32(firmwareIterator(firmwareView, metadata['checksum-size'])); + return [crc32, 4]; case 'none': - return null; + return [null, 0]; default: - return undefined; + return [undefined, 0]; } })(); - if (checksum === undefined) { + if (checksumFunc === undefined) { // FIXME: we should return error/throw instead yield* put( didFailToFinish( @@ -391,8 +379,22 @@ function* loadFirmware( throw new Error('unreachable'); } - if (checksum !== null) { - firmwareView.setUint32(firmwareBase.length, checksum, true); + const firmware = new Uint8Array(firmwareBase.length + checksumExtraLength); + const firmwareView = new DataView(firmware.buffer); + + firmware.set(firmwareBase); + + // empty string means use default name (don't write over firmware) + if (hubName) { + firmware.set(encodeHubName(hubName, metadata), metadata['hub-name-offset']); + } + + if (checksumFunc !== null) { + firmwareView.setUint32( + firmwareBase.length, + checksumFunc(firmwareIterator(firmwareView, metadata['checksum-size'])), + true, + ); } return { firmware, deviceId: metadata['device-id'] }; From 2d2777a4c6b0c508b72c220b9b2207b30d672948 Mon Sep 17 00:00:00 2001 From: James Aguilar Date: Wed, 24 Dec 2025 17:31:18 +0000 Subject: [PATCH 3/4] firmware: Flash the whole EV3 at once. Research in https://github.com/pybricks/support/issues/2375#issuecomment-3677356872 and below shows that erasing the firmware and writing it sector by sector causes hangs during the flashing process. Instead, we should erase the whole firmware at once, and flash it at once. This successfully loads new EV3 firmware without hangs. --- src/firmware/sagas.ts | 101 +++++++++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 36 deletions(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index bff25c32..df955df0 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1138,6 +1138,7 @@ function* handleFlashEV3(action: ReturnType): Generator function* sendCommand( command: number, payload?: Uint8Array, + options?: { timeoutms?: number }, ): SagaGenerator<[DataView | undefined, Error | undefined]> { // We need to start listing for reply before sending command in order // to avoid race conditions. @@ -1163,9 +1164,11 @@ function* handleFlashEV3(action: ReturnType): Generator return [undefined, sendError]; } + const timeoutms = options?.timeoutms ?? 5000; + const { reply, timeout } = yield* race({ reply: take(replyChannel), - timeout: delay(5000), + timeout: delay(timeoutms), }); replyChannel.close(); @@ -1209,55 +1212,81 @@ function* handleFlashEV3(action: ReturnType): Generator // 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, 0, true); // start address + erasePayload.setUint32(4, action.firmware.byteLength, true); // size + console.debug(`Erasing bytes [0x0, ${hex(action.firmware.byteLength, 0)})`); - 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), + yield* put( + alertsShowAlert( + 'firmware', + 'flashProgress', + { + action: 'erase', + progress: undefined, + }, + firmwareBleProgressToastId, + true, + ), + ); + + // Measured erase rate is approximately .25 kB/ms. This was on a powerful + // computer so it may be that flashing from something like a raspberry pi + // would take longer. We'll set a timeout three times as long as would have + // taken at the measured rate. + const eraseTimeoutMs = (action.firmware.byteLength / 256) * 1000 * 3; + const startTime = Date.now(); + const [, eraseError] = yield* sendCommand( + 0xf0, + new Uint8Array(erasePayload.buffer), + { timeoutms: eraseTimeoutMs }, + ); + console.debug( + `EV3 erase took ${Date.now() - startTime} ms for ${ + action.firmware.byteLength + } bytes, timeout was ${eraseTimeoutMs} ms`, + ); + + if (eraseError) { + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: eraseError, + }), ); + // FIXME: should have a better error reason + yield* put(didFailToFinish(FailToFinishReasonType.Unknown, eraseError)); + yield* put(firmwareDidFailToFlashEV3()); + yield* cleanup(); + return; + } - if (eraseError) { + // If we don't write an exact multiple of the sector size, the flash process + // will hang on the last write we send. + const firmware = action.firmware; + for (let i = 0; i < firmware.byteLength; i += maxPayloadSize) { + const payload = firmware.slice(i, i + maxPayloadSize); + console.debug( + `Programming bytes [${hex(i, 0)}, ${hex(i + maxPayloadSize, 0)})`, + ); + + const [, sendError] = yield* sendCommand(0xf2, new Uint8Array(payload)); + if (sendError) { yield* put( alertsShowAlert('alerts', 'unexpectedError', { - error: eraseError, + error: sendError, }), ); // FIXME: should have a better error reason - yield* put(didFailToFinish(FailToFinishReasonType.Unknown, eraseError)); + yield* put(didFailToFinish(FailToFinishReasonType.Unknown, sendError)); 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, sendError)); - yield* put(firmwareDidFailToFlashEV3()); - yield* cleanup(); - return; - } - } - - yield* put( - didProgress((i + sectorData.byteLength) / action.firmware.byteLength), - ); + const progress = (i + payload.byteLength) / firmware.byteLength; + yield* put(didProgress(progress)); yield* put( alertsShowAlert( @@ -1265,7 +1294,7 @@ function* handleFlashEV3(action: ReturnType): Generator 'flashProgress', { action: 'flash', - progress: (i + sectorData.byteLength) / action.firmware.byteLength, + progress: progress, }, firmwareBleProgressToastId, true, From 61ea8709cd6338f2f8bcdbe36089be12203b42c0 Mon Sep 17 00:00:00 2001 From: James Aguilar Date: Wed, 24 Dec 2025 17:34:02 +0000 Subject: [PATCH 4/4] firmware: Firmware must be sector size multiple. If the EV3 firmware size is not a multiple of the sector size, the flashing process will hang. Raise an error if this happens. --- src/firmware/actions.ts | 8 +++++++- src/firmware/sagas.ts | 14 ++++++++++++++ src/notifications/i18n.ts | 3 ++- src/notifications/sagas.test.ts | 3 ++- src/notifications/sagas.ts | 5 ++++- src/notifications/translations/en.json | 1 + 6 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index af64e80f..6a4245bc 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2025 The Pybricks Authors +// Copyright (c) 2020-2026 The Pybricks Authors import { FirmwareReaderError, HubType } from '@pybricks/firmware'; import { createAction } from '../actions'; @@ -47,6 +47,8 @@ export enum FailToFinishReasonType { FailedToCompile = 'flashFirmware.failToFinish.reason.failedToCompile', /** The combined firmware-base.bin and main.mpy are too big. */ FirmwareSize = 'flashFirmware.failToFinish.reason.firmwareSize', + /** The firmware's start or end is not aligned to the sector boundary. */ + FirmwareAlignment = 'flashFirmware.failToFinish.reason.firmwareAlignment', /** An unexpected error occurred. */ Unknown = 'flashFirmware.failToFinish.reason.unknown', } @@ -94,6 +96,9 @@ export type FailToFinishReasonBadMetadata = export type FailToFinishReasonFirmwareSize = Reason; +export type FailToFinishReasonFirmwareAlignment = + Reason; + export type FailToFinishReasonFailedToCompile = Reason; @@ -113,6 +118,7 @@ export type FailToFinishReason = | FailToFinishReasonZipError | FailToFinishReasonBadMetadata | FailToFinishReasonFirmwareSize + | FailToFinishReasonFirmwareAlignment | FailToFinishReasonFailedToCompile | FailToFinishReasonUnknown; diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index df955df0..1071b356 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1212,6 +1212,20 @@ function* handleFlashEV3(action: ReturnType): Generator // FIXME: should be called much earlier. yield* put(didStart()); + console.debug(`Firmware size: ${action.firmware.byteLength} bytes`); + + // Apparently, erasing a span of the flash creates some sort of record in + // the EV3, and we can only write within a given erase span. Writes that + // cross the boundary will hang. To avoid this, we erase the whole firmware + // range at once. + const sectorSize = 64 * 1024; // flash memory sector size + if (action.firmware.byteLength % sectorSize !== 0) { + yield* put(didFailToFinish(FailToFinishReasonType.FirmwareAlignment)); + yield* put(firmwareDidFailToFlashEV3()); + yield* cleanup(); + return; + } + const maxPayloadSize = 1018; // maximum payload size for EV3 commands const erasePayload = new DataView(new ArrayBuffer(8)); diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index eceeb4b2..923e6254 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2020-2025 The Pybricks Authors // // Notification translation keys. @@ -35,6 +35,7 @@ export enum I18nId { FlashFirmwareBadMetadata = 'flashFirmware.badMetadata', FlashFirmwareCompileError = 'flashFirmware.compileError', FlashFirmwareSizeTooBig = 'flashFirmware.sizeTooBig', + FlashFirmwareAlignment = 'flashFirmware.alignment', FlashFirmwareUnexpectedError = 'flashFirmware.unexpectedError', ServiceWorkerUpdateMessage = 'serviceWorker.update.message', ServiceWorkerUpdateAction = 'serviceWorker.update.action', diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 4a9ebecc..7242db9f 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.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 type { ToastOptions, Toaster } from '@blueprintjs/core'; import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware'; @@ -95,6 +95,7 @@ test.each([ ), didFailToFinish(FailToFinishReasonType.FailedToCompile), didFailToFinish(FailToFinishReasonType.FirmwareSize), + didFailToFinish(FailToFinishReasonType.FirmwareAlignment), didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')), appDidCheckForUpdate(false), fileStorageDidFailToInitialize(new Error('test error')), diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index dca25276..93d5e68b 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2026 The Pybricks Authors // Saga for managing notifications (toasts) @@ -228,6 +228,9 @@ function* showFlashFirmwareError( case FailToFinishReasonType.FirmwareSize: yield* showSingleton(Level.Error, I18nId.FlashFirmwareSizeTooBig); break; + case FailToFinishReasonType.FirmwareAlignment: + yield* showSingleton(Level.Error, I18nId.FlashFirmwareAlignment); + break; case FailToFinishReasonType.Unknown: yield* showUnexpectedError( I18nId.FlashFirmwareUnexpectedError, diff --git a/src/notifications/translations/en.json b/src/notifications/translations/en.json index ad6f2e47..915d1952 100644 --- a/src/notifications/translations/en.json +++ b/src/notifications/translations/en.json @@ -33,6 +33,7 @@ "badMetadata": "The firmware.metadata.json file contains missing or invalid entries. Fix it then try again.", "compileError": "The included main.py file could not be compiled. Fix it then try again.", "sizeTooBig": "The combined firmware and main.py are too big to fit in the flash memory.", + "alignment": "The firmware's start or end is not aligned to the sector boundary.", "unexpectedError": "Unexpected error while trying to install firmware: {errorMessage}" }, "mpy": {