diff --git a/CHANGELOG.md b/CHANGELOG.md index f31e6178..04c90597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ## [Unreleased] +### Fixed +- Fixed run button enabled when no file open ([support#691]). +- Fixed flash firmware dialog not showing when settings not open ([support#694]). +- Fixed errors not handled while flashing firmware via USB ([pybricks-code#1011]). + +[pybricks-code#1011]: https://github.com/pybricks/pybricks-code/issues/1011 +[support#691]: https://github.com/pybricks/support/issues/691 +[support#694]: https://github.com/pybricks/support/issues/694 + ## [2.0.0-beta.5] - 2022-07-28 ### Fixed diff --git a/src/app/App.tsx b/src/app/App.tsx index af08dad1..434af466 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,6 +10,8 @@ import SplitterLayout from 'react-splitter-layout'; import { useLocalStorage, useTernaryDarkMode } from 'usehooks-ts'; import Activities from '../activities/Activities'; import Editor from '../editor/Editor'; +import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog'; +import RestoreOfficialDialog from '../firmware/restoreOfficialDialog/RestoreOfficialDialog'; import { useSettingIsShowDocsEnabled } from '../settings/hooks'; import StatusBar from '../status-bar/StatusBar'; import Terminal from '../terminal/Terminal'; @@ -198,6 +200,8 @@ const App: React.VFC = () => { + + ); }; diff --git a/src/firmware/alerts/DfuError.tsx b/src/firmware/alerts/DfuError.tsx new file mode 100644 index 00000000..25149052 --- /dev/null +++ b/src/firmware/alerts/DfuError.tsx @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Button, Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { useI18n } from './i18n'; + +type DfuErrorProps = { + onTryAgain: () => void; +}; + +const DfuError: React.VoidFunctionComponent = ({ onTryAgain }) => { + const i18n = useI18n(); + return ( + <> +

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

+

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

+ + + ); +}; + +export const dfuError: CreateToast = (onAction) => { + return { + message: onAction('tryAgain')} />, + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/FlashProgress.tsx b/src/firmware/alerts/FlashProgress.tsx index 3594488e..e89f929d 100644 --- a/src/firmware/alerts/FlashProgress.tsx +++ b/src/firmware/alerts/FlashProgress.tsx @@ -35,9 +35,7 @@ const FlashProgress: React.VoidFunctionComponent = ({

)} -

- -

+ ); }; diff --git a/src/firmware/alerts/index.ts b/src/firmware/alerts/index.ts index eba5d418..7b99c303 100644 --- a/src/firmware/alerts/index.ts +++ b/src/firmware/alerts/index.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors +import { dfuError } from './DfuError'; import { firmwareMismatch } from './FirmwareMismatch'; import { noDfuHub } from './NoDfuHub'; import { noDfuInterface } from './NoDfuInterface'; @@ -8,6 +9,7 @@ import { noWebUsb } from './NoWebUsb'; import { releaseButton } from './ReleaseButton'; export default { + dfuError, firmwareMismatch, noDfuHub, noDfuInterface, diff --git a/src/firmware/alerts/translations/en.json b/src/firmware/alerts/translations/en.json index 4777c5b2..d893bd5f 100644 --- a/src/firmware/alerts/translations/en.json +++ b/src/firmware/alerts/translations/en.json @@ -1,4 +1,9 @@ { + "dfuError": { + "message": "A USB error ocurred while flashing the firmware.", + "suggestion": "Ensure the USB cable is not damaged and is firmly attached to the hub and to the computer.", + "tryAgainButton": "Try again" + }, "noWebUsb": { "message": "This browser does not support Web USB or Web USB is not enabled.", "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge." diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 8cfeed59..f0e6f7e9 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -13,6 +13,7 @@ import moveHubZip from '@pybricks/firmware/build/movehub.zip'; import technicHubZip from '@pybricks/firmware/build/technichub.zip'; import { WebDFU } from 'dfu'; import { AnyAction } from 'redux'; +import { eventChannel } from 'redux-saga'; import { ActionPattern } from 'redux-saga/effects'; import { SagaGenerator, @@ -27,7 +28,7 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; -import { alertsShowAlert } from '../alerts/actions'; +import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions'; import { fileStorageDidFailToReadFile, fileStorageDidReadFile, @@ -595,6 +596,8 @@ const productIdMap: ReadonlyMap = new Map([ // currently all hubs use the same start address const dfuFirmwareStartAddress = 0x08008000; +const firmwareDfuProgressToastId = 'firmware.dfu.progress'; + function* handleFlashUsbDfu(action: ReturnType): Generator { const defer = new Array<() => void>(); @@ -671,7 +674,20 @@ function* handleFlashUsbDfu(action: ReturnType): Gen yield* call(() => dfu.connect(ifaceIndex)); - defer.push(() => dfu.close()); + defer.push(() => + dfu.close().catch((err) => { + if ( + err instanceof DOMException && + err.code === DOMException.NETWORK_ERR + ) { + // device was disconnected + return; + } + + // not expected + console.log(err); + }), + ); const { firmware, deviceId } = yield* loadFirmware( action.data, @@ -690,35 +706,85 @@ function* handleFlashUsbDfu(action: ReturnType): Gen const toaster = yield* getContext('toaster'); - writeProc.events.on('erase/process', (sent, total) => { - toaster.show( - flashProgress(() => undefined, { - action: 'erase', - progress: sent / total, - }), - 'firmware.dfu.progress', - ); + defer.push( + writeProc.events.on('erase/process', (sent, total) => { + toaster.show( + flashProgress(() => undefined, { + action: 'erase', + progress: sent / total, + }), + firmwareDfuProgressToastId, + ); + }), + ); + + defer.push( + writeProc.events.on('write/process', (sent, total) => { + toaster.show( + flashProgress(() => undefined, { + action: 'flash', + progress: sent / total, + }), + firmwareDfuProgressToastId, + ); + }), + ); + + const endChan = eventChannel((emit) => { + // can't emit null or undefined, so have to emit something + return writeProc.events.on('end', () => emit(true)); }); - writeProc.events.on('write/process', (sent, total) => { - toaster.show( - flashProgress(() => undefined, { - action: 'flash', - progress: sent / total, - }), - 'firmware.dfu.progress', - ); + defer.push(() => endChan.close()); + + const errorChan = eventChannel((emit) => { + return writeProc.events.on('error', emit); }); - writeProc.events.on('error', console.error); + defer.push(() => errorChan.close()); - // REVISIT: we could possibly race the 'write/end' and 'error' events - // here instead of waiting for disconnect + const { error } = yield* (function* () { + // HACK: Somehow an error during the write phase can cause the + // race generator to throw instead of returning the error. + // So we catch the error and return it as if errorChan won the + // race. + try { + return yield* race({ + end: take(endChan), + error: take(errorChan), + }); + } catch (err) { + return { error: err }; + } + })(); - // this is a bit of a hack, but the hub resets when flashing is done - // so we get a disconnect event unless there was an error, so the user - // will probably see the timeout error instead of the underlying error - yield* call(() => dfu.waitDisconnected(30000)); + // errors can happen, e.g. if the USB cable is disconnected while + // flashing the firmware + if (error) { + if (process.env.NODE_ENV !== 'test') { + console.error(error); + } + + toaster.dismiss(firmwareDfuProgressToastId); + yield* put(firmwareDidFailToFlashUsbDfu()); + + yield* put(alertsShowAlert('firmware', 'dfuError')); + + const { action: alertAction } = yield* take< + ReturnType> + >( + alertsDidShowAlert.when( + (a) => a.domain === 'firmware' && a.specific === 'dfuError', + ), + ); + + if (alertAction === 'tryAgain') { + // queue the action that triggered this saga to retry + yield* put(action); + } + + return; + } yield* put(firmwareDidFlashUsbDfu()); } catch (err) { diff --git a/src/index.tsx b/src/index.tsx index 2372c0f8..33d2d5d2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -52,8 +52,10 @@ const store = configureStore({ // contain ArrayBuffer or DataView 'data', 'firmwareZip', + 'payload', // Error is not serializable 'error', + 'props.error', ], }, }) diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index b31f823e..9c805a48 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -22,8 +22,6 @@ import { import { Button } from '../components/Button'; import HelpButton from '../components/HelpButton'; import { firmwareInstallPybricks } from '../firmware/actions'; -import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog'; -import RestoreOfficialDialog from '../firmware/restoreOfficialDialog/RestoreOfficialDialog'; import { firmwareRestoreOfficialDialogShow } from '../firmware/restoreOfficialDialog/actions'; import { pseudolocalize } from '../i18n'; import { useSelector } from '../reducers'; @@ -104,7 +102,6 @@ const Settings: React.VoidFunctionComponent = () => { label={i18n.translate('firmware.flashPybricksButton.label')} onPress={() => dispatch(firmwareInstallPybricks())} /> -