diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 866741259..0eb93669c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -16,6 +16,6 @@ jobs: - uses: actions/checkout@v3 - run: yarn install - run: yarn coverage - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v5 with: directory: coverage diff --git a/config/webpack.config.js b/config/webpack.config.js index 9d4765cde..4af7ace4b 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -705,7 +705,7 @@ module.exports = function (webpackEnv) { // Bump up the default maximum size (2mb) that's precached, // to make lazy-loading failure scenarios less likely. // See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270 - maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, + maximumFileSizeToCacheInBytes: 16 * 1024 * 1024, additionalManifestEntries: [ // FIXME: this path should have a hash { url: 'static/oss-licenses.json', revision: null }, diff --git a/package.json b/package.json index f127d69f5..58f24173e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@pybricks/firmware": "7.22.0", "@pybricks/ide-docs": "2.20.0", - "@pybricks/images": "^1.3.0", + "@pybricks/images": "^1.4.0", "@pybricks/jedi": "1.17.0", "@pybricks/mpy-cross-v5": "^2.0.0", "@pybricks/mpy-cross-v6": "^2.0.0", @@ -35,6 +35,7 @@ "@types/react-transition-group": "^4.4.10", "@types/redux-logger": "^3.0.12", "@types/semver": "^7.5.6", + "@types/w3c-web-hid": "^1.0.6", "@types/w3c-web-usb": "^1.0.10", "@types/web-bluetooth": "^0.0.20", "@types/web-locks-api": "^0.0.5", diff --git a/src/components/hubPicker/HubPicker.tsx b/src/components/hubPicker/HubPicker.tsx index 8be05b02c..15592cf31 100644 --- a/src/components/hubPicker/HubPicker.tsx +++ b/src/components/hubPicker/HubPicker.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors import './hubPicker.scss'; import { Radio, RadioGroup } from '@blueprintjs/core'; @@ -66,6 +66,12 @@ export const HubPicker: React.FunctionComponent = ({ disabled }) label="MINDSTORMS Robot Inventor Hub" /> + + + ); }; diff --git a/src/components/hubPicker/index.ts b/src/components/hubPicker/index.ts index a4200da36..b735beb8b 100644 --- a/src/components/hubPicker/index.ts +++ b/src/components/hubPicker/index.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors /** Supported hub types. */ export enum Hub { @@ -15,6 +15,8 @@ export enum Hub { Prime = 'primehub', /** SPIKE Essential hub */ Essential = 'essentialhub', + /** MINDSTORMS EV3 hub */ + EV3 = 'ev3', } /** @@ -25,6 +27,7 @@ export function hubHasUSB(hub: Hub): boolean { case Hub.Prime: case Hub.Essential: case Hub.Inventor: + case Hub.EV3: return true; default: return false; @@ -52,6 +55,7 @@ export function hubHasExternalFlash(hub: Hub): boolean { case Hub.Prime: case Hub.Essential: case Hub.Inventor: + case Hub.EV3: return true; default: return false; @@ -69,5 +73,7 @@ export function hubBootloaderType(hub: Hub) { case Hub.City: case Hub.Technic: return 'ble-lwp3-bootloader'; + case Hub.EV3: + return 'usb-ev3'; } } diff --git a/src/editor/pybricksMicroPython.ts b/src/editor/pybricksMicroPython.ts index 8e148c8f9..5cca5fdab 100644 --- a/src/editor/pybricksMicroPython.ts +++ b/src/editor/pybricksMicroPython.ts @@ -309,7 +309,9 @@ export const language = { */ function createTemplate(hubClassName: string, deviceClassNames: string[]): string { return `from pybricks.hubs import ${hubClassName} -from pybricks.pupdevices import ${deviceClassNames.join(', ')} +from pybricks.${ + hubClassName === 'EV3Brick' ? 'ev3devices' : 'pupdevices' + } import ${deviceClassNames.join(', ')} from pybricks.parameters import Button, Color, Direction, Port, Side, Stop from pybricks.robotics import DriveBase from pybricks.tools import wait, StopWatch @@ -325,7 +327,8 @@ type HubLabel = | 'technichub' | 'inventorhub' | 'primehub' - | 'essentialhub'; + | 'essentialhub' + | 'ev3'; const templateSnippets: Array< Required< @@ -375,6 +378,18 @@ const templateSnippets: Array< 'ColorLightMatrix', ]), }, + { + label: 'ev3', + documentation: 'Template for MINDSTORMS EV3 program.', + insertText: createTemplate('EV3Brick', [ + 'Motor', + 'ColorSensor', + 'GyroSensor', + 'InfraredSensor', + 'TouchSensor', + 'UltrasonicSensor', + ]), + }, ]; /** diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index 1e7dc03fd..af64e80fd 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2020-2025 The Pybricks Authors import { FirmwareReaderError, HubType } from '@pybricks/firmware'; import { createAction } from '../actions'; @@ -429,3 +429,81 @@ export const firmwareDidRestoreOfficialDfu = createAction(() => ({ export const firmwareDidFailToRestoreOfficialDfu = createAction(() => ({ type: 'firmware.action.didFailToRestoreOfficialDfu', })); + +/** + * Versions of official LEGO EV3 firmware available for restore. + */ +export enum EV3OfficialFirmwareVersion { + /** Official LEGO EV3 firmware version 1.09H (Home edition). */ + home = '1.09H', + /** Official LEGO EV3 firmware version 1.09E (Education edition). */ + education = '1.09E', + /** Official LEGO EV3 firmware version 1.10E (only useful for Microsoft MakeCode). */ + makecode = '1.10E', +} + +/** + * Action that triggers the restore official EV3 firmware saga. + */ +export const firmwareRestoreOfficialEV3 = createAction( + (version: EV3OfficialFirmwareVersion) => ({ + type: 'firmware.action.restoreOfficialEV3', + version, + }), +); + +/** + * Action that indicates {@link firmwareRestoreOfficialEV3} succeeded. + */ +export const firmwareDidRestoreOfficialEV3 = createAction(() => ({ + type: 'firmware.action.didRestoreOfficialEV3', +})); + +/** + * Action that indicates {@link firmwareRestoreOfficialEV3} failed. + */ +export const firmwareDidFailToRestoreOfficialEV3 = createAction(() => ({ + type: 'firmware.action.didFailToRestoreOfficialEV3', +})); + +/** + * Low-level action to flash firmware to an EV3 hub. + * @param firmware The firmware binary blob. + */ +export const firmwareFlashEV3 = createAction((firmware: ArrayBuffer) => ({ + type: 'firmware.action.flashEV3', + firmware, +})); + +/** + * Low-level action that indicates {@link firmwareFlashEV3} succeeded. + */ +export const firmwareDidFlashEV3 = createAction(() => ({ + type: 'firmware.action.didFlashEV3', +})); + +/** + * Low-level action that indicates {@link firmwareFlashEV3} failed. + */ +export const firmwareDidFailToFlashEV3 = createAction(() => ({ + type: 'firmware.action.didFailToFlashEV3', +})); + +export const firmwareDidReceiveEV3Reply = createAction( + ( + length: number, + replyNumber: number, + messageType: number, + replyCommand: number, + status: number, + payload: ArrayBufferLike, + ) => ({ + type: 'firmware.action.didReceiveEV3Reply', + length, + replyNumber, + messageType, + replyCommand, + status, + payload, + }), +); diff --git a/src/firmware/alerts/NoWebHid.tsx b/src/firmware/alerts/NoWebHid.tsx new file mode 100644 index 000000000..d590f4345 --- /dev/null +++ b/src/firmware/alerts/NoWebHid.tsx @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import { Error } from '@blueprintjs/icons'; +import React from 'react'; +import type { CreateToast } from '../../toasterTypes'; +import { useI18n } from './i18n'; + +const NoWebHid: React.FunctionComponent = () => { + const i18n = useI18n(); + return ( + <> +

{i18n.translate('noWebHid.message')}

+

{i18n.translate('noWebHid.suggestion')}

+ + ); +}; + +export const noWebHid: CreateToast = (onAction) => ({ + message: , + icon: , + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), +}); diff --git a/src/firmware/alerts/index.ts b/src/firmware/alerts/index.ts index d25d162c0..a6187418c 100644 --- a/src/firmware/alerts/index.ts +++ b/src/firmware/alerts/index.ts @@ -1,10 +1,11 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors import { dfuError } from './DfuError'; import { flashProgress } from './FlashProgress'; import { noDfuHub } from './NoDfuHub'; import { noDfuInterface } from './NoDfuInterface'; +import { noWebHid } from './NoWebHid'; import { noWebUsb } from './NoWebUsb'; import { releaseButton } from './ReleaseButton'; @@ -13,6 +14,7 @@ export default { flashProgress, noDfuHub, noDfuInterface, + noWebHid, noWebUsb, releaseButton, }; diff --git a/src/firmware/alerts/translations/en.json b/src/firmware/alerts/translations/en.json index c38a961f3..d43aa55d5 100644 --- a/src/firmware/alerts/translations/en.json +++ b/src/firmware/alerts/translations/en.json @@ -5,7 +5,11 @@ "tryAgainButton": "Try again" }, "noWebUsb": { - "message": "This browser does not support Web USB or Web USB is not enabled.", + "message": "This browser does not support WebUSB or WebUSB is not enabled.", + "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge." + }, + "noWebHid": { + "message": "This browser does not support WebHID or WebHID is not enabled.", "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge." }, "noDfuHub": { diff --git a/src/firmware/assets/EV3_Firmware_V1.09H.bin b/src/firmware/assets/EV3_Firmware_V1.09H.bin new file mode 100644 index 000000000..17d8f2d6f Binary files /dev/null and b/src/firmware/assets/EV3_Firmware_V1.09H.bin differ diff --git a/src/firmware/assets/ev3-image-1.10e.bin b/src/firmware/assets/ev3-image-1.10e.bin new file mode 100644 index 000000000..8673dfd1d Binary files /dev/null and b/src/firmware/assets/ev3-image-1.10e.bin differ diff --git a/src/firmware/assets/ev3_firmware_v1.09e.bin b/src/firmware/assets/ev3_firmware_v1.09e.bin new file mode 100644 index 000000000..a07360644 Binary files /dev/null and b/src/firmware/assets/ev3_firmware_v1.09e.bin differ diff --git a/src/firmware/bootloaderInstructions/BootloaderInstructions.tsx b/src/firmware/bootloaderInstructions/BootloaderInstructions.tsx index cc2014524..6f68ced3c 100644 --- a/src/firmware/bootloaderInstructions/BootloaderInstructions.tsx +++ b/src/firmware/bootloaderInstructions/BootloaderInstructions.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors import './bootloaderInstructions.scss'; import { Callout, Intent } from '@blueprintjs/core'; @@ -117,7 +117,11 @@ const BootloaderInstructions: React.FunctionComponent { return { button: i18n.translate( - hubHasBluetoothButton(hubType) ? 'button.bluetooth' : 'button.power', + hubType === Hub.EV3 + ? 'button.right' + : hubHasBluetoothButton(hubType) + ? 'button.bluetooth' + : 'button.power', ), light: i18n.translate( hubHasBluetoothButton(hubType) ? 'light.bluetooth' : 'light.status', @@ -163,13 +167,16 @@ const BootloaderInstructions: React.FunctionComponent ( <> -
  • - {i18n.translate( - hubHasUSB(hubType) - ? 'instructionGroup.prepare.usb' - : 'instructionGroup.prepare.batteries', - )} -
  • + {hubType !== Hub.EV3 && ( +
  • + {i18n.translate( + hubHasUSB(hubType) + ? 'instructionGroup.prepare.usb' + : 'instructionGroup.prepare.batteries', + )} +
  • + )} +
  • {i18n.translate('instructionGroup.prepare.turnOff')}
  • {/* For non-usb recovery, show step about official app */} {recovery && !hubHasUSB(hubType) && ( @@ -179,6 +186,12 @@ const BootloaderInstructions: React.FunctionComponent )} + + {hubType === Hub.EV3 && ( +
  • + {i18n.translate('instructionGroup.bootloaderMode.connectUsb')} +
  • + )} ), [i18n, recovery, hubType], @@ -210,28 +223,36 @@ const BootloaderInstructions: React.FunctionComponent + {i18n.translate( + 'instructionGroup.bootloaderMode.connectUsb', + )} + + )} + + {hubType !== Hub.EV3 && (
  • - {i18n.translate('instructionGroup.bootloaderMode.connectUsb')} + {i18n.translate( + 'instructionGroup.bootloaderMode.waitForLight', + { + button, + light, + lightPattern, + }, + )}
  • )} -
  • - {i18n.translate('instructionGroup.bootloaderMode.waitForLight', { - button, - light, - lightPattern, - })} -
  • - {hubType === Hub.Essential && hubHasUSB(hubType) && (
  • )} + {hubType === Hub.EV3 && ( +
  • + {i18n.translate( + 'instructionGroup.bootloaderMode.pressPowerButtonEV3', + )} +
  • + )} + {recovery && !hubHasUSB(hubType) && (
  • {i18n.translate( - 'instructionGroup.bootloaderMode.releaseButton', + hubType === Hub.EV3 + ? 'instructionGroup.bootloaderMode.releaseButtonsEV3' + : 'instructionGroup.bootloaderMode.releaseButton', { button, }, @@ -342,6 +377,13 @@ const BootloaderInstructions: React.FunctionComponent + {hubType === Hub.EV3 && ( +
  • + {i18n.translate( + 'instructionGroup.connect.selectEV3FirmwareType', + )} +
  • + )}
  • {i18n.translate( 'instructionGroup.connect.clickConnectAndFlash', @@ -389,7 +431,7 @@ const BootloaderInstructions: React.FunctionComponent )} - {hubHasUSB(hubType) && isWindows() && ( + {hubHasUSB(hubType) && hubType !== Hub.EV3 && isWindows() && ( }> {i18n.translate('warning.windows.message', { instructions: ( diff --git a/src/firmware/bootloaderInstructions/translations/en.json b/src/firmware/bootloaderInstructions/translations/en.json index 0d28cf320..f4d2a15fa 100644 --- a/src/firmware/bootloaderInstructions/translations/en.json +++ b/src/firmware/bootloaderInstructions/translations/en.json @@ -11,7 +11,8 @@ }, "button": { "bluetooth": "Bluetooth button", - "power": "button" + "power": "button", + "right": "right button" }, "light": { "bluetooth": "Bluetooth light", @@ -37,10 +38,13 @@ "waitForLight": "Wait for the {light} to start blinking {lightPattern}.", "waitAppConnect": "The app will automatically connect and start restoring the firmware.", "releaseButton": "Release the {button}.", - "keepHolding": "Keep holding the {button} in the next steps. We'll tell you when to let go." + "keepHolding": "Keep holding the {button} in the next steps. We'll tell you when to let go.", + "pressPowerButtonEV3": "Press the center button to turn on the EV3.", + "releaseButtonsEV3": "When the screen says \"Updating...\", release both buttons." }, "connect": { "title": "Install:", + "selectEV3FirmwareType": "Select the version of firmware to restore below.", "clickConnectAndFlash": "Click the {flashButton} button below.", "selectDevice": "In the pop-up dialog, select {deviceName} and click {connectButton}.", "connectButton": "Connect" diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx index b5643ab6e..d4725a9b4 100644 --- a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors import './installPybricksDialog.scss'; import { @@ -142,11 +142,6 @@ const UnsupportedHubs: React.FunctionComponent = () => { 'selectHubPanel.notOnListButton.info.mindstorms.nxt', )}
  • -
  • - {i18n.translate( - 'selectHubPanel.notOnListButton.info.mindstorms.ev3', - )} -
  • {i18n.translate('selectHubPanel.notOnListButton.info.poweredUp.title')} @@ -170,6 +165,11 @@ const UnsupportedHubs: React.FunctionComponent = () => { 'selectHubPanel.notOnListButton.info.poweredUp.mario', )} +
  • + {i18n.translate( + 'selectHubPanel.notOnListButton.info.poweredUp.technicMove', + )} +
  • ); @@ -453,7 +453,8 @@ export const InstallPybricksDialog: React.FunctionComponent = () => { const inProgress = useSelector( (s) => s.firmware.isFirmwareFlashUsbDfuInProgress || - s.firmware.isFirmwareRestoreOfficialDfuInProgress, + s.firmware.isFirmwareRestoreOfficialDfuInProgress || + s.firmware.isFirmwareFlashEV3InProgress, ); const dispatch = useDispatch(); const [hubName, setHubName] = useState(''); diff --git a/src/firmware/installPybricksDialog/actions.ts b/src/firmware/installPybricksDialog/actions.ts index 00449b5d6..84c326736 100644 --- a/src/firmware/installPybricksDialog/actions.ts +++ b/src/firmware/installPybricksDialog/actions.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors import { createAction } from '../../actions'; @@ -8,7 +8,7 @@ export const firmwareInstallPybricksDialogShow = createAction(() => ({ type: 'firmware.installPybricksDialog.action.show', })); -type FlashMethod = 'ble-lwp3-bootloader' | 'usb-lego-dfu'; +type FlashMethod = 'ble-lwp3-bootloader' | 'usb-lego-dfu' | 'usb-ev3'; /** * Action that indicates the user accepted the install Pybricks firmware dialog. diff --git a/src/firmware/installPybricksDialog/translations/en.json b/src/firmware/installPybricksDialog/translations/en.json index b9854230b..42deb3679 100644 --- a/src/firmware/installPybricksDialog/translations/en.json +++ b/src/firmware/installPybricksDialog/translations/en.json @@ -14,15 +14,15 @@ "sponsor": "sponsor" }, "rcx": "RCX: Might work in streaming mode.", - "nxt": "NXT: We have tested this. It could work!", - "ev3": "EV3: Supported using Visual Studio Code." + "nxt": "NXT: We have tested this. It could work!" }, "poweredUp": { "title": "Other hubs", "intro": "Support for these hubs is currently not planned.", "wedo2": "WeDo 2.0 Smart hub.", "duploTrain": "Duplo Train hub.", - "mario": "Mario/Luigi/Peach." + "mario": "Mario/Luigi/Peach.", + "technicMove": "Technic Move hub." } } }, diff --git a/src/firmware/reducers.test.ts b/src/firmware/reducers.test.ts index 1e93e6c39..2a3ae5687 100644 --- a/src/firmware/reducers.test.ts +++ b/src/firmware/reducers.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 { AnyAction } from 'redux'; import { @@ -23,6 +23,7 @@ test('initial state', () => { "installPybricksDialog": { "isOpen": false, }, + "isFirmwareFlashEV3InProgress": false, "isFirmwareFlashUsbDfuInProgress": false, "isFirmwareRestoreOfficialDfuInProgress": false, "progress": null, diff --git a/src/firmware/reducers.ts b/src/firmware/reducers.ts index fd73fb65d..552a6c4e0 100644 --- a/src/firmware/reducers.ts +++ b/src/firmware/reducers.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021-2023 The Pybricks Authors +// Copyright (c) 2021-2025 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; import { @@ -9,10 +9,13 @@ import { didStart, firmwareDidFailToFlashUsbDfu, firmwareDidFailToRestoreOfficialDfu, + firmwareDidFailToRestoreOfficialEV3, firmwareDidFlashUsbDfu, firmwareDidRestoreOfficialDfu, + firmwareDidRestoreOfficialEV3, firmwareFlashUsbDfu, firmwareRestoreOfficialDfu, + firmwareRestoreOfficialEV3, } from './actions'; import dfuWindowsDriverInstallDialog from './dfuWindowsDriverInstallDialog/reducers'; import installPybricksDialog from './installPybricksDialog/reducers'; @@ -77,6 +80,21 @@ const isFirmwareRestoreOfficialDfuInProgress: Reducer = ( return state; }; +const isFirmwareFlashEV3InProgress: Reducer = (state = false, action) => { + if (firmwareRestoreOfficialEV3.matches(action)) { + return true; + } + + if (firmwareDidRestoreOfficialEV3.matches(action)) { + return false; + } + + if (firmwareDidFailToRestoreOfficialEV3.matches(action)) { + return false; + } + return state; +}; + export default combineReducers({ dfuWindowsDriverInstallDialog, installPybricksDialog, @@ -85,4 +103,5 @@ export default combineReducers({ progress, isFirmwareFlashUsbDfuInProgress, isFirmwareRestoreOfficialDfuInProgress, + isFirmwareFlashEV3InProgress, }); diff --git a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx index cd0483557..60a0b1aed 100644 --- a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx +++ b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022-2023 The Pybricks Authors +// Copyright (c) 2022-2025 The Pybricks Authors import { Button, @@ -7,10 +7,13 @@ import { DialogStep, Intent, MultistepDialog, + Radio, + RadioGroup, } from '@blueprintjs/core'; import classNames from 'classnames'; import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; +import { useLocalStorage } from 'usehooks-ts'; import { legoEducationSpikeRegisteredTrademark, legoMindstormsRegisteredTrademark, @@ -20,7 +23,11 @@ import { Hub, hubHasUSB } from '../../components/hubPicker'; import { HubPicker } from '../../components/hubPicker/HubPicker'; import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks'; import { useSelector } from '../../reducers'; -import { firmwareRestoreOfficialDfu } from '../actions'; +import { + EV3OfficialFirmwareVersion, + firmwareRestoreOfficialDfu, + firmwareRestoreOfficialEV3, +} from '../actions'; import BootloaderInstructions from '../bootloaderInstructions/BootloaderInstructions'; import { firmwareRestoreOfficialDialogHide } from './actions'; import { useI18n } from './i18n'; @@ -49,13 +56,23 @@ const RestoreFirmwarePanel: React.FunctionComponent = () => { const inProgress = useSelector( (s) => s.firmware.isFirmwareFlashUsbDfuInProgress || - s.firmware.isFirmwareRestoreOfficialDfuInProgress, + s.firmware.isFirmwareRestoreOfficialDfuInProgress || + s.firmware.isFirmwareFlashEV3InProgress, ); + const [ev3OfficialFirmwareVersion, setEv3OfficialFirmwareVersion] = + useLocalStorage( + 'ev3OfficialFirmwareVersion', + EV3OfficialFirmwareVersion.home, + ); - const handleRestoreButtonClick = useCallback(() => { + const handleRestoreDfuButtonClick = useCallback(() => { dispatch(firmwareRestoreOfficialDfu(hubType)); }, [dispatch, hubType]); + const handleRestoreEV3ButtonClick = useCallback(() => { + dispatch(firmwareRestoreOfficialEV3(ev3OfficialFirmwareVersion)); + }, [dispatch, ev3OfficialFirmwareVersion]); + return (
    { recovery flashButtonText={i18n.translate('restoreFirmwarePanel.flashButton')} /> - {hubHasUSB(hubType) ? ( - <> -

    - {i18n.translate('restoreFirmwarePanel.instruction2.updateApp', { - app: - hubType === Hub.Inventor - ? legoMindstormsRegisteredTrademark - : legoEducationSpikeRegisteredTrademark, - })}{' '} - {hubType !== Hub.Inventor - ? i18n.translate( - 'restoreFirmwarePanel.instruction2.updateAppVersion', - ) - : ''} -

    -
    + {hubType === Hub.EV3 ? ( +
    - + + setEv3OfficialFirmwareVersion( + event.currentTarget.value as EV3OfficialFirmwareVersion, + ) + } + > + + {i18n.translate( + 'restoreFirmwarePanel.ev3FirmwareType.home', + )} + + + {i18n.translate( + 'restoreFirmwarePanel.ev3FirmwareType.education', + )} + + + {i18n.translate( + 'restoreFirmwarePanel.ev3FirmwareType.makecode', + )} + + +
    ) : ( -

    {i18n.translate('restoreFirmwarePanel.instruction2.ble.message')}

    + <> + {hubHasUSB(hubType) ? ( + <> +

    + {i18n.translate( + 'restoreFirmwarePanel.instruction2.updateApp', + { + app: + hubType === Hub.Inventor + ? legoMindstormsRegisteredTrademark + : legoEducationSpikeRegisteredTrademark, + }, + )}{' '} + {hubType !== Hub.Inventor + ? i18n.translate( + 'restoreFirmwarePanel.instruction2.updateAppVersion', + ) + : ''} +

    +
    + + + ) : ( +

    + {i18n.translate( + 'restoreFirmwarePanel.instruction2.ble.message', + )} +

    + )} + )}
    ); diff --git a/src/firmware/restoreOfficialDialog/translations/en.json b/src/firmware/restoreOfficialDialog/translations/en.json index 133e67092..04024fc00 100644 --- a/src/firmware/restoreOfficialDialog/translations/en.json +++ b/src/firmware/restoreOfficialDialog/translations/en.json @@ -22,6 +22,11 @@ "updateApp": "Once the recovery is complete, use the {app} app to get the latest official firmware.", "updateAppVersion": "You can use version 2 (legacy) or version 3 to do so." }, - "flashButton": "Restore" + "flashButton": "Restore", + "ev3FirmwareType": { + "home": "Home edition (v1.09H)", + "education": "Education edition (v1.09E)", + "makecode": "Microsoft MakeCode (v1.10E)" + } } } diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 8225c71ae..b3833e3a8 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2024 The Pybricks Authors +// Copyright (c) 2020-2025 The Pybricks Authors import { FirmwareReader, @@ -22,6 +22,7 @@ import { call, cancel, delay, + fork, getContext, put, race, @@ -61,9 +62,10 @@ import { BootloaderConnectionState } from '../lwp3-bootloader/reducers'; import { compile, didCompile, didFailToCompile } from '../mpy/actions'; import { RootState } from '../reducers'; import { LegoUsbProductId, legoUsbVendorId } from '../usb'; -import { defined, ensureError, hex, maybe } from '../utils'; +import { assert, defined, ensureError, hex, maybe } from '../utils'; import { crc32, fmod, sumComplement32 } from '../utils/math'; import { + EV3OfficialFirmwareVersion, FailToFinishReasonType, HubError, MetadataProblem, @@ -71,13 +73,20 @@ import { didFinish, didProgress, didStart, + firmwareDidFailToFlashEV3, firmwareDidFailToFlashUsbDfu, firmwareDidFailToRestoreOfficialDfu, + firmwareDidFailToRestoreOfficialEV3, + firmwareDidFlashEV3, firmwareDidFlashUsbDfu, + firmwareDidReceiveEV3Reply, firmwareDidRestoreOfficialDfu, + firmwareDidRestoreOfficialEV3, + firmwareFlashEV3, firmwareFlashUsbDfu, firmwareInstallPybricks, firmwareRestoreOfficialDfu, + firmwareRestoreOfficialEV3, flashFirmware, } from './actions'; import { firmwareDfuWindowsDriverInstallDialogDialogShow } from './dfuWindowsDriverInstallDialog/actions'; @@ -689,7 +698,7 @@ function* handleFlashUsbDfu(action: ReturnType): Gen const defer = new Array<() => void>(); try { - // not all web browsers support Web USB + // not all web browsers support WebUSB if (!navigator.usb) { yield* put(alertsShowAlert('firmware', 'noWebUsb')); yield* put(firmwareDidFailToFlashUsbDfu()); @@ -912,6 +921,10 @@ function* handleInstallPybricks(): Generator { ); } break; + case 'usb-ev3': + // TODO: implement flashing via EV3 USB + console.error('Flashing via EV3 USB is not implemented yet'); + break; } } @@ -988,9 +1001,342 @@ function* handleRestoreOfficialDfu( } } +function* handleFlashEV3(action: ReturnType): Generator { + if (navigator.hid === undefined) { + yield* put(alertsShowAlert('firmware', 'noWebHid')); + yield* put(firmwareDidFailToFlashEV3()); + return; + } + + const [hidDevices, hidDevicesError] = yield* call(() => + maybe( + navigator.hid.requestDevice({ + filters: [ + { + vendorId: legoUsbVendorId, + productId: LegoUsbProductId.Ev3Bootloader, + }, + ], + }), + ), + ); + + if (hidDevicesError) { + // TODO: show info message with tips on how to get EV3 into bootloader mode + console.error(hidDevicesError); + yield* put(firmwareDidFailToFlashEV3()); + return; + } + + defined(hidDevices); + + if (hidDevices.length === 0) { + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: new Error('no EV3 HID devices found'), + }), + ); + yield* put(firmwareDidFailToFlashEV3()); + return; + } + + // Only flash one device. + const hidDevice = hidDevices[0]; + + const exitStack: Array<() => Promise> = []; + function* cleanup() { + for (const func of exitStack.reverse()) { + yield* call(() => func()); + } + } + + const [, openError] = yield* call(() => maybe(hidDevice.open())); + if (openError) { + console.error(openError); + yield* put(alertsShowAlert('alerts', 'unexpectedError', { error: openError })); + yield* put(firmwareDidFailToFlashEV3()); + yield* cleanup(); + return; + } + + exitStack.push(() => hidDevice.close()); + + const inputChannel = eventChannel((emit) => { + hidDevice.addEventListener('inputreport', emit); + return () => hidDevice.removeEventListener('inputreport', emit); + }); + exitStack.push(async () => inputChannel.close()); + + function* readInputReports(): SagaGenerator { + for (;;) { + const event = yield* take(inputChannel); + if (event.data.byteLength === 0) { + continue; // ignore empty reports + } + + const length = event.data.getInt16(0, true); + const replyNumber = event.data.getInt16(2, true); + const 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); + + console.debug( + `EV3 reply: length=${length}, replyNumber=${replyNumber}, messageType=${messageType}, replyCommand=${replyCommand}, status=${status}, payload=${payload}`, + ); + + yield* put( + firmwareDidReceiveEV3Reply( + length, + replyNumber, + messageType, + replyCommand, + status, + payload, + ), + ); + } + } + + const readInputReportsTask = yield* fork(readInputReports); + exitStack.push(async () => readInputReportsTask.cancel()); + + function* sendCommand( + command: number, + payload?: Uint8Array, + ): SagaGenerator<[DataView | undefined, Error | undefined]> { + console.debug(`EV3 send: command=${command}, payload=${payload}`); + + const dataBuffer = new Uint8Array((payload?.byteLength ?? 0) + 6); + const data = new DataView(dataBuffer.buffer); + + data.setInt16(0, (payload?.byteLength ?? 0) + 4, true); + data.setInt16(2, 0, true); // TODO: reply number + data.setUint8(4, 0x01); // system command w/ reply + data.setUint8(5, command); + if (payload) { + dataBuffer.set(payload, 6); + } + + const [, sendError] = yield* call(() => maybe(hidDevice.sendReport(0, data))); + + if (sendError) { + return [undefined, sendError]; + } + + const { reply, timeout } = yield* race({ + reply: take(firmwareDidReceiveEV3Reply), + timeout: delay(5000), + }); + if (timeout) { + return [undefined, new Error('Timeout waiting for EV3 reply')]; + } + + defined(reply); + + if (reply.replyCommand !== command) { + return [ + undefined, + new Error( + `EV3 reply command mismatch: expected ${command}, got ${reply.replyCommand}`, + ), + ]; + } + + if (reply.status !== 0) { + return [ + undefined, + new Error( + `EV3 reply status error: ${reply.status} for command ${command}`, + ), + ]; + } + + 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()); + + 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, i, true); + erasePayload.setUint32(4, sectorData.byteLength, true); + const [, eraseError] = yield* sendCommand( + 0xf0, + new Uint8Array(erasePayload.buffer), + ); + + if (eraseError) { + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: eraseError, + }), + ); + // FIXME: should have a better error reason + yield* put(didFailToFinish(FailToFinishReasonType.Unknown)); + 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)); + yield* put(firmwareDidFailToFlashEV3()); + yield* cleanup(); + return; + } + } + + yield* put( + didProgress((i + sectorData.byteLength) / action.firmware.byteLength), + ); + + yield* put( + alertsShowAlert( + 'firmware', + 'flashProgress', + { + action: 'flash', + progress: (i + sectorData.byteLength) / action.firmware.byteLength, + }, + firmwareBleProgressToastId, + true, + ), + ); + } + + yield* put( + alertsShowAlert( + 'firmware', + 'flashProgress', + { + action: 'flash', + progress: 1, + }, + firmwareBleProgressToastId, + true, + ), + ); + + const [, rebootError] = yield* sendCommand(0xf4); // start app + if (rebootError) { + // FIXME: should have a better error reason + yield* put(didFailToFinish(FailToFinishReasonType.Unknown)); + yield* put(firmwareDidFailToFlashEV3()); + yield* cleanup(); + return; + } + + yield* put(didFinish()); + + yield* cleanup(); + + yield* put(firmwareDidFlashEV3()); +} + +function getUrlForEV3FirmwareVersion(version: EV3OfficialFirmwareVersion): URL { + switch (version) { + case EV3OfficialFirmwareVersion.home: + return new URL('./assets/EV3_Firmware_V1.09H.bin', import.meta.url); + case EV3OfficialFirmwareVersion.education: + return new URL('./assets/ev3_firmware_v1.09e.bin', import.meta.url); + case EV3OfficialFirmwareVersion.makecode: + return new URL('./assets/ev3-image-1.10e.bin', import.meta.url); + default: + throw new Error(`unsupported EV3 firmware version: ${version}`); + } +} + +function* handleRestoreOfficialEV3( + action: ReturnType, +): Generator { + try { + const url = getUrlForEV3FirmwareVersion(action.version); + + const response = yield* call(() => fetch(url)); + + if (!response.ok) { + // TODO: replace with proper alert + // istanbul ignore if + if (process.env.NODE_ENV !== 'test') { + console.error(response); + } + throw new Error('failed to fetch'); + } + + const firmwareBlob = yield* call(() => response.blob()); + const firmware = yield* call(() => firmwareBlob.arrayBuffer()); + + yield* put(firmwareFlashEV3(firmware)); + + const { didFailToFlash } = yield* race({ + didFlash: take(firmwareDidFlashEV3), + didFailToFlash: take(firmwareDidFailToFlashEV3), + }); + + if (didFailToFlash) { + yield* put(firmwareDidFailToRestoreOfficialEV3()); + return; + } + + yield* put(firmwareDidRestoreOfficialEV3()); + } catch (err) { + // istanbul ignore if + if (process.env.NODE_ENV !== 'test') { + console.error(err); + } + + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { error: ensureError(err) }), + ); + yield* put(firmwareDidFailToRestoreOfficialEV3()); + } +} + export default function* (): Generator { yield* takeEvery(flashFirmware, handleFlashFirmware); yield* takeEvery(firmwareFlashUsbDfu, handleFlashUsbDfu); yield* takeEvery(firmwareInstallPybricks, handleInstallPybricks); yield* takeEvery(firmwareRestoreOfficialDfu, handleRestoreOfficialDfu); + yield* takeEvery(firmwareFlashEV3, handleFlashEV3); + yield* takeEvery(firmwareRestoreOfficialEV3, handleRestoreOfficialEV3); } diff --git a/src/redux.ts b/src/redux.ts index 2a6991d27..adf96108d 100644 --- a/src/redux.ts +++ b/src/redux.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2023 The Pybricks Authors +// Copyright (c) 2023-2025 The Pybricks Authors import type { SerializableStateInvariantMiddlewareOptions } from '@reduxjs/toolkit'; @@ -13,6 +13,7 @@ export const serializableCheck: SerializableStateInvariantMiddlewareOptions = { // contain ArrayBuffer, Blob or DataView 'data', 'file', + 'firmware', 'firmwareZip', 'payload', 'value', diff --git a/src/usb/alerts/translations/en.json b/src/usb/alerts/translations/en.json index 58484b627..937325679 100644 --- a/src/usb/alerts/translations/en.json +++ b/src/usb/alerts/translations/en.json @@ -1,6 +1,6 @@ { "noWebUsb": { - "message": "This browser does not support Web USB or it is not enabled.", + "message": "This browser does not support WebUSB or it is not enabled.", "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge.", "action": "More Info" }, diff --git a/yarn.lock b/yarn.lock index 7521bc033..201705db5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2939,10 +2939,10 @@ __metadata: languageName: node linkType: hard -"@pybricks/images@npm:^1.3.0": - version: 1.3.0 - resolution: "@pybricks/images@npm:1.3.0" - checksum: aad203588d967cd2dc3859226a4349992fec45f443b74a6b8e55f714c895fa25ee147c2e906b604bc48f93c48cd75526ec487a254ca142426d61ba29b7101d14 +"@pybricks/images@npm:^1.4.0": + version: 1.4.0 + resolution: "@pybricks/images@npm:1.4.0" + checksum: 644be26497cb648bebab6be5f7cc5dae28fbe210f1cb5435316d10411a9a4a016abc37fe6fb52c2dd06c2e45bcdb5c38db0a6fbb31690ee4ea6bfdacbc67c25c languageName: node linkType: hard @@ -2976,7 +2976,7 @@ __metadata: "@pmmmwh/react-refresh-webpack-plugin": ^0.5.11 "@pybricks/firmware": 7.22.0 "@pybricks/ide-docs": 2.20.0 - "@pybricks/images": ^1.3.0 + "@pybricks/images": ^1.4.0 "@pybricks/jedi": 1.17.0 "@pybricks/mpy-cross-v5": ^2.0.0 "@pybricks/mpy-cross-v6": ^2.0.0 @@ -2998,6 +2998,7 @@ __metadata: "@types/react-transition-group": ^4.4.10 "@types/redux-logger": ^3.0.12 "@types/semver": ^7.5.6 + "@types/w3c-web-hid": ^1.0.6 "@types/w3c-web-usb": ^1.0.10 "@types/web-bluetooth": ^0.0.20 "@types/web-locks-api": ^0.0.5 @@ -5464,6 +5465,13 @@ __metadata: languageName: node linkType: hard +"@types/w3c-web-hid@npm:^1.0.6": + version: 1.0.6 + resolution: "@types/w3c-web-hid@npm:1.0.6" + checksum: 14773befa9c458b3459cdb530a8269937e623e6b72c6bd2d7f88b42f8d47c02d8a64ddc98f79c81c930b6eadf1dc1c94917b553ead72acc13c8406f65310c85d + languageName: node + linkType: hard + "@types/w3c-web-usb@npm:^1.0.10": version: 1.0.10 resolution: "@types/w3c-web-usb@npm:1.0.10" @@ -7156,9 +7164,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001538, caniuse-lite@npm:^1.0.30001565": - version: 1.0.30001658 - resolution: "caniuse-lite@npm:1.0.30001658" - checksum: f43ebd6333808843aac90c95d1d938175d95c5580fa25fb7e0f99fd7d7f8830b112d9acd65c7d8db904a281dbfe65966cb7825db3c7f6547d757ca4cea08b183 + version: 1.0.30001733 + resolution: "caniuse-lite@npm:1.0.30001733" + checksum: cf9d0701ef5617231072be7db74a077ac7a453c8672fe0f17df14aee73f8f253b42cd0d95e1f150ff73453edb115b7131e98b416070b798c8f41b25606f15292 languageName: node linkType: hard