Files
pybricks-code/src/firmware/sagas.ts
T
David Lechner fc4cb8eb78 alerts: add alertsHideAlert actions
This is used instead of using toaster.dismiss() directly.
2022-10-19 18:26:37 -05:00

880 lines
28 KiB
TypeScript

// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import {
FirmwareReader,
FirmwareReaderError,
HubType,
encodeHubName,
metadataIsV100,
metadataIsV110,
} from '@pybricks/firmware';
import cityHubZip from '@pybricks/firmware/build/cityhub.zip';
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,
all,
call,
cancel,
delay,
getContext,
put,
race,
select,
take,
takeEvery,
} from 'typed-redux-saga/macro';
import {
alertsDidShowAlert,
alertsHideAlert,
alertsShowAlert,
} from '../alerts/actions';
import {
checksumRequest,
checksumResponse,
connect,
didConnect,
didDisconnect,
didFailToConnect,
didFailToRequest,
didRequest,
disconnect,
eraseRequest,
eraseResponse,
errorResponse,
infoRequest,
infoResponse,
initRequest,
initResponse,
programRequest,
programResponse,
rebootRequest,
} from '../lwp3-bootloader/actions';
import { MaxProgramFlashSize, Result } from '../lwp3-bootloader/protocol';
import { BootloaderConnectionState } from '../lwp3-bootloader/reducers';
import { compile, didCompile, didFailToCompile } from '../mpy/actions';
import { RootState } from '../reducers';
import { LegoUsbProductId, legoUsbVendorId } from '../usb';
import { defined, ensureError, hex, maybe } from '../utils';
import { crc32, fmod, sumComplement32 } from '../utils/math';
import { isAndroid } from '../utils/os';
import {
FailToFinishReasonType,
HubError,
MetadataProblem,
didFailToFinish,
didFinish,
didProgress,
didStart,
firmwareDidFailToFlashUsbDfu,
firmwareDidFlashUsbDfu,
firmwareFlashUsbDfu,
firmwareInstallPybricks,
flashFirmware,
} from './actions';
import {
firmwareInstallPybricksDialogAccept,
firmwareInstallPybricksDialogCancel,
firmwareInstallPybricksDialogShow,
} from './installPybricksDialog/actions';
const firmwareZipMap = new Map<HubType, string>([
[HubType.CityHub, cityHubZip],
[HubType.TechnicHub, technicHubZip],
[HubType.MoveHub, moveHubZip],
]);
const firmwareBleProgressToastId = 'firmware.ble.progress';
/**
* Disconnects the BLE if we are connected and cancels the task (including the
* parent task).
*/
function* disconnectAndCancel(): SagaGenerator<void> {
yield* put(alertsHideAlert(firmwareBleProgressToastId));
const connection = yield* select((s: RootState) => s.bootloader.connection);
if (connection === BootloaderConnectionState.Connected) {
yield* put(disconnect());
}
yield* cancel();
}
function* waitForDidRequest(id: number): SagaGenerator<ReturnType<typeof didRequest>> {
const { requested, failedToRequest } = yield* race({
requested: take(didRequest.when((a) => a.id === id)),
failedToRequest: take(didFailToRequest.when((a) => a.id === id)),
});
if (failedToRequest) {
yield* put(
didFailToFinish(FailToFinishReasonType.BleError, failedToRequest.error),
);
yield* disconnectAndCancel();
}
defined(requested);
return requested;
}
/**
* Waits for a response action, an error response or timeout, whichever comes
* first.
* @param pattern The action type to wait for.
* @param timeout The timeout in milliseconds.
*/
function* waitForResponse<A extends AnyAction>(
pattern: ActionPattern<A>,
timeout = 500,
): SagaGenerator<A> {
const { response, error, disconnected, timedOut } = yield* race({
response: take(pattern),
error: take(errorResponse),
disconnected: take(didDisconnect),
timedOut: delay(timeout),
});
if (timedOut) {
// istanbul ignore if: this hacks around a hardware/OS issue
if (pattern === (errorResponse as unknown)) {
// It has been observed that sometimes this response is not received
// or gets stuck in the Bluetooth stack until another request is sent.
// So, we ignore the timeout and continue. If there really was a
// problem, then the next request should fail anyway.
console.warn('Timeout waiting for erase response, continuing anyway.');
return eraseResponse(Result.OK) as unknown as A;
}
yield* put(didFailToFinish(FailToFinishReasonType.TimedOut));
yield* disconnectAndCancel();
}
if (error) {
yield* put(
didFailToFinish(FailToFinishReasonType.HubError, HubError.UnknownCommand),
);
yield* disconnectAndCancel();
}
if (disconnected) {
yield* put(didFailToFinish(FailToFinishReasonType.Disconnected));
yield* disconnectAndCancel();
}
defined(response);
return response;
}
function* firmwareIterator(data: DataView, maxSize: number): Generator<number> {
// read each 32-bit word of the firmware
for (let i = 0; i < data.byteLength; i += 4) {
yield data.getUint32(i, true);
}
// remaining free space in flash will be 0xff after erase
for (let i = data.byteLength; i < maxSize; i += 4) {
yield ~0;
}
}
/**
* Loads Pybricks firmware from a .zip file.
*
* @param data The zip file raw data
* @param hubName Optional custom name for the hub.
*/
function* loadFirmware(
data: ArrayBuffer,
hubName: string,
): SagaGenerator<{ firmware: Uint8Array; deviceId: HubType }> {
const [reader, readerErr] = yield* call(() => maybe(FirmwareReader.load(data)));
if (readerErr) {
// istanbul ignore else: unexpected error
if (readerErr instanceof FirmwareReaderError) {
yield* put(didFailToFinish(FailToFinishReasonType.ZipError, readerErr));
} else {
yield* put(didFailToFinish(FailToFinishReasonType.Unknown, readerErr));
}
// FIXME: we should return error/throw instead
yield* disconnectAndCancel();
// istanbul ignore next: needed for typescript flow
throw new Error('unreachable');
}
defined(reader);
const firmwareBase = yield* call(() => reader.readFirmwareBase());
const metadata = yield* call(() => reader.readMetadata());
// v1.x allows appending main.py to firmware, later versions do not
if (metadataIsV100(metadata) || metadataIsV110(metadata)) {
const program = (yield* call(() => reader.readMainPy())) ?? '';
if (![5, 6].includes(metadata['mpy-abi-version'])) {
yield* put(
didFailToFinish(
FailToFinishReasonType.BadMetadata,
'mpy-abi-version',
MetadataProblem.NotSupported,
),
);
// FIXME: we should return error/throw instead
yield* disconnectAndCancel();
// istanbul ignore next: needed for typescript flow
throw new Error('unreachable');
}
yield* put(
compile(
program,
metadata['mpy-abi-version'],
metadata['mpy-cross-options'],
),
);
const { mpy, mpyFail } = yield* race({
mpy: take(didCompile),
mpyFail: take(didFailToCompile),
});
if (mpyFail) {
// FIXME: we should return error/throw instead
yield* put(didFailToFinish(FailToFinishReasonType.FailedToCompile));
yield* disconnectAndCancel();
// istanbul ignore next: needed for typescript flow
throw new Error('unreachable');
}
defined(mpy);
// compute offset for checksum - must be aligned to 4-byte boundary
const checksumOffset =
metadata['user-mpy-offset'] +
4 +
mpy.data.length +
fmod(-mpy.data.length, 4);
const firmware = new Uint8Array(checksumOffset + 4);
const firmwareView = new DataView(firmware.buffer);
if (firmware.length > metadata['max-firmware-size']) {
// FIXME: we should return error/throw instead
yield* put(didFailToFinish(FailToFinishReasonType.FirmwareSize));
yield* disconnectAndCancel();
// istanbul ignore next: needed for typescript flow
throw new Error('unreachable');
}
firmware.set(firmwareBase);
firmwareView.setUint32(metadata['user-mpy-offset'], mpy.data.length, true);
firmware.set(mpy.data, metadata['user-mpy-offset'] + 4);
// if the firmware supports it, we can set a custom hub name
if (!metadataIsV100(metadata)) {
// empty string means use default name (don't write over firmware)
if (hubName) {
firmware.set(
encodeHubName(hubName, metadata),
metadata['hub-name-offset'],
);
}
}
const checksum = (function () {
switch (metadata['checksum-type']) {
case 'sum':
return sumComplement32(
firmwareIterator(firmwareView, metadata['max-firmware-size']),
);
case 'crc32':
return crc32(
firmwareIterator(firmwareView, metadata['max-firmware-size']),
);
default:
return undefined;
}
})();
if (!checksum) {
// FIXME: we should return error/throw instead
yield* put(
didFailToFinish(
FailToFinishReasonType.BadMetadata,
'checksum-type',
MetadataProblem.NotSupported,
),
);
yield* disconnectAndCancel();
// istanbul ignore next: needed for typescript flow
throw new Error('unreachable');
}
firmwareView.setUint32(checksumOffset, checksum, true);
return { firmware, deviceId: metadata['device-id'] };
}
const firmware = new Uint8Array(firmwareBase.length + 4);
const firmwareView = new DataView(firmware.buffer);
firmware.set(firmwareBase);
// empty string means use default name (don't write over firmware)
if (hubName) {
firmware.set(encodeHubName(hubName, metadata), metadata['hub-name-offset']);
}
const checksum = (function () {
switch (metadata['checksum-type']) {
case 'sum':
return sumComplement32(
firmwareIterator(firmwareView, metadata['checksum-size']),
);
case 'crc32':
return crc32(firmwareIterator(firmwareView, metadata['checksum-size']));
default:
return undefined;
}
})();
if (!checksum) {
// FIXME: we should return error/throw instead
yield* put(
didFailToFinish(
FailToFinishReasonType.BadMetadata,
'checksum-type',
MetadataProblem.NotSupported,
),
);
yield* disconnectAndCancel();
// istanbul ignore next: needed for typescript flow
throw new Error('unreachable');
}
firmwareView.setUint32(firmwareBase.length, checksum, true);
return { firmware, deviceId: metadata['device-id'] };
}
/**
* Flashes firmware to a Powered Up device.
* @param action The action that triggered this saga.
*/
function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generator {
try {
let firmware: Uint8Array | undefined = undefined;
let deviceId: HubType | undefined = undefined;
if (action.data !== null) {
({ firmware, deviceId } = yield* loadFirmware(action.data, action.hubName));
}
yield* put(connect());
const connectResult = yield* take([didConnect, didFailToConnect]);
if (didFailToConnect.matches(connectResult)) {
yield* put(didFailToFinish(FailToFinishReasonType.FailedToConnect));
return;
}
const nextMessageId = yield* getContext<() => number>('nextMessageId');
const infoAction = yield* put(infoRequest(nextMessageId()));
const { info } = yield* all({
sent: waitForDidRequest(infoAction.id),
info: waitForResponse(infoResponse),
});
if (deviceId !== undefined && info.hubType !== deviceId) {
yield* put(didFailToFinish(FailToFinishReasonType.DeviceMismatch));
yield* disconnectAndCancel();
}
if (firmware === undefined) {
const firmwarePath = firmwareZipMap.get(info.hubType);
if (firmwarePath === undefined) {
yield* put(didFailToFinish(FailToFinishReasonType.NoFirmware));
yield* disconnectAndCancel();
}
defined(firmwarePath);
const response = yield* call(() => fetch(firmwarePath));
if (!response.ok) {
yield* put(
didFailToFinish(FailToFinishReasonType.FailedToFetch, response),
);
yield* disconnectAndCancel();
}
const data = yield* call(() => response.arrayBuffer());
({ firmware, deviceId } = yield* loadFirmware(data, action.hubName));
if (deviceId !== undefined && info.hubType !== deviceId) {
yield* put(didFailToFinish(FailToFinishReasonType.DeviceMismatch));
yield* disconnectAndCancel();
}
}
yield* put(didStart());
yield* put(
alertsShowAlert(
'firmware',
'flashProgress',
{
action: 'erase',
progress: undefined,
},
firmwareBleProgressToastId,
),
);
yield* put(alertsShowAlert('firmware', 'releaseButton'));
const eraseAction = yield* put(
eraseRequest(nextMessageId(), deviceId === HubType.CityHub),
);
const { erase } = yield* all({
sent: waitForDidRequest(eraseAction.id),
erase: waitForResponse(eraseResponse, 5000),
});
if (erase.result !== Result.OK) {
yield* put(
didFailToFinish(FailToFinishReasonType.HubError, HubError.EraseFailed),
);
yield* disconnectAndCancel();
}
const initAction = yield* put(initRequest(nextMessageId(), firmware.length));
const { init } = yield* all({
sent: waitForDidRequest(initAction.id),
init: waitForResponse(initResponse),
});
if (init.result) {
yield* put(
didFailToFinish(FailToFinishReasonType.HubError, HubError.InitFailed),
);
yield* disconnectAndCancel();
}
// 14 is "safe" size for all hubs and Android
const maxDataSize =
(!isAndroid() && MaxProgramFlashSize.get(info.hubType)) || 14;
let runningChecksum = 0xff;
for (let count = 1, offset = 0; ; count++) {
const payload = firmware.slice(offset, offset + maxDataSize);
runningChecksum = payload.reduce(
(prev, curr) => prev ^ curr,
runningChecksum,
);
const programAction = yield* put(
programRequest(
nextMessageId(),
info.startAddress + offset,
payload.buffer,
),
);
yield* waitForDidRequest(programAction.id);
yield* put(didProgress(offset / firmware.length));
yield* put(
alertsShowAlert(
'firmware',
'flashProgress',
{
action: 'flash',
progress: offset / firmware.length,
},
firmwareBleProgressToastId,
),
);
// 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;
if (offset >= firmware.length) {
break;
}
// Request checksum every 10 packets to prevent buffer overrun on
// the hub because of sending too much data at once. The actual
// number of packets that can be queued in the Bluetooth chip on
// the hub is not known and could vary by device.
if (count % 10 === 0) {
const checksumAction = yield* put(checksumRequest(nextMessageId()));
const { response } = yield* all({
sent: waitForDidRequest(checksumAction.id),
response: waitForResponse(checksumResponse, 5000),
});
if (response.checksum !== runningChecksum) {
// istanbul ignore next
if (process.env.NODE_ENV !== 'test') {
console.error(
`checksum: got ${hex(response.checksum, 2)} expected ${hex(
runningChecksum,
2,
)}`,
);
}
yield* put(
didFailToFinish(
FailToFinishReasonType.HubError,
HubError.ChecksumMismatch,
),
);
yield* disconnectAndCancel();
}
}
}
const flash = yield* waitForResponse(programResponse, 5000);
if (flash.count !== firmware.length) {
yield* put(
didFailToFinish(
FailToFinishReasonType.HubError,
HubError.CountMismatch,
),
);
yield* disconnectAndCancel();
}
if (flash.checksum !== runningChecksum) {
// istanbul ignore next
if (process.env.NODE_ENV !== 'test') {
console.error(
`final checksum: got ${hex(flash.checksum, 2)} expected ${hex(
runningChecksum,
2,
)}`,
);
}
yield* put(
didFailToFinish(
FailToFinishReasonType.HubError,
HubError.ChecksumMismatch,
),
);
yield* disconnectAndCancel();
}
yield* put(didProgress(1));
yield* put(
alertsShowAlert(
'firmware',
'flashProgress',
{
action: 'flash',
progress: 1,
},
firmwareBleProgressToastId,
),
);
// this will cause the remote device to disconnect and reboot
const rebootAction = yield* put(rebootRequest(nextMessageId()));
yield* waitForDidRequest(rebootAction.id);
yield* put(didFinish());
} catch (err) {
yield* put(didFailToFinish(FailToFinishReasonType.Unknown, ensureError(err)));
yield* disconnectAndCancel();
}
}
/** Maps USB Product ID to LWP3 hub type ID */
const productIdMap: ReadonlyMap<LegoUsbProductId, HubType> = new Map([
[LegoUsbProductId.SpikePrimeBootloader, HubType.PrimeHub],
[LegoUsbProductId.SpikeEssentialBootloader, HubType.EssentialHub],
[LegoUsbProductId.MindstormsRobotInventorBootloader, HubType.PrimeHub],
]);
// currently all hubs use the same start address
const dfuFirmwareStartAddress = 0x08008000;
const firmwareDfuProgressToastId = 'firmware.dfu.progress';
function* handleDfuEraseProcess(event: {
bytesSent: number;
expectedSize: number;
}): Generator {
yield* put(
alertsShowAlert(
'firmware',
'flashProgress',
{
action: 'erase',
progress: event.bytesSent / event.expectedSize,
},
firmwareDfuProgressToastId,
),
);
}
function* handleDfuWriteProcess(event: {
bytesSent: number;
expectedSize: number;
}): Generator {
yield* put(
alertsShowAlert(
'firmware',
'flashProgress',
{
action: 'flash',
progress: event.bytesSent / event.expectedSize,
},
firmwareDfuProgressToastId,
),
);
}
function* handleFlashUsbDfu(action: ReturnType<typeof firmwareFlashUsbDfu>): Generator {
const defer = new Array<() => void>();
try {
// not all web browsers support Web USB
if (!navigator.usb) {
yield* put(alertsShowAlert('firmware', 'noWebUsb'));
yield* put(firmwareDidFailToFlashUsbDfu());
return;
}
const device = yield* call(() =>
navigator.usb
.requestDevice({
filters: [
{
vendorId: legoUsbVendorId,
productId: LegoUsbProductId.SpikePrimeBootloader,
},
{
vendorId: legoUsbVendorId,
productId: LegoUsbProductId.SpikeEssentialBootloader,
},
{
vendorId: legoUsbVendorId,
productId:
LegoUsbProductId.MindstormsRobotInventorBootloader,
},
],
})
.catch((err) => {
if (err instanceof DOMException && err.name === 'NotFoundError') {
// user clicked cancel button
return undefined;
}
throw err;
}),
);
if (!device) {
yield* put(alertsShowAlert('firmware', 'noDfuHub'));
yield* put(firmwareDidFailToFlashUsbDfu());
return;
}
const dfu = new WebDFU(
device,
// forceInterfacesName is needed to get the flash layout map
{ forceInterfacesName: true },
{
// NB: info and progress are never called in dfu v0.1.5
info: console.debug,
warning: console.warn,
progress: console.debug,
},
);
yield* call(() => dfu.init());
// we want the interface with alt=0
const ifaceIndex = dfu.interfaces.findIndex(
(i) => i.alternate.alternateSetting === 0,
);
if (ifaceIndex === -1) {
yield* put(alertsShowAlert('firmware', 'noDfuInterface'));
yield* put(firmwareDidFailToFlashUsbDfu());
return;
}
yield* call(() => dfu.connect(ifaceIndex));
defer.push(() =>
dfu.close().catch((err) => {
if (err instanceof DOMException && err.name === 'NetworkError') {
// device was disconnected
return;
}
// not expected
console.log(err);
}),
);
const { firmware, deviceId } = yield* loadFirmware(action.data, action.hubName);
if (deviceId !== productIdMap.get(device.productId)) {
yield* put(alertsShowAlert('firmware', 'firmwareMismatch'));
yield* put(firmwareDidFailToFlashUsbDfu());
return;
}
dfu.dfuseStartAddress = dfuFirmwareStartAddress;
const writeProc = dfu.write(1024, firmware, true);
const eraseProcessChan = eventChannel<{
bytesSent: number;
expectedSize: number;
}>((emit) => {
return writeProc.events.on('erase/process', (bytesSent, expectedSize) =>
emit({ bytesSent, expectedSize }),
);
});
defer.push(() => eraseProcessChan.close());
yield* takeEvery(eraseProcessChan, handleDfuEraseProcess);
const writeProcessChan = eventChannel<{
bytesSent: number;
expectedSize: number;
}>((emit) => {
return writeProc.events.on('write/process', (bytesSent, expectedSize) =>
emit({ bytesSent, expectedSize }),
);
});
defer.push(() => writeProcessChan.close());
yield* takeEvery(writeProcessChan, handleDfuWriteProcess);
const endChan = eventChannel<boolean>((emit) => {
// can't emit null or undefined, so have to emit something
return writeProc.events.on('end', () => emit(true));
});
defer.push(() => endChan.close());
const errorChan = eventChannel((emit) => {
return writeProc.events.on('error', emit);
});
defer.push(() => errorChan.close());
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 };
}
})();
// 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);
}
yield* put(alertsHideAlert(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) {
if (process.env.NODE_ENV !== 'test') {
console.error(err);
}
yield* put(
alertsShowAlert('alerts', 'unexpectedError', { error: ensureError(err) }),
);
yield* put(firmwareDidFailToFlashUsbDfu());
} finally {
while (defer.length !== 0) {
defer.pop()?.();
}
}
}
function* handleInstallPybricks(): Generator {
yield* put(firmwareInstallPybricksDialogShow());
const { accepted, canceled } = yield* race({
accepted: take(firmwareInstallPybricksDialogAccept),
canceled: take(firmwareInstallPybricksDialogCancel),
});
if (canceled) {
return;
}
defined(accepted);
switch (accepted.flashMethod) {
case 'ble-lwp3-bootloader':
yield* put(flashFirmware(accepted.firmwareZip, accepted.hubName));
break;
case 'usb-lego-dfu':
yield* put(firmwareFlashUsbDfu(accepted.firmwareZip, accepted.hubName));
break;
}
}
export default function* (): Generator {
yield* takeEvery(flashFirmware, handleFlashFirmware);
yield* takeEvery(firmwareFlashUsbDfu, handleFlashUsbDfu);
yield* takeEvery(firmwareInstallPybricks, handleInstallPybricks);
}