diff --git a/src/firmware/alerts/ReleaseButton.tsx b/src/firmware/alerts/ReleaseButton.tsx
new file mode 100644
index 00000000..a42b6b2a
--- /dev/null
+++ b/src/firmware/alerts/ReleaseButton.tsx
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { Intent } from '@blueprintjs/core';
+import React from 'react';
+import { CreateToast } from '../../i18nToaster';
+import { useI18n } from './i18n';
+
+const ReleaseButton: React.VoidFunctionComponent = () => {
+ const i18n = useI18n();
+ return
{i18n.translate('releaseButton.message')}
;
+};
+
+export const releaseButton: CreateToast = (onAction) => {
+ return {
+ message: ,
+ icon: 'info-sign',
+ intent: Intent.PRIMARY,
+ onDismiss: () => onAction('dismiss'),
+ };
+};
diff --git a/src/firmware/alerts/index.ts b/src/firmware/alerts/index.ts
index 82ea36ab..eba5d418 100644
--- a/src/firmware/alerts/index.ts
+++ b/src/firmware/alerts/index.ts
@@ -5,10 +5,12 @@ import { firmwareMismatch } from './FirmwareMismatch';
import { noDfuHub } from './NoDfuHub';
import { noDfuInterface } from './NoDfuInterface';
import { noWebUsb } from './NoWebUsb';
+import { releaseButton } from './ReleaseButton';
export default {
firmwareMismatch,
noDfuHub,
noDfuInterface,
noWebUsb,
+ releaseButton,
};
diff --git a/src/firmware/alerts/translations/en.json b/src/firmware/alerts/translations/en.json
index 7a8f2f77..4777c5b2 100644
--- a/src/firmware/alerts/translations/en.json
+++ b/src/firmware/alerts/translations/en.json
@@ -21,5 +21,8 @@
"flashProgress": {
"erasing": "Erasing internal flash memory: {percent}",
"flashing": "Writing new firmware: {percent}"
+ },
+ "releaseButton": {
+ "message": "You may release the power button on the hub now."
}
}
diff --git a/src/firmware/sagas.test.ts b/src/firmware/sagas.test.ts
index 5d0a4ba6..e9ae1c9c 100644
--- a/src/firmware/sagas.test.ts
+++ b/src/firmware/sagas.test.ts
@@ -1,13 +1,16 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
+import { IToaster } from '@blueprintjs/core';
import {
FirmwareMetadata,
FirmwareReaderError,
FirmwareReaderErrorCode,
} from '@pybricks/firmware';
+import { mock } from 'jest-mock-extended';
import JSZip from 'jszip';
import { AsyncSaga } from '../../test';
+import { alertsShowAlert } from '../alerts/actions';
import {
BootloaderConnectionFailureReason,
checksumRequest,
@@ -78,6 +81,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -130,6 +134,9 @@ describe('flashFirmware', () => {
// erase first
+ action = await saga.take();
+ expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
+
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -224,6 +231,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -273,6 +281,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -340,6 +349,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -403,6 +413,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -469,6 +480,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -528,6 +540,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -593,6 +606,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -675,6 +689,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -740,6 +755,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -790,6 +806,9 @@ describe('flashFirmware', () => {
// erase first
+ action = await saga.take();
+ expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
+
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -835,6 +854,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -885,6 +905,9 @@ describe('flashFirmware', () => {
// erase first
+ action = await saga.take();
+ expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
+
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -939,6 +962,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -989,6 +1013,9 @@ describe('flashFirmware', () => {
// erase first
+ action = await saga.take();
+ expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
+
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -1083,6 +1110,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -1133,6 +1161,9 @@ describe('flashFirmware', () => {
// erase first
+ action = await saga.take();
+ expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
+
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -1225,6 +1256,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -1283,6 +1315,9 @@ describe('flashFirmware', () => {
// erase first
+ action = await saga.take();
+ expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
+
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -1374,6 +1409,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -1421,6 +1457,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -1467,6 +1504,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -1527,6 +1565,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -1588,6 +1627,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -1652,6 +1692,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -1741,6 +1782,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
+ toaster: mock(),
});
// saga is triggered by this action
@@ -1791,12 +1833,14 @@ describe('flashFirmware', () => {
// erase first
+ action = await saga.take();
+ expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
+
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
saga.put(didRequest(1));
saga.put(eraseResponse(Result.OK));
-
// then write the new firmware
const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8;
diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts
index 6f7ca1ae..8cfeed59 100644
--- a/src/firmware/sagas.ts
+++ b/src/firmware/sagas.ts
@@ -94,6 +94,10 @@ const firmwareZipMap = new Map([
* parent task).
*/
function* disconnectAndCancel(): SagaGenerator {
+ const toaster = yield* getContext('toaster');
+
+ toaster.dismiss('firmware.ble.progress');
+
const connection = yield* select((s: RootState) => s.bootloader.connection);
if (connection === BootloaderConnectionState.Connected) {
@@ -328,6 +332,8 @@ function* loadFirmware(
* @param action The action that triggered this saga.
*/
function* handleFlashFirmware(action: ReturnType): Generator {
+ const toaster = yield* getContext('toaster');
+
try {
let firmware: Uint8Array | undefined = undefined;
let deviceId: HubType | undefined = undefined;
@@ -418,6 +424,16 @@ function* handleFlashFirmware(action: ReturnType): Generat
yield* put(didStart());
+ toaster.show(
+ flashProgress(() => undefined, {
+ action: 'erase',
+ progress: undefined,
+ }),
+ 'firmware.ble.progress',
+ );
+
+ yield* put(alertsShowAlert('firmware', 'releaseButton'));
+
const eraseAction = yield* put(
eraseRequest(nextMessageId(), deviceId === HubType.CityHub),
);
@@ -469,6 +485,14 @@ function* handleFlashFirmware(action: ReturnType): Generat
yield* put(didProgress(offset / firmware.length));
+ toaster.show(
+ flashProgress(() => undefined, {
+ action: 'flash',
+ progress: offset / firmware.length,
+ }),
+ 'firmware.ble.progress',
+ );
+
// we don't want to request checksum if this is the last packet since
// the bootloader will send a response to the program request already.
offset += maxDataSize;
@@ -542,6 +566,14 @@ function* handleFlashFirmware(action: ReturnType): Generat
yield* put(didProgress(1));
+ toaster.show(
+ flashProgress(() => undefined, {
+ action: 'flash',
+ progress: 1,
+ }),
+ 'firmware.ble.progress',
+ );
+
// this will cause the remote device to disconnect and reboot
const rebootAction = yield* put(rebootRequest(nextMessageId()));
yield* waitForDidRequest(rebootAction.id);