Files
pybricks-code/src/ble/sagas.ts
T
David Lechner 1d2c55eba6 ble/alerts: move old firmware from notifications
also add button to open new install firmware dialog
2022-07-20 17:26:08 -05:00

465 lines
15 KiB
TypeScript

// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
//
// Manages connection to a Bluetooth Low Energy device running Pybricks firmware.
// TODO: this file needs to be combined with the firmware BLE connection management
// to reduce duplicated code
import { firmwareVersion } from '@pybricks/firmware';
import { Task, buffers, eventChannel } from 'redux-saga';
import { satisfies } from 'semver';
import {
call,
cancel,
delay,
fork,
put,
select,
spawn,
take,
takeEvery,
} from 'typed-redux-saga/macro';
import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions';
import {
bleDIServiceDidReceiveFirmwareRevision,
bleDIServiceDidReceivePnPId,
bleDIServiceDidReceiveSoftwareRevision,
} from '../ble-device-info-service/actions';
import {
decodePnpId,
deviceInformationServiceUUID,
firmwareRevisionStringUUID,
pnpIdUUID,
softwareRevisionStringUUID,
} from '../ble-device-info-service/protocol';
import {
didFailToWrite as didFailToWriteUart,
didNotify as didNotifyUart,
didWrite as didWriteUart,
write as writeUart,
} from '../ble-nordic-uart-service/actions';
import {
nordicUartRxCharUUID,
nordicUartServiceUUID,
nordicUartTxCharUUID,
} from '../ble-nordic-uart-service/protocol';
import {
didFailToWriteCommand,
didNotifyEvent,
didWriteCommand,
writeCommand,
} from '../ble-pybricks-service/actions';
import {
pybricksControlCharacteristicUUID,
pybricksServiceUUID,
} from '../ble-pybricks-service/protocol';
import { firmwareInstallPybricks } from '../firmware/actions';
import { RootState } from '../reducers';
import { ensureError } from '../utils';
import { pythonVersionToSemver } from '../utils/version';
import {
bleConnectPybricks as bleConnectPybricks,
bleDidConnectPybricks,
bleDidDisconnectPybricks,
bleDidFailToConnectPybricks,
bleDisconnectPybricks,
toggleBluetooth,
} from './actions';
import { BleConnectionState } from './reducers';
const decoder = new TextDecoder();
function* handlePybricksControlValueChanged(data: DataView): Generator {
yield* put(didNotifyEvent(data));
}
function* handleWriteCommand(
char: BluetoothRemoteGATTCharacteristic,
action: ReturnType<typeof writeCommand>,
): Generator {
try {
yield* call(() => char.writeValueWithoutResponse(action.value.buffer));
yield* put(didWriteCommand(action.id));
} catch (err) {
yield* put(didFailToWriteCommand(action.id, ensureError(err)));
}
}
function* handleUartValueChanged(data: DataView): Generator {
yield* put(didNotifyUart(data));
}
function* handleWriteUart(
char: BluetoothRemoteGATTCharacteristic,
action: ReturnType<typeof writeUart>,
): Generator {
try {
yield* call(() => char.writeValueWithoutResponse(action.value.buffer));
yield* put(didWriteUart(action.id));
} catch (err) {
yield* put(didFailToWriteUart(action.id, ensureError(err)));
}
}
function* handleBleConnectPybricks(): Generator {
if (navigator.bluetooth === undefined) {
yield* put(alertsShowAlert('ble', 'noWebBluetooth'));
yield* put(bleDidFailToConnectPybricks());
return;
}
const available = yield* call(() => navigator.bluetooth.getAvailability());
if (!available) {
yield* put(alertsShowAlert('ble', 'bluetoothNotAvailable'));
yield* put(bleDidFailToConnectPybricks());
return;
}
// spawned tasks that will need to be canceled later
const tasks = new Array<Task>();
const defer = new Array<() => void>();
try {
const device = yield* call(() =>
navigator.bluetooth
.requestDevice({
filters: [{ services: [pybricksServiceUUID] }],
optionalServices: [
pybricksServiceUUID,
deviceInformationServiceUUID,
nordicUartServiceUUID,
],
})
.catch((err) => {
if (
err instanceof DOMException &&
err.code === DOMException.NOT_FOUND_ERR
) {
// this means the user clicked the cancel button in the scan dialog
return undefined;
}
throw err;
}),
);
if (!device) {
yield* put(alertsShowAlert('ble', 'noHub'));
yield* put(bleDidFailToConnectPybricks());
const { action } = yield* take<
ReturnType<typeof alertsDidShowAlert<'ble', 'noHub'>>
>(
alertsDidShowAlert.when(
(a) => a.domain === 'ble' && a.specific === 'noHub',
),
);
if (action === 'flashFirmware') {
yield* put(firmwareInstallPybricks());
}
return;
}
const gatt = device.gatt;
if (!gatt) {
yield* put(alertsShowAlert('ble', 'noGatt'));
yield* put(bleDidFailToConnectPybricks());
return;
}
const disconnectChannel = eventChannel<Event>((emit) => {
device.addEventListener('gattserverdisconnected', emit);
return (): void =>
device.removeEventListener('gattserverdisconnected', emit);
}, buffers.sliding(1));
defer.push(() => disconnectChannel.close());
const server = yield* call(() => gatt.connect());
defer.push(() => server.disconnect());
// istanbul ignore if
if (process.env.NODE_ENV !== 'test') {
// give OS Bluetooth stack some time to settle
yield* delay(1000);
}
const deviceInfoService = yield* call(() =>
server.getPrimaryService(deviceInformationServiceUUID).catch((err) => {
if (
err instanceof DOMException &&
err.code === DOMException.NOT_FOUND_ERR
) {
return undefined;
}
throw err;
}),
);
if (!deviceInfoService) {
yield* put(
alertsShowAlert('ble', 'missingService', {
serviceName: 'Device Information',
hubName: device.name || 'Pybricks Hub',
}),
);
yield* put(bleDidFailToConnectPybricks());
return;
}
const firmwareVersionChar = yield* call(() =>
deviceInfoService.getCharacteristic(firmwareRevisionStringUUID),
);
const firmwareRevision = decoder.decode(
yield* call(() => firmwareVersionChar.readValue()),
);
yield* put(bleDIServiceDidReceiveFirmwareRevision(firmwareRevision));
// notify user if old firmware
if (
satisfies(
pythonVersionToSemver(firmwareRevision),
`<${pythonVersionToSemver(firmwareVersion)}`,
)
) {
yield* put(alertsShowAlert('ble', 'oldFirmware'));
// initiate flashing firmware if user requested
const flashIfRequested = function* () {
const { action } = yield* take<
ReturnType<typeof alertsDidShowAlert<'ble', 'oldFirmware'>>
>(
alertsDidShowAlert.when(
(a) => a.domain === 'ble' && a.specific === 'oldFirmware',
),
);
if (action === 'flashFirmware') {
yield* put(firmwareInstallPybricks());
}
};
// have to spawn so that we don't block the task and it still works
// if parent task ends
yield* spawn(flashIfRequested);
}
const softwareVersionChar = yield* call(() =>
deviceInfoService.getCharacteristic(softwareRevisionStringUUID),
);
const softwareRevision = decoder.decode(
yield* call(() => softwareVersionChar.readValue()),
);
yield* put(bleDIServiceDidReceiveSoftwareRevision(softwareRevision));
const pnpIdChar = yield* call(() =>
deviceInfoService.getCharacteristic(pnpIdUUID).catch((err) => {
if (
err instanceof DOMException &&
err.code === DOMException.NOT_FOUND_ERR
) {
// istanbul ignore if
if (process.env.NODE_ENV !== 'test') {
console.warn(
'PnP ID characteristic requires Pybricks firmware v3.1.0a1 or later',
);
}
return undefined;
}
throw err;
}),
);
if (pnpIdChar) {
const pnpId = decodePnpId(yield* call(() => pnpIdChar.readValue()));
yield* put(bleDIServiceDidReceivePnPId(pnpId));
}
const pybricksService = yield* call(() =>
server.getPrimaryService(pybricksServiceUUID).catch((err) => {
if (
err instanceof DOMException &&
err.code === DOMException.NOT_FOUND_ERR
) {
return undefined;
}
throw err;
}),
);
if (!pybricksService) {
yield* put(
alertsShowAlert('ble', 'missingService', {
serviceName: 'Pybricks',
hubName: device.name || 'Pybricks Hub',
}),
);
yield* put(bleDidFailToConnectPybricks());
return;
}
const pybricksControlChar = yield* call(() =>
pybricksService.getCharacteristic(pybricksControlCharacteristicUUID),
);
const pybricksControlChannel = eventChannel<DataView>((emit) => {
const listener = (): void => {
if (!pybricksControlChar.value) {
return;
}
emit(pybricksControlChar.value);
};
pybricksControlChar.addEventListener(
'characteristicvaluechanged',
listener,
);
return (): void =>
pybricksControlChar.removeEventListener(
'characteristicvaluechanged',
listener,
);
});
defer.push(() => pybricksControlChannel.close());
tasks.push(
yield* takeEvery(pybricksControlChannel, handlePybricksControlValueChanged),
);
// REVISIT: possible Pybricks firmware bug (or chromium bug on Linux)
// where 'characteristicvaluechanged' is not called after disconnecting
// 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(() => pybricksControlChar.stopNotifications());
yield* call(() => pybricksControlChar.startNotifications());
tasks.push(
yield* takeEvery(writeCommand, handleWriteCommand, pybricksControlChar),
);
const uartService = yield* call(() =>
server.getPrimaryService(nordicUartServiceUUID).catch((err) => {
if (
err instanceof DOMException &&
err.code === DOMException.NOT_FOUND_ERR
) {
return undefined;
}
throw err;
}),
);
if (!uartService) {
yield* put(
alertsShowAlert('ble', 'missingService', {
serviceName: 'Nordic UART',
hubName: device.name || 'Pybricks Hub',
}),
);
yield* put(bleDidFailToConnectPybricks());
return;
}
const uartRxChar = yield* call(() =>
uartService.getCharacteristic(nordicUartRxCharUUID),
);
const uartTxChar = yield* call(() =>
uartService.getCharacteristic(nordicUartTxCharUUID),
);
const uartTxChannel = eventChannel<DataView>((emitter) => {
const listener = (): void => {
if (!uartTxChar.value) {
return;
}
emitter(uartTxChar.value);
};
uartTxChar.addEventListener('characteristicvaluechanged', listener);
return (): void =>
uartTxChar.removeEventListener('characteristicvaluechanged', listener);
});
defer.push(() => uartTxChannel.close());
tasks.push(yield* takeEvery(uartTxChannel, handleUartValueChanged));
// REVISIT: possible Pybricks firmware bug (or chromium bug on Linux)
// where 'characteristicvaluechanged' is not called after disconnecting
// 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(() => uartTxChar.stopNotifications());
yield* call(() => uartTxChar.startNotifications());
tasks.push(yield* takeEvery(writeUart, handleWriteUart, uartRxChar));
yield* put(bleDidConnectPybricks(device.id, device.name || ''));
const handleDisconnectRequest = function* (): Generator {
yield* take(bleDisconnectPybricks);
server.disconnect();
};
yield* fork(handleDisconnectRequest);
// wait for disconnection
yield* take(disconnectChannel);
yield* put(bleDidDisconnectPybricks());
} catch (err) {
// istanbul ignore if
if (process.env.NODE_ENV !== 'test') {
// log error so it can still be copied even if alert is closed
console.error(err);
}
yield* put(
alertsShowAlert('alerts', 'unexpectedError', {
error: ensureError(err),
}),
);
yield* put(bleDidFailToConnectPybricks());
} finally {
yield* cancel(tasks);
while (defer.length > 0) {
defer.pop()?.();
}
}
}
function* handleToggleBluetooth(): Generator {
const connectionState = (yield select(
(s: RootState) => s.ble.connection,
)) as BleConnectionState;
switch (connectionState) {
case BleConnectionState.Connected:
yield* put(bleDisconnectPybricks());
break;
case BleConnectionState.Disconnected:
yield* put(bleConnectPybricks());
break;
}
}
export default function* (): Generator {
yield* takeEvery(bleConnectPybricks, handleBleConnectPybricks);
yield* takeEvery(toggleBluetooth, handleToggleBluetooth);
}