Merge pull request #254 from pybricks/dlech

improved firmware flash error handling
This commit is contained in:
David Lechner
2021-01-23 17:36:52 -06:00
committed by GitHub
18 changed files with 2255 additions and 574 deletions
+150 -160
View File
@@ -11,10 +11,8 @@ import { assert } from '../utils';
export enum FlashFirmwareActionType {
/** Request to flash new firmware to the device. */
FlashFirmware = 'flashFirmware.action.flashFirmware',
/** Flashing started. */
/** Actual modification of the flash memory on the device started. */
DidStart = 'flashFirmware.action.didStart',
/** Flashing was not able to start. */
DidFailToStart = 'flashFirmware.action.didFailStart',
/** Firmware flash progress. */
DidProgress = 'flashFirmware.action.didProgress',
/** Flashing finished successfully. */
@@ -37,84 +35,49 @@ export enum HubError {
}
function isHubError(arg: unknown): arg is HubError {
if (typeof arg !== 'string') {
return false;
}
return Object.keys(HubError).includes(arg);
return Object.values(HubError).includes(arg as HubError);
}
type Reason<T> = {
reason: T;
};
export enum FailToStartReasonType {
/** Connecting to the hub failed. */
FailedToConnect = 'flashFirmware.failToStart.reason.failedToConnect',
/** The is no firmware available that matches the connected hub. */
NoFirmware = 'flashFirmware.failToStart.reason.noFirmware',
/** The provided firmware.zip does not match the connected hub. */
DeviceMismatch = 'flashFirmware.failToStart.reason.deviceMismatch',
/** There was a problem with the zip file. */
ZipError = 'flashFirmware.failToStart.reason.zipError',
/** Metadata property is missing or invalid. */
BadMetadata = 'flashFirmware.failToStart.reason.badMetadata',
/** The main.py file failed to compile. */
FailedToCompile = 'flashFirmware.failToStart.reason.failedToCompile',
/** The combined firmware-base.bin and main.mpy are too big. */
FirmwareSize = 'flashFirmware.failToStart.reason.firmwareSize',
/** An unexpected error occurred. */
Unknown = 'flashFirmware.failToStart.reason.unknown',
}
export type FailToStartReasonFailedToConnect = Reason<FailToStartReasonType.FailedToConnect>;
export type FailToStartReasonNoFirmware = Reason<FailToStartReasonType.NoFirmware>;
export type FailToStartReasonDeviceMismatch = Reason<FailToStartReasonType.DeviceMismatch>;
export type FailToStartReasonZipError = Reason<FailToStartReasonType.ZipError> & {
err: FirmwareReaderError;
};
export type FailToStartReasonBadMetadata = Reason<FailToStartReasonType.BadMetadata> & {
property: keyof FirmwareMetadata;
problem: MetadataProblem;
};
export type FailToStartReasonFirmwareSize = Reason<FailToStartReasonType.FirmwareSize>;
export type FailToStartReasonFailedToCompile = Reason<FailToStartReasonType.FailedToCompile>;
export type FailToStartReasonUnknown = Reason<FailToStartReasonType.Unknown> & {
err: Error;
};
export type FailToStartReason =
| FailToStartReasonFailedToConnect
| FailToStartReasonNoFirmware
| FailToStartReasonDeviceMismatch
| FailToStartReasonZipError
| FailToStartReasonBadMetadata
| FailToStartReasonFirmwareSize
| FailToStartReasonFailedToCompile
| FailToStartReasonUnknown;
export enum FailToFinishReasonType {
/** Waiting for a response from the hub took too long. */
/** Connecting to the hub failed. */
FailedToConnect = 'flashFirmware.failToFinish.reason.failedToConnect',
/** The hub connection timed out. */
TimedOut = 'flashFirmware.failToFinish.reason.timedOut',
/** Something went wrong with the BLE connection. */
BleError = 'flashFirmware.failToFinish.reason.bleError',
/** The BLE connection was lost before flashing completed. */
/** The hub was disconnected. */
Disconnected = 'flashFirmware.failToFinish.reason.disconnected',
/** The hub sent a response indicating a problem. */
HubError = 'flashFirmware.failToFinish.reason.hubError',
/** The is no firmware available that matches the connected hub. */
NoFirmware = 'flashFirmware.failToFinish.reason.noFirmware',
/** The provided firmware.zip does not match the connected hub. */
DeviceMismatch = 'flashFirmware.failToFinish.reason.deviceMismatch',
/** Failed to fetch firmware from the server. */
FailedToFetch = 'flashFirmware.failToFinish.reason.failedToFetch',
/** There was a problem with the zip file. */
ZipError = 'flashFirmware.failToFinish.reason.zipError',
/** Metadata property is missing or invalid. */
BadMetadata = 'flashFirmware.failToFinish.reason.badMetadata',
/** The main.py file failed to compile. */
FailedToCompile = 'flashFirmware.failToFinish.reason.failedToCompile',
/** The combined firmware-base.bin and main.mpy are too big. */
FirmwareSize = 'flashFirmware.failToFinish.reason.firmwareSize',
/** An unexpected error occurred. */
Unknown = 'flashFirmware.failToFinish.reason.unknown',
}
type Reason<T extends FailToFinishReasonType> = {
reason: T;
};
export type FailToFinishReasonFailedToConnect = Reason<FailToFinishReasonType.FailedToConnect>;
export type FailToFinishReasonTimedOut = Reason<FailToFinishReasonType.TimedOut>;
export type FailToFinishReasonBleError = Reason<FailToFinishReasonType.BleError>;
export type FailToFinishReasonBleError = Reason<FailToFinishReasonType.BleError> & {
err: Error;
};
export type FailToFinishReasonDisconnected = Reason<FailToFinishReasonType.Disconnected>;
@@ -122,15 +85,44 @@ export type FailToFinishReasonHubError = Reason<FailToFinishReasonType.HubError>
hubError: HubError;
};
export type FailToFinishReasonNoFirmware = Reason<FailToFinishReasonType.NoFirmware>;
export type FailToFinishReasonDeviceMismatch = Reason<FailToFinishReasonType.DeviceMismatch>;
export type FailToFinishReasonFailedToFetch = Reason<FailToFinishReasonType.FailedToFetch> & {
response: Response;
};
export type FailToFinishReasonZipError = Reason<FailToFinishReasonType.ZipError> & {
err: FirmwareReaderError;
};
export type FailToFinishReasonBadMetadata = Reason<FailToFinishReasonType.BadMetadata> & {
property: keyof FirmwareMetadata;
problem: MetadataProblem;
};
export type FailToFinishReasonFirmwareSize = Reason<FailToFinishReasonType.FirmwareSize>;
export type FailToFinishReasonFailedToCompile = Reason<FailToFinishReasonType.FailedToCompile>;
export type FailToFinishReasonUnknown = Reason<FailToFinishReasonType.Unknown> & {
err: Error;
};
export type FailToFinishReason =
| FailToFinishReasonFailedToConnect
| FailToFinishReasonTimedOut
| FailToFinishReasonBleError
| FailToFinishReasonDisconnected
| FailToFinishReasonHubError
| FailToFinishReasonNoFirmware
| FailToFinishReasonDeviceMismatch
| FailToFinishReasonFailedToFetch
| FailToFinishReasonZipError
| FailToFinishReasonBadMetadata
| FailToFinishReasonFirmwareSize
| FailToFinishReasonFailedToCompile
| FailToFinishReasonUnknown;
/**
@@ -160,94 +152,6 @@ export function didStart(): FlashFirmwareDidStartAction {
return { type: FlashFirmwareActionType.DidStart };
}
/** Action that indicates flashing did not start because of an error. */
export type FlashFirmwareDidFailToStartAction = Action<FlashFirmwareActionType.DidFailToStart> & {
reason: FailToStartReason;
};
export function didFailToStart(
reason: FailToStartReasonType.ZipError,
err: FirmwareReaderError,
): FlashFirmwareDidFailToStartAction;
export function didFailToStart(
reason: FailToStartReasonType.BadMetadata,
property: keyof FirmwareMetadata,
problem: MetadataProblem,
): FlashFirmwareDidFailToStartAction;
export function didFailToStart(
reason: FailToStartReasonType.Unknown,
err: Error,
): FlashFirmwareDidFailToStartAction;
export function didFailToStart(
reason: Exclude<
FailToStartReasonType,
| FailToStartReasonType.ZipError
| FailToStartReasonType.BadMetadata
| FailToStartReasonType.Unknown
>,
): FlashFirmwareDidFailToStartAction;
/**
* Action that indicates flashing did not start because of an error.
* @param total The total number of bytes to be flashed.
*/
export function didFailToStart(
reason: FailToStartReasonType,
arg1?: string | Error,
arg2?: MetadataProblem,
): FlashFirmwareDidFailToStartAction {
if (reason === FailToStartReasonType.ZipError) {
// istanbul ignore if: programmer error give wrong arg
if (!(arg1 instanceof FirmwareReaderError)) {
throw new Error('missing or invalid err');
}
return {
type: FlashFirmwareActionType.DidFailToStart,
reason: { reason, err: arg1 },
};
}
if (reason === FailToStartReasonType.BadMetadata) {
// istanbul ignore if: programmer error give wrong arg
if (
arg1 !== 'metadata-version' &&
arg1 !== 'firmware-version' &&
arg1 !== 'device-id' &&
arg1 !== 'checksum-type' &&
arg1 !== 'mpy-abi-version' &&
arg1 !== 'mpy-cross-options' &&
arg1 !== 'user-mpy-offset' &&
arg1 !== 'max-firmware-size'
) {
throw new Error('missing or invalid property');
}
// istanbul ignore if: programmer error give wrong arg
if (arg2 === undefined) {
throw new Error('missing or invalid problem');
}
return {
type: FlashFirmwareActionType.DidFailToStart,
reason: { reason, property: arg1, problem: arg2 },
};
}
if (reason === FailToStartReasonType.Unknown) {
// istanbul ignore if: programmer error give wrong arg
if (!(arg1 instanceof Error)) {
throw new Error('missing or invalid err');
}
return {
type: FlashFirmwareActionType.DidFailToStart,
reason: { reason, err: arg1 },
};
}
return { type: FlashFirmwareActionType.DidFailToStart, reason: { reason } };
}
/** Action that indicates current firmware flashing progress. */
export type FlashFirmwareDidProgressAction = Action<FlashFirmwareActionType.DidProgress> & {
/** The current progress (0 to 1). */
@@ -276,11 +180,32 @@ export type FlashFirmwareDidFailToFinishAction = Action<FlashFirmwareActionType.
reason: FailToFinishReason;
};
export function didFailToFinish(
reason: FailToFinishReasonType.BleError,
err: Error,
): FlashFirmwareDidFailToFinishAction;
export function didFailToFinish(
reason: FailToFinishReasonType.HubError,
hubError: HubError,
): FlashFirmwareDidFailToFinishAction;
export function didFailToFinish(
reason: FailToFinishReasonType.FailedToFetch,
response: Response,
): FlashFirmwareDidFailToFinishAction;
export function didFailToFinish(
reason: FailToFinishReasonType.ZipError,
err: FirmwareReaderError,
): FlashFirmwareDidFailToFinishAction;
export function didFailToFinish(
reason: FailToFinishReasonType.BadMetadata,
property: keyof FirmwareMetadata,
problem: MetadataProblem,
): FlashFirmwareDidFailToFinishAction;
export function didFailToFinish(
reason: FailToFinishReasonType.Unknown,
err: Error,
@@ -289,26 +214,92 @@ export function didFailToFinish(
export function didFailToFinish(
reason: Exclude<
FailToFinishReasonType,
FailToFinishReasonType.HubError | FailToFinishReasonType.Unknown
| FailToFinishReasonType.BleError
| FailToFinishReasonType.HubError
| FailToFinishReasonType.FailedToFetch
| FailToFinishReasonType.ZipError
| FailToFinishReasonType.BadMetadata
| FailToFinishReasonType.Unknown
>,
): FlashFirmwareDidFailToFinishAction;
/** Action that indicates that flashing failed. */
/**
* Action that indicates flashing did not start because of an error.
* @param total The total number of bytes to be flashed.
*/
export function didFailToFinish(
reason: FailToFinishReasonType,
arg1?: HubError | Error,
arg1?: string | HubError | Error | Response,
arg2?: MetadataProblem,
): FlashFirmwareDidFailToFinishAction {
if (reason === FailToFinishReasonType.HubError) {
if (reason === FailToFinishReasonType.BleError) {
// istanbul ignore if: programmer error give wrong arg
if (!isHubError(arg1)) {
if (!(arg1 instanceof Error)) {
throw new Error('missing or invalid err');
}
return {
type: FlashFirmwareActionType.DidFailToFinish,
reason: { reason, err: arg1 },
};
}
if (reason === FailToFinishReasonType.HubError) {
// istanbul ignore if: programmer error give wrong arg
if (!isHubError(arg1)) {
throw new Error('missing or invalid hubError');
}
return {
type: FlashFirmwareActionType.DidFailToFinish,
reason: { reason, hubError: arg1 },
};
}
if (reason === FailToFinishReasonType.FailedToFetch) {
// istanbul ignore if: programmer error give wrong arg
if (!(arg1 instanceof Response)) {
throw new Error('missing or invalid response');
}
return {
type: FlashFirmwareActionType.DidFailToFinish,
reason: { reason, response: arg1 },
};
}
if (reason === FailToFinishReasonType.ZipError) {
// istanbul ignore if: programmer error give wrong arg
if (!(arg1 instanceof FirmwareReaderError)) {
throw new Error('missing or invalid err');
}
return {
type: FlashFirmwareActionType.DidFailToFinish,
reason: { reason, err: arg1 },
};
}
if (reason === FailToFinishReasonType.BadMetadata) {
// istanbul ignore if: programmer error give wrong arg
if (
arg1 !== 'metadata-version' &&
arg1 !== 'firmware-version' &&
arg1 !== 'device-id' &&
arg1 !== 'checksum-type' &&
arg1 !== 'mpy-abi-version' &&
arg1 !== 'mpy-cross-options' &&
arg1 !== 'user-mpy-offset' &&
arg1 !== 'max-firmware-size'
) {
throw new Error('missing or invalid property');
}
// istanbul ignore if: programmer error give wrong arg
if (arg2 === undefined) {
throw new Error('missing or invalid problem');
}
return {
type: FlashFirmwareActionType.DidFailToFinish,
reason: { reason, property: arg1, problem: arg2 },
};
}
if (reason === FailToFinishReasonType.Unknown) {
// istanbul ignore if: programmer error give wrong arg
if (!(arg1 instanceof Error)) {
@@ -329,7 +320,6 @@ export function didFailToFinish(
export type FlashFirmwareAction =
| FlashFirmwareFlashAction
| FlashFirmwareDidStartAction
| FlashFirmwareDidFailToStartAction
| FlashFirmwareDidProgressAction
| FlashFirmwareDidFinishAction
| FlashFirmwareDidFailToFinishAction;
+10
View File
@@ -41,6 +41,10 @@ export enum BootloaderConnectionActionType {
* The connection received a message.
*/
DidReceive = 'bootloader.action.connection.did.receive',
/**
* Initiate disconnection/
*/
Disconnect = 'bootloader.action.connection.disconnect',
/**
* The connection has been closed.
*/
@@ -59,6 +63,12 @@ export function didConnect(): BootloaderConnectionDidConnectAction {
return { type: BootloaderConnectionActionType.DidConnect };
}
export type BootloaderConnectionDisconnectAction = Action<BootloaderConnectionActionType.Disconnect>;
export function disconnect(): BootloaderConnectionDisconnectAction {
return { type: BootloaderConnectionActionType.Disconnect };
}
/**
* Possible reasons a device could fail to connect.
*/
+14
View File
@@ -13,6 +13,20 @@
"action": "Reload"
}
},
"flashFirmware": {
"timedOut": "The hub took too long to respond. Restart the hub and try again.",
"bleError": "There was a problem with Bluetooth.",
"disconnected": "The hub was disconnected before flashing was completed. Restart the hub and try again.",
"hubError": "The hub said something went wrong.",
"unsupportedDevice": "The connected hub is not supported.",
"deviceMismatch": "The firmware is for a different kind of hub from the connected hub.",
"failToFetch": "Failed to fetch firmware from the server: {status}",
"badZipFile": "The firmware.zip file is missing required files or is corrupt.",
"badMetadata": "The firmware.metadata.py file contains missing or invalid entries. Fix it then try again.",
"compileError": "The included main.py file could not be compiled. Fix it then try again.",
"sizeTooBig": "The combined firmware and main.py are too big to fit in the flash memory.",
"unexpectedError": "Unexpected error while trying to flash firmware: {errorMessage}"
},
"mpy": {
"error": "{errorMessage}"
},
+12
View File
@@ -10,6 +10,18 @@ export enum MessageId {
BleGattPermission = 'ble.gattPermission',
BleGattServiceNotFound = 'ble.gattServiceNotFound',
BleNoWebBluetooth = 'ble.noWebBluetooth',
FlashFirmwareTimedOut = 'flashFirmware.timedOut',
FlashFirmwareBleError = 'flashFirmware.bleError',
FlashFirmwareDisconnected = 'flashFirmware.disconnected',
FlashFirmwareHubError = 'flashFirmware.hubError',
FlashFirmwareUnsupportedDevice = 'flashFirmware.unsupportedDevice',
FlashFirmwareDeviceMismatch = 'flashFirmware.deviceMismatch',
FlashFirmwareFailToFetch = 'flashFirmware.failToFetch',
FlashFirmwareBadZipFile = 'flashFirmware.badZipFile',
FlashFirmwareBadMetadata = 'flashFirmware.badMetadata',
FlashFirmwareCompileError = 'flashFirmware.compileError',
FlashFirmwareSizeTooBig = 'flashFirmware.sizeTooBig',
FlashFirmwareUnexpectedError = 'flashFirmware.unexpectedError',
ProgramChangedMessage = 'editor.programChanged.message',
ProgramChangedAction = 'editor.programChanged.action',
ServiceWorkerUpdateMessage = 'serviceWorker.update.message',
@@ -1,31 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`flashFirmware normal flow 1`] = `
Object {
"options": Array [
"-mno-unicode",
],
"script": "print(\\"test\\")",
"type": "mpy.action.compile",
}
`;
exports[`flashFirmware user supplied firmware.zip success 1`] = `
Object {
"options": Array [
"-mno-unicode",
],
"script": "print(\\"test\\")",
"type": "mpy.action.compile",
}
`;
exports[`flashFirmware user supplied main.py 1`] = `
Object {
"options": Array [
"-mno-unicode",
],
"script": "print(\\"test\\")",
"type": "mpy.action.compile",
}
`;
-9
View File
@@ -1,9 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`compiler error works 1`] = `
Array [
"Traceback (most recent call last):",
" File \\"main.py\\", line 1",
"SyntaxError: invalid syntax",
]
`;
+4 -7
View File
@@ -11,11 +11,10 @@ jest.mock('ace-builds');
jest.mock('file-saver');
test('open', async () => {
const saga = new AsyncSaga(editor);
const mockEditor = mock<Ace.EditSession>();
const data = new Uint8Array().buffer;
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
saga.setState({ editor: { current: mockEditor } });
const data = new Uint8Array().buffer;
saga.put(open(data));
expect(mockEditor.setValue).toBeCalled();
@@ -24,10 +23,9 @@ test('open', async () => {
});
test('saveAs', async () => {
const saga = new AsyncSaga(editor);
const mockEditor = mock<Ace.EditSession>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
saga.setState({ editor: { current: mockEditor } });
saga.put(saveAs());
expect(mockEditor.getValue).toBeCalled();
@@ -36,10 +34,9 @@ test('saveAs', async () => {
});
test('reloadProgram', async () => {
const saga = new AsyncSaga(editor);
const mockEditor = mock<Ace.EditSession>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
saga.setState({ editor: { current: mockEditor } });
saga.put(reloadProgram());
expect(mockEditor.setValue).toHaveBeenCalled();
File diff suppressed because it is too large Load Diff
+259 -190
View File
@@ -20,19 +20,20 @@ import {
} from 'typed-redux-saga/macro';
import { Action } from '../actions';
import {
FailToStartReasonType,
FailToFinishReasonType,
FlashFirmwareActionType,
FlashFirmwareFlashAction,
didFailToStart,
HubError,
MetadataProblem,
didFailToFinish,
didFinish,
didProgress,
didStart,
} from '../actions/flash-firmware';
import {
BootloaderChecksumResponseAction,
BootloaderConnectionAction,
BootloaderConnectionActionType,
BootloaderConnectionDidConnectAction,
BootloaderConnectionDidFailToConnectAction,
BootloaderDidRequestAction,
BootloaderDidRequestType,
BootloaderEraseResponseAction,
@@ -44,7 +45,7 @@ import {
BootloaderResponseActionType,
checksumRequest,
connect,
disconnectRequest,
disconnect,
eraseRequest,
infoRequest,
initRequest,
@@ -57,9 +58,9 @@ import {
MpyDidFailToCompileAction,
compile,
} from '../actions/mpy';
import * as notification from '../actions/notification';
import { MaxProgramFlashSize } from '../protocols/lwp3-bootloader';
import { MaxProgramFlashSize, Result } from '../protocols/lwp3-bootloader';
import { RootState } from '../reducers';
import { BootloaderConnectionState } from '../reducers/bootloader';
import { defined, maybe } from '../utils';
import { fmod, sumComplement32 } from '../utils/math';
@@ -69,10 +70,30 @@ const firmwareZipMap = new Map<HubType, string>([
[HubType.MoveHub, moveHubZip],
]);
/**
* Disconnects the BLE if we are connected and cancels the task (including the
* parent task).
*/
function* disconnectAndCancel(): SagaGenerator<void> {
const connection = yield* select((s: RootState) => s.bootloader.connection);
if (connection === BootloaderConnectionState.Connected) {
yield* put(disconnect());
}
yield* cancel();
}
function* waitForDidRequest(id: number): SagaGenerator<BootloaderDidRequestAction> {
return yield* take<BootloaderDidRequestAction>(
const request = yield* take<BootloaderDidRequestAction>(
(a: Action) => a.type === BootloaderDidRequestType && a.id === id,
);
if (request.err) {
yield* put(didFailToFinish(FailToFinishReasonType.BleError, request.err));
yield* disconnectAndCancel();
}
return request;
}
/**
@@ -84,16 +105,34 @@ function* waitForDidRequest(id: number): SagaGenerator<BootloaderDidRequestActio
function* waitForResponse<T extends BootloaderResponseAction>(
type: BootloaderResponseActionType,
timeout = 500,
): SagaGenerator<{
response?: T;
error?: BootloaderErrorResponseAction;
timeout?: boolean;
}> {
return yield* race({
): SagaGenerator<T> {
const { response, error, disconnected, timedOut } = yield* race({
response: take<T>(type),
error: take<BootloaderErrorResponseAction>(BootloaderResponseActionType.Error),
timeout: delay(timeout),
disconnected: take(BootloaderConnectionActionType.DidDisconnect),
timedOut: delay(timeout),
});
if (timedOut) {
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> {
@@ -108,7 +147,8 @@ function* firmwareIterator(data: DataView, maxSize: number): Generator<number> {
}
/**
* Loads Pybricks firmware from a .zip file
* Loads Pybricks firmware from a .zip file.
*
* @param data The zip file raw data
* @param program User program or `undefined` to use main.py from firmware.zip
*/
@@ -121,11 +161,11 @@ function* loadFirmware(
if (readerErr) {
// istanbul ignore else: unexpected error
if (readerErr instanceof FirmwareReaderError) {
yield* put(didFailToStart(FailToStartReasonType.ZipError, readerErr));
yield* put(didFailToFinish(FailToFinishReasonType.ZipError, readerErr));
} else {
yield* put(didFailToStart(FailToStartReasonType.Unknown, readerErr));
yield* put(didFailToFinish(FailToFinishReasonType.Unknown, readerErr));
}
yield* cancel();
yield* disconnectAndCancel();
}
defined(reader);
@@ -139,9 +179,14 @@ function* loadFirmware(
}
if (metadata['mpy-abi-version'] !== 5) {
throw Error(
`Firmware requires mpy-cross ABI version ${metadata['mpy-abi-version']} we have v5`,
yield* put(
didFailToFinish(
FailToFinishReasonType.BadMetadata,
'mpy-abi-version',
MetadataProblem.NotSupported,
),
);
yield* disconnectAndCancel();
}
yield* put(compile(program, metadata['mpy-cross-options']));
@@ -151,7 +196,8 @@ function* loadFirmware(
});
if (mpyFail) {
throw Error(mpyFail.err.join('\n'));
yield* put(didFailToFinish(FailToFinishReasonType.FailedToCompile));
yield* disconnectAndCancel();
}
defined(mpy);
@@ -164,7 +210,8 @@ function* loadFirmware(
const firmwareView = new DataView(firmware.buffer);
if (firmware.length > metadata['max-firmware-size']) {
throw Error('firmware + main.mpy is too large');
yield* put(didFailToFinish(FailToFinishReasonType.FirmwareSize));
yield* disconnectAndCancel();
}
firmware.set(firmwareBase);
@@ -172,15 +219,22 @@ function* loadFirmware(
firmware.set(mpy.data, metadata['user-mpy-offset'] + 4);
if (metadata['checksum-type'] !== 'sum') {
throw Error(`Unknown checksum type "${metadata['checksum-type']}"`);
yield* put(
didFailToFinish(
FailToFinishReasonType.BadMetadata,
'checksum-type',
MetadataProblem.NotSupported,
),
);
yield* disconnectAndCancel();
}
firmwareView.setUint32(
checksumOffset,
sumComplement32(firmwareIterator(firmwareView, metadata['max-firmware-size'])),
true,
const checksum = sumComplement32(
firmwareIterator(firmwareView, metadata['max-firmware-size']),
);
firmwareView.setUint32(checksumOffset, checksum, true);
return { firmware, deviceId: metadata['device-id'] };
}
@@ -189,183 +243,198 @@ function* loadFirmware(
* @param action The action that triggered this saga.
*/
function* flashFirmware(action: FlashFirmwareFlashAction): Generator {
let firmware: Uint8Array | undefined = undefined;
let deviceId: HubType | undefined = undefined;
try {
let firmware: Uint8Array | undefined = undefined;
let deviceId: HubType | undefined = undefined;
let program: string | undefined = undefined;
let program: string | undefined = undefined;
const flashCurrentProgram = yield* select(
(s: RootState) => s.settings.flashCurrentProgram,
);
if (flashCurrentProgram) {
const editor = yield* select((s: RootState) => s.editor.current);
// istanbul ignore if: it is a bug to dispatch this action with no current editor
if (editor === null) {
console.error('flashFirmware: No current editor');
return;
}
program = editor.getValue();
}
if (action.data !== undefined) {
({ firmware, deviceId } = yield* loadFirmware(action.data, program));
}
yield* put(connect());
const connectResult = yield* take<
| BootloaderConnectionDidConnectAction
| BootloaderConnectionDidFailToConnectAction
>([
BootloaderConnectionActionType.DidConnect,
BootloaderConnectionActionType.DidFailToConnect,
]);
if (connectResult.type === BootloaderConnectionActionType.DidFailToConnect) {
return;
}
const nextMessageId = yield* getContext<() => number>('nextMessageId');
const infoAction = yield* put(infoRequest(nextMessageId()));
const { info } = yield* all({
sent: waitForDidRequest(infoAction.id),
info: waitForResponse<BootloaderInfoResponseAction>(
BootloaderResponseActionType.Info,
),
});
if (!info.response) {
throw Error(`failed to get info: ${info}`);
}
if (deviceId !== undefined && info.response.hubType !== deviceId) {
throw Error(
`Connected to ${info.response.hubType} but firmware is for ${deviceId}`,
const flashCurrentProgram = yield* select(
(s: RootState) => s.settings.flashCurrentProgram,
);
}
if (firmware === undefined) {
const firmwarePath = firmwareZipMap.get(info.response.hubType);
if (firmwarePath === undefined) {
yield* put(
notification.add(
'error',
"Sorry, we don't have firmware for this hub yet.",
),
);
yield* put(disconnectRequest(nextMessageId()));
if (flashCurrentProgram) {
const editor = yield* select((s: RootState) => s.editor.current);
// istanbul ignore if: it is a bug to dispatch this action with no current editor
if (editor === null) {
console.error('flashFirmware: No current editor');
return;
}
program = editor.getValue();
}
if (action.data !== undefined) {
({ firmware, deviceId } = yield* loadFirmware(action.data, program));
}
yield* put(connect());
const connectResult = yield* take<BootloaderConnectionAction>([
BootloaderConnectionActionType.DidConnect,
BootloaderConnectionActionType.DidFailToConnect,
]);
if (connectResult.type === BootloaderConnectionActionType.DidFailToConnect) {
yield* put(didFailToFinish(FailToFinishReasonType.FailedToConnect));
return;
}
const response = yield* call(() => fetch(firmwarePath));
if (!response.ok) {
yield* put(notification.add('error', 'Failed to fetch firmware.'));
const disconnectAction = yield* put(disconnectRequest(nextMessageId()));
yield* waitForDidRequest(disconnectAction.id);
return;
}
const nextMessageId = yield* getContext<() => number>('nextMessageId');
const data = yield* call(() => response.arrayBuffer());
({ firmware, deviceId } = yield* loadFirmware(data, program));
if (deviceId !== undefined && info.response.hubType !== deviceId) {
throw Error(
`Connected to ${info.response.hubType} but firmware is for ${deviceId}`,
);
}
}
yield* put(didStart());
const eraseAction = yield* put(eraseRequest(nextMessageId()));
const { erase } = yield* all({
sent: waitForDidRequest(eraseAction.id),
erase: waitForResponse<BootloaderEraseResponseAction>(
BootloaderResponseActionType.Erase,
5000,
),
});
if (!erase.response || erase.response.result) {
// TODO: proper error handling
throw Error(`Failed to erase: ${erase}`);
}
const initAction = yield* put(initRequest(nextMessageId(), firmware.length));
const { init } = yield* all({
sent: waitForDidRequest(initAction.id),
init: waitForResponse<BootloaderInitResponseAction>(
BootloaderResponseActionType.Init,
),
});
if (!init.response || init.response.result) {
// TODO: proper error handling
throw Error(`Failed to init: ${init}`);
}
// 14 is "safe" size for all hubs
const maxDataSize = MaxProgramFlashSize.get(info.response.hubType) || 14;
for (let count = 1, offset = 0; ; count++) {
const payload = firmware.slice(offset, offset + maxDataSize);
const programAction = yield* put(
programRequest(
nextMessageId(),
info.response.startAddress + offset,
payload.buffer,
const infoAction = yield* put(infoRequest(nextMessageId()));
const { info } = yield* all({
sent: waitForDidRequest(infoAction.id),
info: waitForResponse<BootloaderInfoResponseAction>(
BootloaderResponseActionType.Info,
),
);
yield* waitForDidRequest(programAction.id);
});
yield* put(didProgress(offset / firmware.length));
// 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;
if (deviceId !== undefined && info.hubType !== deviceId) {
yield* put(didFailToFinish(FailToFinishReasonType.DeviceMismatch));
yield* disconnectAndCancel();
}
// 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 { checksum } = yield* all({
sent: waitForDidRequest(checksumAction.id),
checksum: waitForResponse<BootloaderChecksumResponseAction>(
BootloaderResponseActionType.Checksum,
5000,
),
});
if (!checksum.response) {
// TODO: proper error handling
throw Error(`Failed to get checksum: ${checksum}`);
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, program));
if (deviceId !== undefined && info.hubType !== deviceId) {
yield* put(didFailToFinish(FailToFinishReasonType.DeviceMismatch));
yield* disconnectAndCancel();
}
}
yield* put(didStart());
const eraseAction = yield* put(eraseRequest(nextMessageId()));
const { erase } = yield* all({
sent: waitForDidRequest(eraseAction.id),
erase: waitForResponse<BootloaderEraseResponseAction>(
BootloaderResponseActionType.Erase,
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<BootloaderInitResponseAction>(
BootloaderResponseActionType.Init,
),
});
if (init.result) {
yield* put(
didFailToFinish(FailToFinishReasonType.HubError, HubError.InitFailed),
);
yield* disconnectAndCancel();
}
// 14 is "safe" size for all hubs
const maxDataSize = MaxProgramFlashSize.get(info.hubType) || 14;
for (let count = 1, offset = 0; ; count++) {
const payload = firmware.slice(offset, offset + maxDataSize);
const programAction = yield* put(
programRequest(
nextMessageId(),
info.startAddress + offset,
payload.buffer,
),
);
yield* waitForDidRequest(programAction.id);
yield* put(didProgress(offset / firmware.length));
// 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()));
yield* all({
sent: waitForDidRequest(checksumAction.id),
checksum: waitForResponse<BootloaderChecksumResponseAction>(
BootloaderResponseActionType.Checksum,
5000,
),
});
}
}
const flash = yield* waitForResponse<BootloaderProgramResponseAction>(
BootloaderResponseActionType.Program,
5000,
);
if (flash.count !== firmware.length) {
yield* put(
didFailToFinish(
FailToFinishReasonType.HubError,
HubError.CountMismatch,
),
);
yield* disconnectAndCancel();
}
const checksum = firmware.reduce((prev, curr) => prev ^ curr, 0xff);
if (flash.checksum !== checksum) {
if (process.env.NODE_ENV !== 'test') {
console.log(
'checksum:',
flash.checksum.toString(16).padStart(2, '0').padStart(4, '0x'),
checksum.toString(16).padStart(2, '0').padStart(4, '0x'),
);
}
yield* put(
didFailToFinish(
FailToFinishReasonType.HubError,
HubError.ChecksumMismatch,
),
);
yield* disconnectAndCancel();
}
yield* put(didProgress(1));
// 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, err));
yield* disconnectAndCancel();
}
const flash = yield* waitForResponse<BootloaderProgramResponseAction>(
BootloaderResponseActionType.Program,
5000,
);
if (!flash.response) {
throw Error(`failed to get final response: ${flash}`);
}
if (flash.response.count !== firmware.length) {
// TODO: proper error handling
throw Error("Didn't flash all bytes");
}
yield* put(didProgress(1));
// this will cause the remote device to disconnect and reboot
const rebootAction = yield* put(rebootRequest(nextMessageId()));
yield* waitForDidRequest(rebootAction.id);
yield* put(didFinish());
}
export default function* (): Generator {
+7 -5
View File
@@ -22,10 +22,12 @@ jest.mock('ace-builds');
describe('downloadAndRun', () => {
test('no errors', async () => {
const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() });
const mockEditor = mock<Ace.EditSession>();
saga.setState({ editor: { current: mockEditor } });
const saga = new AsyncSaga(
hub,
{ editor: { current: mockEditor } },
{ nextMessageId: createCountFunc() },
);
saga.put(downloadAndRun());
@@ -76,7 +78,7 @@ describe('downloadAndRun', () => {
});
test('repl', async () => {
const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(hub, {}, { nextMessageId: createCountFunc() });
saga.put(repl());
@@ -87,7 +89,7 @@ test('repl', async () => {
});
test('stop', async () => {
const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(hub, {}, { nextMessageId: createCountFunc() });
saga.put(stop());
+3 -6
View File
@@ -16,7 +16,7 @@ afterAll(() => {
describe('fetchLicenses', () => {
test('first call', async () => {
const testLicenseList: LicenseList = [];
const saga = new AsyncSaga(license);
const saga = new AsyncSaga(license, { license: { list: null } });
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(testLicenseList)),
@@ -24,7 +24,6 @@ describe('fetchLicenses', () => {
// initially, license list starts as null, so fetch is called to get
// the list
saga.setState({ license: { list: null } });
saga.put(openLicenseDialog());
const action = await saga.take();
@@ -34,7 +33,7 @@ describe('fetchLicenses', () => {
});
test('second call', async () => {
const testLicenseList: LicenseList = [];
const saga = new AsyncSaga(license);
const saga = new AsyncSaga(license, { license: { list: testLicenseList } });
jest.spyOn(globalThis, 'fetch').mockRejectedValue(
'fetch () should not have been called',
@@ -42,7 +41,6 @@ describe('fetchLicenses', () => {
// after we have the list, we don't fetch it again since it will
// always be the same list
saga.setState({ license: { list: testLicenseList } });
saga.put(openLicenseDialog());
// have to yield to be sure fetch call would have taken place on error
@@ -52,11 +50,10 @@ describe('fetchLicenses', () => {
});
test('failed fetch', async () => {
const failResponse = new Response(undefined, { status: 404 });
const saga = new AsyncSaga(license);
const saga = new AsyncSaga(license, { license: { list: null } });
jest.spyOn(globalThis, 'fetch').mockResolvedValue(failResponse);
saga.setState({ license: { list: null } });
saga.put(openLicenseDialog());
const action = await saga.take();
+22 -3
View File
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2021 The Pybricks Authors
// File: sagas/lwp3-bootloader-ble.ts
// Handles Bluetooth Low Energy connection to LEGO Wireless Protocol v3 Bootloader service.
import { END, eventChannel } from 'redux-saga';
import { call, cancel, put, takeEvery, takeMaybe } from 'redux-saga/effects';
import { call, cancel, put, spawn, takeEvery, takeMaybe } from 'redux-saga/effects';
import {
BootloaderConnectionAction,
BootloaderConnectionActionType,
@@ -132,6 +132,14 @@ function* connect(_action: BootloaderConnectionAction): Generator {
});
try {
try {
yield call([characteristic, 'stopNotifications']);
} catch {
// HACK: Chromium on Linux (BlueZ) will not receive notifications
// if a device disconnects while notifications are enabled and then
// reconnects. So we have to call stopNotifications() first to get
// back to a known state. https://crbug.com/1170085
}
yield call([characteristic, 'startNotifications']);
} catch (err) {
notificationChannel.close();
@@ -141,8 +149,19 @@ function* connect(_action: BootloaderConnectionAction): Generator {
return;
}
// Spawning write so that it can't be canceled. This is important because
// other sagas always expect it to complete with success action or error
// action.
function* spawnWrite(action: BootloaderConnectionSendAction): Generator {
yield spawn(write, characteristic, action);
}
yield takeEvery(notificationChannel, handleNotify);
yield takeEvery(BootloaderConnectionActionType.Send, write, characteristic);
yield takeEvery(BootloaderConnectionActionType.Send, spawnWrite);
yield takeEvery(
BootloaderConnectionActionType.Disconnect,
server.disconnect.bind(server),
);
yield put(didConnect());
+7 -1
View File
@@ -37,7 +37,13 @@ test('compiler error works', async () => {
const action = await saga.take();
expect(action.type).toBe(MpyActionType.DidFailToCompile);
const { err } = action as MpyDidFailToCompileAction;
expect(err).toMatchSnapshot();
expect(err).toMatchInlineSnapshot(`
Array [
"Traceback (most recent call last):",
" File \\"main.py\\", line 1",
"SyntaxError: invalid syntax",
]
`);
await saga.end();
});
+36 -3
View File
@@ -2,6 +2,7 @@
// Copyright (c) 2021 The Pybricks Authors
import { IToaster } from '@blueprintjs/core';
import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware';
import { AsyncSaga } from '../../test';
import { Action } from '../actions';
import {
@@ -9,6 +10,12 @@ import {
didFailToConnect as bleDidFailToConnect,
} from '../actions/ble';
import { storageChanged } from '../actions/editor';
import {
FailToFinishReasonType,
HubError,
MetadataProblem,
didFailToFinish,
} from '../actions/flash-firmware';
import {
BootloaderConnectionFailureReason,
didFailToConnect as bootloaderDidFailToConnect,
@@ -37,6 +44,31 @@ test.each([
add('warning', 'message'),
add('error', 'message', 'url'),
didUpdate({} as ServiceWorkerRegistration),
didFailToFinish(FailToFinishReasonType.TimedOut),
didFailToFinish(
FailToFinishReasonType.BleError,
new DOMException('test error', 'NetworkError'),
),
didFailToFinish(FailToFinishReasonType.Disconnected),
didFailToFinish(FailToFinishReasonType.HubError, HubError.UnknownCommand),
didFailToFinish(FailToFinishReasonType.NoFirmware),
didFailToFinish(FailToFinishReasonType.DeviceMismatch),
didFailToFinish(
FailToFinishReasonType.FailedToFetch,
new Response(undefined, { status: 404 }),
),
didFailToFinish(
FailToFinishReasonType.ZipError,
new FirmwareReaderError(FirmwareReaderErrorCode.ZipError),
),
didFailToFinish(
FailToFinishReasonType.BadMetadata,
'device-id',
MetadataProblem.NotSupported,
),
didFailToFinish(FailToFinishReasonType.FailedToCompile),
didFailToFinish(FailToFinishReasonType.FirmwareSize),
didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')),
])('actions that should show notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
@@ -50,7 +82,7 @@ test.each([
clear,
};
const saga = new AsyncSaga(notification, { notification: { toaster } });
const saga = new AsyncSaga(notification, {}, { notification: { toaster } });
saga.put(action);
@@ -64,6 +96,7 @@ test.each([
test.each([
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Canceled }),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Canceled),
didFailToFinish(FailToFinishReasonType.FailedToConnect),
didSucceed({} as ServiceWorkerRegistration),
])('actions that should not show a notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
@@ -78,7 +111,7 @@ test.each([
clear,
};
const saga = new AsyncSaga(notification, { notification: { toaster } });
const saga = new AsyncSaga(notification, {}, { notification: { toaster } });
saga.put(action);
@@ -105,7 +138,7 @@ test.each([[didCompile(new Uint8Array()), MessageId.MpyError]])(
clear,
};
const saga = new AsyncSaga(notification, { notification: { toaster } });
const saga = new AsyncSaga(notification, {}, { notification: { toaster } });
saga.put(action);
+66
View File
@@ -21,6 +21,11 @@ import {
BleDeviceFailToConnectReasonType,
} from '../actions/ble';
import { EditorActionType, reloadProgram } from '../actions/editor';
import {
FailToFinishReasonType,
FlashFirmwareActionType,
FlashFirmwareDidFailToFinishAction,
} from '../actions/flash-firmware';
import {
BootloaderConnectionActionType,
BootloaderConnectionDidFailToConnectAction,
@@ -226,6 +231,66 @@ function* showEditorStorageChanged(): Generator {
yield put(reloadProgram());
}
function* showFlashFirmwareError(
action: FlashFirmwareDidFailToFinishAction,
): Generator {
switch (action.reason.reason) {
case FailToFinishReasonType.TimedOut:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareTimedOut);
break;
case FailToFinishReasonType.BleError:
yield* showUnexpectedError(
MessageId.FlashFirmwareBleError,
action.reason.err,
);
break;
case FailToFinishReasonType.Disconnected:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareDisconnected);
break;
case FailToFinishReasonType.HubError:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareHubError);
if (process.env.NODE_ENV !== 'test') {
console.error(action.reason.hubError);
}
break;
case FailToFinishReasonType.NoFirmware:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareUnsupportedDevice);
break;
case FailToFinishReasonType.DeviceMismatch:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareDeviceMismatch);
break;
case FailToFinishReasonType.FailedToFetch:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareFailToFetch, {
status: action.reason.response.statusText,
});
break;
case FailToFinishReasonType.ZipError:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareBadZipFile);
if (process.env.NODE_ENV !== 'test') {
console.error(action.reason.err);
}
break;
case FailToFinishReasonType.BadMetadata:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareBadMetadata);
if (process.env.NODE_ENV !== 'test') {
console.error(action.reason.property, action.reason.problem);
}
break;
case FailToFinishReasonType.FailedToCompile:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareCompileError);
break;
case FailToFinishReasonType.FirmwareSize:
yield* showSingleton(Level.Error, MessageId.FlashFirmwareSizeTooBig);
break;
case FailToFinishReasonType.Unknown:
yield* showUnexpectedError(
MessageId.FlashFirmwareUnexpectedError,
action.reason.err,
);
break;
}
}
function* dismissCompilerError(): Generator {
const { toaster } = (yield getContext('notification')) as NotificationContext;
toaster.dismiss(MessageId.MpyError);
@@ -282,6 +347,7 @@ export default function* (): Generator {
showBootloaderDidFailToConnectError,
);
yield takeEvery(EditorActionType.StorageChanged, showEditorStorageChanged);
yield takeEvery(FlashFirmwareActionType.DidFailToFinish, showFlashFirmwareError);
yield takeEvery(MpyActionType.DidCompile, dismissCompilerError);
yield takeEvery(MpyActionType.DidFailToCompile, showCompilerError);
yield takeEvery(NotificationActionType.Add, addNotification);
+6 -8
View File
@@ -210,7 +210,7 @@ describe('startup', () => {
describe('store settings to local storage', () => {
test('failed storage', async () => {
const saga = new AsyncSaga(settings);
const saga = new AsyncSaga(settings, { settings: { showDocs: false } });
const testError = new Error('local storage is disabled');
@@ -220,7 +220,6 @@ describe('store settings to local storage', () => {
throw testError;
});
saga.setState({ settings: { showDocs: false } });
saga.put(setBoolean(SettingId.ShowDocs, true));
expect(mockSetItem).toHaveBeenCalled();
@@ -236,7 +235,7 @@ describe('store settings to local storage', () => {
});
test('showDocs', async () => {
const saga = new AsyncSaga(settings);
const saga = new AsyncSaga(settings, { settings: { showDocs: false } });
const mockSetItem = jest
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
@@ -245,7 +244,6 @@ describe('store settings to local storage', () => {
expect(value).toBe('true');
});
saga.setState({ settings: { showDocs: false } });
saga.put(setBoolean(SettingId.ShowDocs, true));
expect(mockSetItem).toHaveBeenCalled();
@@ -256,7 +254,7 @@ describe('store settings to local storage', () => {
});
test('darkMode', async () => {
const saga = new AsyncSaga(settings);
const saga = new AsyncSaga(settings, { settings: { darkMode: true } });
const mockSetItem = jest
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
@@ -265,7 +263,6 @@ describe('store settings to local storage', () => {
expect(value).toBe('false');
});
saga.setState({ settings: { darkMode: true } });
saga.put(setBoolean(SettingId.DarkMode, false));
expect(mockSetItem).toHaveBeenCalled();
@@ -276,7 +273,9 @@ describe('store settings to local storage', () => {
});
test('flashCurrentProgram', async () => {
const saga = new AsyncSaga(settings);
const saga = new AsyncSaga(settings, {
settings: { flashCurrentProgram: true },
});
const mockSetItem = jest
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
@@ -285,7 +284,6 @@ describe('store settings to local storage', () => {
expect(value).toBe('false');
});
saga.setState({ settings: { flashCurrentProgram: true } });
saga.put(setBoolean(SettingId.FlashCurrentProgram, false));
expect(mockSetItem).toHaveBeenCalled();
+46 -22
View File
@@ -30,10 +30,13 @@ import terminal from './terminal';
describe('Data receiver filters out hub status', () => {
test('normal message - no status', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// sending ASCII space character
saga.setState({ hub: { runtime: HubRuntimeState.Unknown } });
saga.put(notify(new DataView(new Uint8Array([0x20]).buffer)));
const action = await saga.take();
@@ -44,9 +47,12 @@ describe('Data receiver filters out hub status', () => {
});
test('checksum message', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Loading } },
{ nextMessageId: createCountFunc() },
);
saga.setState({ hub: { runtime: HubRuntimeState.Loading } });
saga.put(notify(new DataView(new Uint8Array([0xaa]).buffer)));
const action = await saga.take();
@@ -57,10 +63,13 @@ describe('Data receiver filters out hub status', () => {
});
test('idle message', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '>>>> IDLE'
saga.setState({ hub: { runtime: HubRuntimeState.Unknown } });
saga.put(
notify(
new DataView(
@@ -89,10 +98,13 @@ describe('Data receiver filters out hub status', () => {
});
test('idle message with extra text', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '0>>>> IDLE1'
saga.setState({ hub: { runtime: HubRuntimeState.Unknown } });
saga.put(
notify(
new DataView(
@@ -133,10 +145,13 @@ describe('Data receiver filters out hub status', () => {
});
test('error message', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '>>>> ERROR'
saga.setState({ hub: { runtime: HubRuntimeState.Unknown } });
saga.put(
notify(
new DataView(
@@ -166,10 +181,13 @@ describe('Data receiver filters out hub status', () => {
});
test('error message with extra text', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '0>>>> ERROR1'
saga.setState({ hub: { runtime: HubRuntimeState.Unknown } });
saga.put(
notify(
new DataView(
@@ -211,10 +229,13 @@ describe('Data receiver filters out hub status', () => {
});
test('running message', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '>>>> ERROR'
saga.setState({ hub: { runtime: HubRuntimeState.Unknown } });
saga.put(
notify(
new DataView(
@@ -246,10 +267,13 @@ describe('Data receiver filters out hub status', () => {
});
test('running message with extra text', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(
terminal,
{ hub: { runtime: HubRuntimeState.Unknown } },
{ nextMessageId: createCountFunc() },
);
// '0>>>> RUNNING1'
saga.setState({ hub: { runtime: HubRuntimeState.Unknown } });
saga.put(
notify(
new DataView(
@@ -294,7 +318,7 @@ describe('Data receiver filters out hub status', () => {
});
test('Terminal data source responds to send data actions', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() });
saga.put(didStart());
const dataSourceAction = await saga.take();
@@ -321,7 +345,7 @@ describe('Terminal data source responds to receive data actions', () => {
const expected = new Uint8Array([0x74, 0x65, 0x73, 0x74, 0x31, 0x32, 0x33, 0x34]);
test('basic function works', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() });
saga.put(receiveData('test1234'));
@@ -333,7 +357,7 @@ describe('Terminal data source responds to receive data actions', () => {
});
test('messages are queued until previous has completed', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() });
saga.put(receiveData('test1234'));
await delay(50); // without delay, messages are combined
@@ -361,7 +385,7 @@ describe('Terminal data source responds to receive data actions', () => {
});
test('messages are queued until previous has failed', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() });
saga.put(receiveData('test1234'));
await delay(50); // without delay, messages are combined
@@ -391,7 +415,7 @@ describe('Terminal data source responds to receive data actions', () => {
});
test('small messages are combined', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() });
saga.put(receiveData('test1234'));
saga.put(receiveData('test1234'));
@@ -406,7 +430,7 @@ describe('Terminal data source responds to receive data actions', () => {
});
test('long messages are split', async () => {
const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() });
const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() });
saga.put(receiveData('012345678901234567890123456789'));
+11 -4
View File
@@ -16,11 +16,15 @@ export class AsyncSaga {
private state: RecursivePartial<RootState>;
private task: Task;
public constructor(saga: Saga, context?: Record<string, unknown>) {
public constructor(
saga: Saga,
state: RecursivePartial<RootState> = {},
context?: Record<string, unknown>,
) {
this.channel = stdChannel();
this.dispatches = [];
this.takers = [];
this.state = {};
this.state = state;
this.task = runSaga(
{
channel: this.channel,
@@ -67,8 +71,11 @@ export class AsyncSaga {
return Promise.resolve(next);
}
public setState(state: RecursivePartial<RootState>): void {
this.state = state;
public updateState(state: RecursivePartial<RootState>): void {
for (const key of Object.keys(state) as Array<keyof RootState>) {
// @ts-expect-error: writing to readonly for testing
this.state[key] = { ...this.state[key], ...state[key] };
}
}
public async end(): Promise<void> {