firmware: add multi-step dialog

This commit is contained in:
David Lechner
2022-07-20 12:32:17 -05:00
parent b70777178c
commit 85deb42f76
42 changed files with 1119 additions and 327 deletions
@@ -0,0 +1,424 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import './installPybricksDialog.scss';
import {
Button,
Checkbox,
Classes,
ControlGroup,
DialogStep,
FormGroup,
IRef,
Icon,
InputGroup,
Intent,
MultistepDialog,
NonIdealState,
Spinner,
Switch,
} from '@blueprintjs/core';
import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2';
import classNames from 'classnames';
import React, { useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { appName } from '../../app/constants';
import HelpButton from '../../components/HelpButton';
import {
Hub,
hubHasBluetoothButton,
hubHasExternalFlash,
hubHasUSB,
} from '../../components/hubPicker';
import { HubPicker } from '../../components/hubPicker/HubPicker';
import { useSelector } from '../../reducers';
import {
firmwareInstallPybricksDialogAccept,
firmwareInstallPybricksDialogCancel,
} from './actions';
import { useFirmware } from './hooks';
import { I18nId, useI18n } from './i18n';
import { validateHubName } from '.';
const dialogBody = classNames(
Classes.DIALOG_BODY,
'pb-firmware-installPybricksDialog-body',
);
type SelectHubPanelProps = {
hubType: Hub;
onChange: (hubType: Hub) => void;
};
const SelectHubPanel: React.VoidFunctionComponent<SelectHubPanelProps> = ({
hubType,
onChange,
}) => {
const i18n = useI18n();
return (
<div className={dialogBody}>
<p>{i18n.translate(I18nId.SelectHubPanelMessage)}</p>
<HubPicker hubType={hubType} onChange={onChange} />
<Popover2
popoverClassName={Classes2.POPOVER2_CONTENT_SIZING}
placement="right-end"
content={
<div>
<h3>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsTitle,
)}
</h3>
<ul>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsRcx,
)}
</li>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsNxt,
)}
</li>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsEv3,
)}
</li>
</ul>
<h3>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpTitle,
)}
</h3>
<ul>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpWedo2,
)}
<em>*</em>
</li>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain,
)}
<em>*</em>
</li>
<li>
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpMario,
)}
</li>
</ul>
<em>
*{' '}
{i18n.translate(
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpFootnote,
)}
</em>
</div>
}
renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => (
<Button
elementRef={ref as IRef<HTMLButtonElement>}
{...targetProps}
>
{i18n.translate(I18nId.SelectHubPanelNotOnListButtonLabel)}
</Button>
)}
/>
</div>
);
};
type AcceptLicensePanelProps = {
hubType: Hub;
licenseAccepted: boolean;
onLicenseAcceptedChanged: (accepted: boolean) => void;
};
const AcceptLicensePanel: React.VoidFunctionComponent<AcceptLicensePanelProps> = ({
hubType,
licenseAccepted,
onLicenseAcceptedChanged,
}) => {
const { data, error } = useFirmware(hubType);
const i18n = useI18n();
return (
<div className={dialogBody}>
<div className="pb-firmware-installPybricksDialog-license">
<div className="pb-firmware-installPybricksDialog-license-text">
{data && <pre>{data.licenseText}</pre>}
{!data && (
<NonIdealState>
{(error &&
i18n.translate(
I18nId.LicensePanelLicenseTextError,
)) || <Spinner />}
</NonIdealState>
)}
</div>
<Checkbox
label={i18n.translate(I18nId.LicensePanelAcceptCheckboxLabel)}
checked={licenseAccepted}
onChange={(e) => onLicenseAcceptedChanged(e.currentTarget.checked)}
disabled={!data}
/>
</div>
</div>
);
};
type SelectOptionsPanelProps = {
hubType: Hub;
hubName: string;
includeProgram: boolean;
onChangeHubName(hubName: string): void;
onChangeIncludeProgram(includeProgram: boolean): void;
};
const ConfigureOptionsPanel: React.VoidFunctionComponent<SelectOptionsPanelProps> = ({
hubType,
hubName,
includeProgram,
onChangeHubName,
onChangeIncludeProgram,
}) => {
const i18n = useI18n();
const isHubNameValid = validateHubName(hubName);
return (
<div className={dialogBody}>
<FormGroup
label={i18n.translate(I18nId.OptionsPanelHubNameLabel)}
labelInfo={i18n.translate(I18nId.OptionsPanelHubNameLabelInfo)}
>
<ControlGroup>
<InputGroup
id="hub-name-input"
value={hubName}
onChange={(e) => onChangeHubName(e.currentTarget.value)}
onMouseOver={(e) => e.preventDefault()}
onMouseDown={(e) => e.stopPropagation()}
intent={isHubNameValid ? Intent.NONE : Intent.DANGER}
placeholder="Pybricks Hub"
rightElement={
isHubNameValid ? undefined : (
<Icon
icon="error"
intent={Intent.DANGER}
itemType="div"
/>
)
}
/>
<HelpButton
helpForLabel={i18n.translate(I18nId.OptionsPanelHubNameLabel)}
content={i18n.translate(I18nId.OptionsPanelHubNameHelp)}
/>
</ControlGroup>
</FormGroup>
<FormGroup
label={i18n.translate(I18nId.OptionsPanelCustomMainLabel)}
labelInfo={i18n.translate(I18nId.OptionsPanelCustomMainLabelInfo)}
>
{(hubHasExternalFlash(hubType) && (
<p>
{i18n.translate(
I18nId.OptionsPanelCustomMainNotApplicableMessage,
)}
</p>
)) || (
<ControlGroup>
<Switch
label={i18n.translate(
I18nId.OptionsPanelCustomMainIncludeCurrentProgramLabel,
)}
checked={includeProgram}
onChange={(e) =>
onChangeIncludeProgram(
(e.target as HTMLInputElement).checked,
)
}
/>
<HelpButton
helpForLabel={i18n.translate(
I18nId.OptionsPanelCustomMainIncludeCurrentProgramLabel,
)}
content={i18n.translate(
I18nId.OptionsPanelCustomMainIncludeCurrentProgramHelp,
{
appName,
},
)}
/>
</ControlGroup>
)}
</FormGroup>
</div>
);
};
type BootloaderModePanelProps = {
hubType: Hub;
};
const BootloaderModePanel: React.VoidFunctionComponent<BootloaderModePanelProps> = ({
hubType,
}) => {
const i18n = useI18n();
const { button, light, lightPattern } = useMemo(() => {
return {
button: i18n.translate(
hubHasBluetoothButton(hubType)
? I18nId.BootloaderPanelButtonBluetooth
: I18nId.BootloaderPanelButtonPower,
),
light: i18n.translate(
hubHasBluetoothButton(hubType)
? I18nId.BootloaderPanelLightBluetooth
: I18nId.BootloaderPanelLightStatus,
),
lightPattern: i18n.translate(
hubHasBluetoothButton(hubType)
? I18nId.BootloaderPanelLightPatternBluetooth
: I18nId.BootloaderPanelLightPatternStatus,
),
};
}, [i18n, hubType]);
return (
<div className={dialogBody}>
<p>{i18n.translate(I18nId.BootloaderPanelInstruction1)}</p>
<ol>
{hubHasUSB(hubType) && (
<li>{i18n.translate(I18nId.BootloaderPanelStepDisconnectUsb)}</li>
)}
<li>{i18n.translate(I18nId.BootloaderPanelStepPowerOff)}</li>
{/* City hub has power issues and requires disconnecting motors/sensors */}
{hubType === Hub.City && (
<li>{i18n.translate(I18nId.BootloaderPanelStepDisconnectIo)}</li>
)}
<li>
{i18n.translate(I18nId.BootloaderPanelStepHoldButton, { button })}
</li>
{hubHasUSB(hubType) && (
<li>{i18n.translate(I18nId.BootloaderPanelStepConnectUsb)}</li>
)}
<li>
{i18n.translate(I18nId.BootloaderPanelStepWaitForLight, {
button,
light,
lightPattern,
})}
</li>
<li>
{i18n.translate(
/* hubs with USB will keep the power on, but other hubs won't */
hubHasUSB(hubType)
? I18nId.BootloaderPanelStepReleaseButton
: I18nId.BootloaderPanelStepKeepHolding,
{
button,
},
)}
</li>
</ol>
<p>
{i18n.translate(I18nId.BootloaderPanelInstruction2, {
flashFirmware: (
<strong>
{i18n.translate(I18nId.FlashFirmwareButtonLabel)}
</strong>
),
})}
</p>
</div>
);
};
const defaultHubType = Hub.Technic;
export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog);
const dispatch = useDispatch();
const [hubType, setHubType] = useState(defaultHubType);
const [hubName, setHubName] = useState('');
const [includeProgram, setIncludeProgram] = useState(false);
const [licenseAccepted, setLicenseAccepted] = useState(false);
const { data } = useFirmware(hubType);
const i18n = useI18n();
return (
<MultistepDialog
title={i18n.translate(I18nId.Title)}
isOpen={isOpen}
onClose={() => dispatch(firmwareInstallPybricksDialogCancel())}
finalButtonProps={{
text: i18n.translate(I18nId.FlashFirmwareButtonLabel),
onClick: () =>
dispatch(
firmwareInstallPybricksDialogAccept(
data?.firmwareZip ?? new ArrayBuffer(0),
undefined,
hubName,
),
),
}}
>
<DialogStep
id="hub"
title={i18n.translate(I18nId.SelectHubPanelTitle)}
panel={<SelectHubPanel hubType={hubType} onChange={setHubType} />}
nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }}
/>
<DialogStep
id="license"
title={i18n.translate(I18nId.LicensePanelTitle)}
panel={
<AcceptLicensePanel
hubType={hubType}
licenseAccepted={licenseAccepted}
onLicenseAcceptedChanged={setLicenseAccepted}
/>
}
backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
nextButtonProps={{
disabled: !licenseAccepted,
text: i18n.translate(I18nId.NextButtonLabel),
}}
/>
<DialogStep
id="options"
title={i18n.translate(I18nId.OptionsPanelTitle)}
panel={
<ConfigureOptionsPanel
hubType={hubType}
hubName={hubName}
includeProgram={includeProgram}
onChangeHubName={setHubName}
onChangeIncludeProgram={setIncludeProgram}
/>
}
backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }}
/>
<DialogStep
id="bootloader"
title={i18n.translate(I18nId.BootloaderPanelTitle)}
panel={<BootloaderModePanel hubType={hubType} />}
backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
/>
</MultistepDialog>
);
};
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { createAction } from '../../actions';
/** Actions that request the install Pybricks firmware dialog to be shown. */
export const firmwareInstallPybricksDialogShow = createAction(() => ({
type: 'firmware.installPybricksDialog.action.show',
}));
/** Actions that indicates the user accepted the install Pybricks firmware dialog. */
export const firmwareInstallPybricksDialogAccept = createAction(
(firmwareZip: ArrayBuffer, customProgram: string | undefined, hubName: string) => ({
type: 'firmware.installPybricksDialog.action.accept',
firmwareZip,
customProgram,
hubName,
}),
);
/** Actions that indicates the user canceled the install Pybricks firmware dialog. */
export const firmwareInstallPybricksDialogCancel = createAction(() => ({
type: 'firmware.installPybricksDialog.action.cancel',
}));
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// based on https://usehooks-ts.com/react-hook/use-fetch
import { FirmwareReader } from '@pybricks/firmware';
import cityHubZip from '@pybricks/firmware/build/cityhub.zip';
import moveHubZip from '@pybricks/firmware/build/movehub.zip';
import technicHubZip from '@pybricks/firmware/build/technichub.zip';
import { useEffect, useReducer, useRef } from 'react';
import { Hub } from '../../components/hubPicker';
type FirmwareData = {
firmwareZip: ArrayBuffer;
licenseText: string;
};
interface State {
/** The firmware.zip data or undefined if `fetch()` is not complete or on error. */
data?: FirmwareData;
/** Undefined `fetch()` is not complete yet or was successful, otherwise the error. */
error?: Error;
}
type Cache = { [url: string]: FirmwareData };
// discriminated union type
type Action =
| { type: 'loading' }
| { type: 'fetched'; payload: FirmwareData }
| { type: 'error'; payload: Error };
const firmwareZipMap = new Map<Hub, string>([
[Hub.City, cityHubZip],
[Hub.Technic, technicHubZip],
[Hub.Move, moveHubZip],
]);
/**
* Gets Pybricks firmware .zip file for the specified hub type.
* @param hubType The hub type.
* @returns The current state.
*/
export function useFirmware(hubType: Hub): State {
const url = firmwareZipMap.get(hubType);
const cache = useRef<Cache>({});
// Used to prevent state update if the component is unmounted
const cancelRequest = useRef<boolean>(false);
const initialState: State = {
error: undefined,
data: undefined,
};
// Keep state logic separated
const fetchReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'loading':
return { ...initialState };
case 'fetched':
return { ...initialState, data: action.payload };
case 'error':
return { ...initialState, error: action.payload };
default:
return state;
}
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
// Do nothing if the url is not given
if (!url) {
return;
}
cancelRequest.current = false;
const fetchData = async () => {
dispatch({ type: 'loading' });
// If a cache exists for this url, return it
if (cache.current[url]) {
dispatch({ type: 'fetched', payload: cache.current[url] });
return;
}
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
const firmwareZip = await response.arrayBuffer();
const reader = await FirmwareReader.load(firmwareZip);
const licenseText = await reader.readReadMeOss();
const data = { firmwareZip, licenseText };
cache.current[url] = data;
if (cancelRequest.current) {
return;
}
dispatch({ type: 'fetched', payload: data });
} catch (error) {
if (process.env.NODE_ENV !== 'test') {
console.error(error);
}
if (cancelRequest.current) {
return;
}
dispatch({ type: 'error', payload: error as Error });
}
};
void fetchData();
// Use the cleanup function for avoiding a possible
// state update after the component was unmounted
return () => {
cancelRequest.current = true;
};
}, [url]);
return state;
}
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
import { lookup } from '../../../test';
import { I18nId } from './i18n';
import en from './translations/en.json';
describe('Ensure .json file has matches for I18nId', () => {
test.each(Object.values(I18nId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
//
// Settings translation keys.
import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n';
export function useI18n(): I18n {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
export enum I18nId {
Title = 'title',
SelectHubPanelTitle = 'selectHubPanel.title',
SelectHubPanelMessage = 'selectHubPanel.message',
SelectHubPanelNotOnListButtonLabel = 'selectHubPanel.notOnListButton.label',
SelectHubPanelNotOnListButtonInfoMindstormsTitle = 'selectHubPanel.notOnListButton.info.mindstorms.title',
SelectHubPanelNotOnListButtonInfoMindstormsRcx = 'selectHubPanel.notOnListButton.info.mindstorms.rcx',
SelectHubPanelNotOnListButtonInfoMindstormsNxt = 'selectHubPanel.notOnListButton.info.mindstorms.nxt',
SelectHubPanelNotOnListButtonInfoMindstormsEv3 = 'selectHubPanel.notOnListButton.info.mindstorms.ev3',
SelectHubPanelNotOnListButtonInfoPoweredUpTitle = 'selectHubPanel.notOnListButton.info.poweredUp.title',
SelectHubPanelNotOnListButtonInfoPoweredUpWedo2 = 'selectHubPanel.notOnListButton.info.poweredUp.wedo2',
SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain = 'selectHubPanel.notOnListButton.info.poweredUp.duploTrain',
SelectHubPanelNotOnListButtonInfoPoweredUpMario = 'selectHubPanel.notOnListButton.info.poweredUp.mario',
SelectHubPanelNotOnListButtonInfoPoweredUpFootnote = 'selectHubPanel.notOnListButton.info.poweredUp.footnote',
LicensePanelTitle = 'licensePanel.title',
LicensePanelLicenseTextError = 'licensePanel.licenseText.error',
LicensePanelAcceptCheckboxLabel = 'licensePanel.acceptCheckbox.label',
OptionsPanelTitle = 'optionsPanel.title',
OptionsPanelHubNameLabel = 'optionsPanel.hubName.label',
OptionsPanelHubNameLabelInfo = 'optionsPanel.hubName.labelInfo',
OptionsPanelHubNameHelp = 'optionsPanel.hubName.help',
OptionsPanelHubNameError = 'optionsPanel.hubName.error',
OptionsPanelCustomMainLabel = 'optionsPanel.customMain.label',
OptionsPanelCustomMainLabelInfo = 'optionsPanel.customMain.labelInfo',
OptionsPanelCustomMainNotApplicableMessage = 'optionsPanel.customMain.notApplicable.message',
OptionsPanelCustomMainIncludeCurrentProgramLabel = 'optionsPanel.customMain.includeCurrentProgram.label',
OptionsPanelCustomMainIncludeCurrentProgramHelp = 'optionsPanel.customMain.includeCurrentProgram.help',
BootloaderPanelTitle = 'bootloaderPanel.title',
BootloaderPanelInstruction1 = 'bootloaderPanel.instruction1',
BootloaderPanelButtonBluetooth = 'bootloaderPanel.button.bluetooth',
BootloaderPanelButtonPower = 'bootloaderPanel.button.power',
BootloaderPanelLightBluetooth = 'bootloaderPanel.light.bluetooth',
BootloaderPanelLightStatus = 'bootloaderPanel.light.status',
BootloaderPanelLightPatternBluetooth = 'bootloaderPanel.lightPattern.bluetooth',
BootloaderPanelLightPatternStatus = 'bootloaderPanel.lightPattern.status',
BootloaderPanelStepDisconnectUsb = 'bootloaderPanel.step.disconnectUsb',
BootloaderPanelStepPowerOff = 'bootloaderPanel.step.powerOff',
BootloaderPanelStepDisconnectIo = 'bootloaderPanel.step.disconnectIo',
BootloaderPanelStepHoldButton = 'bootloaderPanel.step.holdButton',
BootloaderPanelStepConnectUsb = 'bootloaderPanel.step.connectUsb',
BootloaderPanelStepWaitForLight = 'bootloaderPanel.step.waitForLight',
BootloaderPanelStepReleaseButton = 'bootloaderPanel.step.releaseButton',
BootloaderPanelStepKeepHolding = 'bootloaderPanel.step.keepHolding',
BootloaderPanelInstruction2 = 'bootloaderPanel.instruction2',
NextButtonLabel = 'nextButton.label',
BackButtonLabel = 'backButton.label',
FlashFirmwareButtonLabel = 'flashFirmwareButton.label',
}
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
const encoder = new TextEncoder();
/**
* Validates the hub name.
* @param hubName The hub name.
* @returns True if the name if valid, otherwise false.
*/
export function validateHubName(hubName: string): boolean {
const encoded = encoder.encode(hubName);
// Technically, the max hub name size is determined by each individual
// firmware file, so we can't check until the firmware has been selected.
// However all firmware currently have 16 bytes allocated (including zero-
// termination), so we can hard code the check here to allow notifying the
// user earlier for better UX.
return encoded.length < 16;
}
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
.pb-firmware-installPybricksDialog {
&-body {
min-height: 250px;
}
&-license {
display: flex;
flex-direction: column;
gap: 10px;
min-height: inherit;
&-text {
flex-grow: 1;
min-height: 0;
max-height: 200px;
overflow: auto;
}
}
}
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Reducer, combineReducers } from '@reduxjs/toolkit';
import {
firmwareInstallPybricksDialogAccept,
firmwareInstallPybricksDialogCancel,
firmwareInstallPybricksDialogShow,
} from './actions';
/** Controls the flash Pybricks firmware dialog open state. */
const isOpen: Reducer<boolean> = (state = false, action) => {
if (firmwareInstallPybricksDialogShow.matches(action)) {
return true;
}
if (firmwareInstallPybricksDialogAccept.matches(action)) {
return false;
}
if (firmwareInstallPybricksDialogCancel.matches(action)) {
return false;
}
return state;
};
export default combineReducers({ isOpen });
@@ -0,0 +1,90 @@
{
"title": "Install Pybricks Firmware",
"selectHubPanel": {
"title": "Select hub type",
"message": "Which kind of hub do you want to use?",
"notOnListButton": {
"label": "My hub is not in the list.",
"info": {
"mindstorms": {
"title": "MINDSTORMS Programmable Bricks",
"rcx": "RCX - not enough memory to run Pybricks",
"nxt": "NXT - maybe some day",
"ev3": "EV3 - supported using VS Code instead of Pybricks Code"
},
"poweredUp": {
"title": "Unsupported Powered Up Hubs",
"wedo2": "WeDo 2.0 Smart hub",
"duploTrain": "Duplo Train hub",
"mario": "Mario/Luigi/Peach",
"footnote": "firmware cannot be updated"
}
}
}
},
"licensePanel": {
"title": "Accept licenses",
"licenseText": {
"error": "There was a problem while getting the firmware file."
},
"acceptCheckbox": {
"label": "I have read and agree to the license terms and conditions."
}
},
"optionsPanel": {
"title": "Configure options",
"hubName": {
"label": "Hub name",
"labelInfo": "(optional)",
"help": "Enter a name here to customize the hub name when flashing the firmware. This name will be used in the Bluetooth advertising data and can be used to identify the hub when connecting.",
"error": "The name is too long."
},
"customMain": {
"label": "Custom program",
"labelInfo": "(optional)",
"notApplicable": {
"message": "This hub has external flash memory so including a custom program when flashing firmware is not needed."
},
"includeCurrentProgram": {
"label": "Include current program",
"help": "Enable to include your program when flashing the firmware or disable to use the default program. Flashing your program along with the firmware will allow you to run your program without being connected to {appName}"
}
}
},
"bootloaderPanel": {
"title": "Place hub in bootloader mode",
"instruction1": "To flash the firmware, the hub must be placed in bootloader mode. Follow the steps below to do this:",
"button": {
"bluetooth": "Bluetooth button",
"power": "power button"
},
"light": {
"bluetooth": "Bluetooth light",
"status": "hub status light"
},
"lightPattern": {
"bluetooth": "pink-green-blue-off",
"status": "light purple"
},
"step": {
"disconnectUsb": "Disconnect the USB cable from the hub.",
"powerOff": "Turn off the hub.",
"disconnectIo": "Disconnect all motors and sensors from the I/O ports on the hub.",
"holdButton": "Press and hold the {button} on the hub.",
"connectUsb": "Connect the USB cable.",
"waitForLight": "Keep holding the {button} and wait for the {light} to start flashing {lightPattern}. This takes about 5 seconds.",
"releaseButton": "Release the {button}",
"keepHolding": "Keep holding the {button}."
},
"instruction2": "Then click the {flashFirmware} button below to connect to the hub and flash the firmware."
},
"backButton": {
"label": "Back"
},
"nextButton": {
"label": "Next"
},
"flashFirmwareButton": {
"label": "Flash Firmware"
}
}