diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index af64e80fd..6a4245bc6 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 a25e5b26a..1071b356e 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, @@ -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'] }; @@ -1136,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. @@ -1161,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(); @@ -1204,80 +1209,98 @@ function* handleFlashEV3(action: ReturnType): Generator return [new DataView(reply.payload), undefined]; } - const [version, versionError] = yield* sendCommand(0xf6); // get version + // FIXME: should be called much earlier. + yield* put(didStart()); - if (versionError) { - yield* put( - alertsShowAlert('alerts', 'unexpectedError', { - error: ensureError(versionError), - }), - ); + 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; } - defined(version); + const maxPayloadSize = 1018; // maximum payload size for EV3 commands + + 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)})`); - console.debug( - `EV3 bootloader version: ${version.getUint32( - 0, + yield* put( + alertsShowAlert( + 'firmware', + 'flashProgress', + { + action: 'erase', + progress: undefined, + }, + firmwareBleProgressToastId, 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 + // 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`, + ); - 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'); + 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; + } - 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 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)})`, ); - if (eraseError) { + 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( @@ -1285,7 +1308,7 @@ function* handleFlashEV3(action: ReturnType): Generator 'flashProgress', { action: 'flash', - progress: (i + sectorData.byteLength) / action.firmware.byteLength, + progress: progress, }, firmwareBleProgressToastId, true, diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index eceeb4b22..923e62542 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 4a9ebecc6..7242db9fd 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 dca252761..93d5e68b2 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 ad6f2e47e..915d19527 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": {