diff --git a/src/ble-device-info-service/protocol.ts b/src/ble-device-info-service/protocol.ts new file mode 100644 index 00000000..f1517a86 --- /dev/null +++ b/src/ble-device-info-service/protocol.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors +// +// Pybricks uses the standard Device Info service. +// Refer to Device Information Service (DIS) at https://www.bluetooth.com/specifications/specs/ +// and assigned numbers at https://www.bluetooth.com/specifications/assigned-numbers/ + +/** Device Information service UUID. */ +export const serviceUUID = 0x180a; + +/** Firmware Revision String characteristic UUID. */ +export const firmwareRevisionStringUUID = 0x2a26; + +/** Software Revision String characteristic UUID. */ +export const softwareRevisionStringUUID = 0x2a28; diff --git a/src/ble-uart/sagas.ts b/src/ble-uart/sagas.ts index ce85d20d..a16e0d2c 100644 --- a/src/ble-uart/sagas.ts +++ b/src/ble-uart/sagas.ts @@ -15,6 +15,11 @@ import { takeEvery, takeMaybe, } from 'typed-redux-saga/macro'; +import { + serviceUUID as deviceInfoServiceUUID, + firmwareRevisionStringUUID, + softwareRevisionStringUUID, +} from '../ble-device-info-service/protocol'; import { BlePybricksServiceActionType, didFailToWriteCommand, @@ -53,6 +58,8 @@ import { TxCharUUID as uartTxCharUUID, } from './protocol'; +const decoder = new TextDecoder(); + function disconnect( server: BluetoothRemoteGATTServer, _action: BleDeviceDisconnectAction, @@ -109,7 +116,11 @@ function* connect(_action: BleDeviceConnectAction): Generator { device = yield* call(() => navigator.bluetooth.requestDevice({ filters: [{ services: [pybricksServiceUUID] }], - optionalServices: [pybricksServiceUUID, uartServiceUUID], + optionalServices: [ + pybricksServiceUUID, + deviceInfoServiceUUID, + uartServiceUUID, + ], }), ); } catch (err) { @@ -145,6 +156,79 @@ function* connect(_action: BleDeviceConnectAction): Generator { 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( @@ -155,7 +239,7 @@ function* connect(_action: BleDeviceConnectAction): Generator { server.disconnect(); yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - yield* put(didFailToConnect({ reason: Reason.NoService })); + yield* put(didFailToConnect({ reason: Reason.NoPybricksService })); } else { yield* put(didFailToConnect({ reason: Reason.Unknown, err })); } @@ -231,7 +315,7 @@ function* connect(_action: BleDeviceConnectAction): Generator { server.disconnect(); yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - yield* put(didFailToConnect({ reason: Reason.NoService })); + yield* put(didFailToConnect({ reason: Reason.NoPybricksService })); } else { yield* put(didFailToConnect({ reason: Reason.Unknown, err })); } diff --git a/src/ble/actions.ts b/src/ble/actions.ts index 0847fd7a..aa1a5ce6 100644 --- a/src/ble/actions.ts +++ b/src/ble/actions.ts @@ -58,7 +58,8 @@ export enum BleDeviceFailToConnectReasonType { NoBluetooth = 'ble.device.didFailToConnect.noBluetooth', Canceled = 'ble.device.didFailToConnect.canceled', NoGatt = 'ble.device.didFailToConnect.noGatt', - NoService = 'ble.device.didFailToConnect.noService', + NoDeviceInfoService = 'ble.device.didFailToConnect.noDeviceInfoService', + NoPybricksService = 'ble.device.didFailToConnect.noPybricksService', Unknown = 'ble.device.didFailToConnect.unknown', } @@ -74,7 +75,9 @@ export type BleDeviceFailToConnectCanceledReason = Reason; -export type BleDeviceFailToConnectNoServiceReason = Reason; +export type BleDeviceFailToConnectNoDeviceInfoServiceReason = Reason; + +export type BleDeviceFailToConnectNoPybricksServiceReason = Reason; export type BleDeviceFailToConnectUnknownReason = Reason & { err: Error; @@ -85,7 +88,8 @@ export type BleDeviceDidFailToConnectReason = | BleDeviceFailToConnectNoBluetoothReason | BleDeviceFailToConnectCanceledReason | BleDeviceFailToConnectNoGattReason - | BleDeviceFailToConnectNoServiceReason + | BleDeviceFailToConnectNoDeviceInfoServiceReason + | BleDeviceFailToConnectNoPybricksServiceReason | BleDeviceFailToConnectUnknownReason; export type BleDeviceDidFailToConnectAction = Action & diff --git a/src/notifications/i18n.en.json b/src/notifications/i18n.en.json index c1ab03e6..c7264b45 100644 --- a/src/notifications/i18n.en.json +++ b/src/notifications/i18n.en.json @@ -6,7 +6,7 @@ }, "ble": { "gattPermission": "The web browser did not give permission to use Bluetooth Low Energy", - "gattServiceNotFound": "Connected to hub but failed to get {serviceName} service. Try removing the \"{hubName}\" device in your OS Bluetooth settings, then try again.", + "gattServiceNotFound": "Connected to hub but failed to get {serviceName} service. Ensure that you are using the most recent firmware. If the problem persists, try removing the \"{hubName}\" device in your OS Bluetooth settings, then try connecting again.", "noWebBluetooth": "This web browser does not support Web Bluetooth or it is not enabled.", "noBluetooth": "No Bluetooth adapter could be found. Bluetooth won't work.", "unexpectedError": "Unexpected error while trying to connect: {errorMessage}" diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 125bc967..bf6157ea 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -31,7 +31,10 @@ test.each([ bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoWebBluetooth }), bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoBluetooth }), bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoGatt }), - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoService }), + bleDidFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, + }), + bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoPybricksService }), bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Unknown, err: { name: 'test', message: 'unknown' }, diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index acfce26a..74aa93fb 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -168,12 +168,18 @@ function* showBleDeviceDidFailToConnectError( yield* showSingleton(Level.Error, MessageId.BleGattPermission); break; - case BleDeviceFailToConnectReasonType.NoService: + case BleDeviceFailToConnectReasonType.NoPybricksService: yield* showSingleton(Level.Error, MessageId.BleGattServiceNotFound, { serviceName: 'Pybricks', hubName: 'Pybricks Hub', }); break; + case BleDeviceFailToConnectReasonType.NoDeviceInfoService: + yield* showSingleton(Level.Error, MessageId.BleGattServiceNotFound, { + serviceName: 'Device Information', + hubName: 'Pybricks Hub', + }); + break; case BleDeviceFailToConnectReasonType.NoBluetooth: yield* showSingleton(Level.Error, MessageId.BleNoBluetooth); break;