diff --git a/.eslintrc.js b/.eslintrc.js index fef7d592..c8eea72f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,15 +21,13 @@ module.exports = { 'no-multi-spaces': 'error', 'no-trailing-spaces': 'error', 'no-multiple-empty-lines': 'error', - '@typescript-eslint/no-unused-vars': ["error", { argsIgnorePattern: "^_" }], - 'no-unused-vars': ["error", { argsIgnorePattern: "^_" }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'sort-imports': ['error', { ignoreDeclarationSort: true }], 'import/order': ['error', { alphabetize: { order: 'asc' } }], }, settings: { react: { version: 'detect' }, }, - ignorePatterns: [ - "test/env.js" - ], + ignorePatterns: ['test/env.js'], }; diff --git a/package.json b/package.json index 1e5d9c8e..40105086 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pybricks/pybricks-code", - "version": "1.0.0-beta.7", + "version": "1.0.0-beta.8", "license": "MIT", "author": "The Pybricks Authors", "repository": { @@ -10,7 +10,7 @@ "dependencies": { "@blueprintjs/core": "^3.41.0", "@craco/craco": "^6.1.1", - "@pybricks/firmware": "4.7.0", + "@pybricks/firmware": "4.7.1", "@pybricks/ide-docs": "1.1.1", "@pybricks/mpy-cross-v5": "^2.0.0", "@shopify/react-i18n": "^5.3.0", 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/index.scss b/src/index.scss index 32230b79..f5c7cb43 100644 --- a/src/index.scss +++ b/src/index.scss @@ -37,6 +37,7 @@ body { .#{$ns}-toast { user-select: text; + max-width: 700px; } .#{$ns}-button { diff --git a/src/notifications/Notification.tsx b/src/notifications/Notification.tsx index 88ec7c6f..fc49da35 100644 --- a/src/notifications/Notification.tsx +++ b/src/notifications/Notification.tsx @@ -20,5 +20,20 @@ export default function Notification(props: OwnProps): JSX.Element { fallback: en, }); const { messageId, replacements } = props; - return <>{i18n.translate(messageId, replacements)}; + let message = i18n.translate(messageId, replacements) as + | React.ReactElement + | string; + + // Use newline characters to create paragraphs + if (typeof message === 'string') { + message = ( + <> + {message.split('\n').map((x, i) => ( +

{x}

+ ))} + + ); + } + + return message; } diff --git a/src/notifications/i18n.en.json b/src/notifications/i18n.en.json index c1ab03e6..d20fb9f5 100644 --- a/src/notifications/i18n.en.json +++ b/src/notifications/i18n.en.json @@ -6,14 +6,14 @@ }, "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.\nEnsure that you are using the most recent firmware.\nIf 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}" }, "editor": { "programChanged": { - "message": "The program was changed in another window. Do you want to delete this program and replace it with the new program?", + "message": "The program was changed in another window.\nDo you want to delete this program and replace it with the new program?", "action": "Reload" } }, 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..5ecb6b8d 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; @@ -310,7 +316,11 @@ function* dismissCompilerError(): Generator { function* showCompilerError(action: MpyDidFailToCompileAction): Generator { yield* showSingleton(Level.Error, MessageId.MpyError, { - errorMessage: React.createElement('pre', undefined, action.err.join('\n')), + errorMessage: React.createElement( + 'pre', + { style: { whiteSpace: 'pre-wrap', wordBreak: 'keep-all' } }, + action.err.join('\n'), + ), }); } diff --git a/yarn.lock b/yarn.lock index 99427465..c314ad6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1500,10 +1500,10 @@ schema-utils "^2.6.5" source-map "^0.7.3" -"@pybricks/firmware@4.7.0": - version "4.7.0" - resolved "https://registry.yarnpkg.com/@pybricks/firmware/-/firmware-4.7.0.tgz#5b3cdfa009dc65ac7d3e3ccd769ab74143a1bda6" - integrity sha512-Ln5puxxiEiWOv39AFja4Rr7OeAhxe/yJnWFI0IJXVnAPqFR9FIrME8rDZhPXyw5yIT76MMUuv6B21IJmA9WURw== +"@pybricks/firmware@4.7.1": + version "4.7.1" + resolved "https://registry.yarnpkg.com/@pybricks/firmware/-/firmware-4.7.1.tgz#2d800bfc9ce2096e6790a43393a5d0f8a795c94f" + integrity sha512-nsYNhN2vOkclujsmeXyaziFz3xoCeLElUm81lQahxCCFl+bYaqQtXqhXqQ/pkYTWq+5bcd5cG8u/Y49jpeZ57A== dependencies: jszip "^3.5.0"