From 571aea74e44ddbcdfac0647953ed60a6720be028 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sun, 4 Jan 2026 21:02:51 +0000 Subject: [PATCH 1/4] firmware/ev3: allow buggy replies from EV3 bootloader Add a hack to not fail if the EV3 bootloader sends an echo of the request instead of the expected response. There is a known compatibility issue with the EV3 bootloader USB and, e.g. Windows with USB 3.0 ports. This apparently causes a race condition where commands that don't take long to process before sending a response will have the request echoed back instead of receiving the actual response. If this happens, we can ignore it and assume the command was successful. It just won't work, e.g. for the version command since that has a payload in the response. --- src/firmware/sagas.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 946b1447..93865685 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1107,16 +1107,33 @@ function* handleFlashEV3(action: ReturnType): Generator continue; // ignore empty reports } - const length = event.data.getInt16(0, true); + let length = event.data.getInt16(0, true); const replyNumber = event.data.getInt16(2, true); - const messageType = event.data.getUint8(4); + let 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); + let status = event.data.getUint8(6); + let payload = event.data.buffer.slice(7, 7 + length + 2); + + if (messageType === 0x01) { + // HACK: This works around a strange bug that can be triggered + // e.g. by USB 3.0 on Windows. Sometimes the EV3 bootloader will + // send the request back instead of the reply. In this case, + // fake the reply to avoid protocol errors. This seems to work + // as long as we aren't sending commands that have a reply + // with a payload (like reading version or checksum) + + console.warn( + `Bad EV3 reply: length=${length}, replyNumber=${replyNumber}, messageType=${messageType}, replyCommand=${replyCommand}, status=${status}`, + ); + length = 5; + messageType = 0x03; + status = 0x00; + payload = new ArrayBuffer(0); - console.debug( - `EV3 reply: length=${length}, replyNumber=${replyNumber}, messageType=${messageType}, replyCommand=${replyCommand}, status=${status}, payload=${payload}`, - ); + console.info( + `Fixed EV3 reply: length=${length}, replyNumber=${replyNumber}, messageType=${messageType}, replyCommand=${replyCommand}, status=${status}`, + ); + } yield* put( firmwareDidReceiveEV3Reply( From 20ab992b0f3d7153bfd6ce80c695fd57da9a4a94 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sun, 4 Jan 2026 21:07:40 +0000 Subject: [PATCH 2/4] firmware/ev3: erase 2 sectors at one time Change from erasing 1 sector at a time to erasing 2 sectors at a time. This works around a USB issue where commands that don't take long to execute can receive an incorrect response. The point of erasing 2 sectors at a time is that when we write the data to the sectors that we just erased, the last (partial) chunk of data is now twice as large, which makes it take twice as long to write. This seems to be long enough to avoid the USB issue. --- src/firmware/sagas.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 93865685..32fcc348 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1225,9 +1225,17 @@ function* handleFlashEV3(action: ReturnType): Generator 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'); + // HACK: Ideally, we would erase one sector at a time to minimize required + // alignment and make the progress indicator smoother. However, there is a + // bug triggered, e.g. by USB 3.0 on Windows, that causes bad replies from + // certain commands. This bug happens sometimes when the payload size is + // 384 bytes (triggered by 65536 % 1018 = 384). To work around this, we + // always erase two sectors to make the last chunk be twice as big + // (131072 % 1018 = 768). + const eraseSize = sectorSize * 2; // flash memory sector size + + for (let i = 0; i < action.firmware.byteLength; i += eraseSize) { + const sectorData = action.firmware.slice(i, i + eraseSize); const erasePayload = new DataView(new ArrayBuffer(8)); erasePayload.setUint32(0, i, true); From 2b07bf6bf7fb84206e3cdae5391a47ff87acf28e Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sun, 4 Jan 2026 21:16:45 +0000 Subject: [PATCH 3/4] firmware/ev3: add extra progress report Add an extra progress between erasing and writing. This makes the progress not stall for quite as long before progressing when flashing smaller firmwares. --- src/firmware/sagas.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 32fcc348..8935569d 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1254,6 +1254,22 @@ function* handleFlashEV3(action: ReturnType): Generator return; } + // Erasing takes about the same time as writing, so this will make the + // progress bar smoother. + yield* put( + alertsShowAlert( + 'firmware', + 'flashProgress', + { + action: 'flash', + progress: + (i + sectorData.byteLength / 2) / action.firmware.byteLength, + }, + firmwareEv3ProgressToastId, + true, + ), + ); + for (let j = 0; j < sectorData.byteLength; j += maxPayloadSize) { const payload = sectorData.slice(j, j + maxPayloadSize); From 5ed38610939c683373c34c2302d402a60b244372 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sun, 4 Jan 2026 21:18:26 +0000 Subject: [PATCH 4/4] firmware/ev3: pad firmware to avoid bugs Add a function to pad EV3 firmware size to avoid potentially triggering bugs in the EV3 bootloader. When we do a download command with too small of a payload, the bootloader can send a bad response. We can work around this by ensuring that the firmware size is such that the last chunk is the maximum size. This way we never send a small chunk that could trigger the bug. --- src/firmware/sagas.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 8935569d..5b483bc8 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -199,6 +199,25 @@ function* firmwareIterator(data: DataView, maxSize: number): Generator { } } +function computeEv3ExtraLength(firmwareLength: number): number { + // HACK: If the EV3 firmware size is not a multiple of 2 * 64KiB, then we + // need to add some padding to avoid possibly triggering a bug where small + // download payloads can cause bad replies. This is done by ensuring that + // the last chunk is 1018 bytes. + + const maxChunkSize = 1018; + const eraseSize = 2 * 64 * 1024; + const remainder = firmwareLength % eraseSize; + + if (remainder === 0) { + // Adding padding would cause an entire extra erase to be needed. Don't + // do that and rely on a a different workaround (always erasing two sectors). + return 0; + } + + return maxChunkSize - (remainder % maxChunkSize); +} + /** * Loads Pybricks firmware from a .zip file. * @@ -376,7 +395,14 @@ function* loadFirmware( throw new Error('unreachable'); } - const firmware = new Uint8Array(firmwareBase.length + checksumExtraLength); + const ev3ExtraLength = + metadata['device-id'] === HubType.EV3 + ? computeEv3ExtraLength(firmwareBase.length) + : 0; + + const firmware = new Uint8Array( + firmwareBase.length + checksumExtraLength + ev3ExtraLength, + ); const firmwareView = new DataView(firmware.buffer); firmware.set(firmwareBase);