firmware/sagas: handle usb error during dfu flash

Fixes: https://github.com/pybricks/pybricks-code/issues/1011
This commit is contained in:
David Lechner
2022-08-12 18:00:57 -05:00
parent 722fc3d8ef
commit 122b37da29
5 changed files with 133 additions and 25 deletions
+33
View File
@@ -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<DfuErrorProps> = ({ onTryAgain }) => {
const i18n = useI18n();
return (
<>
<p>{i18n.translate('dfuError.message')}</p>
<p>{i18n.translate('dfuError.suggestion')}</p>
<Button onClick={onTryAgain}>
{i18n.translate('dfuError.tryAgainButton')}
</Button>
</>
);
};
export const dfuError: CreateToast<never, 'dismiss' | 'tryAgain'> = (onAction) => {
return {
message: <DfuError onTryAgain={() => onAction('tryAgain')} />,
icon: 'error',
intent: Intent.DANGER,
onDismiss: () => onAction('dismiss'),
};
};
+2
View File
@@ -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,
+5
View File
@@ -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."
+91 -25
View File
@@ -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<LegoUsbProductId, HubType> = new Map([
// currently all hubs use the same start address
const dfuFirmwareStartAddress = 0x08008000;
const firmwareDfuProgressToastId = 'firmware.dfu.progress';
function* handleFlashUsbDfu(action: ReturnType<typeof firmwareFlashUsbDfu>): Generator {
const defer = new Array<() => void>();
@@ -671,7 +674,20 @@ function* handleFlashUsbDfu(action: ReturnType<typeof firmwareFlashUsbDfu>): 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<typeof firmwareFlashUsbDfu>): Gen
const toaster = yield* getContext<IToaster>('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<boolean>((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<typeof alertsDidShowAlert<'firmware', 'dfuError'>>
>(
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) {