// SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors import './installPybricksDialog.scss'; import { Button, Callout, Checkbox, Classes, Collapse, ControlGroup, DialogStep, FormGroup, Icon, InputGroup, Intent, MultistepDialog, NonIdealState, Pre, Spinner, } from '@blueprintjs/core'; import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2'; import { FirmwareMetadata, HubType } from '@pybricks/firmware'; import { fileOpen } from 'browser-fs-access'; import classNames from 'classnames'; import React, { useCallback, useMemo, useState } from 'react'; import { useDropzone } from 'react-dropzone'; import { useDispatch } from 'react-redux'; import { useLocalStorage } from 'usehooks-ts'; import { alertsShowAlert } from '../../alerts/actions'; import { pybricksUsbDfuWindowsDriverInstallUrl, pybricksUsbLinuxUdevRulesUrl, } from '../../app/constants'; import HelpButton from '../../components/HelpButton'; import { Hub, hubBootloaderType, hubHasBluetoothButton, hubHasUSB, } from '../../components/hubPicker'; import { HubPicker } from '../../components/hubPicker/HubPicker'; import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks'; import { useSelector } from '../../reducers'; import { ensureError } from '../../utils'; import ExternalLinkIcon from '../../utils/ExternalLinkIcon'; import { isLinux, isWindows } from '../../utils/os'; import { firmwareInstallPybricksDialogAccept, firmwareInstallPybricksDialogCancel, } from './actions'; import { useCustomFirmware, useFirmware } from './hooks'; import { useI18n } from './i18n'; import { validateHubName } from '.'; const dialogBody = classNames( Classes.DIALOG_BODY, 'pb-firmware-installPybricksDialog-body', ); /** Translates hub type from firmware metadata to local hub type. */ function getHubTypeFromMetadata( metadata: FirmwareMetadata | undefined, fallback: Hub, ): Hub { switch (metadata?.['device-id']) { case HubType.MoveHub: return Hub.Move; case HubType.CityHub: return Hub.City; case HubType.TechnicHub: return Hub.Technic; case HubType.PrimeHub: return Hub.Prime; case HubType.EssentialHub: return Hub.Essential; default: return fallback; } } function getHubTypeNameFromMetadata(metadata: FirmwareMetadata | undefined): string { switch (metadata?.['device-id']) { case HubType.MoveHub: return 'BOOST Move Hub'; case HubType.CityHub: return 'City Hub'; case HubType.TechnicHub: return 'Technic Hub'; case HubType.PrimeHub: return 'SPIKE Prime/MINDSTORMS Robot Inventor hub'; case HubType.EssentialHub: return 'SPIKE Essential hub'; default: return '?'; } } const UnsupportedHubs: React.VoidFunctionComponent = () => { const i18n = useI18n(); return (

{i18n.translate('selectHubPanel.notOnListButton.info.mindstorms.title')}

{i18n.translate('selectHubPanel.notOnListButton.info.poweredUp.title')}

*{' '} {i18n.translate( 'selectHubPanel.notOnListButton.info.poweredUp.footnote', )}
); }; type SelectHubPanelProps = { customFirmwareZip: File | undefined; onCustomFirmwareZip: (firmwareZip: File | undefined) => void; }; const SelectHubPanel: React.VoidFunctionComponent = ({ customFirmwareZip, onCustomFirmwareZip, }) => { const { isCustomFirmwareRequested, customFirmwareData } = useCustomFirmware(customFirmwareZip); const [isAdvancedOpen, setIsAdvancedOpen] = useLocalStorage( 'installPybricksDialog.isAdvancedOpen', false, ); const i18n = useI18n(); const dispatch = useDispatch(); const onDrop = useCallback((acceptedFiles: File[]) => { // should only be one file since multiple={false} acceptedFiles.forEach((f) => { onCustomFirmwareZip(f); }); }, []); const onClick = useCallback(async () => { try { const file = await fileOpen({ id: 'customFirmware', mimeTypes: ['application/zip'], extensions: ['.zip'], // TODO: translate description description: 'Zip Files', excludeAcceptAllOption: true, startIn: 'downloads', }); onCustomFirmwareZip(file); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { // user cancelled, nothing to do } else { dispatch( alertsShowAlert('alerts', 'unexpectedError', { error: ensureError(err), }), ); } } }, []); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key !== 'Enter' && e.key !== ' ') { return; } e.stopPropagation(); onClick(); }, [onClick], ); const { getRootProps, getInputProps } = useDropzone({ accept: { 'application/zip': ['.zip'] }, multiple: false, // react-dropzone doesn't allow full control of File System API, so we // implement our own using browser-fs-access instead. noClick: true, onDrop, }); return (
{isCustomFirmwareRequested ? ( <>

{i18n.translate('selectHubPanel.customFirmware.message')}

{i18n.translate('selectHubPanel.customFirmware.hubType', { hubTypeName: getHubTypeNameFromMetadata( customFirmwareData?.metadata, ), })}

{i18n.translate( 'selectHubPanel.customFirmware.firmwareVersion', { version: customFirmwareData?.metadata['firmware-version'], }, )}

) : ( <>

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

} renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => ( )} /> )}
{i18n.translate( 'selectHubPanel.advanced.customFirmwareDropzone.label', )}
); }; type AcceptLicensePanelProps = { hubType: Hub; licenseAccepted: boolean; customFirmwareZip: File | undefined; onLicenseAcceptedChanged: (accepted: boolean) => void; }; const AcceptLicensePanel: React.VoidFunctionComponent = ({ hubType, licenseAccepted, customFirmwareZip, onLicenseAcceptedChanged, }) => { const { firmwareData, firmwareError } = useFirmware(hubType); const { isCustomFirmwareRequested, customFirmwareData, customFirmwareError } = useCustomFirmware(customFirmwareZip); const i18n = useI18n(); const selectedFirmwareData = isCustomFirmwareRequested ? customFirmwareData : firmwareData; const selectedFirmwareError = isCustomFirmwareRequested ? customFirmwareError : firmwareError; return (
{selectedFirmwareData ? (
{selectedFirmwareData.licenseText}
) : ( } description={ selectedFirmwareError ? i18n.translate('licensePanel.licenseText.error') : undefined } /> )}
onLicenseAcceptedChanged(e.currentTarget.checked)} disabled={!selectedFirmwareData} />
); }; type SelectOptionsPanelProps = { hubName: string; onChangeHubName(hubName: string): void; }; const ConfigureOptionsPanel: React.VoidFunctionComponent = ({ hubName, onChangeHubName, }) => { const i18n = useI18n(); const isHubNameValid = validateHubName(hubName); return (
onChangeHubName(e.currentTarget.value)} onMouseOver={(e) => e.preventDefault()} onMouseDown={(e) => e.stopPropagation()} intent={isHubNameValid ? Intent.NONE : Intent.DANGER} placeholder="Pybricks Hub" rightElement={ isHubNameValid ? undefined : ( ) } />
); }; type BootloaderModePanelProps = { hubType: Hub; }; const BootloaderModePanel: React.VoidFunctionComponent = ({ hubType, }) => { const i18n = useI18n(); const { button, light, lightPattern } = useMemo(() => { return { button: i18n.translate( hubHasBluetoothButton(hubType) ? 'bootloaderPanel.button.bluetooth' : 'bootloaderPanel.button.power', ), light: i18n.translate( hubHasBluetoothButton(hubType) ? 'bootloaderPanel.light.bluetooth' : 'bootloaderPanel.light.status', ), lightPattern: i18n.translate( hubHasBluetoothButton(hubType) ? 'bootloaderPanel.lightPattern.bluetooth' : 'bootloaderPanel.lightPattern.status', ), }; }, [i18n, hubType]); return (
{hubHasUSB(hubType) && isLinux() && ( {i18n.translate('bootloaderPanel.warning.linux')}{' '} {i18n.translate('bootloaderPanel.warning.learnMore')} )} {hubHasUSB(hubType) && isWindows() && ( {i18n.translate('bootloaderPanel.warning.windows')}{' '} {i18n.translate('bootloaderPanel.warning.learnMore')} )}

{i18n.translate('bootloaderPanel.instruction1')}

    {hubHasUSB(hubType) && (
  1. {i18n.translate('bootloaderPanel.step.disconnectUsb')}
  2. )}
  3. {i18n.translate('bootloaderPanel.step.powerOff')}
  4. {/* City hub has power issues and requires disconnecting motors/sensors */} {hubType === Hub.City && (
  5. {i18n.translate('bootloaderPanel.step.disconnectIo')}
  6. )}
  7. {i18n.translate('bootloaderPanel.step.holdButton', { button })}
  8. {hubHasUSB(hubType) && (
  9. {i18n.translate('bootloaderPanel.step.connectUsb')}
  10. )}
  11. {i18n.translate('bootloaderPanel.step.waitForLight', { button, light, lightPattern, })}
  12. {i18n.translate( /* hubs with USB will keep the power on, but other hubs won't */ hubHasUSB(hubType) ? 'bootloaderPanel.step.releaseButton' : 'bootloaderPanel.step.keepHolding', { button, }, )}

{i18n.translate('bootloaderPanel.instruction2', { flashFirmware: ( {i18n.translate('flashFirmwareButton.label')} ), })}

); }; export const InstallPybricksDialog: React.VoidFunctionComponent = () => { const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog); const dispatch = useDispatch(); const [hubName, setHubName] = useState(''); const [licenseAccepted, setLicenseAccepted] = useState(false); const [hubType] = useHubPickerSelectedHub(); const { firmwareData } = useFirmware(hubType); const [customFirmwareZip, setCustomFirmwareZip] = useState(); const { isCustomFirmwareRequested, customFirmwareData } = useCustomFirmware(customFirmwareZip); const i18n = useI18n(); const selectedFirmwareData = isCustomFirmwareRequested ? customFirmwareData : firmwareData; const selectedHubType = isCustomFirmwareRequested ? getHubTypeFromMetadata(customFirmwareData?.metadata, hubType) : hubType; return ( dispatch(firmwareInstallPybricksDialogCancel())} finalButtonProps={{ text: i18n.translate('flashFirmwareButton.label'), onClick: () => dispatch( firmwareInstallPybricksDialogAccept( hubBootloaderType(selectedHubType), selectedFirmwareData?.firmwareZip ?? new ArrayBuffer(0), hubName, ), ), }} > } nextButtonProps={{ text: i18n.translate('nextButton.label') }} /> } backButtonProps={{ text: i18n.translate('backButton.label') }} nextButtonProps={{ disabled: !licenseAccepted, text: i18n.translate('nextButton.label'), }} /> } backButtonProps={{ text: i18n.translate('backButton.label') }} nextButtonProps={{ text: i18n.translate('nextButton.label') }} /> } backButtonProps={{ text: i18n.translate('backButton.label') }} /> ); };