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/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/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; } 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 b3833e3a8..a25e5b26a 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'; @@ -18,6 +20,7 @@ import { eventChannel } from 'redux-saga'; import { ActionPattern } from 'redux-saga/effects'; import { SagaGenerator, + actionChannel, all, call, cancel, @@ -63,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, @@ -342,6 +346,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 +369,8 @@ function* loadFirmware( ); case 'crc32': return crc32(firmwareIterator(firmwareView, metadata['checksum-size'])); + case 'none': + return null; default: return undefined; } @@ -380,7 +391,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'] }; } @@ -922,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; } } @@ -1001,6 +1031,8 @@ function* handleRestoreOfficialDfu( } } +const getNextEV3MessageId = createCountFunc(); + function* handleFlashEV3(action: ReturnType): Generator { if (navigator.hid === undefined) { yield* put(alertsShowAlert('firmware', 'noWebHid')); @@ -1105,13 +1137,18 @@ 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); + 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) { @@ -1119,21 +1156,33 @@ 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')]; } 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, @@ -1202,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; @@ -1219,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; @@ -1260,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; 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) => 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"