diff --git a/src/sagas/app.ts b/src/sagas/app.ts index 1a4b2041..192b92de 100644 --- a/src/sagas/app.ts +++ b/src/sagas/app.ts @@ -1,23 +1,21 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { call, takeEvery } from 'redux-saga/effects'; +import { call, takeEvery } from 'typed-redux-saga/macro'; import { AppActionType } from '../actions/app'; function* reload(): Generator { // unregister the service worker so that when the page reloads, it uses // the new version - const registrations = (yield call(() => - navigator.serviceWorker.getRegistrations(), - )) as ServiceWorkerRegistration[]; + const registrations = yield* call(() => navigator.serviceWorker.getRegistrations()); for (const r of registrations) { - yield call(() => r.unregister()); + yield* call(() => r.unregister()); } location.reload(); } export default function* app(): Generator { - yield takeEvery(AppActionType.Reload, reload); + yield* takeEvery(AppActionType.Reload, reload); } diff --git a/src/sagas/ble-uart.ts b/src/sagas/ble-uart.ts index 75159607..a1b81493 100644 --- a/src/sagas/ble-uart.ts +++ b/src/sagas/ble-uart.ts @@ -4,7 +4,14 @@ // Manages connection to a Bluetooth Low Energy device with the Nordic (nRF) UART service. import { END, eventChannel } from 'redux-saga'; -import { call, cancel, put, select, takeEvery, takeMaybe } from 'redux-saga/effects'; +import { + call, + cancel, + put, + select, + takeEvery, + takeMaybe, +} from 'typed-redux-saga/macro'; import { BLEActionType, BleDeviceActionType as BLEDeviceActionType, @@ -42,7 +49,7 @@ function disconnect( } function* handleValueChanged(data: DataView): Generator { - yield put(notify(data)); + yield* put(notify(data)); } function* write( @@ -50,16 +57,16 @@ function* write( action: BleUartWriteAction, ): Generator { try { - yield call(() => rxChar.writeValueWithoutResponse(action.value.buffer)); - yield put(didWrite(action.id)); + yield* call(() => rxChar.writeValueWithoutResponse(action.value.buffer)); + yield* put(didWrite(action.id)); } catch (err) { - yield put(didFailToWrite(action.id, err)); + yield* put(didFailToWrite(action.id, err)); } } function* connect(_action: BleDeviceConnectAction): Generator { if (navigator.bluetooth === undefined) { - yield put(didFailToConnect({ reason: Reason.NoWebBluetooth })); + yield* put(didFailToConnect({ reason: Reason.NoWebBluetooth })); return; } @@ -67,24 +74,24 @@ function* connect(_action: BleDeviceConnectAction): Generator { let device: BluetoothDevice; try { - device = (yield call(() => + device = yield* call(() => navigator.bluetooth.requestDevice({ filters: [{ services: [pybricksServiceUUID] }], optionalServices: [uartServiceUUID], }), - )) as BluetoothDevice; + ); } catch (err) { if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { // this can happen if the use cancels the dialog - yield put(didFailToConnect({ reason: Reason.Canceled })); + yield* put(didFailToConnect({ reason: Reason.Canceled })); } else { - yield put(didFailToConnect({ reason: Reason.Unknown, err })); + yield* put(didFailToConnect({ reason: Reason.Unknown, err })); } return; } if (device.gatt === undefined) { - yield put(didFailToConnect({ reason: Reason.NoGatt })); + yield* put(didFailToConnect({ reason: Reason.NoGatt })); return; } @@ -97,57 +104,48 @@ function* connect(_action: BleDeviceConnectAction): Generator { let server: BluetoothRemoteGATTServer; try { - server = (yield call([device.gatt, 'connect'])) as BluetoothRemoteGATTServer; + server = yield* call([device.gatt, 'connect']); } catch (err) { disconnectChannel.close(); - yield put(didFailToConnect({ reason: Reason.Unknown, err })); + yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } - yield takeEvery(BLEDeviceActionType.Disconnect, disconnect, server); + yield* takeEvery(BLEDeviceActionType.Disconnect, disconnect, server); let service: BluetoothRemoteGATTService; try { - service = (yield call( - [server, 'getPrimaryService'], - uartServiceUUID, - )) as BluetoothRemoteGATTService; + service = yield* call([server, 'getPrimaryService'], uartServiceUUID); } catch (err) { server.disconnect(); - yield takeMaybe(disconnectChannel); + yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { // Possibly/probably caused by Chrome BlueZ back-end bug // https://chromium-review.googlesource.com/c/chromium/src/+/2214098 - yield put(didFailToConnect({ reason: Reason.NoService })); + yield* put(didFailToConnect({ reason: Reason.NoService })); } else { - yield put(didFailToConnect({ reason: Reason.Unknown, err })); + yield* put(didFailToConnect({ reason: Reason.Unknown, err })); } return; } let rxChar: BluetoothRemoteGATTCharacteristic; try { - rxChar = (yield call( - [service, 'getCharacteristic'], - urtRxCharUUID, - )) as BluetoothRemoteGATTCharacteristic; + rxChar = yield* call([service, 'getCharacteristic'], urtRxCharUUID); } catch (err) { server.disconnect(); - yield takeMaybe(disconnectChannel); - yield put(didFailToConnect({ reason: Reason.Unknown, err })); + yield* takeMaybe(disconnectChannel); + yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } let txChar: BluetoothRemoteGATTCharacteristic; try { - txChar = (yield call( - [service, 'getCharacteristic'], - uartTxCharUUID, - )) as BluetoothRemoteGATTCharacteristic; + txChar = yield* call([service, 'getCharacteristic'], uartTxCharUUID); } catch (err) { server.disconnect(); - yield takeMaybe(disconnectChannel); - yield put(didFailToConnect({ reason: Reason.Unknown, err })); + yield* takeMaybe(disconnectChannel); + yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } @@ -169,27 +167,27 @@ function* connect(_action: BleDeviceConnectAction): Generator { // and reconnecting unless we stop notifications before we start them // again. Wireshark shows that no enable notification descriptor write // is performed but notifications are received. - yield call([txChar, 'stopNotifications']); - yield call([txChar, 'startNotifications']); + yield* call([txChar, 'stopNotifications']); + yield* call([txChar, 'startNotifications']); } catch (err) { txChannel.close(); server.disconnect(); - yield takeMaybe(disconnectChannel); - yield put(didFailToConnect({ reason: Reason.Unknown, err })); + yield* takeMaybe(disconnectChannel); + yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } - yield takeEvery(txChannel, handleValueChanged); - yield takeEvery(BleUartActionType.Write, write, rxChar); + yield* takeEvery(txChannel, handleValueChanged); + yield* takeEvery(BleUartActionType.Write, write, rxChar); - yield put(didConnect()); + yield* put(didConnect()); - yield takeMaybe(disconnectChannel); + yield* takeMaybe(disconnectChannel); txChannel.close(); try { - yield cancel(); // have to cancel to stop forked effects + yield* cancel(); // have to cancel to stop forked effects } finally { - yield put(didDisconnect()); + yield* put(didDisconnect()); } } @@ -200,15 +198,15 @@ function* toggle(_action: BLEToggleAction): Generator { switch (connectionState) { case BleConnectionState.Connected: - yield put(disconnectAction()); + yield* put(disconnectAction()); break; case BleConnectionState.Disconnected: - yield put(connectAction()); + yield* put(connectAction()); break; } } export default function* (): Generator { - yield takeEvery(BLEDeviceActionType.Connect, connect); - yield takeEvery(BLEActionType.Toggle, toggle); + yield* takeEvery(BLEDeviceActionType.Connect, connect); + yield* takeEvery(BLEActionType.Toggle, toggle); } diff --git a/src/sagas/editor.ts b/src/sagas/editor.ts index 8b80f5a8..7db2ec38 100644 --- a/src/sagas/editor.ts +++ b/src/sagas/editor.ts @@ -1,9 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { Ace } from 'ace-builds'; import FileSaver from 'file-saver'; -import { select, takeEvery } from 'redux-saga/effects'; +import { select, takeEvery } from 'typed-redux-saga/macro'; import { EditorActionType, EditorOpenAction, @@ -15,9 +14,7 @@ import { RootState } from '../reducers'; const decoder = new TextDecoder(); function* open(action: EditorOpenAction): Generator { - const editor = (yield select( - (s: RootState) => s.editor.current, - )) as Ace.EditSession | null; + const editor = yield* select((s: RootState) => s.editor.current); // istanbul ignore next: it is a bug to dispatch this action with no current editor if (editor === null) { @@ -30,9 +27,7 @@ function* open(action: EditorOpenAction): Generator { } function* saveAs(_action: EditorSaveAsAction): Generator { - const editor = (yield select( - (s: RootState) => s.editor.current, - )) as Ace.EditSession | null; + const editor = yield* select((s: RootState) => s.editor.current); // istanbul ignore next: it is a bug to dispatch this action with no current editor if (editor === null) { @@ -46,9 +41,7 @@ function* saveAs(_action: EditorSaveAsAction): Generator { } function* reloadProgram(_action: EditorReloadProgramAction): Generator { - const editor = (yield select( - (s: RootState) => s.editor.current, - )) as Ace.EditSession | null; + const editor = yield* select((s: RootState) => s.editor.current); // istanbul ignore next: it is a bug to dispatch this action with no current editor if (editor === null) { @@ -60,7 +53,7 @@ function* reloadProgram(_action: EditorReloadProgramAction): Generator { } export default function* (): Generator { - yield takeEvery(EditorActionType.Open, open); - yield takeEvery(EditorActionType.SaveAs, saveAs); - yield takeEvery(EditorActionType.ReloadProgram, reloadProgram); + yield* takeEvery(EditorActionType.Open, open); + yield* takeEvery(EditorActionType.SaveAs, saveAs); + yield* takeEvery(EditorActionType.ReloadProgram, reloadProgram); } diff --git a/src/sagas/error-log.ts b/src/sagas/error-log.ts index 5353c020..79325d9f 100644 --- a/src/sagas/error-log.ts +++ b/src/sagas/error-log.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { takeEvery } from 'redux-saga/effects'; +import { takeEvery } from 'typed-redux-saga/macro'; import { BleDeviceActionType, BleDeviceDidFailToConnectAction, @@ -43,12 +43,12 @@ function licenseDidFailToFetch(action: LicenseDidFailToFetchListAction): void { } export default function* (): Generator { - yield takeEvery(BleDeviceActionType.DidFailToConnect, bleDeviceDidFailToConnect); - yield takeEvery(BleUartActionType.DidFailToWrite, bleDataDidFailToWrite); - yield takeEvery( + yield* takeEvery(BleDeviceActionType.DidFailToConnect, bleDeviceDidFailToConnect); + yield* takeEvery(BleUartActionType.DidFailToWrite, bleDataDidFailToWrite); + yield* takeEvery( BootloaderConnectionActionType.DidFailToConnect, bootloaderDidFailToConnect, ); - yield takeEvery(BootloaderConnectionActionType.DidError, bootloaderDidError); - yield takeEvery(LicenseActionType.DidFailToFetchList, licenseDidFailToFetch); + yield* takeEvery(BootloaderConnectionActionType.DidError, bootloaderDidError); + yield* takeEvery(LicenseActionType.DidFailToFetchList, licenseDidFailToFetch); } diff --git a/src/sagas/flash-firmware.ts b/src/sagas/flash-firmware.ts index 84961607..2176f82d 100644 --- a/src/sagas/flash-firmware.ts +++ b/src/sagas/flash-firmware.ts @@ -408,8 +408,9 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { const checksum = firmware.reduce((prev, curr) => prev ^ curr, 0xff); if (flash.checksum !== checksum) { + // istanbul ignore next if (process.env.NODE_ENV !== 'test') { - console.log( + console.error( 'checksum:', flash.checksum.toString(16).padStart(2, '0').padStart(4, '0x'), checksum.toString(16).padStart(2, '0').padStart(4, '0x'), diff --git a/src/sagas/hub.ts b/src/sagas/hub.ts index ba1ba949..c7578deb 100644 --- a/src/sagas/hub.ts +++ b/src/sagas/hub.ts @@ -1,11 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2021 The Pybricks Authors -import { Ace } from 'ace-builds'; -import { Channel } from 'redux-saga'; import { - RaceEffect, - TakeEffect, + SagaGenerator, actionChannel, getContext, put, @@ -13,14 +10,13 @@ import { select, take, takeEvery, -} from 'redux-saga/effects'; +} from 'typed-redux-saga/macro'; import { Action } from '../actions'; import { BleDeviceActionType } from '../actions/ble'; import { BleUartActionType, BleUartDidFailToWriteAction, BleUartDidWriteAction, - BleUartWriteAction, write, } from '../actions/ble-uart'; import { @@ -41,21 +37,29 @@ import { } from '../actions/mpy'; import { SafeTxCharLength } from '../protocols/nrf-uart'; import { RootState } from '../reducers'; +import { defined } from '../utils'; import { xor8 } from '../utils/math'; const downloadChunkSize = 100; -function waitForWrite(id: number): RaceEffect { - return race([ - take((a: Action) => a.type === BleUartActionType.DidWrite && a.id === id), - take((a: Action) => a.type === BleUartActionType.DidFailToWrite && a.id === id), - ]); +function* waitForWrite( + id: number, +): SagaGenerator<{ + didWrite: BleUartDidWriteAction | undefined; + didFailToWrite: BleUartDidFailToWriteAction | undefined; +}> { + return yield* race({ + didWrite: take( + (a: Action) => a.type === BleUartActionType.DidWrite && a.id === id, + ), + didFailToWrite: take( + (a: Action) => a.type === BleUartActionType.DidFailToWrite && a.id === id, + ), + }); } function* downloadAndRun(_action: HubDownloadAndRunAction): Generator { - const editor = (yield select( - (s: RootState) => s.editor.current, - )) as Ace.EditSession | null; + const editor = yield* select((s: RootState) => s.editor.current); // istanbul ignore next: it is a bug to dispatch this action with no current editor if (editor === null) { @@ -64,48 +68,45 @@ function* downloadAndRun(_action: HubDownloadAndRunAction): Generator { } const script = editor.getValue(); - yield put(compile(script, ['-mno-unicode'])); - const [mpy, mpyFail] = (yield race([ - take(MpyActionType.DidCompile), - take(MpyActionType.DidFailToCompile), - ])) as [MpyDidCompileAction, MpyDidFailToCompileAction]; + yield* put(compile(script, ['-mno-unicode'])); + const { mpy, mpyFail } = yield* race({ + mpy: take(MpyActionType.DidCompile), + mpyFail: take(MpyActionType.DidFailToCompile), + }); if (mpyFail) { return; } + defined(mpy); + // let everyone know the runtime is busy loading the program - yield put(updateStatus(HubRuntimeStatusType.Loading)); + yield* put(updateStatus(HubRuntimeStatusType.Loading)); - const checksumChannel = (yield actionChannel( + const checksumChannel = yield* actionChannel( HubMessageActionType.Checksum, - )) as Channel; + ); - const nextMessageId = (yield getContext('nextMessageId')) as () => number; + const nextMessageId = yield* getContext<() => number>('nextMessageId'); // first send payload size as big-endian 32-bit integer const sizeBuf = new Uint8Array(4); const sizeView = new DataView(sizeBuf.buffer); sizeView.setUint32(0, mpy.data.byteLength, true); - const writeAction = (yield put( - write(nextMessageId(), sizeBuf), - )) as BleUartWriteAction; - const [, didFailToWrite] = (yield waitForWrite(writeAction.id)) as [ - BleUartDidWriteAction, - BleUartDidFailToWriteAction, - ]; + const writeAction = yield* put(write(nextMessageId(), sizeBuf)); + const { didFailToWrite } = yield* waitForWrite(writeAction.id); if (didFailToWrite) { - yield put(updateStatus(HubRuntimeStatusType.Error)); + yield* put(updateStatus(HubRuntimeStatusType.Error)); return; } - const checksumAction = (yield take(checksumChannel)) as HubChecksumMessageAction; + const checksumAction = yield* take(checksumChannel); if (checksumAction.checksum !== (0xff ^ xor8(sizeBuf))) { console.error( `bad checksum ${checksumAction.checksum} vs ${0xff ^ xor8(sizeBuf)}`, ); - yield put(updateStatus(HubRuntimeStatusType.Error)); + yield* put(updateStatus(HubRuntimeStatusType.Error)); return; } @@ -117,56 +118,51 @@ function* downloadAndRun(_action: HubDownloadAndRunAction): Generator { // we can actually only write 20 bytes at a time for (let j = 0; j < chunk.length; j += SafeTxCharLength) { - const writeAction = (yield put( + const writeAction = yield* put( write(nextMessageId(), chunk.slice(j, j + SafeTxCharLength)), - )) as BleUartWriteAction; - const [, didFailToWrite] = (yield waitForWrite(writeAction.id)) as [ - BleUartDidWriteAction, - BleUartDidFailToWriteAction, - ]; + ); + const { didFailToWrite } = yield* waitForWrite(writeAction.id); if (didFailToWrite) { - yield put(updateStatus(HubRuntimeStatusType.Error)); + yield* put(updateStatus(HubRuntimeStatusType.Error)); return; } // TODO: dispatch progress } - const checksumAction = (yield take( - checksumChannel, - )) as HubChecksumMessageAction; + const checksumAction = yield* take(checksumChannel); if (checksumAction.checksum !== (0xff ^ xor8(chunk))) { console.error( `bad checksum ${checksumAction.checksum} vs ${0xff ^ xor8(chunk)}`, ); - yield put(updateStatus(HubRuntimeStatusType.Error)); + yield* put(updateStatus(HubRuntimeStatusType.Error)); return; } } // let everyone know the runtime is done loading the program - yield put(updateStatus(HubRuntimeStatusType.Loaded)); + yield* put(updateStatus(HubRuntimeStatusType.Loaded)); } // SPACE, SPACE, SPACE, SPACE const startReplCommand = new Uint8Array([0x20, 0x20, 0x20, 0x20]); function* startRepl(_action: HubReplAction): Generator { - const nextMessageId = (yield getContext('nextMessageId')) as () => number; - yield put(write(nextMessageId(), startReplCommand)); + const nextMessageId = yield* getContext<() => number>('nextMessageId'); + yield* put(write(nextMessageId(), startReplCommand)); } // CTRL+C, CTRL+C, CTRL+D const stopCommand = new Uint8Array([0x03, 0x03, 0x04]); function* stop(_action: HubStopAction): Generator { - const nextMessageId = (yield getContext('nextMessageId')) as () => number; - yield put(write(nextMessageId(), stopCommand)); + const nextMessageId = yield* getContext<() => number>('nextMessageId'); + yield* put(write(nextMessageId(), stopCommand)); } export default function* (): Generator { - yield takeEvery(HubActionType.DownloadAndRun, downloadAndRun); - yield takeEvery(HubActionType.Repl, startRepl); - yield takeEvery(HubActionType.Stop, stop); + yield* takeEvery(HubActionType.DownloadAndRun, downloadAndRun); + yield* takeEvery(HubActionType.Repl, startRepl); + yield* takeEvery(HubActionType.Stop, stop); // calling stop right after connecting should get the hub into a known state - yield takeEvery(BleDeviceActionType.DidConnect, stop); + yield* takeEvery(BleDeviceActionType.DidConnect, stop); } diff --git a/src/sagas/index.ts b/src/sagas/index.ts index 1e88d206..b79ecf12 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2021 The Pybricks Authors -import { all, put } from 'redux-saga/effects'; +import { all, put } from 'typed-redux-saga/macro'; import { didStart } from '../actions/app'; import app from './app'; import bleUart from './ble-uart'; @@ -19,7 +19,7 @@ import terminal from './terminal'; /* istanbul ignore next */ export default function* (): Generator { - yield all([ + yield* all([ app(), bleUart(), lwp3BootloaderBle(), diff --git a/src/sagas/license.ts b/src/sagas/license.ts index 0bd1eac0..cb672624 100644 --- a/src/sagas/license.ts +++ b/src/sagas/license.ts @@ -1,32 +1,29 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { call, put, select, takeEvery } from 'redux-saga/effects'; +import { call, put, select, takeEvery } from 'typed-redux-saga/macro'; import { AppActionType } from '../actions/app'; import { didFailToFetchList, didFetchList } from '../actions/license'; import { RootState } from '../reducers'; -import { LicenseList } from '../reducers/license'; function* fetchLicenses(): Generator { - const licenses = (yield select( - (s: RootState) => s.license.list, - )) as LicenseList | null; + const licenses = yield* select((s: RootState) => s.license.list); // if we already have license list, nothing to do if (licenses !== null) { return; } - const response = (yield call(() => fetch('static/oss-licenses.json'))) as Response; + const response = yield* call(() => fetch('static/oss-licenses.json')); if (!response.ok || response.body === null) { - yield put(didFailToFetchList(response)); + yield* put(didFailToFetchList(response)); return; } - const list = (yield call(() => response.json())) as LicenseList; - yield put(didFetchList(list)); + const list = yield* call(() => response.json()); + yield* put(didFetchList(list)); } export default function* (): Generator { - yield takeEvery(AppActionType.OpenLicenseDialog, fetchLicenses); + yield* takeEvery(AppActionType.OpenLicenseDialog, fetchLicenses); } diff --git a/src/sagas/lwp3-bootloader-ble.ts b/src/sagas/lwp3-bootloader-ble.ts index 8fe9fbc7..3d825535 100644 --- a/src/sagas/lwp3-bootloader-ble.ts +++ b/src/sagas/lwp3-bootloader-ble.ts @@ -4,7 +4,7 @@ // Handles Bluetooth Low Energy connection to LEGO Wireless Protocol v3 Bootloader service. import { END, eventChannel } from 'redux-saga'; -import { call, cancel, put, spawn, takeEvery, takeMaybe } from 'redux-saga/effects'; +import { call, cancel, put, spawn, takeEvery, takeMaybe } from 'typed-redux-saga/macro'; import { BootloaderConnectionAction, BootloaderConnectionActionType, @@ -19,7 +19,7 @@ import { import { CharacteristicUUID, ServiceUUID } from '../protocols/lwp3-bootloader'; function* handleNotify(data: DataView): Generator { - yield put(didReceive(data)); + yield* put(didReceive(data)); } function* write( @@ -28,19 +28,19 @@ function* write( ): Generator { try { if (action.withResponse) { - yield call(() => characteristic.writeValueWithResponse(action.data)); + yield* call(() => characteristic.writeValueWithResponse(action.data)); } else { - yield call(() => characteristic.writeValueWithoutResponse(action.data)); + yield* call(() => characteristic.writeValueWithoutResponse(action.data)); } - yield put(didSend()); + yield* put(didSend()); } catch (err) { - yield put(didSend(err)); + yield* put(didSend(err)); } } function* connect(_action: BootloaderConnectionAction): Generator { if (navigator.bluetooth === undefined) { - yield put(didFailToConnect(Reason.NoWebBluetooth)); + yield* put(didFailToConnect(Reason.NoWebBluetooth)); return; } @@ -48,24 +48,24 @@ function* connect(_action: BootloaderConnectionAction): Generator { let device: BluetoothDevice; try { - device = (yield call(() => + device = yield* call(() => navigator.bluetooth.requestDevice({ filters: [{ services: [ServiceUUID] }], optionalServices: [ServiceUUID], }), - )) as BluetoothDevice; + ); } catch (err) { if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { // this can happen if the use cancels the dialog - yield put(didFailToConnect(Reason.Canceled)); + yield* put(didFailToConnect(Reason.Canceled)); } else { - yield put(didFailToConnect(Reason.Unknown, err)); + yield* put(didFailToConnect(Reason.Unknown, err)); } return; } if (device.gatt === undefined) { - yield put( + yield* put( didFailToConnect(Reason.Unknown, new Error('Device does not support GATT')), ); return; @@ -80,42 +80,39 @@ function* connect(_action: BootloaderConnectionAction): Generator { let server: BluetoothRemoteGATTServer; try { - server = (yield call([device.gatt, 'connect'])) as BluetoothRemoteGATTServer; + server = yield* call([device.gatt, 'connect']); } catch (err) { disconnectChannel.close(); - yield put(didFailToConnect(Reason.Unknown, err)); + yield* put(didFailToConnect(Reason.Unknown, err)); return; } let service: BluetoothRemoteGATTService; try { - service = (yield call( - [server, 'getPrimaryService'], - ServiceUUID, - )) as BluetoothRemoteGATTService; + service = yield* call([server, 'getPrimaryService'], ServiceUUID); } catch (err) { server.disconnect(); - yield takeMaybe(disconnectChannel); + yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { // Possibly/probably caused by Chrome BlueZ back-end bug // https://chromium-review.googlesource.com/c/chromium/src/+/2214098 - yield put(didFailToConnect(Reason.GattServiceNotFound)); + yield* put(didFailToConnect(Reason.GattServiceNotFound)); } else { - yield put(didFailToConnect(Reason.Unknown, err)); + yield* put(didFailToConnect(Reason.Unknown, err)); } return; } let characteristic: BluetoothRemoteGATTCharacteristic; try { - characteristic = (yield call( + characteristic = yield* call( [service, 'getCharacteristic'], CharacteristicUUID, - )) as BluetoothRemoteGATTCharacteristic; + ); } catch (err) { server.disconnect(); - yield takeMaybe(disconnectChannel); - yield put(didFailToConnect(Reason.Unknown, err)); + yield* takeMaybe(disconnectChannel); + yield* put(didFailToConnect(Reason.Unknown, err)); return; } @@ -133,19 +130,19 @@ function* connect(_action: BootloaderConnectionAction): Generator { try { try { - yield call([characteristic, 'stopNotifications']); + 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']); + yield* call([characteristic, 'startNotifications']); } catch (err) { notificationChannel.close(); server.disconnect(); - yield takeMaybe(disconnectChannel); - yield put(didFailToConnect(Reason.Unknown, err)); + yield* takeMaybe(disconnectChannel); + yield* put(didFailToConnect(Reason.Unknown, err)); return; } @@ -153,27 +150,27 @@ function* connect(_action: BootloaderConnectionAction): Generator { // other sagas always expect it to complete with success action or error // action. function* spawnWrite(action: BootloaderConnectionSendAction): Generator { - yield spawn(write, characteristic, action); + yield* spawn(write, characteristic, action); } - yield takeEvery(notificationChannel, handleNotify); - yield takeEvery(BootloaderConnectionActionType.Send, spawnWrite); - yield takeEvery( + yield* takeEvery(notificationChannel, handleNotify); + yield* takeEvery(BootloaderConnectionActionType.Send, spawnWrite); + yield* takeEvery( BootloaderConnectionActionType.Disconnect, server.disconnect.bind(server), ); - yield put(didConnect()); + yield* put(didConnect()); - yield takeMaybe(disconnectChannel); + yield* takeMaybe(disconnectChannel); notificationChannel.close(); try { - yield cancel(); // have to cancel to stop forked effects + yield* cancel(); // have to cancel to stop forked effects } finally { - yield put(didDisconnect()); + yield* put(didDisconnect()); } } export default function* (): Generator { - yield takeEvery(BootloaderConnectionActionType.Connect, connect); + yield* takeEvery(BootloaderConnectionActionType.Connect, connect); } diff --git a/src/sagas/lwp3-bootloader-protocol.ts b/src/sagas/lwp3-bootloader-protocol.ts index e60f5422..cc858eb3 100644 --- a/src/sagas/lwp3-bootloader-protocol.ts +++ b/src/sagas/lwp3-bootloader-protocol.ts @@ -3,8 +3,7 @@ // File: sagas/lwp3-bootloader-protocol.ts // Handles LEGO Wireless Protocol v3 Bootloader protocol. -import { Channel } from 'redux-saga'; -import { actionChannel, fork, put, take, takeEvery } from 'redux-saga/effects'; +import { actionChannel, fork, put, take, takeEvery } from 'typed-redux-saga/macro'; import { Action } from '../actions'; import { BootloaderConnectionActionType, @@ -53,13 +52,14 @@ import { hex } from '../utils'; function* encodeRequest(): Generator { // Using a while loop to serialize sending data to avoid "busy" errors. - const chan = (yield actionChannel((a: Action) => + const chan = yield* actionChannel((a: Action) => Object.values(BootloaderRequestActionType).includes( a.type as BootloaderRequestActionType, ), - )) as Channel; + ); + while (true) { - const action = (yield take(chan)) as BootloaderRequestAction; + const action = yield* take(chan); // NB: Commands other than program on city hub will cause BlueZ to // disconnect because they will send a response even if we write without @@ -70,10 +70,10 @@ function* encodeRequest(): Generator { switch (action.type) { case BootloaderRequestActionType.Erase: - yield put(send(createEraseFlashRequest())); + yield* put(send(createEraseFlashRequest())); break; case BootloaderRequestActionType.Program: - yield put( + yield* put( send( createProgramFlashRequest(action.address, action.payload), /* withResponse */ false, @@ -81,22 +81,22 @@ function* encodeRequest(): Generator { ); break; case BootloaderRequestActionType.Reboot: - yield put(send(createStartAppRequest(), /* withResponse */ false)); + yield* put(send(createStartAppRequest(), /* withResponse */ false)); break; case BootloaderRequestActionType.Init: - yield put(send(createInitLoaderRequest(action.firmwareSize))); + yield* put(send(createInitLoaderRequest(action.firmwareSize))); break; case BootloaderRequestActionType.Info: - yield put(send(createGetInfoRequest())); + yield* put(send(createGetInfoRequest())); break; case BootloaderRequestActionType.Checksum: - yield put(send(createGetChecksumRequest())); + yield* put(send(createGetChecksumRequest())); break; case BootloaderRequestActionType.State: - yield put(send(createGetFlashStateRequest())); + yield* put(send(createGetFlashStateRequest())); break; case BootloaderRequestActionType.Disconnect: - yield put(send(createDisconnectRequest(), /* withResponse */ false)); + yield* put(send(createDisconnectRequest(), /* withResponse */ false)); break; /* istanbul ignore next: should not be possible to reach */ default: @@ -104,10 +104,10 @@ function* encodeRequest(): Generator { continue; } - const sent = (yield take( + const sent = yield* take( BootloaderConnectionActionType.DidSend, - )) as BootloaderConnectionDidSendAction; - yield put(didRequest(action.id, sent.err)); + ); + yield* put(didRequest(action.id, sent.err)); } } @@ -120,25 +120,25 @@ function* decodeResponse(action: BootloaderConnectionDidReceiveAction): Generato const responseType = getMessageType(action.data); switch (responseType) { case Command.EraseFlash: - yield put(eraseResponse(parseEraseFlashResponse(action.data))); + yield* put(eraseResponse(parseEraseFlashResponse(action.data))); break; case Command.ProgramFlash: - yield put(programResponse(...parseProgramFlashResponse(action.data))); + yield* put(programResponse(...parseProgramFlashResponse(action.data))); break; case Command.InitLoader: - yield put(initResponse(parseInitLoaderResponse(action.data))); + yield* put(initResponse(parseInitLoaderResponse(action.data))); break; case Command.GetInfo: - yield put(infoResponse(...parseGetInfoResponse(action.data))); + yield* put(infoResponse(...parseGetInfoResponse(action.data))); break; case Command.GetChecksum: - yield put(checksumResponse(parseGetChecksumResponse(action.data))); + yield* put(checksumResponse(parseGetChecksumResponse(action.data))); break; case Command.GetFlashState: - yield put(stateResponse(parseGetFlashStateResponse(action.data))); + yield* put(stateResponse(parseGetFlashStateResponse(action.data))); break; case ErrorBytecode: - yield put(errorResponse(parseErrorResponse(action.data))); + yield* put(errorResponse(parseErrorResponse(action.data))); break; default: throw new ProtocolError( @@ -147,11 +147,11 @@ function* decodeResponse(action: BootloaderConnectionDidReceiveAction): Generato ); } } catch (err) { - yield put(didError(err)); + yield* put(didError(err)); } } export default function* (): Generator { - yield fork(encodeRequest); - yield takeEvery(BootloaderConnectionActionType.DidReceive, decodeResponse); + yield* fork(encodeRequest); + yield* takeEvery(BootloaderConnectionActionType.DidReceive, decodeResponse); } diff --git a/src/sagas/mpy.ts b/src/sagas/mpy.ts index 5c15c43d..0ef64485 100644 --- a/src/sagas/mpy.ts +++ b/src/sagas/mpy.ts @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { CompileResult, compile as mpyCrossCompile } from '@pybricks/mpy-cross-v5'; +import { compile as mpyCrossCompile } from '@pybricks/mpy-cross-v5'; import wasm from '@pybricks/mpy-cross-v5/build/mpy-cross.wasm'; -import { call, put, takeEvery } from 'redux-saga/effects'; +import { call, put, takeEvery } from 'typed-redux-saga/macro'; import { MpyActionType, MpyCompileAction, @@ -17,7 +17,7 @@ import { * @param action A mpy compile action. */ function* compile(action: MpyCompileAction): Generator { - const result = (yield call(() => + const result = yield* call(() => mpyCrossCompile( 'main.py', action.script, @@ -25,14 +25,14 @@ function* compile(action: MpyCompileAction): Generator { // HACK: testing user agent for jsdom is needed only for getting unit tests to work navigator.userAgent.includes('jsdom') ? undefined : wasm, ), - )) as CompileResult; + ); if (result.status === 0 && result.mpy) { - yield put(didCompile(result.mpy)); + yield* put(didCompile(result.mpy)); } else { - yield put(didFailToCompile(result.err)); + yield* put(didFailToCompile(result.err)); } } export default function* (): Generator { - yield takeEvery(MpyActionType.Compile, compile); + yield* takeEvery(MpyActionType.Compile, compile); } diff --git a/src/sagas/notification.ts b/src/sagas/notification.ts index 7f2aa445..b9fb0c7a 100644 --- a/src/sagas/notification.ts +++ b/src/sagas/notification.ts @@ -13,7 +13,7 @@ import { import { Replacements } from '@shopify/react-i18n'; import React from 'react'; import { channel } from 'redux-saga'; -import { delay, getContext, put, take, takeEvery } from 'redux-saga/effects'; +import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro'; import { reload } from '../actions/app'; import { BleDeviceActionType, @@ -119,7 +119,7 @@ function* showSingleton( action?: IActionProps & ILinkProps, onDismiss?: (didTimeoutExpire: boolean) => void, ): Generator { - const { toaster } = (yield getContext('notification')) as NotificationContext; + const { toaster } = yield* getContext('notification'); // if the message is already showing, close it and wait some time so that // users can see that something triggered the message again @@ -130,7 +130,7 @@ function* showSingleton( .includes(messageId) ) { toaster.dismiss(messageId); - yield delay(500); + yield* delay(500); } toaster.show( @@ -148,7 +148,7 @@ function* showSingleton( /** Shows a special notification for unexpected errors. */ function* showUnexpectedError(messageId: MessageId, err: Error): Generator { - const { toaster } = (yield getContext('notification')) as NotificationContext; + const { toaster } = yield* getContext('notification'); toaster.show({ intent: mapIntent(Level.Error), icon: mapIcon(Level.Error), @@ -226,9 +226,9 @@ function* showEditorStorageChanged(): Generator { // if the notification is dismissed without clicking on the action, the // saga will be cancelled here - yield take(ch); + yield* take(ch); - yield put(reloadProgram()); + yield* put(reloadProgram()); } function* showFlashFirmwareError( @@ -249,6 +249,7 @@ function* showFlashFirmwareError( break; case FailToFinishReasonType.HubError: yield* showSingleton(Level.Error, MessageId.FlashFirmwareHubError); + // istanbul ignore next if (process.env.NODE_ENV !== 'test') { console.error(action.reason.hubError); } @@ -266,12 +267,14 @@ function* showFlashFirmwareError( break; case FailToFinishReasonType.ZipError: yield* showSingleton(Level.Error, MessageId.FlashFirmwareBadZipFile); + // istanbul ignore next if (process.env.NODE_ENV !== 'test') { console.error(action.reason.err); } break; case FailToFinishReasonType.BadMetadata: yield* showSingleton(Level.Error, MessageId.FlashFirmwareBadMetadata); + // istanbul ignore next if (process.env.NODE_ENV !== 'test') { console.error(action.reason.property, action.reason.problem); } @@ -292,7 +295,7 @@ function* showFlashFirmwareError( } function* dismissCompilerError(): Generator { - const { toaster } = (yield getContext('notification')) as NotificationContext; + const { toaster } = yield* getContext('notification'); toaster.dismiss(MessageId.MpyError); } @@ -303,7 +306,7 @@ function* showCompilerError(action: MpyDidFailToCompileAction): Generator { } function* addNotification(action: NotificationAddAction): Generator { - const { toaster } = (yield getContext('notification')) as NotificationContext; + const { toaster } = yield* getContext('notification'); toaster.show({ intent: mapIntent(action.level as Level), @@ -332,24 +335,24 @@ function* showServiceWorkerUpdate(): Generator { ch.close, ); - yield take(ch); + yield* take(ch); - yield put(reload()); + yield* put(reload()); } export default function* (): Generator { - yield takeEvery( + yield* takeEvery( BleDeviceActionType.DidFailToConnect, showBleDeviceDidFailToConnectError, ); - yield takeEvery( + yield* takeEvery( BootloaderConnectionActionType.DidFailToConnect, 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); - yield takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate); + 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); + yield* takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate); } diff --git a/src/sagas/settings.ts b/src/sagas/settings.ts index b39411d6..3bd84274 100644 --- a/src/sagas/settings.ts +++ b/src/sagas/settings.ts @@ -6,7 +6,7 @@ // change action that can be used by reducers to compute the new state. import { EventChannel, eventChannel } from 'redux-saga'; -import { call, fork, put, select, take, takeEvery } from 'redux-saga/effects'; +import { call, fork, put, select, take, takeEvery } from 'typed-redux-saga/macro'; import { AppActionType } from '../actions/app'; import { SettingsActionType, @@ -36,12 +36,10 @@ function createLocalStorageEventChannel(): EventChannel { } function* monitorLocalStorage(): Generator { - const chan = (yield call( - createLocalStorageEventChannel, - )) as EventChannel; + const chan = yield* call(createLocalStorageEventChannel); while (true) { - const event = (yield take(chan)) as StorageEvent; + const event = yield* take(chan); // only care about storage keys 'setting.*' if (!event.key?.startsWith('setting.')) { @@ -56,7 +54,7 @@ function* monitorLocalStorage(): Generator { continue; } - yield put(didBooleanChange(id, stringToBoolean(event.newValue || 'false'))); + yield* put(didBooleanChange(id, stringToBoolean(event.newValue || 'false'))); } } @@ -68,7 +66,7 @@ function* loadSettings(): Generator { storageValue === null ? defaultValue : stringToBoolean(storageValue); if (value !== defaultValue) { - yield put(didBooleanChange(id, value)); + yield* put(didBooleanChange(id, value)); } } } @@ -80,13 +78,13 @@ function* storeSetting(action: SettingsSetBooleanAction): Generator { try { localStorage.setItem(key, newValue); } catch (err) { - yield put(didFailToSetBoolean(action.id, err)); + yield* put(didFailToSetBoolean(action.id, err)); } // storage event is only raised when a value is changed externally, so we // mimic the event when we call setItem(), whether it actually succeeded // or not. - const oldState = (yield select((s: RootState) => s.settings[action.id])) as boolean; + const oldState = yield* select((s: RootState) => s.settings[action.id]); if (action.newState !== oldState) { window.dispatchEvent( new StorageEvent('storage', { @@ -100,7 +98,7 @@ function* storeSetting(action: SettingsSetBooleanAction): Generator { } export default function* (): Generator { - yield fork(monitorLocalStorage); - yield takeEvery(AppActionType.DidStart, loadSettings); - yield takeEvery(SettingsActionType.SetBoolean, storeSetting); + yield* fork(monitorLocalStorage); + yield* takeEvery(AppActionType.DidStart, loadSettings); + yield* takeEvery(SettingsActionType.SetBoolean, storeSetting); } diff --git a/src/sagas/terminal.ts b/src/sagas/terminal.ts index 7a828b93..621392d5 100644 --- a/src/sagas/terminal.ts +++ b/src/sagas/terminal.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2021 The Pybricks Authors -import { Channel } from 'redux-saga'; import { actionChannel, delay, @@ -12,16 +11,11 @@ import { select, take, takeEvery, -} from 'redux-saga/effects'; +} from 'typed-redux-saga/macro'; import PushStream from 'zen-push'; import { Action } from '../actions'; import { AppActionType, AppDidStartAction } from '../actions/app'; -import { - BleUartActionType, - BleUartNotifyAction, - BleUartWriteAction, - write, -} from '../actions/ble-uart'; +import { BleUartActionType, BleUartNotifyAction, write } from '../actions/ble-uart'; import { HubRuntimeStatusType, checksum, updateStatus } from '../actions/hub'; import { TerminalActionType, @@ -32,13 +26,14 @@ import { import { SafeTxCharLength } from '../protocols/nrf-uart'; import { RootState } from '../reducers'; import { HubRuntimeState } from '../reducers/hub'; +import { defined } from '../utils'; const encoder = new TextEncoder(); const decoder = new TextDecoder(); const terminalDataSource = new PushStream(); function* startup(_action: AppDidStartAction): Generator { - yield put(setDataSource(terminalDataSource.observable)); + yield* put(setDataSource(terminalDataSource.observable)); } function* handleMatch( @@ -50,24 +45,24 @@ function* handleMatch( } if (match[1]) { - yield put(sendData(match[1])); + yield* put(sendData(match[1])); } - yield put(updateStatus(status)); + yield* put(updateStatus(status)); if (match[2]) { - yield put(sendData(match[2])); + yield* put(sendData(match[2])); } return true; } function* receiveUartData(action: BleUartNotifyAction): Generator { - const hubState = (yield select((s: RootState) => s.hub.runtime)) as HubRuntimeState; + const hubState = yield* select((s: RootState) => s.hub.runtime); if (hubState === HubRuntimeState.Loading && action.value.buffer.byteLength === 1) { const view = new DataView(action.value.buffer); - yield put(checksum(view.getUint8(0))); + yield* put(checksum(view.getUint8(0))); return; } @@ -97,40 +92,41 @@ function* receiveUartData(action: BleUartNotifyAction): Generator { return; } - yield put(sendData(value)); + yield* put(sendData(value)); } function* receiveTerminalData(): Generator { - const channel = (yield actionChannel( + const channel = yield* actionChannel( TerminalActionType.ReceivedData, - )) as Channel; + ); while (true) { // wait for input from terminal - const action = (yield take(channel)) as TerminalDataReceiveDataAction; + const action = yield* take(channel); let value = action.value; // Try to collect more data so that we aren't sending just one byte at time while (value.length < SafeTxCharLength) { - const [action, timeout] = (yield race([take(channel), delay(20)])) as [ - TerminalDataReceiveDataAction, - boolean, - ]; + const { action, timeout } = yield* race({ + action: take(channel), + timeout: delay(20), + }); if (timeout) { break; } + defined(action); value += action.value; } - const nextMessageId = (yield getContext('nextMessageId')) as () => number; + const nextMessageId = yield* getContext<() => number>('nextMessageId'); // stdin gets piped to BLE connection const data = encoder.encode(value); for (let i = 0; i < data.length; i += SafeTxCharLength) { - const { id } = (yield put( + const { id } = yield* put( write(nextMessageId(), data.slice(i, i + SafeTxCharLength)), - )) as BleUartWriteAction; + ); - yield take( + yield* take( (a: Action) => (a.type === BleUartActionType.DidWrite || a.type === BleUartActionType.DidFailToWrite) && @@ -138,7 +134,7 @@ function* receiveTerminalData(): Generator { ); // wait for echo so tht we don't overrun the hub with messages - yield race([take(BleUartActionType.Notify), delay(100)]); + yield* race([take(BleUartActionType.Notify), delay(100)]); } } } @@ -149,8 +145,8 @@ function sendTerminalData(action: TerminalDataReceiveDataAction): void { } export default function* (): Generator { - yield takeEvery(AppActionType.DidStart, startup); - yield takeEvery(BleUartActionType.Notify, receiveUartData); - yield fork(receiveTerminalData); - yield takeEvery(TerminalActionType.SendData, sendTerminalData); + yield* takeEvery(AppActionType.DidStart, startup); + yield* takeEvery(BleUartActionType.Notify, receiveUartData); + yield* fork(receiveTerminalData); + yield* takeEvery(TerminalActionType.SendData, sendTerminalData); }