diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts
index a339e39c..051a95e8 100644
--- a/src/firmware/actions.ts
+++ b/src/firmware/actions.ts
@@ -3,6 +3,7 @@
import { FirmwareReaderError } from '@pybricks/firmware';
import { createAction } from '../actions';
+import { Hub } from '../components/hubPicker';
export enum MetadataProblem {
Missing = 'metadata.missing',
@@ -406,3 +407,25 @@ export const firmwareDidInstallPybricks = createAction(() => ({
export const firmwareDidFailToInstallPybricks = createAction(() => ({
type: 'firmware.action.didFailToInstallPybricks',
}));
+
+/**
+ * Action that triggers the restore official DFU firmware saga.
+ */
+export const firmwareRestoreOfficialDfu = createAction((hub: Hub) => ({
+ type: 'firmware.action.restoreOfficialDfu',
+ hub,
+}));
+
+/**
+ * Action that indicates {@link firmwareRestoreOfficialDfu} succeeded.
+ */
+export const firmwareDidRestoreOfficialDfu = createAction(() => ({
+ type: 'firmware.action.didRestoreOfficialDfu',
+}));
+
+/**
+ * Action that indicates {@link firmwareRestoreOfficialDfu} failed.
+ */
+export const firmwareDidFailToRestoreOfficialDfu = createAction(() => ({
+ type: 'firmware.action.didFailToRestoreOfficialDfu',
+}));
diff --git a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.test.tsx b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.test.tsx
new file mode 100644
index 00000000..1320af01
--- /dev/null
+++ b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.test.tsx
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { cleanup } from '@testing-library/react';
+import React from 'react';
+import { testRender } from '../../../test';
+import { Hub } from '../../components/hubPicker';
+import { firmwareRestoreOfficialDfu } from '../actions';
+import RestoreOfficialDialog from './RestoreOfficialDialog';
+import { firmwareRestoreOfficialDialogHide } from './actions';
+
+afterEach(() => {
+ cleanup();
+ jest.resetAllMocks();
+ localStorage.clear();
+});
+
+describe('closing', () => {
+ it('should close when close button is clicked', async () => {
+ const [user, dialog, dispatch] = testRender( , {
+ firmware: { restoreOfficialDialog: { isOpen: true } },
+ });
+
+ await user.click(dialog.getByRole('button', { name: 'Close' }));
+
+ expect(dispatch).toHaveBeenCalledWith(firmwareRestoreOfficialDialogHide());
+ });
+
+ it('should close when done button is clicked', async () => {
+ const [user, dialog, dispatch] = testRender( , {
+ firmware: { restoreOfficialDialog: { isOpen: true } },
+ });
+
+ await user.click(dialog.getByRole('button', { name: 'Next' }));
+ await user.click(dialog.getByRole('button', { name: 'Done' }));
+
+ expect(dispatch).toHaveBeenCalledWith(firmwareRestoreOfficialDialogHide());
+ });
+});
+
+describe('flashing', () => {
+ it.each([
+ ['SPIKE Prime Hub', Hub.Prime],
+ ['SPIKE Essential Hub', Hub.Essential],
+ ['MINDSTORMS Robot Inventor Hub', Hub.Inventor],
+ ])(
+ 'should flash %s when flash button is clicked',
+ async (hubName: string, hub: Hub) => {
+ const [user, dialog, dispatch] = testRender( , {
+ firmware: { restoreOfficialDialog: { isOpen: true } },
+ });
+
+ await user.click(dialog.getByRole('radio', { name: hubName }));
+ await user.click(dialog.getByRole('button', { name: 'Next' }));
+ await user.click(dialog.getByRole('button', { name: 'Flash' }));
+
+ expect(dispatch).toHaveBeenCalledWith(firmwareRestoreOfficialDfu(hub));
+ },
+ );
+});
diff --git a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx
index d59b5951..26b581f5 100644
--- a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx
+++ b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx
@@ -1,53 +1,129 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
-import { Classes, Dialog } from '@blueprintjs/core';
+import {
+ Button,
+ Classes,
+ DialogStep,
+ Intent,
+ MultistepDialog,
+} from '@blueprintjs/core';
import classNames from 'classnames';
import React from 'react';
import { useDispatch } from 'react-redux';
import {
+ legoRegisteredTrademark,
pybricksBleFirmwareRestoreVideoUrl,
- pybricksDfuRestoreUrl,
} from '../../app/constants';
+import { hubHasUSB } from '../../components/hubPicker';
+import { HubPicker } from '../../components/hubPicker/HubPicker';
+import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks';
import { useSelector } from '../../reducers';
import ExternalLinkIcon from '../../utils/ExternalLinkIcon';
+import { firmwareRestoreOfficialDfu } from '../actions';
+import BootloaderInstructions from '../bootloaderInstructions/BootloaderInstructions';
import { firmwareRestoreOfficialDialogHide } from './actions';
import { useI18n } from './i18n';
+const SelectHubPanel: React.VoidFunctionComponent = () => {
+ const i18n = useI18n();
+
+ return (
+
+
+ {i18n.translate('selectHubPanel.message', {
+ lego: legoRegisteredTrademark,
+ next: (
+ {i18n.translate('selectHubPanel.nextButton')}
+ ),
+ })}
+
+
+
+
+ );
+};
+
+const RestoreFirmwarePanel: React.VoidFunctionComponent = () => {
+ const [hubType] = useHubPickerSelectedHub();
+ const dispatch = useDispatch();
+ const i18n = useI18n();
+
+ return (
+
+
+ {hubHasUSB(hubType) ? (
+ <>
+
+ {i18n.translate('restoreFirmwarePanel.instruction2.dfu', {
+ flashFirmware: (
+
+ {i18n.translate('restoreFirmwarePanel.flashButton')}
+
+ ),
+ lego: legoRegisteredTrademark,
+ })}
+
+
+
dispatch(firmwareRestoreOfficialDfu(hubType))}
+ >
+ {i18n.translate('restoreFirmwarePanel.flashButton')}
+
+ >
+ ) : (
+
+ {i18n.translate('restoreFirmwarePanel.instruction2.ble.message', {
+ lego: legoRegisteredTrademark,
+ thisVideo: (
+ <>
+
+ {i18n.translate(
+ 'restoreFirmwarePanel.instruction2.ble.thisVideo',
+ )}
+
+
+ >
+ ),
+ })}
+
+ )}
+
+ );
+};
+
const RestoreOfficialDialog: React.VoidFunctionComponent = () => {
const { isOpen } = useSelector((s) => s.firmware.restoreOfficialDialog);
const dispatch = useDispatch();
const i18n = useI18n();
return (
- dispatch(firmwareRestoreOfficialDialogHide())}
+ finalButtonProps={{
+ text: i18n.translate('doneButton.label'),
+ onClick: () => dispatch(firmwareRestoreOfficialDialogHide()),
+ }}
>
-
-
+ }
+ nextButtonProps={{ text: i18n.translate('selectHubPanel.nextButton') }}
+ />
+ }
+ />
+
);
};
diff --git a/src/firmware/restoreOfficialDialog/translations/en.json b/src/firmware/restoreOfficialDialog/translations/en.json
index cd36ad08..95727a3e 100644
--- a/src/firmware/restoreOfficialDialog/translations/en.json
+++ b/src/firmware/restoreOfficialDialog/translations/en.json
@@ -1,13 +1,22 @@
{
- "title": "Restore official LEGO® Firmware",
- "poweredUpHubs": {
- "title": "Powered Up Hubs",
- "message": "The official firmware can be restored on Powered Up hubs by putting the hub in bootloader mode and connecting to the hub using one of the official LEGO apps.",
- "action": "Watch video."
+ "title": "Restore official {lego} Firmware",
+ "doneButton": {
+ "label": "Done"
},
- "spikeHubs": {
- "title": "SPIKE/MINDSTORMS Hubs",
- "message": "The official LEGO software for these hubs does not have a way to restore the firmware on these hubs. So, we have provided a special site to do this for you.",
- "action": "Pybricks DFU restore tool."
+ "selectHubPanel": {
+ "title": "Select hub",
+ "message": "Select the hub to restore the official {lego} firmware on, then click {next}.",
+ "nextButton": "Next"
+ },
+ "restoreFirmwarePanel": {
+ "title": "Restore firmware",
+ "instruction2": {
+ "ble": {
+ "message": "Then use the official {lego} app for this hub to connect to the hub and restore the firmware. Watch {thisVideo} to see how.",
+ "thisVideo": "this video"
+ },
+ "dfu": "Then click the {flashFirmware} button below to connect to the hub and flash the firmware. After flashing is complete, connect the hub to one of the official {lego} apps and use it to update to the latest official firmware."
+ },
+ "flashButton": "Flash"
}
}