Files
pybricks-code/src/lwp3-bootloader/sagas-ble.ts
T

198 lines
6.0 KiB
TypeScript

// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
//
// Handles Bluetooth Low Energy connection to LEGO Wireless Protocol v3 Bootloader service.
import { END, eventChannel } from 'redux-saga';
import {
call,
cancel,
delay,
put,
spawn,
takeEvery,
takeMaybe,
} from 'typed-redux-saga/macro';
import { alertsShowAlert } from '../alerts/actions';
import { ensureError } from '../utils';
import {
BootloaderConnectionFailureReason as Reason,
connect,
didConnect,
didDisconnect,
didFailToConnect,
didFailToSend,
didReceive,
didSend,
disconnect,
send,
} from './actions';
import {
lwp3BootloaderCharacteristicUUID,
lwp3BootloaderServiceUUID,
} from './protocol';
function* handleNotify(data: DataView): Generator {
yield* put(didReceive(data));
}
function* write(
characteristic: BluetoothRemoteGATTCharacteristic,
action: ReturnType<typeof send>,
): Generator {
try {
if (action.withResponse) {
yield* call(() => characteristic.writeValueWithResponse(action.data));
} else {
yield* call(() => characteristic.writeValueWithoutResponse(action.data));
}
yield* put(didSend());
} catch (err) {
yield* put(didFailToSend(ensureError(err)));
}
}
function* handleConnect(): Generator {
if (navigator.bluetooth === undefined) {
yield* put(alertsShowAlert('ble', 'noWebBluetooth'));
yield* put(didFailToConnect(Reason.NoWebBluetooth));
return;
}
const available = yield* call(() => navigator.bluetooth.getAvailability());
if (!available) {
yield* put(alertsShowAlert('ble', 'bluetoothNotAvailable'));
yield* put(didFailToConnect(Reason.NoBluetooth));
return;
}
let device: BluetoothDevice;
try {
device = yield* call(() =>
navigator.bluetooth.requestDevice({
filters: [{ services: [lwp3BootloaderServiceUUID] }],
optionalServices: [lwp3BootloaderServiceUUID],
}),
);
} catch (err) {
if (err instanceof DOMException && err.name === 'NotFoundError') {
// this can happen if the use cancels the dialog
yield* put(didFailToConnect(Reason.Canceled));
} else {
yield* put(didFailToConnect(Reason.Unknown, ensureError(err)));
}
return;
}
if (device.gatt === undefined) {
yield* put(
didFailToConnect(Reason.Unknown, new Error('Device does not support GATT')),
);
return;
}
const disconnectChannel = eventChannel((emitter) => {
const listener = (): void => emitter(END);
device.addEventListener('gattserverdisconnected', listener);
return (): void =>
device.removeEventListener('gattserverdisconnected', listener);
});
let server: BluetoothRemoteGATTServer;
try {
server = yield* call([device.gatt, 'connect']);
} catch (err) {
disconnectChannel.close();
yield* put(didFailToConnect(Reason.Unknown, ensureError(err)));
return;
}
// istanbul ignore if
if (process.env.NODE_ENV !== 'test') {
// give OS Bluetooth stack some time to settle
yield* delay(1000);
}
let service: BluetoothRemoteGATTService;
try {
service = yield* call([server, 'getPrimaryService'], lwp3BootloaderServiceUUID);
} catch (err) {
server.disconnect();
yield* takeMaybe(disconnectChannel);
if (err instanceof DOMException && err.name === 'NotFoundError') {
yield* put(didFailToConnect(Reason.GattServiceNotFound));
} else {
yield* put(didFailToConnect(Reason.Unknown, ensureError(err)));
}
return;
}
let characteristic: BluetoothRemoteGATTCharacteristic;
try {
characteristic = yield* call(
[service, 'getCharacteristic'],
lwp3BootloaderCharacteristicUUID,
);
} catch (err) {
server.disconnect();
yield* takeMaybe(disconnectChannel);
yield* put(didFailToConnect(Reason.Unknown, ensureError(err)));
return;
}
const notificationChannel = eventChannel<DataView>((emitter) => {
const listener = (): void => {
if (!characteristic.value) {
return;
}
emitter(characteristic.value);
};
characteristic.addEventListener('characteristicvaluechanged', listener);
return (): void =>
characteristic.removeEventListener('characteristicvaluechanged', listener);
});
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();
server.disconnect();
yield* takeMaybe(disconnectChannel);
yield* put(didFailToConnect(Reason.Unknown, ensureError(err)));
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: ReturnType<typeof send>): Generator {
yield* spawn(write, characteristic, action);
}
yield* takeEvery(notificationChannel, handleNotify);
yield* takeEvery(send, spawnWrite);
yield* takeEvery(disconnect, server.disconnect.bind(server));
yield* put(didConnect());
yield* takeMaybe(disconnectChannel);
notificationChannel.close();
try {
yield* cancel(); // have to cancel to stop forked effects
} finally {
yield* put(didDisconnect());
}
}
export default function* (): Generator {
yield* takeEvery(connect, handleConnect);
}