Merge pull request #259 from pybricks/dlech

saga conversions
This commit is contained in:
David Lechner
2021-01-23 19:07:49 -06:00
committed by GitHub
14 changed files with 248 additions and 271 deletions
+4 -6
View File
@@ -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);
}
+45 -47
View File
@@ -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);
}
+7 -14
View File
@@ -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);
}
+6 -6
View File
@@ -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);
}
+2 -1
View File
@@ -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'),
+49 -53
View File
@@ -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<TakeEffect> {
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<BleUartDidWriteAction>(
(a: Action) => a.type === BleUartActionType.DidWrite && a.id === id,
),
didFailToWrite: take<BleUartDidFailToWriteAction>(
(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<MpyDidCompileAction>(MpyActionType.DidCompile),
mpyFail: take<MpyDidFailToCompileAction>(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<HubChecksumMessageAction>(
HubMessageActionType.Checksum,
)) as Channel<HubChecksumMessageAction>;
);
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);
}
+2 -2
View File
@@ -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(),
+7 -10
View File
@@ -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);
}
+35 -38
View File
@@ -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);
}
+26 -26
View File
@@ -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<BootloaderRequestAction>((a: Action) =>
Object.values(BootloaderRequestActionType).includes(
a.type as BootloaderRequestActionType,
),
)) as Channel<BootloaderRequestAction>;
);
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<BootloaderConnectionDidSendAction>(
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);
}
+7 -7
View File
@@ -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);
}
+21 -18
View File
@@ -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<NotificationContext>('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<NotificationContext>('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<NotificationContext>('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<NotificationContext>('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);
}
+10 -12
View File
@@ -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<StorageEvent> {
}
function* monitorLocalStorage(): Generator {
const chan = (yield call(
createLocalStorageEventChannel,
)) as EventChannel<StorageEvent>;
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);
}
+27 -31
View File
@@ -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<string>();
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<TerminalDataReceiveDataAction>(
TerminalActionType.ReceivedData,
)) as Channel<TerminalDataReceiveDataAction>;
);
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);
}