// SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors import './bootloaderInstructions.scss'; import { Callout, Intent } from '@blueprintjs/core'; import classNames from 'classnames'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { legoRegisteredTrademark, pybricksUsbLinuxUdevRulesUrl, } from '../../app/constants'; import ExternalLinkIcon from '../../components/ExternalLinkIcon'; import { Hub, hubHasBluetoothButton, hubHasUSB } from '../../components/hubPicker'; import { isLinux, isWindows } from '../../utils/os'; import { firmwareDfuWindowsDriverInstallDialogDialogShow } from '../dfuWindowsDriverInstallDialog/actions'; import cityHubMp4 from './assets/bootloader-cityhub-540.mp4'; import cityHubVtt from './assets/bootloader-cityhub-metadata.vtt'; import essentialHubMp4 from './assets/bootloader-essentialhub-540.mp4'; import essentialHubVtt from './assets/bootloader-essentialhub-metadata.vtt'; import inventorHubMp4 from './assets/bootloader-inventorhub-540.mp4'; import inventorHubVtt from './assets/bootloader-inventorhub-metadata.vtt'; import moveHubMp4 from './assets/bootloader-movehub-540.mp4'; import moveHubVtt from './assets/bootloader-movehub-metadata.vtt'; import primeHubMp4 from './assets/bootloader-primehub-540.mp4'; import primeHubVtt from './assets/bootloader-primehub-metadata.vtt'; import technicHubMp4 from './assets/bootloader-technichub-540.mp4'; import technicHubVtt from './assets/bootloader-technichub-metadata.vtt'; import cityHubRecoveryMp4 from './assets/recover-cityhub-540.mp4'; import cityHubRecoveryVtt from './assets/recover-cityhub-metadata.vtt'; import moveHubRecoveryMp4 from './assets/recover-movehub-540.mp4'; import moveHubRecoveryVtt from './assets/recover-movehub-metadata.vtt'; import technicHubRecoveryMp4 from './assets/recover-technichub-540.mp4'; import technicHubRecoveryVtt from './assets/recover-technichub-metadata.vtt'; import { useI18n } from './i18n'; type BootloaderInstructionsProps = { /** * The instructions and video will be customized for this hub. */ hubType: Hub; /** * If true, show official firmware recovery video and steps for supported hubs. * @default false */ recovery?: boolean; /** * Flash button name that can be referenced in the instructions. */ flashButtonText: string; }; const bootloaderDeviceNameMap: ReadonlyMap = new Map([ [Hub.City, 'LEGO Bootloader'], [Hub.Essential, 'LEGO Technic Small Hub in DFU Mode'], [Hub.Inventor, 'LEGO Technic Large Hub in DFU Mode'], [Hub.Move, 'LEGO Bootloader'], [Hub.Prime, 'LEGO Technic Large Hub in DFU Mode'], [Hub.Technic, 'LEGO Bootloader'], ]); const videoFileMap: ReadonlyMap = new Map([ [Hub.City, cityHubMp4], [Hub.Essential, essentialHubMp4], [Hub.Inventor, inventorHubMp4], [Hub.Move, moveHubMp4], [Hub.Prime, primeHubMp4], [Hub.Technic, technicHubMp4], ]); const metadataFileMap: ReadonlyMap = new Map([ [Hub.City, cityHubVtt], [Hub.Essential, essentialHubVtt], [Hub.Inventor, inventorHubVtt], [Hub.Move, moveHubVtt], [Hub.Prime, primeHubVtt], [Hub.Technic, technicHubVtt], ]); const recoveryVideoFileMap: ReadonlyMap = new Map([ [Hub.City, cityHubRecoveryMp4], [Hub.Essential, essentialHubMp4], [Hub.Inventor, inventorHubMp4], [Hub.Move, moveHubRecoveryMp4], [Hub.Prime, primeHubMp4], [Hub.Technic, technicHubRecoveryMp4], ]); const recoveryMetadataFileMap: ReadonlyMap = new Map([ [Hub.City, cityHubRecoveryVtt], [Hub.Essential, essentialHubVtt], [Hub.Inventor, inventorHubVtt], [Hub.Move, moveHubRecoveryVtt], [Hub.Prime, primeHubVtt], [Hub.Technic, technicHubRecoveryVtt], ]); function countValidChildren(children: React.ReactNode) { return React.Children.toArray(children).filter((child) => React.isValidElement(child), ).length; } /** * Provides customized instructions on how to enter bootloader mode based * on the hub type. */ const BootloaderInstructions: React.VoidFunctionComponent< BootloaderInstructionsProps > = ({ hubType, recovery, flashButtonText }) => { const dispatch = useDispatch(); const i18n = useI18n(); const { button, light, lightPattern } = useMemo(() => { return { button: i18n.translate( hubHasBluetoothButton(hubType) ? 'button.bluetooth' : 'button.power', ), light: i18n.translate( hubHasBluetoothButton(hubType) ? 'light.bluetooth' : 'light.status', ), lightPattern: i18n.translate( hubHasBluetoothButton(hubType) ? 'lightPattern.bluetooth' : 'lightPattern.status', ), }; }, [i18n, hubType]); const metadataTrackRef = useRef(null); const [activeStep, setActiveStep] = useState(''); useEffect(() => { const element = metadataTrackRef.current; // istanbul ignore if: should not happen if (element === null) { return; } // istanbul ignore else: jsdom doesn't support video if (process.env.NODE_ENV === 'test') { return; } const handleCueChange = (e: Event) => { const track = e.target as TextTrack; setActiveStep(track.activeCues?.[0]?.id ?? ''); }; element.track.addEventListener('cuechange', handleCueChange); element.track.mode = 'hidden'; return () => { element.track.removeEventListener('cuechange', handleCueChange); element.track.mode = 'disabled'; }; }, [setActiveStep]); const prepareSteps = useMemo( () => ( <>
  • {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) && (
  • {i18n.translate('instructionGroup.prepare.app', { lego: legoRegisteredTrademark, })}
  • )} ), [i18n, recovery, hubType], ); const bootloaderModeSteps = useMemo( () => ( <> {/* City hub has power issues and requires disconnecting motors/sensors */} {hubType === Hub.City && (
  • {i18n.translate('instructionGroup.bootloaderMode.disconnectIo')}
  • )}
  • {i18n.translate('instructionGroup.bootloaderMode.holdButton', { button, })}
  • {/* not strictly necessary, but order is swapped in the video, so we match it here. */} {hubType !== Hub.Essential && hubHasUSB(hubType) && (
  • {i18n.translate('instructionGroup.bootloaderMode.connectUsb')}
  • )}
  • {i18n.translate('instructionGroup.bootloaderMode.waitForLight', { button, light, lightPattern, })}
  • {hubType === Hub.Essential && hubHasUSB(hubType) && (
  • {i18n.translate('instructionGroup.bootloaderMode.connectUsb')}
  • )} {recovery && !hubHasUSB(hubType) && (
  • {i18n.translate( 'instructionGroup.bootloaderMode.waitAppConnect', )}
  • )} {/* hubs with USB will keep the power on, but other hubs won't */} {recovery || hubHasUSB(hubType) ? (
  • {i18n.translate( 'instructionGroup.bootloaderMode.releaseButton', { button, }, )}
  • ) : (
  • {i18n.translate('instructionGroup.bootloaderMode.keepHolding', { button, })}
  • )} ), [recovery, activeStep, i18n, button, hubType, light, lightPattern], ); return ( <> {process.env.NODE_ENV !== 'test' && ( )}

    {i18n.translate('instructionGroup.prepare.title')}

      {prepareSteps}

    {i18n.translate('instructionGroup.bootloaderMode.title')}

      {bootloaderModeSteps}
    {(hubHasUSB(hubType) || (!hubHasUSB(hubType) && !recovery)) && ( <>

    {i18n.translate('instructionGroup.connect.title')}

    1. {i18n.translate( 'instructionGroup.connect.clickConnectAndFlash', { flashButton: {flashButtonText}, }, )}
    2. {i18n.translate('instructionGroup.connect.selectDevice', { deviceName: ( {bootloaderDeviceNameMap.get(hubType)} ), connectButton: ( {i18n.translate( 'instructionGroup.connect.connectButton', )} ), })}
    )} {hubHasUSB(hubType) && isLinux() && ( {i18n.translate('warning.linux.message', { learnMore: ( <> {i18n.translate('warning.linux.learnMore')} ), })} )} {hubHasUSB(hubType) && isWindows() && ( {i18n.translate('warning.windows.message', { instructions: ( { e.stopPropagation(); e.preventDefault(); dispatch( firmwareDfuWindowsDriverInstallDialogDialogShow(), ); }} > {i18n.translate('warning.windows.instructions')} ), })} )} ); }; export default BootloaderInstructions;