mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
Merge pull request #254 from pybricks/dlech
improved firmware flash error handling
This commit is contained in:
+150
-160
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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}"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
`;
|
||||
@@ -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",
|
||||
]
|
||||
`;
|
||||
@@ -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();
|
||||
|
||||
+1602
-125
File diff suppressed because it is too large
Load Diff
+259
-190
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user