From 75dc62729bf8302ccba3c64bb9bea9c4a7504eff Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 30 Aug 2025 21:48:51 +0000 Subject: [PATCH 1/8] yarn: patch @types/w3c-web-usb Fix issues with null vs. undefined. Based on https://github.com/DefinitelyTyped/DefinitelyTyped/pull/73568 --- ...es-w3c-web-usb-npm-1.0.10-82b33e05cb.patch | 54 +++++++++++++++++++ package.json | 3 +- yarn.lock | 9 +++- 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 .yarn/patches/@types-w3c-web-usb-npm-1.0.10-82b33e05cb.patch diff --git a/.yarn/patches/@types-w3c-web-usb-npm-1.0.10-82b33e05cb.patch b/.yarn/patches/@types-w3c-web-usb-npm-1.0.10-82b33e05cb.patch new file mode 100644 index 000000000..02ae4bdf9 --- /dev/null +++ b/.yarn/patches/@types-w3c-web-usb-npm-1.0.10-82b33e05cb.patch @@ -0,0 +1,54 @@ +diff --git a/index.d.ts b/index.d.ts +index b59810ce0fcaaf647830359010987348e11b0479..0f9d24ab390ac12010b671bc8aab02909e93fa89 100644 +--- a/index.d.ts ++++ b/index.d.ts +@@ -39,7 +39,7 @@ interface USBConnectionEventInit extends EventInit { + + declare class USBConfiguration { + readonly configurationValue: number; +- readonly configurationName?: string | undefined; ++ readonly configurationName: string | null; + readonly interfaces: USBInterface[]; + } + +@@ -57,14 +57,14 @@ declare class USBAlternateInterface { + readonly interfaceClass: number; + readonly interfaceSubclass: number; + readonly interfaceProtocol: number; +- readonly interfaceName?: string | undefined; ++ readonly interfaceName: string | null; + readonly endpoints: USBEndpoint[]; + } + + declare class USBInTransferResult { + constructor(status: USBTransferStatus, data?: DataView); + readonly data?: DataView | undefined; +- readonly status?: USBTransferStatus | undefined; ++ readonly status: USBTransferStatus; + } + + declare class USBOutTransferResult { +@@ -76,7 +76,7 @@ declare class USBOutTransferResult { + declare class USBIsochronousInTransferPacket { + constructor(status: USBTransferStatus, data?: DataView); + readonly data?: DataView | undefined; +- readonly status?: USBTransferStatus | undefined; ++ readonly status: USBTransferStatus; + } + + declare class USBIsochronousInTransferResult { +@@ -140,10 +140,10 @@ declare class USBDevice { + readonly deviceVersionMajor: number; + readonly deviceVersionMinor: number; + readonly deviceVersionSubminor: number; +- readonly manufacturerName?: string | undefined; +- readonly productName?: string | undefined; +- readonly serialNumber?: string | undefined; +- readonly configuration?: USBConfiguration | undefined; ++ readonly manufacturerName: string | null; ++ readonly productName: string | null; ++ readonly serialNumber: string | null; ++ readonly configuration: USBConfiguration | null; + readonly configurations: USBConfiguration[]; + readonly opened: boolean; + open(): Promise; diff --git a/package.json b/package.json index 58f24173e..0164f6e30 100644 --- a/package.json +++ b/package.json @@ -173,7 +173,8 @@ "react-error-overlay": "6.0.9", "react-dev-utils@^12.0.1": "patch:react-dev-utils@npm:12.0.1#.yarn/patches/react-dev-utils-npm-12.0.1-83ba06e3ee.patch", "jsdom@^20.0.0": "patch:jsdom@npm%3A20.0.0#./.yarn/patches/jsdom-npm-20.0.0-9c1ad43ab8.patch", - "pyodide@0.23.0": "patch:pyodide@npm%3A0.23.0#./.yarn/patches/pyodide-npm-0.23.0-64dc9bd6f1.patch" + "pyodide@0.23.0": "patch:pyodide@npm%3A0.23.0#./.yarn/patches/pyodide-npm-0.23.0-64dc9bd6f1.patch", + "@types/w3c-web-usb@^1.0.10": "patch:@types/w3c-web-usb@npm%3A1.0.10#./.yarn/patches/@types-w3c-web-usb-npm-1.0.10-82b33e05cb.patch" }, "jest": { "roots": [ diff --git a/yarn.lock b/yarn.lock index 201705db5..002bca243 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5472,13 +5472,20 @@ __metadata: languageName: node linkType: hard -"@types/w3c-web-usb@npm:^1.0.10": +"@types/w3c-web-usb@npm:1.0.10": version: 1.0.10 resolution: "@types/w3c-web-usb@npm:1.0.10" checksum: 6ac6786a0788f0846a48b103ab06ca5fde5eb95674217b522420a2f6157bee3e181a961c1b7011940f497c55f4f5cc46129657d881fdd8112b48764089679ad6 languageName: node linkType: hard +"@types/w3c-web-usb@patch:@types/w3c-web-usb@npm%3A1.0.10#./.yarn/patches/@types-w3c-web-usb-npm-1.0.10-82b33e05cb.patch::locator=%40pybricks%2Fpybricks-code%40workspace%3A.": + version: 1.0.10 + resolution: "@types/w3c-web-usb@patch:@types/w3c-web-usb@npm%3A1.0.10#./.yarn/patches/@types-w3c-web-usb-npm-1.0.10-82b33e05cb.patch::version=1.0.10&hash=171f12&locator=%40pybricks%2Fpybricks-code%40workspace%3A." + checksum: 9528b1c501a5a21f05e36998b58501f8e2a4790af82c41b88e86bed8a618dc3135d486ffd55771532837a4e4112c1093ada293986451fe665f937081c9f2b7d7 + languageName: node + linkType: hard + "@types/web-bluetooth@npm:^0.0.20": version: 0.0.20 resolution: "@types/web-bluetooth@npm:0.0.20" From 37f4c11076cddb1e384cb791f486eafe157258ee Mon Sep 17 00:00:00 2001 From: Attila Farago Date: Sat, 30 Aug 2025 22:03:38 +0000 Subject: [PATCH 2/8] usb/sagas: always set configuration On some OSes, like macOS, the configuration may not be selected, so we need to make sure it is always set. --- src/usb/sagas.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/usb/sagas.ts b/src/usb/sagas.ts index 0eea6f0d2..3e9ae654e 100644 --- a/src/usb/sagas.ts +++ b/src/usb/sagas.ts @@ -161,17 +161,16 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator { return; } - // REVISIT: For now, we are making the assumption that there is only one - // configuration and it is already selected and that it contains a Pybricks - // interface. - assert( - usbDevice.configuration !== undefined, - 'USB device configuration is undefined', - ); - assert( - usbDevice.configuration.interfaces.length > 0, - 'USB device has no interfaces', - ); + const [, selectErr] = yield* call(() => maybe(usbDevice.selectConfiguration(1))); + if (selectErr) { + // TODO: show error message to user here + console.error('Failed to select USB device configuration:', selectErr); + yield* put(usbDidFailToConnectPybricks()); + yield* cleanup(); + return; + } + + assert(usbDevice.configuration !== null, 'USB device configuration is null'); const iface = usbDevice.configuration.interfaces.find( (iface) => From 8936ba2087fd4a5286dcac7383c29b2ac31d3195 Mon Sep 17 00:00:00 2001 From: Attila Farago Date: Sat, 30 Aug 2025 22:05:08 +0000 Subject: [PATCH 3/8] firmware/installPybricksDialog/hooks: dispatch error if no url Dispatch an error in case there is no firmware URL. Normally this should never happen, but currently it does because we don't have EV3 firmware yet. The error is more useful than just spinning forever. --- src/firmware/installPybricksDialog/hooks.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/firmware/installPybricksDialog/hooks.ts b/src/firmware/installPybricksDialog/hooks.ts index e3c5999fd..684e7b384 100644 --- a/src/firmware/installPybricksDialog/hooks.ts +++ b/src/firmware/installPybricksDialog/hooks.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors // based on https://usehooks-ts.com/react-hook/use-fetch import { FirmwareMetadata, FirmwareReader } from '@pybricks/firmware'; @@ -78,8 +78,10 @@ export function useFirmware(hubType: Hub): State { const [state, dispatch] = useReducer(fetchReducer, initialState); useEffect(() => { - // Do nothing if the url is not given + // Raise error if the url is not given, to show that something is wrong + // instead of a misleading intermediate state. if (!url) { + dispatch({ type: 'error', payload: new Error('No URL for this hub type') }); return; } From 7b4b395c3803096ef964c06d455fdd272aacd35f Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 30 Aug 2025 22:21:29 +0000 Subject: [PATCH 4/8] firmware/sagas: handle case of checksum-type == 'none' The v2.1 spec allows this, but up to now, Pybricks code didn't handle it. --- src/firmware/sagas.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index b3833e3a8..48a40b86a 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -8,6 +8,8 @@ import { encodeHubName, metadataIsV100, metadataIsV110, + metadataIsV200, + metadataIsV210, } from '@pybricks/firmware'; import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; import moveHubZip from '@pybricks/firmware/build/movehub.zip'; @@ -342,6 +344,11 @@ function* loadFirmware( return { firmware, deviceId: metadata['device-id'] }; } + assert( + metadataIsV200(metadata) || metadataIsV210(metadata), + 'Expected metadata to be v2.x', + ); + const firmware = new Uint8Array(firmwareBase.length + 4); const firmwareView = new DataView(firmware.buffer); @@ -360,6 +367,8 @@ function* loadFirmware( ); case 'crc32': return crc32(firmwareIterator(firmwareView, metadata['checksum-size'])); + case 'none': + return null; default: return undefined; } @@ -380,7 +389,9 @@ function* loadFirmware( throw new Error('unreachable'); } - firmwareView.setUint32(firmwareBase.length, checksum, true); + if (checksum !== null) { + firmwareView.setUint32(firmwareBase.length, checksum, true); + } return { firmware, deviceId: metadata['device-id'] }; } From 373187b5371197c07f4b5e20dfb7d7c7c8909af9 Mon Sep 17 00:00:00 2001 From: Attila Farago Date: Sat, 30 Aug 2025 22:30:22 +0000 Subject: [PATCH 5/8] firmware/sagas: fix race condition in EV3 sendCommand() Start listening for reply before sending command to avoid race condition of reply being received and action dispatched before we call take(). --- src/firmware/sagas.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 48a40b86a..1fd337829 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -20,6 +20,7 @@ import { eventChannel } from 'redux-saga'; import { ActionPattern } from 'redux-saga/effects'; import { SagaGenerator, + actionChannel, all, call, cancel, @@ -1116,8 +1117,11 @@ function* handleFlashEV3(action: ReturnType): Generator command: number, payload?: Uint8Array, ): SagaGenerator<[DataView | undefined, Error | undefined]> { - console.debug(`EV3 send: command=${command}, payload=${payload}`); + // We need to start listing for reply before sending command in order + // to avoid race conditions. + const replyChannel = yield* actionChannel(firmwareDidReceiveEV3Reply); + // Send the command const dataBuffer = new Uint8Array((payload?.byteLength ?? 0) + 6); const data = new DataView(dataBuffer.buffer); @@ -1130,15 +1134,18 @@ function* handleFlashEV3(action: ReturnType): Generator } const [, sendError] = yield* call(() => maybe(hidDevice.sendReport(0, data))); - if (sendError) { + replyChannel.close(); return [undefined, sendError]; } const { reply, timeout } = yield* race({ - reply: take(firmwareDidReceiveEV3Reply), + reply: take(replyChannel), timeout: delay(5000), }); + + replyChannel.close(); + if (timeout) { return [undefined, new Error('Timeout waiting for EV3 reply')]; } From 1d105efcada095b91a411200973d6cf6c57964cf Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 30 Aug 2025 22:37:51 +0000 Subject: [PATCH 6/8] firmware/sagas: finish EV3 message ID TODO Implement unique message ID for EV3 firmware messages. --- src/firmware/sagas.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 1fd337829..214f5b03f 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -66,6 +66,7 @@ import { compile, didCompile, didFailToCompile } from '../mpy/actions'; import { RootState } from '../reducers'; import { LegoUsbProductId, legoUsbVendorId } from '../usb'; import { assert, defined, ensureError, hex, maybe } from '../utils'; +import { createCountFunc } from '../utils/iter'; import { crc32, fmod, sumComplement32 } from '../utils/math'; import { EV3OfficialFirmwareVersion, @@ -1013,6 +1014,8 @@ function* handleRestoreOfficialDfu( } } +const getNextEV3MessageId = createCountFunc(); + function* handleFlashEV3(action: ReturnType): Generator { if (navigator.hid === undefined) { yield* put(alertsShowAlert('firmware', 'noWebHid')); @@ -1125,8 +1128,10 @@ function* handleFlashEV3(action: ReturnType): Generator const dataBuffer = new Uint8Array((payload?.byteLength ?? 0) + 6); const data = new DataView(dataBuffer.buffer); + const messageId = getNextEV3MessageId() & 0xffff; + data.setInt16(0, (payload?.byteLength ?? 0) + 4, true); - data.setInt16(2, 0, true); // TODO: reply number + data.setInt16(2, messageId, true); data.setUint8(4, 0x01); // system command w/ reply data.setUint8(5, command); if (payload) { @@ -1152,6 +1157,15 @@ function* handleFlashEV3(action: ReturnType): Generator defined(reply); + if (reply.replyNumber !== messageId) { + return [ + undefined, + new Error( + `EV3 reply message ID mismatch: expected ${messageId}, got ${reply.replyNumber}`, + ), + ]; + } + if (reply.replyCommand !== command) { return [ undefined, From 311a83396f7fe681e0f3f4c6e01bfa00f6240763 Mon Sep 17 00:00:00 2001 From: Attila Farago Date: Sat, 30 Aug 2025 22:42:40 +0000 Subject: [PATCH 7/8] firmware/installPybricksDialog: finish EV3 implementation Add missing bits to get firmware flashing on EV3 working. Currently requires custom firmware since we don't have a packaged one yet. --- .../InstallPybricksDialog.tsx | 4 ++++ src/firmware/installPybricksDialog/index.ts | 1 + src/firmware/sagas.ts | 21 +++++++++++++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx index d4725a9b4..810df0086 100644 --- a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -72,6 +72,8 @@ function getHubTypeFromMetadata( return Hub.Prime; case HubType.EssentialHub: return Hub.Essential; + case HubType.EV3: + return Hub.EV3; default: return fallback; } @@ -89,6 +91,8 @@ function getHubTypeNameFromMetadata(metadata: FirmwareMetadata | undefined): str return 'SPIKE Prime/MINDSTORMS Robot Inventor hub'; case HubType.EssentialHub: return 'SPIKE Essential hub'; + case HubType.EV3: + return 'MINDSTORMS EV3 hub'; default: return '?'; } diff --git a/src/firmware/installPybricksDialog/index.ts b/src/firmware/installPybricksDialog/index.ts index 129c51fe7..7b83d51b8 100644 --- a/src/firmware/installPybricksDialog/index.ts +++ b/src/firmware/installPybricksDialog/index.ts @@ -37,6 +37,7 @@ const supportHubs: readonly HubType[] = [ HubType.TechnicHub, HubType.PrimeHub, HubType.EssentialHub, + HubType.EV3, ]; export function validateMetadata(metadata: FirmwareMetadata) { diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 214f5b03f..d4c3f4690 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -935,8 +935,25 @@ function* handleInstallPybricks(): Generator { } break; case 'usb-ev3': - // TODO: implement flashing via EV3 USB - console.error('Flashing via EV3 USB is not implemented yet'); + try { + const { firmware } = yield* loadFirmware( + accepted.firmwareZip, + accepted.hubName, + ); + + yield* put(firmwareFlashEV3(firmware.buffer as ArrayBuffer)); + } catch (err) { + // istanbul ignore if + if (process.env.NODE_ENV !== 'test') { + console.error(err); + } + + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: ensureError(err), + }), + ); + } break; } } From ba9e2f598ca5777aed3ec23053b92428f9bacbe6 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 30 Aug 2025 22:56:19 +0000 Subject: [PATCH 8/8] firmware/sagas: fix missing error arg when unknown reason For some reason, the type checker doesn't catch this, but FailToFinishReasonType.Unknown requires an error argument, otherwise we get an unhandled exception. --- src/firmware/sagas.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index d4c3f4690..a25e5b26a 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1251,7 +1251,7 @@ function* handleFlashEV3(action: ReturnType): Generator }), ); // FIXME: should have a better error reason - yield* put(didFailToFinish(FailToFinishReasonType.Unknown)); + yield* put(didFailToFinish(FailToFinishReasonType.Unknown, eraseError)); yield* put(firmwareDidFailToFlashEV3()); yield* cleanup(); return; @@ -1268,7 +1268,7 @@ function* handleFlashEV3(action: ReturnType): Generator }), ); // FIXME: should have a better error reason - yield* put(didFailToFinish(FailToFinishReasonType.Unknown)); + yield* put(didFailToFinish(FailToFinishReasonType.Unknown, sendError)); yield* put(firmwareDidFailToFlashEV3()); yield* cleanup(); return; @@ -1309,7 +1309,7 @@ function* handleFlashEV3(action: ReturnType): Generator const [, rebootError] = yield* sendCommand(0xf4); // start app if (rebootError) { // FIXME: should have a better error reason - yield* put(didFailToFinish(FailToFinishReasonType.Unknown)); + yield* put(didFailToFinish(FailToFinishReasonType.Unknown, rebootError)); yield* put(firmwareDidFailToFlashEV3()); yield* cleanup(); return;