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);