// SPDX-License-Identifier: MIT // Copyright (c) 2020-2021 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 { END, Task, eventChannel } from 'redux-saga'; import { call, cancel, put, select, takeEvery, takeMaybe, } from 'typed-redux-saga/macro'; import { serviceUUID as deviceInfoServiceUUID, firmwareRevisionStringUUID, softwareRevisionStringUUID, } from '../ble-device-info-service/protocol'; import { BlePybricksServiceActionType, didFailToWriteCommand, didNotifyEvent, didWriteCommand, } from '../ble-pybricks-service/actions'; import { ControlCharacteristicUUID as pybricksCommandCharacteristicUUID, ServiceUUID as pybricksServiceUUID, } from '../ble-pybricks-service/protocol'; import { BLEActionType, BleDeviceActionType as BLEDeviceActionType, BLEToggleAction, BleDeviceConnectAction, BleDeviceDisconnectAction, BleDeviceFailToConnectReasonType as Reason, connect as connectAction, didConnect, didDisconnect, didFailToConnect, disconnect as disconnectAction, } from '../ble/actions'; import { BleConnectionState } from '../ble/reducers'; import { RootState } from '../reducers'; import { BleUartActionType, BleUartWriteAction, didFailToWrite as didFailToWriteUart, didNotify as didNotifyUart, didWrite as didWriteUart, } from './actions'; import { RxCharUUID as uartRxCharUUID, ServiceUUID as uartServiceUUID, TxCharUUID as uartTxCharUUID, } from './protocol'; const decoder = new TextDecoder(); function disconnect( server: BluetoothRemoteGATTServer, _action: BleDeviceDisconnectAction, ): void { server.disconnect(); } function* handlePybricksControlValueChanged(data: DataView): Generator { yield* put(didNotifyEvent(data)); } function* writePybricksCommand( char: BluetoothRemoteGATTCharacteristic, action: BleUartWriteAction, ): Generator { try { yield* call(() => char.writeValueWithoutResponse(action.value.buffer)); yield* put(didWriteCommand(action.id)); } catch (err) { yield* put(didFailToWriteCommand(action.id, err)); } } function* handleUartValueChanged(data: DataView): Generator { yield* put(didNotifyUart(data)); } function* writeUart( char: BluetoothRemoteGATTCharacteristic, action: BleUartWriteAction, ): Generator { try { yield* call(() => char.writeValueWithoutResponse(action.value.buffer)); yield* put(didWriteUart(action.id)); } catch (err) { yield* put(didFailToWriteUart(action.id, err)); } } function* connect(_action: BleDeviceConnectAction): Generator { if (navigator.bluetooth === undefined) { yield* put(didFailToConnect({ reason: Reason.NoWebBluetooth })); return; } const available = yield* call(() => navigator.bluetooth.getAvailability()); if (!available) { yield* put(didFailToConnect({ reason: Reason.NoBluetooth })); return; } let device: BluetoothDevice; try { device = yield* call(() => navigator.bluetooth.requestDevice({ filters: [{ services: [pybricksServiceUUID] }], optionalServices: [ pybricksServiceUUID, deviceInfoServiceUUID, uartServiceUUID, ], }), ); } 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 })); } else { yield* put(didFailToConnect({ reason: Reason.Unknown, err })); } return; } if (device.gatt === undefined) { yield* put(didFailToConnect({ reason: Reason.NoGatt })); 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: Reason.Unknown, err })); return; } yield* takeEvery(BLEDeviceActionType.Disconnect, disconnect, server); let deviceInfoService: BluetoothRemoteGATTService; try { deviceInfoService = yield* call( [server, 'getPrimaryService'], deviceInfoServiceUUID, ); } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { yield* put(didFailToConnect({ reason: Reason.NoDeviceInfoService })); } else { yield* put(didFailToConnect({ reason: Reason.Unknown, err })); } return; } let firmwareVersionChar: BluetoothRemoteGATTCharacteristic; try { firmwareVersionChar = yield* call( [deviceInfoService, 'getCharacteristic'], firmwareRevisionStringUUID, ); } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } let firmwareVersion: string; try { firmwareVersion = decoder.decode( yield* call([firmwareVersionChar, 'readValue']), ); } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } // TODO: save firmware version for later use console.log(`Hub firmware version: ${firmwareVersion}`); let softwareVersionChar: BluetoothRemoteGATTCharacteristic; try { softwareVersionChar = yield* call( [deviceInfoService, 'getCharacteristic'], softwareRevisionStringUUID, ); } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } let protocolVersion: string; try { protocolVersion = decoder.decode( yield* call([softwareVersionChar, 'readValue']), ); } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } // TODO: verify that minimum protocol version is met console.log(`Pybricks protocol version: ${protocolVersion}`); let pybricksService: BluetoothRemoteGATTService; try { pybricksService = yield* call( [server, 'getPrimaryService'], pybricksServiceUUID, ); } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { yield* put(didFailToConnect({ reason: Reason.NoPybricksService })); } else { yield* put(didFailToConnect({ reason: Reason.Unknown, err })); } return; } let pybricksControlChar: BluetoothRemoteGATTCharacteristic; try { pybricksControlChar = yield* call( [pybricksService, 'getCharacteristic'], pybricksCommandCharacteristicUUID, ); } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } const pybricksControlChannel = eventChannel((emitter) => { const listener = (): void => { if (!pybricksControlChar.value) { return; } emitter(pybricksControlChar.value); }; pybricksControlChar.addEventListener('characteristicvaluechanged', listener); return (): void => pybricksControlChar.removeEventListener( 'characteristicvaluechanged', listener, ); }); // forked tasks that will need to be canceled later const tasks = new Array(); tasks.push( yield* takeEvery(pybricksControlChannel, handlePybricksControlValueChanged), ); try { // 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']); } catch (err) { yield* cancel(tasks); pybricksControlChannel.close(); server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } tasks.push( yield* takeEvery( BlePybricksServiceActionType.WriteCommand, writePybricksCommand, pybricksControlChar, ), ); let uartService: BluetoothRemoteGATTService; try { uartService = yield* call([server, 'getPrimaryService'], uartServiceUUID); } catch (err) { yield* cancel(tasks); pybricksControlChannel.close(); server.disconnect(); yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { yield* put(didFailToConnect({ reason: Reason.NoPybricksService })); } else { yield* put(didFailToConnect({ reason: Reason.Unknown, err })); } return; } let uartRxChar: BluetoothRemoteGATTCharacteristic; try { uartRxChar = yield* call([uartService, 'getCharacteristic'], uartRxCharUUID); } catch (err) { yield* cancel(tasks); pybricksControlChannel.close(); server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } let uartTxChar: BluetoothRemoteGATTCharacteristic; try { uartTxChar = yield* call([uartService, 'getCharacteristic'], uartTxCharUUID); } catch (err) { yield* cancel(tasks); pybricksControlChannel.close(); server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } const uartTxChannel = eventChannel((emitter) => { const listener = (): void => { if (!uartTxChar.value) { return; } emitter(uartTxChar.value); }; uartTxChar.addEventListener('characteristicvaluechanged', listener); return (): void => uartTxChar.removeEventListener('characteristicvaluechanged', listener); }); tasks.push(yield* takeEvery(uartTxChannel, handleUartValueChanged)); try { // 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']); } catch (err) { yield* cancel(tasks); uartTxChannel.close(); pybricksControlChannel.close(); server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put(didFailToConnect({ reason: Reason.Unknown, err })); return; } tasks.push(yield* takeEvery(BleUartActionType.Write, writeUart, uartRxChar)); yield* put(didConnect()); // wait for disconnection yield* takeMaybe(disconnectChannel); yield* cancel(tasks); uartTxChannel.close(); pybricksControlChannel.close(); yield* put(didDisconnect()); } function* toggle(_action: BLEToggleAction): Generator { const connectionState = (yield select( (s: RootState) => s.ble.connection, )) as BleConnectionState; switch (connectionState) { case BleConnectionState.Connected: yield* put(disconnectAction()); break; case BleConnectionState.Disconnected: yield* put(connectAction()); break; } } export default function* (): Generator { yield* takeEvery(BLEDeviceActionType.Connect, connect); yield* takeEvery(BLEActionType.Toggle, toggle); }