From af2386178dcdc1f3f2614fa8daacfa4510f821fa Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jul 2022 18:15:34 -0500 Subject: [PATCH 01/26] ble/sagas: add basic test This gets about 50% coverage of the ble sagas. --- src/ble-device-info-service/protocol.test.ts | 4 +- src/ble/sagas.test.ts | 359 +++++++++++++++++++ 2 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 src/ble/sagas.test.ts diff --git a/src/ble-device-info-service/protocol.test.ts b/src/ble-device-info-service/protocol.test.ts index 33237599..8b82309b 100644 --- a/src/ble-device-info-service/protocol.test.ts +++ b/src/ble-device-info-service/protocol.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { HubType, @@ -8,7 +8,7 @@ import { } from '../ble-lwp3-service/protocol'; import { decodePnpId, getHubTypeName } from './protocol'; -function encodeInfo(id: HubType, variant?: number) { +export function encodeInfo(id: HubType, variant?: number) { return new DataView( new Uint8Array([ 1, // Bluetooth SIG diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts new file mode 100644 index 00000000..dbadcc6c --- /dev/null +++ b/src/ble/sagas.test.ts @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { HubType } from '@pybricks/firmware'; +import { MockProxy, mock } from 'jest-mock-extended'; +import { AsyncSaga } from '../../test'; +import { + bleDIServiceDidReceiveFirmwareRevision, + bleDIServiceDidReceivePnPId, + bleDIServiceDidReceiveSoftwareRevision, +} from '../ble-device-info-service/actions'; +import { + serviceUUID as deviceInfoServiceUUID, + firmwareRevisionStringUUID, + pnpIdUUID, + softwareRevisionStringUUID, +} from '../ble-device-info-service/protocol'; +import { encodeInfo } from '../ble-device-info-service/protocol.test'; +import { + RxCharUUID as uartRxCharUUID, + ServiceUUID as uartServiceUUID, + TxCharUUID as uartTxCharUUID, +} from '../ble-nordic-uart-service/protocol'; +import { + ControlCharacteristicUUID as pybricksCommandCharacteristicUUID, + ServiceUUID as pybricksServiceUUID, +} from '../ble-pybricks-service/protocol'; +import { + BleDeviceFailToConnectReasonType, + connect, + didConnect, + didFailToConnect, +} from './actions'; +import ble from './sagas'; + +const encoder = new TextEncoder(); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('connect action is dispatched', () => { + let saga: AsyncSaga; + + beforeEach(() => { + saga = new AsyncSaga(ble); + }); + + it('should fail if no web bluetooth', async () => { + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoWebBluetooth, + }), + ); + }); + + describe('has web bluetooth', () => { + beforeEach(() => { + navigator.bluetooth = mock(); + }); + + it('should fail if bluetooth is not available', async () => { + jest.spyOn(navigator.bluetooth, 'getAvailability').mockResolvedValue(false); + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoBluetooth, + }), + ); + }); + + describe('bluetooth is available', () => { + beforeEach(() => { + jest.spyOn(navigator.bluetooth, 'getAvailability').mockResolvedValue( + true, + ); + }); + + it('should fail if user canceled', async () => { + jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( + new DOMException('test error', 'NotFoundError'), + ); + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Canceled, + }), + ); + }); + + it('should fail on other exception', async () => { + const testError = new DOMException('test error', 'SecurityError'); + jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( + testError, + ); + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + }); + + describe('device found', () => { + let device: MockProxy; + + beforeEach(() => { + const deviceEvents = new EventTarget(); + + device = mock({ + id: 'test-id', + name: 'test name', + gatt: undefined, + addEventListener: deviceEvents.addEventListener.bind( + deviceEvents, + ) as BluetoothDevice['addEventListener'], + removeEventListener: + deviceEvents.removeEventListener.bind(deviceEvents), + dispatchEvent: deviceEvents.dispatchEvent.bind(deviceEvents), + }); + + jest.spyOn(navigator.bluetooth, 'requestDevice').mockResolvedValue( + device, + ); + }); + + it('should fail if no gatt', async () => { + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoGatt, + }), + ); + }); + + describe('has gatt', () => { + let gatt: MockProxy; + + beforeEach(() => { + gatt = mock(); + Object.defineProperty(device, 'gatt', { value: gatt }); + }); + + it('should fail if gatt connect fails', async () => { + const testError = new DOMException( + 'test error', + 'NetworkError', + ); + gatt.connect.mockRejectedValue(testError); + + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + }); + + describe('gatt connect succeeded', () => { + beforeEach(() => { + gatt.connect.mockResolvedValue(gatt); + gatt.disconnect.mockImplementation(() => { + setTimeout(() => { + device.dispatchEvent( + new Event('gattserverdisconnected'), + ); + }, 10); + }); + }); + + it('should fail if device does not have device info service', async () => { + const testError = new DOMException( + 'test error', + 'NotFoundError', + ); + gatt.getPrimaryService.mockRejectedValue(testError); + + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, + }), + ); + + expect(gatt.disconnect).toHaveBeenCalled(); + }); + + describe('has device info service', () => { + let deviceInfoService: MockProxy; + + beforeEach(() => { + deviceInfoService = mock(); + gatt.getPrimaryService + .calledWith(deviceInfoServiceUUID) + .mockResolvedValue(deviceInfoService); + }); + + it('should fail if getting firmware version characteristic fails', async () => { + const testError = new Error('test error'); + deviceInfoService.getCharacteristic.mockRejectedValue( + testError, + ); + + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(gatt.disconnect).toHaveBeenCalled(); + }); + + describe('has firmware version', () => { + let firmwareRevisionChar: MockProxy; + let softwareRevisionChar: MockProxy; + let pnpIdChar: MockProxy; + let pybricksService: MockProxy; + let pybricksChar: MockProxy; + let uartService: MockProxy; + let uartRxChar: MockProxy; + let uartTxChar: MockProxy; + + beforeEach(() => { + firmwareRevisionChar = + mock(); + firmwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('3.2.0b2').buffer), + ); + + softwareRevisionChar = + mock(); + softwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('1.1.0').buffer), + ); + + pnpIdChar = + mock(); + pnpIdChar.readValue.mockResolvedValue( + new DataView( + encodeInfo(HubType.TechnicHub).buffer, + ), + ); + + deviceInfoService.getCharacteristic + .calledWith(firmwareRevisionStringUUID) + .mockResolvedValue(firmwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(softwareRevisionStringUUID) + .mockResolvedValue(softwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(pnpIdUUID) + .mockResolvedValue(pnpIdChar); + + pybricksService = + mock(); + + gatt.getPrimaryService + .calledWith(pybricksServiceUUID) + .mockResolvedValue(pybricksService); + + const pybricksCharEventTarget = new EventTarget(); + pybricksChar = + mock({ + addEventListener: + pybricksCharEventTarget.addEventListener.bind( + pybricksCharEventTarget, + ), + removeEventListener: + pybricksCharEventTarget.removeEventListener.bind( + pybricksCharEventTarget, + ), + dispatchEvent: + pybricksCharEventTarget.dispatchEvent.bind( + pybricksCharEventTarget, + ), + }); + pybricksChar.startNotifications.mockResolvedValue( + pybricksChar, + ); + pybricksChar.stopNotifications.mockResolvedValue( + pybricksChar, + ); + + pybricksService.getCharacteristic + .calledWith(pybricksCommandCharacteristicUUID) + .mockResolvedValue(pybricksChar); + + uartService = mock(); + + gatt.getPrimaryService + .calledWith(uartServiceUUID) + .mockResolvedValue(uartService); + + uartRxChar = + mock(); + uartService.getCharacteristic + .calledWith(uartRxCharUUID) + .mockResolvedValue(uartRxChar); + + uartTxChar = + mock(); + uartService.getCharacteristic + .calledWith(uartTxCharUUID) + .mockResolvedValue(uartTxChar); + }); + + it('should put didConnection action', async () => { + saga.put(connect()); + + // TODO: there are a bunch of untested failure paths + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveFirmwareRevision( + '3.2.0b2', + ), + ); + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveSoftwareRevision('1.1.0'), + ); + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceivePnPId({ + productId: 0x80, + productVersion: 0, + vendorId: 919, + vendorIdSource: 1, + }), + ); + + await expect(saga.take()).resolves.toEqual( + didConnect('test-id', 'test name'), + ); + }); + }); + }); + }); + }); + }); + }); + }); + + afterEach(async () => { + await saga.end(); + }); +}); From 25e86c2f602cc1f8f4e31f5b82aa4ce5ea78183f Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jul 2022 20:43:29 -0500 Subject: [PATCH 02/26] ble/sagas: flatten tests This removes much of the nesting in tests to make the code easier to read. Also we can now add more coverage for all of the skipped error paths. --- ...t-mock-extended-npm-2.0.6-86ec410111.patch | 39 + package.json | 3 +- src/ble/sagas.test.ts | 744 ++++++++++++------ yarn.lock | 14 +- 4 files changed, 562 insertions(+), 238 deletions(-) create mode 100644 .yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch diff --git a/.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch b/.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch new file mode 100644 index 00000000..2f6f4e7c --- /dev/null +++ b/.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch @@ -0,0 +1,39 @@ +diff --git a/lib/CalledWithFn.js b/lib/CalledWithFn.js +index 56674799caceb24951e71ab00f20eb53a52c5b6c..1aec49f8c67eb840dba54e937fe49f66a9c13872 100644 +--- a/lib/CalledWithFn.js ++++ b/lib/CalledWithFn.js +@@ -30,7 +30,7 @@ const calledWithFn = () => { + fn.mockImplementation((...args) => checkCalledWith(calledWithStack, args)); + calledWithStack = []; + } +- calledWithStack.push({ args, calledWithFn }); ++ calledWithStack.unshift({ args, calledWithFn }); + return calledWithFn; + }; + return fn; +diff --git a/lib/cjs/CalledWithFn.js b/lib/cjs/CalledWithFn.js +index 0cdfc48c26a86f4372e19b8abc2cd3c2dd32f357..d374b9bbe2d93ed27f2ea8d8443338dde970a33a 100644 +--- a/lib/cjs/CalledWithFn.js ++++ b/lib/cjs/CalledWithFn.js +@@ -30,7 +30,7 @@ const calledWithFn = () => { + fn.mockImplementation((...args) => checkCalledWith(calledWithStack, args)); + calledWithStack = []; + } +- calledWithStack.push({ args, calledWithFn }); ++ calledWithStack.unshift({ args, calledWithFn }); + return calledWithFn; + }; + return fn; +diff --git a/lib/mjs/CalledWithFn.js b/lib/mjs/CalledWithFn.js +index 4c90aeb893cf4c11cacc386d08aea6147cc8f9e1..5c4677c2d088392a067d94742415237f539b77f1 100644 +--- a/lib/mjs/CalledWithFn.js ++++ b/lib/mjs/CalledWithFn.js +@@ -27,7 +27,7 @@ export const calledWithFn = () => { + fn.mockImplementation((...args) => checkCalledWith(calledWithStack, args)); + calledWithStack = []; + } +- calledWithStack.push({ args, calledWithFn }); ++ calledWithStack.unshift({ args, calledWithFn }); + return calledWithFn; + }; + return fn; diff --git a/package.json b/package.json index 7fbcdfbd..a9299c6a 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,8 @@ "resolutions": { "mq-polyfill@1.1.8": "patch:mq-polyfill@npm:1.1.8#.yarn/patches/mq-polyfill-npm-1.1.8-62fe162439.patch", "react-error-overlay": "6.0.9", - "react-dev-utils@^12.0.1": "patch:react-dev-utils@npm:12.0.1#.yarn/patches/react-dev-utils-npm-12.0.1-83ba06e3ee.patch" + "react-dev-utils@^12.0.1": "patch:react-dev-utils@npm:12.0.1#.yarn/patches/react-dev-utils-npm-12.0.1-83ba06e3ee.patch", + "jest-mock-extended@^2.0.6": "patch:jest-mock-extended@npm:2.0.6#.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch" }, "jest": { "roots": [ diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts index dbadcc6c..8f3eb3e2 100644 --- a/src/ble/sagas.test.ts +++ b/src/ble/sagas.test.ts @@ -29,8 +29,12 @@ import { BleDeviceFailToConnectReasonType, connect, didConnect, + didDisconnect, didFailToConnect, + disconnect, + toggleBluetooth, } from './actions'; +import { BleConnectionState } from './reducers'; import ble from './sagas'; const encoder = new TextEncoder(); @@ -39,6 +43,192 @@ afterEach(() => { jest.clearAllMocks(); }); +type Mocks = { + bluetooth: MockProxy; + device: MockProxy; + gatt: MockProxy; + deviceInfoService: MockProxy; + firmwareRevisionChar: MockProxy; + softwareRevisionChar: MockProxy; + pnpIdChar: MockProxy; + pybricksService: MockProxy; + pybricksChar: MockProxy; + uartService: MockProxy; + uartRxChar: MockProxy; + uartTxChar: MockProxy; +}; + +/** + * Creates mocks used in connect tests. + */ +function createMocks(): Mocks { + const firmwareRevisionChar = mock(); + firmwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('3.2.0b2').buffer), + ); + + const softwareRevisionChar = mock(); + softwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('1.1.0').buffer), + ); + + const pnpIdChar = mock(); + pnpIdChar.readValue.mockResolvedValue( + new DataView(encodeInfo(HubType.TechnicHub).buffer), + ); + + const deviceInfoService = mock(); + deviceInfoService.getCharacteristic + .calledWith(firmwareRevisionStringUUID) + .mockResolvedValue(firmwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(softwareRevisionStringUUID) + .mockResolvedValue(softwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(pnpIdUUID) + .mockResolvedValue(pnpIdChar); + + const pybricksCharEventTarget = new EventTarget(); + const pybricksChar = mock({ + addEventListener: pybricksCharEventTarget.addEventListener.bind( + pybricksCharEventTarget, + ), + removeEventListener: pybricksCharEventTarget.removeEventListener.bind( + pybricksCharEventTarget, + ), + dispatchEvent: pybricksCharEventTarget.dispatchEvent.bind( + pybricksCharEventTarget, + ), + }); + pybricksChar.startNotifications.mockResolvedValue(pybricksChar); + pybricksChar.stopNotifications.mockResolvedValue(pybricksChar); + + const pybricksService = mock(); + pybricksService.getCharacteristic + .calledWith(pybricksCommandCharacteristicUUID) + .mockResolvedValue(pybricksChar); + + const uartRxChar = mock(); + + const uartTxCharEventTarget = new EventTarget(); + const uartTxChar = mock({ + addEventListener: + uartTxCharEventTarget.addEventListener.bind(uartTxCharEventTarget), + removeEventListener: + uartTxCharEventTarget.removeEventListener.bind(uartTxCharEventTarget), + dispatchEvent: uartTxCharEventTarget.dispatchEvent.bind(uartTxCharEventTarget), + }); + + const uartService = mock(); + uartService.getCharacteristic + .calledWith(uartRxCharUUID) + .mockResolvedValue(uartRxChar); + uartService.getCharacteristic + .calledWith(uartTxCharUUID) + .mockResolvedValue(uartTxChar); + + const gatt = mock(); + gatt.connect.mockResolvedValue(gatt); + gatt.disconnect.mockImplementation(() => { + setTimeout(() => { + device.dispatchEvent(new Event('gattserverdisconnected')); + }, 10); + }); + gatt.getPrimaryService + .calledWith(deviceInfoServiceUUID) + .mockResolvedValue(deviceInfoService); + gatt.getPrimaryService + .calledWith(pybricksServiceUUID) + .mockResolvedValue(pybricksService); + gatt.getPrimaryService.calledWith(uartServiceUUID).mockResolvedValue(uartService); + + const deviceEvents = new EventTarget(); + const device = mock({ + id: 'test-id', + name: 'test name', + gatt, + addEventListener: deviceEvents.addEventListener.bind( + deviceEvents, + ) as BluetoothDevice['addEventListener'], + removeEventListener: deviceEvents.removeEventListener.bind(deviceEvents), + dispatchEvent: deviceEvents.dispatchEvent.bind(deviceEvents), + }); + + const bluetooth = mock(); + bluetooth.getAvailability.mockResolvedValue(true); + bluetooth.requestDevice.mockResolvedValue(device); + + return { + bluetooth, + device, + gatt, + deviceInfoService, + firmwareRevisionChar, + softwareRevisionChar, + pnpIdChar, + pybricksService, + pybricksChar, + uartService, + uartRxChar, + uartTxChar, + }; +} + +enum ConnectRunPoint { + Connect, + DidReceiveFirmwareRevision, + DidReceiveSoftwareRevision, + DidReceivePnpId, + DidConnect, +} + +/** + * Run the "success" path of the connect saga until a given point. + * + * This helps avoid duplicate code in tests. + * + * @param saga The saga. + * @param point The point at which to stop running. + */ +async function runConnectUntil(saga: AsyncSaga, point: ConnectRunPoint): Promise { + saga.put(connect()); + + if (point === ConnectRunPoint.Connect) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveFirmwareRevision('3.2.0b2'), + ); + + if (point === ConnectRunPoint.DidReceiveFirmwareRevision) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveSoftwareRevision('1.1.0'), + ); + + if (point === ConnectRunPoint.DidReceiveSoftwareRevision) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceivePnPId({ + productId: 0x80, + productVersion: 0, + vendorId: 919, + vendorIdSource: 1, + }), + ); + + if (point === ConnectRunPoint.DidReceivePnpId) { + return; + } + + await expect(saga.take()).resolves.toEqual(didConnect('test-id', 'test name')); +} + describe('connect action is dispatched', () => { let saga: AsyncSaga; @@ -47,7 +237,7 @@ describe('connect action is dispatched', () => { }); it('should fail if no web bluetooth', async () => { - saga.put(connect()); + await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( didFailToConnect({ @@ -57,13 +247,16 @@ describe('connect action is dispatched', () => { }); describe('has web bluetooth', () => { + let mocks: Mocks; beforeEach(() => { - navigator.bluetooth = mock(); + mocks = createMocks(); + navigator.bluetooth = mocks.bluetooth; }); it('should fail if bluetooth is not available', async () => { jest.spyOn(navigator.bluetooth, 'getAvailability').mockResolvedValue(false); - saga.put(connect()); + + await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( didFailToConnect({ @@ -72,284 +265,341 @@ describe('connect action is dispatched', () => { ); }); - describe('bluetooth is available', () => { - beforeEach(() => { - jest.spyOn(navigator.bluetooth, 'getAvailability').mockResolvedValue( - true, - ); - }); + it('should fail if user canceled requestDevice', async () => { + jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( + new DOMException('test error', 'NotFoundError'), + ); - it('should fail if user canceled', async () => { - jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( - new DOMException('test error', 'NotFoundError'), - ); - saga.put(connect()); + await runConnectUntil(saga, ConnectRunPoint.Connect); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Canceled, - }), - ); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Canceled, + }), + ); + }); - it('should fail on other exception', async () => { - const testError = new DOMException('test error', 'SecurityError'); - jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( - testError, - ); - saga.put(connect()); + it('should fail on other exception in requestDevice', async () => { + const testError = new DOMException('test error', 'SecurityError'); + jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( + testError, + ); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Unknown, - err: testError, - }), - ); - }); + await runConnectUntil(saga, ConnectRunPoint.Connect); - describe('device found', () => { - let device: MockProxy; + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + }); - beforeEach(() => { - const deviceEvents = new EventTarget(); + it('should fail if device has no gatt property', async () => { + Object.defineProperty(mocks.device, 'gatt', { value: undefined }); - device = mock({ - id: 'test-id', - name: 'test name', - gatt: undefined, - addEventListener: deviceEvents.addEventListener.bind( - deviceEvents, - ) as BluetoothDevice['addEventListener'], - removeEventListener: - deviceEvents.removeEventListener.bind(deviceEvents), - dispatchEvent: deviceEvents.dispatchEvent.bind(deviceEvents), - }); + await runConnectUntil(saga, ConnectRunPoint.Connect); - jest.spyOn(navigator.bluetooth, 'requestDevice').mockResolvedValue( - device, - ); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoGatt, + }), + ); + }); - it('should fail if no gatt', async () => { - saga.put(connect()); + it('should fail if gatt connect fails', async () => { + const testError = new DOMException('test error', 'NetworkError'); + mocks.gatt.connect.mockRejectedValueOnce(testError); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.NoGatt, - }), - ); - }); + await runConnectUntil(saga, ConnectRunPoint.Connect); - describe('has gatt', () => { - let gatt: MockProxy; + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + }); - beforeEach(() => { - gatt = mock(); - Object.defineProperty(device, 'gatt', { value: gatt }); - }); + it('should fail if device does not have device info service', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.gatt.getPrimaryService + .calledWith(deviceInfoServiceUUID) + .mockRejectedValueOnce(testError); - it('should fail if gatt connect fails', async () => { - const testError = new DOMException( - 'test error', - 'NetworkError', - ); - gatt.connect.mockRejectedValue(testError); + await runConnectUntil(saga, ConnectRunPoint.Connect); - saga.put(connect()); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, + }), + ); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Unknown, - err: testError, - }), - ); - }); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - describe('gatt connect succeeded', () => { - beforeEach(() => { - gatt.connect.mockResolvedValue(gatt); - gatt.disconnect.mockImplementation(() => { - setTimeout(() => { - device.dispatchEvent( - new Event('gattserverdisconnected'), - ); - }, 10); - }); - }); + it('should fail if getting firmware revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.deviceInfoService.getCharacteristic + .calledWith(firmwareRevisionStringUUID) + .mockRejectedValue(testError); - it('should fail if device does not have device info service', async () => { - const testError = new DOMException( - 'test error', - 'NotFoundError', - ); - gatt.getPrimaryService.mockRejectedValue(testError); + await runConnectUntil(saga, ConnectRunPoint.Connect); - saga.put(connect()); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, - }), - ); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - expect(gatt.disconnect).toHaveBeenCalled(); - }); + it('should fail if reading firmware revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.firmwareRevisionChar.readValue.mockRejectedValue(testError); - describe('has device info service', () => { - let deviceInfoService: MockProxy; + await runConnectUntil(saga, ConnectRunPoint.Connect); - beforeEach(() => { - deviceInfoService = mock(); - gatt.getPrimaryService - .calledWith(deviceInfoServiceUUID) - .mockResolvedValue(deviceInfoService); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - it('should fail if getting firmware version characteristic fails', async () => { - const testError = new Error('test error'); - deviceInfoService.getCharacteristic.mockRejectedValue( - testError, - ); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - saga.put(connect()); + it('should fail if getting software revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.deviceInfoService.getCharacteristic + .calledWith(softwareRevisionStringUUID) + .mockRejectedValueOnce(testError); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Unknown, - err: testError, - }), - ); + await runConnectUntil(saga, ConnectRunPoint.DidReceiveFirmwareRevision); - expect(gatt.disconnect).toHaveBeenCalled(); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - describe('has firmware version', () => { - let firmwareRevisionChar: MockProxy; - let softwareRevisionChar: MockProxy; - let pnpIdChar: MockProxy; - let pybricksService: MockProxy; - let pybricksChar: MockProxy; - let uartService: MockProxy; - let uartRxChar: MockProxy; - let uartTxChar: MockProxy; + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - beforeEach(() => { - firmwareRevisionChar = - mock(); - firmwareRevisionChar.readValue.mockResolvedValue( - new DataView(encoder.encode('3.2.0b2').buffer), - ); + it('should fail if reading software revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.softwareRevisionChar.readValue.mockRejectedValue(testError); - softwareRevisionChar = - mock(); - softwareRevisionChar.readValue.mockResolvedValue( - new DataView(encoder.encode('1.1.0').buffer), - ); + await runConnectUntil(saga, ConnectRunPoint.DidReceiveFirmwareRevision); - pnpIdChar = - mock(); - pnpIdChar.readValue.mockResolvedValue( - new DataView( - encodeInfo(HubType.TechnicHub).buffer, - ), - ); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - deviceInfoService.getCharacteristic - .calledWith(firmwareRevisionStringUUID) - .mockResolvedValue(firmwareRevisionChar); - deviceInfoService.getCharacteristic - .calledWith(softwareRevisionStringUUID) - .mockResolvedValue(softwareRevisionChar); - deviceInfoService.getCharacteristic - .calledWith(pnpIdUUID) - .mockResolvedValue(pnpIdChar); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - pybricksService = - mock(); + it('should skip bleDIServiceDidReceivePnPId action if getting pnp id characteristic fails', async () => { + const testError = new Error('test error'); + mocks.deviceInfoService.getCharacteristic + .calledWith(pnpIdUUID) + .mockRejectedValueOnce(testError); - gatt.getPrimaryService - .calledWith(pybricksServiceUUID) - .mockResolvedValue(pybricksService); + await runConnectUntil(saga, ConnectRunPoint.DidReceiveSoftwareRevision); - const pybricksCharEventTarget = new EventTarget(); - pybricksChar = - mock({ - addEventListener: - pybricksCharEventTarget.addEventListener.bind( - pybricksCharEventTarget, - ), - removeEventListener: - pybricksCharEventTarget.removeEventListener.bind( - pybricksCharEventTarget, - ), - dispatchEvent: - pybricksCharEventTarget.dispatchEvent.bind( - pybricksCharEventTarget, - ), - }); - pybricksChar.startNotifications.mockResolvedValue( - pybricksChar, - ); - pybricksChar.stopNotifications.mockResolvedValue( - pybricksChar, - ); + await expect(saga.take()).resolves.toEqual( + didConnect('test-id', 'test name'), + ); + }); - pybricksService.getCharacteristic - .calledWith(pybricksCommandCharacteristicUUID) - .mockResolvedValue(pybricksChar); + it('should fail if reading pnp id characteristic fails', async () => { + const testError = new Error('test error'); + mocks.pnpIdChar.readValue.mockRejectedValue(testError); - uartService = mock(); + await runConnectUntil(saga, ConnectRunPoint.DidReceiveSoftwareRevision); - gatt.getPrimaryService - .calledWith(uartServiceUUID) - .mockResolvedValue(uartService); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - uartRxChar = - mock(); - uartService.getCharacteristic - .calledWith(uartRxCharUUID) - .mockResolvedValue(uartRxChar); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - uartTxChar = - mock(); - uartService.getCharacteristic - .calledWith(uartTxCharUUID) - .mockResolvedValue(uartTxChar); - }); + it('should fail if device does not have pybricks service', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.gatt.getPrimaryService + .calledWith(pybricksServiceUUID) + .mockRejectedValueOnce(testError); - it('should put didConnection action', async () => { - saga.put(connect()); + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); - // TODO: there are a bunch of untested failure paths + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoPybricksService, + }), + ); - await expect(saga.take()).resolves.toEqual( - bleDIServiceDidReceiveFirmwareRevision( - '3.2.0b2', - ), - ); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - await expect(saga.take()).resolves.toEqual( - bleDIServiceDidReceiveSoftwareRevision('1.1.0'), - ); + it('should fail if getting pybricks characteristic fails', async () => { + const testError = new Error('test error'); + mocks.pybricksService.getCharacteristic + .calledWith(pybricksCommandCharacteristicUUID) + .mockRejectedValue(testError); - await expect(saga.take()).resolves.toEqual( - bleDIServiceDidReceivePnPId({ - productId: 0x80, - productVersion: 0, - vendorId: 919, - vendorIdSource: 1, - }), - ); + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); - await expect(saga.take()).resolves.toEqual( - didConnect('test-id', 'test name'), - ); - }); - }); - }); - }); - }); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if stopping pybricks characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.pybricksChar.stopNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if starting pybricks characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.pybricksChar.startNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if device does not have nordic uart service', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.gatt.getPrimaryService + .calledWith(uartServiceUUID) + .mockRejectedValueOnce(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + // FIXME: this is wrong error + reason: BleDeviceFailToConnectReasonType.NoPybricksService, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if getting nordic uart rx characteristic fails', async () => { + const testError = new Error('test error'); + mocks.uartService.getCharacteristic + .calledWith(uartRxCharUUID) + .mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if getting nordic uart tx characteristic fails', async () => { + const testError = new Error('test error'); + mocks.uartService.getCharacteristic + .calledWith(uartTxCharUUID) + .mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if stopping nordic uart tx characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.uartTxChar.stopNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if starting nordic uart tx characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.uartTxChar.startNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should put didConnection action', async () => { + await runConnectUntil(saga, ConnectRunPoint.DidConnect); + }); + + it('should handle disconnect', async () => { + await runConnectUntil(saga, ConnectRunPoint.DidConnect); + + saga.put(disconnect()); + + await expect(saga.take()).resolves.toEqual(didDisconnect()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); }); }); @@ -357,3 +607,25 @@ describe('connect action is dispatched', () => { await saga.end(); }); }); + +describe('toggleBluetooth action', () => { + it('should connect when disconnected', async () => { + const saga = new AsyncSaga(ble); + + saga.updateState({ ble: { connection: BleConnectionState.Disconnected } }); + + saga.put(toggleBluetooth()); + + await expect(saga.take()).resolves.toEqual(connect()); + }); + + it('should disconnect when connected', async () => { + const saga = new AsyncSaga(ble); + + saga.updateState({ ble: { connection: BleConnectionState.Connected } }); + + saga.put(toggleBluetooth()); + + await expect(saga.take()).resolves.toEqual(disconnect()); + }); +}); diff --git a/yarn.lock b/yarn.lock index 76cd5630..db1f1ded 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9681,7 +9681,7 @@ __metadata: languageName: node linkType: hard -"jest-mock-extended@npm:^2.0.6": +"jest-mock-extended@npm:2.0.6": version: 2.0.6 resolution: "jest-mock-extended@npm:2.0.6" dependencies: @@ -9693,6 +9693,18 @@ __metadata: languageName: node linkType: hard +"jest-mock-extended@patch:jest-mock-extended@npm:2.0.6#.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch::locator=%40pybricks%2Fpybricks-code%40workspace%3A.": + version: 2.0.6 + resolution: "jest-mock-extended@patch:jest-mock-extended@npm%3A2.0.6#.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch::version=2.0.6&hash=9057e0&locator=%40pybricks%2Fpybricks-code%40workspace%3A." + dependencies: + ts-essentials: ^7.0.3 + peerDependencies: + jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 + typescript: ^3.0.0 || ^4.0.0 + checksum: 02045304db317a929482d376a46d69d7987b7b5577f602620d678eb129070a87b8e61c213696dbc135eff970a43bb808ea5417d3554819b259aedc4f997eaded + languageName: node + linkType: hard + "jest-mock@npm:^28.1.3": version: 28.1.3 resolution: "jest-mock@npm:28.1.3" From ecd32e61b9c5e2c6e4fe2371aaab1a9d6d42a1af Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 15 Jul 2022 11:16:23 -0500 Subject: [PATCH 03/26] ble/actions: namespace actions This way we don't have to use aliases when importing. --- src/ble/actions.ts | 36 ++++----- src/ble/reducers.test.ts | 53 +++++++----- src/ble/reducers.ts | 38 +++++---- src/ble/sagas.test.ts | 68 ++++++++-------- src/ble/sagas.ts | 139 ++++++++++++++++++++++++-------- src/error-log/sagas.test.ts | 8 +- src/error-log/sagas.ts | 6 +- src/hub/reducers.test.ts | 7 +- src/hub/reducers.ts | 6 +- src/hub/sagas.ts | 4 +- src/notifications/sagas.test.ts | 22 +++-- src/notifications/sagas.ts | 6 +- 12 files changed, 245 insertions(+), 148 deletions(-) diff --git a/src/ble/actions.ts b/src/ble/actions.ts index 0c85d711..6cf0f3e8 100644 --- a/src/ble/actions.ts +++ b/src/ble/actions.ts @@ -5,17 +5,17 @@ import { createAction } from '../actions'; /** - * Creates an action that indicates connecting has been requested. + * Creates an action that initiates a connection to a hub running Pybricks firmware. */ -export const connect = createAction(() => ({ - type: 'ble.device.action.connect', +export const bleConnectPybricks = createAction(() => ({ + type: 'ble.action.connectPybricks', })); /** - * Creates an action that indicates a device was connected. + * Response that indicates {@link bleConnectPybricks} succeeded. */ -export const didConnect = createAction((id: string, name: string) => ({ - type: 'ble.device.action.didConnect', +export const bleDidConnectPybricks = createAction((id: string, name: string) => ({ + type: 'ble.device.action.didConnectPybricks', id, name, })); @@ -67,34 +67,34 @@ export type BleDeviceDidFailToConnectReason = | BleDeviceFailToConnectUnknownReason; /** - * Creates an action that indicates a device failed to connect. + * Response that indicates {@link bleConnectPybricks} failed. */ -export const didFailToConnect = createAction( +export const bleDidFailToConnectPybricks = createAction( (reason: BleDeviceDidFailToConnectReason) => ({ - type: 'ble.device.action.didFailToConnect', + type: 'ble.action.didFailToConnectPybricks', ...reason, }), ); /** - * Creates an action that indicates disconnecting was requested. + * Creates an action to request disconnecting a hub running Pybricks firmware. */ -export const disconnect = createAction(() => ({ - type: 'ble.device.action.disconnect', +export const bleDisconnectPybricks = createAction(() => ({ + type: 'ble.action.disconnectPybricks', })); /** - * Creates an action that indicates a device was disconnected. + * Creates an action that indicates that {@link bleDisconnectPybricks} succeeded. */ -export const didDisconnect = createAction(() => ({ - type: 'ble.device.action.didDisconnect', +export const bleDidDisconnectPybricks = createAction(() => ({ + type: 'ble.action.didDisconnectPybricks', })); /** - * Creates an action that indicates a device failed to disconnect. + * Creates an action that indicates that {@link bleDisconnectPybricks} failed. */ -export const didFailToDisconnect = createAction(() => ({ - type: 'ble.device.action.didFailToDisconnect', +export const bleDidFailToDisconnectPybricks = createAction(() => ({ + type: 'ble.action.didFailToDisconnectPybricks', })); /** diff --git a/src/ble/reducers.test.ts b/src/ble/reducers.test.ts index 742c5ed3..4c043cb3 100644 --- a/src/ble/reducers.test.ts +++ b/src/ble/reducers.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { AnyAction } from 'redux'; import { @@ -12,12 +12,12 @@ import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; import { BleDeviceDidFailToConnectReason, - connect, - didConnect, - didDisconnect, - didFailToConnect, - didFailToDisconnect, - disconnect, + bleConnectPybricks, + bleDidConnectPybricks, + bleDidDisconnectPybricks, + bleDidFailToConnectPybricks, + bleDidFailToDisconnectPybricks, + bleDisconnectPybricks, } from './actions'; import reducers, { BleConnectionState } from './reducers'; @@ -38,35 +38,39 @@ test('initial state', () => { test('connection', () => { expect( - reducers({ connection: BleConnectionState.Disconnected } as State, connect()) - .connection, + reducers( + { connection: BleConnectionState.Disconnected } as State, + bleConnectPybricks(), + ).connection, ).toBe(BleConnectionState.Connecting); expect( reducers( { connection: BleConnectionState.Connecting } as State, - didConnect('test-id', 'Test Name'), + bleDidConnectPybricks('test-id', 'Test Name'), ).connection, ).toBe(BleConnectionState.Connected); expect( reducers( { connection: BleConnectionState.Connecting } as State, - didFailToConnect({} as BleDeviceDidFailToConnectReason), + bleDidFailToConnectPybricks({} as BleDeviceDidFailToConnectReason), ).connection, ).toBe(BleConnectionState.Disconnected); expect( - reducers({ connection: BleConnectionState.Connected } as State, disconnect()) - .connection, + reducers( + { connection: BleConnectionState.Connected } as State, + bleDisconnectPybricks(), + ).connection, ).toBe(BleConnectionState.Disconnecting); expect( reducers( { connection: BleConnectionState.Disconnecting } as State, - didDisconnect(), + bleDidDisconnectPybricks(), ).connection, ).toBe(BleConnectionState.Disconnected); expect( reducers( { connection: BleConnectionState.Disconnecting } as State, - didFailToDisconnect(), + bleDidFailToDisconnectPybricks(), ).connection, ).toBe(BleConnectionState.Connected); }); @@ -76,11 +80,13 @@ test('deviceName', () => { const testName = 'Test Name'; expect( - reducers({ deviceName: '' } as State, didConnect(testId, testName)).deviceName, + reducers({ deviceName: '' } as State, bleDidConnectPybricks(testId, testName)) + .deviceName, ).toBe(testName); expect( - reducers({ deviceName: testName } as State, didDisconnect()).deviceName, + reducers({ deviceName: testName } as State, bleDidDisconnectPybricks()) + .deviceName, ).toBe(''); }); @@ -98,7 +104,8 @@ test('deviceType', () => { ).toBe('Move hub'); expect( - reducers({ deviceType: 'Move hub' } as State, didDisconnect()).deviceType, + reducers({ deviceType: 'Move hub' } as State, bleDidDisconnectPybricks()) + .deviceType, ).toBe(''); }); @@ -113,8 +120,10 @@ test('deviceFirmwareVersion', () => { ).toBe(testVersion); expect( - reducers({ deviceFirmwareVersion: testVersion } as State, didDisconnect()) - .deviceFirmwareVersion, + reducers( + { deviceFirmwareVersion: testVersion } as State, + bleDidDisconnectPybricks(), + ).deviceFirmwareVersion, ).toBe(''); }); @@ -134,14 +143,14 @@ test('deviceLowBatteryWarning', () => { ).toBeFalsy(); expect( - reducers({ deviceLowBatteryWarning: true } as State, didDisconnect()) + reducers({ deviceLowBatteryWarning: true } as State, bleDidDisconnectPybricks()) .deviceLowBatteryWarning, ).toBeFalsy(); }); test('deviceBatteryCharging', () => { expect( - reducers({ deviceBatteryCharging: true } as State, didDisconnect()) + reducers({ deviceBatteryCharging: true } as State, bleDidDisconnectPybricks()) .deviceBatteryCharging, ).toBeFalsy(); }); diff --git a/src/ble/reducers.ts b/src/ble/reducers.ts index f69d9eaf..808a42fa 100644 --- a/src/ble/reducers.ts +++ b/src/ble/reducers.ts @@ -13,12 +13,12 @@ import { getHubTypeName } from '../ble-device-info-service/protocol'; import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; import { - connect, - didConnect, - didDisconnect, - didFailToConnect, - didFailToDisconnect, - disconnect, + bleConnectPybricks, + bleDidConnectPybricks, + bleDidDisconnectPybricks, + bleDidFailToConnectPybricks, + bleDidFailToDisconnectPybricks, + bleDisconnectPybricks, } from './actions'; /** @@ -47,19 +47,25 @@ const connection: Reducer = ( state = BleConnectionState.Disconnected, action, ) => { - if (connect.matches(action)) { + if (bleConnectPybricks.matches(action)) { return BleConnectionState.Connecting; } - if (didConnect.matches(action) || didFailToDisconnect.matches(action)) { + if ( + bleDidConnectPybricks.matches(action) || + bleDidFailToDisconnectPybricks.matches(action) + ) { return BleConnectionState.Connected; } - if (disconnect.matches(action)) { + if (bleDisconnectPybricks.matches(action)) { return BleConnectionState.Disconnecting; } - if (didFailToConnect.matches(action) || didDisconnect.matches(action)) { + if ( + bleDidFailToConnectPybricks.matches(action) || + bleDidDisconnectPybricks.matches(action) + ) { return BleConnectionState.Disconnected; } @@ -67,11 +73,11 @@ const connection: Reducer = ( }; const deviceName: Reducer = (state = '', action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return ''; } - if (didConnect.matches(action)) { + if (bleDidConnectPybricks.matches(action)) { return action.name; } @@ -79,7 +85,7 @@ const deviceName: Reducer = (state = '', action) => { }; const deviceType: Reducer = (state = '', action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return ''; } @@ -91,7 +97,7 @@ const deviceType: Reducer = (state = '', action) => { }; const deviceFirmwareVersion: Reducer = (state = '', action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return ''; } @@ -103,7 +109,7 @@ const deviceFirmwareVersion: Reducer = (state = '', action) => { }; const deviceLowBatteryWarning: Reducer = (state = false, action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return false; } @@ -117,7 +123,7 @@ const deviceLowBatteryWarning: Reducer = (state = false, action) => { }; const deviceBatteryCharging: Reducer = (state = false, action) => { - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return false; } diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts index 8f3eb3e2..453c11ff 100644 --- a/src/ble/sagas.test.ts +++ b/src/ble/sagas.test.ts @@ -27,11 +27,11 @@ import { } from '../ble-pybricks-service/protocol'; import { BleDeviceFailToConnectReasonType, - connect, - didConnect, - didDisconnect, - didFailToConnect, - disconnect, + bleConnectPybricks, + bleDidConnectPybricks, + bleDidDisconnectPybricks, + bleDidFailToConnectPybricks, + bleDisconnectPybricks, toggleBluetooth, } from './actions'; import { BleConnectionState } from './reducers'; @@ -191,7 +191,7 @@ enum ConnectRunPoint { * @param point The point at which to stop running. */ async function runConnectUntil(saga: AsyncSaga, point: ConnectRunPoint): Promise { - saga.put(connect()); + saga.put(bleConnectPybricks()); if (point === ConnectRunPoint.Connect) { return; @@ -226,7 +226,9 @@ async function runConnectUntil(saga: AsyncSaga, point: ConnectRunPoint): Promise return; } - await expect(saga.take()).resolves.toEqual(didConnect('test-id', 'test name')); + await expect(saga.take()).resolves.toEqual( + bleDidConnectPybricks('test-id', 'test name'), + ); } describe('connect action is dispatched', () => { @@ -240,7 +242,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.NoWebBluetooth, }), ); @@ -259,7 +261,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.NoBluetooth, }), ); @@ -273,7 +275,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Canceled, }), ); @@ -288,7 +290,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -301,7 +303,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.NoGatt, }), ); @@ -314,7 +316,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -330,7 +332,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, }), ); @@ -347,7 +349,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -363,7 +365,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -381,7 +383,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceiveFirmwareRevision); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -397,7 +399,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceiveFirmwareRevision); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -415,7 +417,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceiveSoftwareRevision); await expect(saga.take()).resolves.toEqual( - didConnect('test-id', 'test name'), + bleDidConnectPybricks('test-id', 'test name'), ); }); @@ -426,7 +428,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceiveSoftwareRevision); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -444,7 +446,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.NoPybricksService, }), ); @@ -461,7 +463,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -477,7 +479,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -493,7 +495,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -511,7 +513,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ // FIXME: this is wrong error reason: BleDeviceFailToConnectReasonType.NoPybricksService, }), @@ -529,7 +531,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -547,7 +549,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -563,7 +565,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -579,7 +581,7 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); await expect(saga.take()).resolves.toEqual( - didFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: testError, }), @@ -595,9 +597,9 @@ describe('connect action is dispatched', () => { it('should handle disconnect', async () => { await runConnectUntil(saga, ConnectRunPoint.DidConnect); - saga.put(disconnect()); + saga.put(bleDisconnectPybricks()); - await expect(saga.take()).resolves.toEqual(didDisconnect()); + await expect(saga.take()).resolves.toEqual(bleDidDisconnectPybricks()); expect(mocks.gatt.disconnect).toHaveBeenCalled(); }); @@ -616,7 +618,7 @@ describe('toggleBluetooth action', () => { saga.put(toggleBluetooth()); - await expect(saga.take()).resolves.toEqual(connect()); + await expect(saga.take()).resolves.toEqual(bleConnectPybricks()); }); it('should disconnect when connected', async () => { @@ -626,6 +628,6 @@ describe('toggleBluetooth action', () => { saga.put(toggleBluetooth()); - await expect(saga.take()).resolves.toEqual(disconnect()); + await expect(saga.take()).resolves.toEqual(bleDisconnectPybricks()); }); }); diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index 44a5d09d..a5cc6d1e 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -52,11 +52,11 @@ import { RootState } from '../reducers'; import { ensureError } from '../utils'; import { BleDeviceFailToConnectReasonType as Reason, - connect, - didConnect, - didDisconnect, - didFailToConnect, - disconnect, + bleConnectPybricks as bleConnectPybricks, + bleDidConnectPybricks, + bleDidDisconnectPybricks, + bleDidFailToConnectPybricks, + bleDisconnectPybricks, toggleBluetooth, } from './actions'; import { BleConnectionState } from './reducers'; @@ -99,15 +99,15 @@ function* handleWriteUart( } } -function* handleConnect(): Generator { +function* handleBleConnectPybricks(): Generator { if (navigator.bluetooth === undefined) { - yield* put(didFailToConnect({ reason: Reason.NoWebBluetooth })); + yield* put(bleDidFailToConnectPybricks({ reason: Reason.NoWebBluetooth })); return; } const available = yield* call(() => navigator.bluetooth.getAvailability()); if (!available) { - yield* put(didFailToConnect({ reason: Reason.NoBluetooth })); + yield* put(bleDidFailToConnectPybricks({ reason: Reason.NoBluetooth })); return; } @@ -126,17 +126,20 @@ function* handleConnect(): Generator { } 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 })); + yield* put(bleDidFailToConnectPybricks({ reason: Reason.Canceled })); } else { yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), ); } return; } if (device.gatt === undefined) { - yield* put(didFailToConnect({ reason: Reason.NoGatt })); + yield* put(bleDidFailToConnectPybricks({ reason: Reason.NoGatt })); return; } @@ -152,11 +155,16 @@ function* handleConnect(): Generator { server = yield* call([device.gatt, 'connect']); } catch (err) { disconnectChannel.close(); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } - yield* takeEvery(disconnect, handleDisconnect, server); + yield* takeEvery(bleDisconnectPybricks, handleDisconnect, server); let deviceInfoService: BluetoothRemoteGATTService; try { @@ -168,10 +176,15 @@ function* handleConnect(): Generator { server.disconnect(); yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - yield* put(didFailToConnect({ reason: Reason.NoDeviceInfoService })); + yield* put( + bleDidFailToConnectPybricks({ reason: Reason.NoDeviceInfoService }), + ); } else { yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), ); } return; @@ -186,7 +199,12 @@ function* handleConnect(): Generator { } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } @@ -196,7 +214,12 @@ function* handleConnect(): Generator { } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } @@ -209,7 +232,12 @@ function* handleConnect(): Generator { } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } @@ -219,7 +247,12 @@ function* handleConnect(): Generator { } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } @@ -240,7 +273,10 @@ function* handleConnect(): Generator { server.disconnect(); yield* takeMaybe(disconnectChannel); yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), ); return; } @@ -256,10 +292,15 @@ function* handleConnect(): Generator { server.disconnect(); yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - yield* put(didFailToConnect({ reason: Reason.NoPybricksService })); + yield* put( + bleDidFailToConnectPybricks({ reason: Reason.NoPybricksService }), + ); } else { yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), ); } return; @@ -274,7 +315,12 @@ function* handleConnect(): Generator { } catch (err) { server.disconnect(); yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } @@ -313,7 +359,12 @@ function* handleConnect(): Generator { pybricksControlChannel.close(); server.disconnect(); yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } @@ -328,10 +379,15 @@ function* handleConnect(): Generator { server.disconnect(); yield* takeMaybe(disconnectChannel); if (err instanceof DOMException && err.code === DOMException.NOT_FOUND_ERR) { - yield* put(didFailToConnect({ reason: Reason.NoPybricksService })); + yield* put( + bleDidFailToConnectPybricks({ reason: Reason.NoPybricksService }), + ); } else { yield* put( - didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) }), + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), ); } return; @@ -345,7 +401,12 @@ function* handleConnect(): Generator { pybricksControlChannel.close(); server.disconnect(); yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } @@ -357,7 +418,12 @@ function* handleConnect(): Generator { pybricksControlChannel.close(); server.disconnect(); yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } @@ -389,13 +455,18 @@ function* handleConnect(): Generator { pybricksControlChannel.close(); server.disconnect(); yield* takeMaybe(disconnectChannel); - yield* put(didFailToConnect({ reason: Reason.Unknown, err: ensureError(err) })); + yield* put( + bleDidFailToConnectPybricks({ + reason: Reason.Unknown, + err: ensureError(err), + }), + ); return; } tasks.push(yield* takeEvery(writeUart, handleWriteUart, uartRxChar)); - yield* put(didConnect(device.id, device.name || '')); + yield* put(bleDidConnectPybricks(device.id, device.name || '')); // wait for disconnection yield* takeMaybe(disconnectChannel); @@ -404,7 +475,7 @@ function* handleConnect(): Generator { uartTxChannel.close(); pybricksControlChannel.close(); - yield* put(didDisconnect()); + yield* put(bleDidDisconnectPybricks()); } function* handleToggleBluetooth(): Generator { @@ -414,15 +485,15 @@ function* handleToggleBluetooth(): Generator { switch (connectionState) { case BleConnectionState.Connected: - yield* put(disconnect()); + yield* put(bleDisconnectPybricks()); break; case BleConnectionState.Disconnected: - yield* put(connect()); + yield* put(bleConnectPybricks()); break; } } export default function* (): Generator { - yield* takeEvery(connect, handleConnect); + yield* takeEvery(bleConnectPybricks, handleBleConnectPybricks); yield* takeEvery(toggleBluetooth, handleToggleBluetooth); } diff --git a/src/error-log/sagas.test.ts b/src/error-log/sagas.test.ts index 52711661..468f99a9 100644 --- a/src/error-log/sagas.test.ts +++ b/src/error-log/sagas.test.ts @@ -6,7 +6,7 @@ import { didFailToWrite } from '../ble-nordic-uart-service/actions'; import { eventProtocolError } from '../ble-pybricks-service/actions'; import { BleDeviceFailToConnectReasonType, - didFailToConnect as bleDidFailToConnect, + bleDidFailToConnectPybricks, } from '../ble/actions'; import { BootloaderConnectionFailureReason, @@ -21,12 +21,14 @@ test('bleDeviceDidFailToConnect', async () => { console.error = jest.fn(); saga.put( - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Canceled }), + bleDidFailToConnectPybricks({ + reason: BleDeviceFailToConnectReasonType.Canceled, + }), ); expect(console.error).toHaveBeenCalledTimes(0); saga.put( - bleDidFailToConnect({ + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: new Error('test error'), }), diff --git a/src/error-log/sagas.ts b/src/error-log/sagas.ts index aca7848a..3c52074a 100644 --- a/src/error-log/sagas.ts +++ b/src/error-log/sagas.ts @@ -6,7 +6,7 @@ import { didFailToWrite as bleUartDidFailToWrite } from '../ble-nordic-uart-serv import { eventProtocolError as pybricksEventProtocolError } from '../ble-pybricks-service/actions'; import { BleDeviceFailToConnectReasonType, - didFailToConnect as bleDeviceDidFailToConnect, + bleDidFailToConnectPybricks, } from '../ble/actions'; import { fileStorageDidFailToStoreTextFileValue } from '../fileStorage/actions'; import { @@ -16,7 +16,7 @@ import { } from '../lwp3-bootloader/actions'; function handleBleDeviceDidFailToConnect( - action: ReturnType, + action: ReturnType, ): void { if (action.reason === BleDeviceFailToConnectReasonType.Unknown) { console.error(action.err); @@ -54,7 +54,7 @@ function handleFileStorageDidFailToStoreTextFileValue( } export default function* (): Generator { - yield* takeEvery(bleDeviceDidFailToConnect, handleBleDeviceDidFailToConnect); + yield* takeEvery(bleDidFailToConnectPybricks, handleBleDeviceDidFailToConnect); yield* takeEvery(pybricksEventProtocolError, handlePybricksEventProtocolError); yield* takeEvery(bleUartDidFailToWrite, handleBleUartDidFailToWrite); yield* takeEvery(bootloaderDidFailToConnect, handleBootloaderDidFailToConnect); diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index 21526eb8..d590b1b6 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -4,7 +4,7 @@ import { AnyAction } from 'redux'; import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; -import { didConnect, didDisconnect } from '../ble/actions'; +import { bleDidConnectPybricks, bleDidDisconnectPybricks } from '../ble/actions'; import { didFailToFinishDownload, didFinishDownload, @@ -30,7 +30,7 @@ describe('runtime', () => { expect( reducers( { runtime: HubRuntimeState.Disconnected } as State, - didConnect('test-id', 'Test Name'), + bleDidConnectPybricks('test-id', 'Test Name'), ).runtime, ).toBe(HubRuntimeState.Unknown); }); @@ -38,7 +38,8 @@ describe('runtime', () => { test.each(Object.values(HubRuntimeState))('didDisconnect', (startingState) => { // all states are overridden by disconnect expect( - reducers({ runtime: startingState } as State, didDisconnect()).runtime, + reducers({ runtime: startingState } as State, bleDidDisconnectPybricks()) + .runtime, ).toBe(HubRuntimeState.Disconnected); }); diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index c0b4ef52..16986c61 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -6,7 +6,7 @@ import * as semver from 'semver'; import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; import { didReceiveStatusReport } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; -import { didConnect, didDisconnect } from '../ble/actions'; +import { bleDidConnectPybricks, bleDidDisconnectPybricks } from '../ble/actions'; import { pythonVersionToSemver } from '../utils/version'; import { didFailToFinishDownload, @@ -49,11 +49,11 @@ const runtime: Reducer = ( state = HubRuntimeState.Disconnected, action, ) => { - if (didConnect.matches(action)) { + if (bleDidConnectPybricks.matches(action)) { return HubRuntimeState.Unknown; } - if (didDisconnect.matches(action)) { + if (bleDidDisconnectPybricks.matches(action)) { return HubRuntimeState.Disconnected; } diff --git a/src/hub/sagas.ts b/src/hub/sagas.ts index f2013b10..a64fbf6a 100644 --- a/src/hub/sagas.ts +++ b/src/hub/sagas.ts @@ -18,7 +18,7 @@ import { didSendCommand, sendStopUserProgramCommand, } from '../ble-pybricks-service/actions'; -import { didConnect } from '../ble/actions'; +import { bleDidConnectPybricks } from '../ble/actions'; import { editorGetValue } from '../editor/sagas'; import { compile, didCompile, didFailToCompile } from '../mpy/actions'; import { defined } from '../utils'; @@ -190,5 +190,5 @@ export default function* (): Generator { yield* takeEvery(repl, handleRepl); yield* takeEvery(stop, handleStop); // calling stop right after connecting should get the hub into a known state - yield* takeEvery(didConnect, handleStop); + yield* takeEvery(bleDidConnectPybricks, handleStop); } diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index c3d08c18..edac4794 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -14,7 +14,7 @@ import { appDidCheckForUpdate } from '../app/actions'; import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; import { BleDeviceFailToConnectReasonType, - didFailToConnect as bleDidFailToConnect, + bleDidFailToConnectPybricks, } from '../ble/actions'; import { editorDidFailToOpenFile } from '../editor/actions'; import { EditorError } from '../editor/error'; @@ -61,14 +61,20 @@ function createTestToasterSaga(): { toaster: IToaster; saga: AsyncSaga } { } test.each([ - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoWebBluetooth }), - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoBluetooth }), - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoGatt }), - bleDidFailToConnect({ + bleDidFailToConnectPybricks({ + reason: BleDeviceFailToConnectReasonType.NoWebBluetooth, + }), + bleDidFailToConnectPybricks({ + reason: BleDeviceFailToConnectReasonType.NoBluetooth, + }), + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.NoGatt }), + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, }), - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoPybricksService }), - bleDidFailToConnect({ + bleDidFailToConnectPybricks({ + reason: BleDeviceFailToConnectReasonType.NoPybricksService, + }), + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Unknown, err: { name: 'test', message: 'unknown' }, }), @@ -129,7 +135,7 @@ test.each([ }); test.each([ - bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Canceled }), + bleDidFailToConnectPybricks({ reason: BleDeviceFailToConnectReasonType.Canceled }), bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Canceled), didFailToFinish(FailToFinishReasonType.FailedToConnect), serviceWorkerDidSucceed(), diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 5b4470f1..75728fac 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -16,7 +16,7 @@ import { appName } from '../app/constants'; import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; import { BleDeviceFailToConnectReasonType, - didFailToConnect as bleDeviceDidFailToConnect, + bleDidFailToConnectPybricks, } from '../ble/actions'; import { editorDidFailToOpenFile } from '../editor/actions'; import { EditorError } from '../editor/error'; @@ -171,7 +171,7 @@ function* showUnexpectedError(messageId: I18nId, error: Error): Generator { } function* showBleDeviceDidFailToConnectError( - action: ReturnType, + action: ReturnType, ): Generator { switch (action.reason) { case BleDeviceFailToConnectReasonType.NoGatt: @@ -454,7 +454,7 @@ function* showExplorerFailToDelete( } export default function* (): Generator { - yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError); + yield* takeEvery(bleDidFailToConnectPybricks, showBleDeviceDidFailToConnectError); yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError); yield* takeEvery(didFailToFinish, showFlashFirmwareError); yield* takeEvery(didCompile, dismissCompilerError); From e9f9aca3ef51d800ffe37cbe4e71171bd514babf Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 15 Jul 2022 11:42:44 -0500 Subject: [PATCH 04/26] ble/sagas: don't spam console in tests --- src/ble/sagas.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index a5cc6d1e..57fee43f 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -260,9 +260,11 @@ function* handleBleConnectPybricks(): Generator { try { pnpIdChar = yield* call([deviceInfoService, 'getCharacteristic'], pnpIdUUID); } catch (err) { - console.warn( - 'PnP ID characteristic requires Pybricks firmware v3.1.0a1 or later', - ); + if (process.env.NODE_ENV !== 'test') { + console.warn( + 'PnP ID characteristic requires Pybricks firmware v3.1.0a1 or later', + ); + } } if (pnpIdChar) { From b2b173e103bcd45bd62321f86551116dfc8ab80e Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 15 Jul 2022 12:37:36 -0500 Subject: [PATCH 05/26] utils/os: add Linux and iOS Also update to navigator.userAgentData to avoid warnings from chromium. --- package.json | 1 + src/react-app-env.d.ts | 1 + src/utils/os.test.ts | 63 ++++++++++++++++++++++++++++++++++++------ src/utils/os.ts | 27 +++++++++++++----- yarn.lock | 8 ++++++ 5 files changed, 85 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index a9299c6a..025efd4c 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "typed-redux-saga": "^1.5.0", "typescript": "~4.7.4", "usehooks-ts": "^2.6.0", + "user-agent-data-types": "^0.3.0", "web-vitals": "^2.1.4", "webpack": "^5.73.0", "webpack-dev-server": "^4.9.3", diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 222dc10e..3366c193 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -3,6 +3,7 @@ /// /// +/// declare namespace NodeJS { interface ProcessEnv { diff --git a/src/utils/os.test.ts b/src/utils/os.test.ts index 51346573..c02617ef 100644 --- a/src/utils/os.test.ts +++ b/src/utils/os.test.ts @@ -1,7 +1,28 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors -import { isAndroid, isMacOS, isWindows } from './os'; +import { isAndroid, isIOS, isLinux, isMacOS, isWindows } from './os'; +import { defined } from '.'; + +class TestUserAgentData implements NavigatorUAData { + getHighEntropyValues(_hints: string[]): Promise { + throw new Error('Method not implemented.'); + } + toJSON(): UALowEntropyJSON { + throw new Error('Method not implemented.'); + } + get brands(): NavigatorUABrandVersion[] { + throw new Error('Method not implemented.'); + } + get mobile(): boolean { + throw new Error('Method not implemented.'); + } + get platform(): string { + return 'test-agent'; + } +} + +Object.defineProperty(navigator, 'userAgentData', { value: new TestUserAgentData() }); afterEach(() => { jest.resetAllMocks(); @@ -9,33 +30,59 @@ afterEach(() => { describe('isAndroid', () => { test('is true', () => { - jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Android'); + defined(navigator.userAgentData); + jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue( + 'Android', + ); expect(isAndroid()).toBeTruthy(); }); test('is false', () => { - jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Linux'); expect(isAndroid()).toBeFalsy(); }); }); describe('isMacOS', () => { test('is true', () => { - jest.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel'); + defined(navigator.userAgentData); + jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue('macOS'); expect(isMacOS()).toBeTruthy(); }); test('is false', () => { - jest.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32'); expect(isMacOS()).toBeFalsy(); }); }); describe('isWindows', () => { test('is true', () => { - jest.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32'); + defined(navigator.userAgentData); + jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue( + 'Windows', + ); expect(isWindows()).toBeTruthy(); }); test('is false', () => { - jest.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel'); expect(isWindows()).toBeFalsy(); }); }); + +describe('isLinux', () => { + test('is true', () => { + defined(navigator.userAgentData); + jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue('Linux'); + expect(isLinux()).toBeTruthy(); + }); + test('is false', () => { + expect(isLinux()).toBeFalsy(); + }); +}); + +describe('isIOS', () => { + test('is true', () => { + defined(navigator.userAgentData); + jest.spyOn(navigator.userAgentData, 'platform', 'get').mockReturnValue('iOS'); + expect(isIOS()).toBeTruthy(); + }); + test('is false', () => { + expect(isIOS()).toBeFalsy(); + }); +}); diff --git a/src/utils/os.ts b/src/utils/os.ts index 00913d16..eba07f90 100644 --- a/src/utils/os.ts +++ b/src/utils/os.ts @@ -1,17 +1,14 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors // Utility functions for dealing with operating systems. -// TODO: replace with navigator.userAgentData when it is more widely available -// https://developer.mozilla.org/en-US/docs/Web/API/User-Agent_Client_Hints_API - /** * Tests if we are running on Android. * @returns `true` if running on Android, otherwise `false`. */ export function isAndroid(): boolean { - return /android/i.test(navigator.userAgent); + return navigator.userAgentData?.platform === 'Android'; } /** @@ -19,7 +16,7 @@ export function isAndroid(): boolean { * @returns `true` if running on macOS, otherwise `false`. */ export function isMacOS(): boolean { - return /mac/i.test(navigator.platform); + return navigator.userAgentData?.platform === 'macOS'; } /** @@ -27,5 +24,21 @@ export function isMacOS(): boolean { * @returns `true` if running on Windows, otherwise `false`. */ export function isWindows(): boolean { - return /win/i.test(navigator.platform); + return navigator.userAgentData?.platform === 'Windows'; +} + +/** + * Tests if we are running on Linux. + * @returns `true` if running on Linux, otherwise `false`. + */ +export function isLinux(): boolean { + return navigator.userAgentData?.platform === 'Linux'; +} + +/** + * Tests if we are running on iOS. + * @returns `true` if running on iOS, otherwise `false`. + */ +export function isIOS(): boolean { + return navigator.userAgentData?.platform === 'iOS'; } diff --git a/yarn.lock b/yarn.lock index db1f1ded..526c34d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2410,6 +2410,7 @@ __metadata: typed-redux-saga: ^1.5.0 typescript: ~4.7.4 usehooks-ts: ^2.6.0 + user-agent-data-types: ^0.3.0 web-vitals: ^2.1.4 webpack: ^5.73.0 webpack-dev-server: ^4.9.3 @@ -14736,6 +14737,13 @@ __metadata: languageName: node linkType: hard +"user-agent-data-types@npm:^0.3.0": + version: 0.3.0 + resolution: "user-agent-data-types@npm:0.3.0" + checksum: 73a61ddfba17e7289a1312fc98c4f1f98d6353e6ace3e3159b36735e6a679b05bcb2afdfb8e602d690a89e6af44669783045ed9059f4bd0890f8e0ee8a772ee5 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" From f86a9ad0514c68f979c8844993a42d49c23c0399 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 15 Jul 2022 13:10:42 -0500 Subject: [PATCH 06/26] ble/alerts: move and improve no web bluetooth message --- src/alerts.ts | 2 ++ src/ble/alerts/NoWebBluetooth.tsx | 49 ++++++++++++++++++++++++++ src/ble/alerts/i18n.test.ts | 12 +++++++ src/ble/alerts/i18n.ts | 16 +++++++++ src/ble/alerts/index.ts | 7 ++++ src/ble/alerts/translations/en.json | 8 +++++ src/ble/sagas.test.ts | 4 +++ src/ble/sagas.ts | 2 ++ src/lwp3-bootloader/sagas-ble.ts | 2 ++ src/notifications/i18n.ts | 1 - src/notifications/sagas.test.ts | 8 ++--- src/notifications/sagas.ts | 20 ----------- src/notifications/translations/en.json | 1 - 13 files changed, 106 insertions(+), 26 deletions(-) create mode 100644 src/ble/alerts/NoWebBluetooth.tsx create mode 100644 src/ble/alerts/i18n.test.ts create mode 100644 src/ble/alerts/i18n.ts create mode 100644 src/ble/alerts/index.ts create mode 100644 src/ble/alerts/translations/en.json diff --git a/src/alerts.ts b/src/alerts.ts index f05a8134..4e372ef0 100644 --- a/src/alerts.ts +++ b/src/alerts.ts @@ -3,12 +3,14 @@ import { IToastProps } from '@blueprintjs/core'; import alerts from './alerts/alerts'; +import ble from './ble/alerts'; import explorer from './explorer/alerts'; import { CreateToast } from './i18nToaster'; /** This collects alerts from all of the subsystems of the app */ const alertDomains = { alerts, + ble, explorer, }; diff --git a/src/ble/alerts/NoWebBluetooth.tsx b/src/ble/alerts/NoWebBluetooth.tsx new file mode 100644 index 00000000..175cd784 --- /dev/null +++ b/src/ble/alerts/NoWebBluetooth.tsx @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Button, Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { isIOS, isLinux } from '../../utils/os'; +import { I18nId, useI18n } from './i18n'; + +const NoWebBluetooth: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return ( + <> +

{i18n.translate(I18nId.NoWebBluetoothMessage)}

+ {!isLinux() && !isIOS() && ( +

{i18n.translate(I18nId.NoWebBluetoothSuggestion)}

+ )} + {isLinux() && ( + <> +

{i18n.translate(I18nId.NoWebBluetoothLinux)}

+

+ + chrome://flags/#enable-experimental-web-platform-features + + + + {i18n.translate(I18nId.NoHubTroubleshootButton)} + + + + + ); +}; + +export const noHub: CreateToast = (onAction) => { + return { + message: onAction('flashFirmware')} />, + icon: 'info-sign', + intent: Intent.PRIMARY, + timeout: 15000, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/ble/alerts/i18n.ts b/src/ble/alerts/i18n.ts index c524cbde..81045edd 100644 --- a/src/ble/alerts/i18n.ts +++ b/src/ble/alerts/i18n.ts @@ -19,4 +19,9 @@ export enum I18nId { MissingServiceMessage = 'missingService.message', MissingServiceSuggestion1 = 'missingService.suggestion1', MissingServiceSuggestion2 = 'missingService.suggestion2', + NoHubMessage = 'noHub.message', + NoHubSuggestion1 = 'noHub.suggestion1', + NoHubSuggestion2 = 'noHub.suggestion2', + NoHubFlashFirmwareButton = 'noHub.flashFirmwareButton', + NoHubTroubleshootButton = 'noHub.troubleshootButton', } diff --git a/src/ble/alerts/index.ts b/src/ble/alerts/index.ts index 65488a6a..2e5ec4dd 100644 --- a/src/ble/alerts/index.ts +++ b/src/ble/alerts/index.ts @@ -4,7 +4,8 @@ import { bluetoothNotAvailable } from './BluetoothNotAvailable'; import { missingService } from './MissingService'; import { noGatt } from './NoGatt'; +import { noHub } from './NoHub'; import { noWebBluetooth } from './NoWebBluetooth'; // gathers all of the alert creation functions for passing up to the top level -export default { bluetoothNotAvailable, missingService, noGatt, noWebBluetooth }; +export default { bluetoothNotAvailable, missingService, noGatt, noHub, noWebBluetooth }; diff --git a/src/ble/alerts/noHub.scss b/src/ble/alerts/noHub.scss new file mode 100644 index 00000000..c011e9a3 --- /dev/null +++ b/src/ble/alerts/noHub.scss @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +.pb-ble-alerts-noHub { + &-buttons { + display: flex; + gap: 10px; + } +} diff --git a/src/ble/alerts/translations/en.json b/src/ble/alerts/translations/en.json index 3e90df14..94c9f34d 100644 --- a/src/ble/alerts/translations/en.json +++ b/src/ble/alerts/translations/en.json @@ -16,5 +16,12 @@ "message": "Connected to hub but failed to get {serviceName} service.", "suggestion1": "Ensure that you are using the most recent firmware.", "suggestion2": "If the problem persists, try removing the \"{hubName}\" device in your OS Bluetooth settings, then try connecting again." + }, + "noHub": { + "message": "Could not find your hub?", + "suggestion1": "{appName} requires custom firmware to be flashed to your hub. If you have not done this already, click the {buttonName} button below to do so now.", + "suggestion2": "If you have flashed the Pybricks firmware to the hub already and you are still having problems connecting, please visit the troubleshooting guide.", + "flashFirmwareButton": "Flash Firmware", + "troubleshootButton": "Troubleshooting Tips" } } diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts index 096009c7..f58f2086 100644 --- a/src/ble/sagas.test.ts +++ b/src/ble/sagas.test.ts @@ -4,7 +4,7 @@ import { HubType } from '@pybricks/firmware'; import { MockProxy, mock } from 'jest-mock-extended'; import { AsyncSaga } from '../../test'; -import { alertsShowAlert } from '../alerts/actions'; +import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions'; import { bleDIServiceDidReceiveFirmwareRevision, bleDIServiceDidReceivePnPId, @@ -26,6 +26,7 @@ import { pybricksControlCharacteristicUUID, pybricksServiceUUID, } from '../ble-pybricks-service/protocol'; +import { firmwareInstallPybricks } from '../firmware/actions'; import { bleConnectPybricks, bleDidConnectPybricks, @@ -274,7 +275,11 @@ describe('connect action is dispatched', () => { await runConnectUntil(saga, ConnectRunPoint.Connect); + await expect(saga.take()).resolves.toEqual(alertsShowAlert('ble', 'noHub')); await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks()); + + saga.put(alertsDidShowAlert('ble', 'noHub', 'flashFirmware')); + await expect(saga.take()).resolves.toEqual(firmwareInstallPybricks()); }); it('should fail on other exception in requestDevice', async () => { diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index 6f8a39d7..87d43e1f 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -17,7 +17,7 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; -import { alertsShowAlert } from '../alerts/actions'; +import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions'; import { bleDIServiceDidReceiveFirmwareRevision, bleDIServiceDidReceivePnPId, @@ -51,6 +51,7 @@ import { pybricksControlCharacteristicUUID, pybricksServiceUUID, } from '../ble-pybricks-service/protocol'; +import { firmwareInstallPybricks } from '../firmware/actions'; import { RootState } from '../reducers'; import { ensureError } from '../utils'; import { @@ -141,7 +142,21 @@ function* handleBleConnectPybricks(): Generator { ); if (!device) { + yield* put(alertsShowAlert('ble', 'noHub')); yield* put(bleDidFailToConnectPybricks()); + + const { action } = yield* take< + ReturnType> + >( + alertsDidShowAlert.when( + (a) => a.domain === 'ble' && a.specific === 'noHub', + ), + ); + + if (action === 'flashFirmware') { + yield* put(firmwareInstallPybricks()); + } + return; } diff --git a/src/components/hubPicker/HubPicker.tsx b/src/components/hubPicker/HubPicker.tsx new file mode 100644 index 00000000..13889395 --- /dev/null +++ b/src/components/hubPicker/HubPicker.tsx @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Radio, RadioGroup } from '@blueprintjs/core'; +import React from 'react'; +import { Hub } from '.'; + +type HubPickerProps = { + hubType: Hub; + onChange: (hubType: Hub) => void; +}; + +export const HubPicker: React.VoidFunctionComponent = ({ + hubType, + onChange, +}) => { + return ( + onChange(e.currentTarget.value as Hub)} + > + BOOST Move Hub + City Hub + Technic Hub + SPIKE Prime Hub + SPIKE Essential Hub + MINDSTORMS Robot Inventor Hub + + ); +}; diff --git a/src/components/hubPicker/index.ts b/src/components/hubPicker/index.ts new file mode 100644 index 00000000..95f65b2b --- /dev/null +++ b/src/components/hubPicker/index.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +/** Supported hub types. */ +export enum Hub { + /** BOOST Move hub */ + Move = 'movehub', + /** City hub */ + City = 'cityhub', + /** Technic hub */ + Technic = 'technichub', + /** MINDSTORMS Robot Inventor hub */ + Inventor = 'inventorhub', + /** SPIKE Prime hub */ + Prime = 'primehub', + /** SPIKE Essential hub */ + Essential = 'essentialhub', +} + +/** + * Tests if hub has a USB port. + */ +export function hubHasUSB(hub: Hub): boolean { + switch (hub) { + case Hub.Prime: + case Hub.Essential: + case Hub.Inventor: + return true; + default: + return false; + } +} + +/** + * Tests if hub has a Bluetooth button. + */ +export function hubHasBluetoothButton(hub: Hub): boolean { + switch (hub) { + case Hub.Prime: + case Hub.Inventor: + return true; + default: + return false; + } +} + +/** + * Tests if hub has external flash memory. + */ +export function hubHasExternalFlash(hub: Hub): boolean { + switch (hub) { + case Hub.Prime: + case Hub.Essential: + case Hub.Inventor: + return true; + default: + return false; + } +} diff --git a/src/explorer/newFileWizard/NewFileWizard.test.tsx b/src/explorer/newFileWizard/NewFileWizard.test.tsx index c2755314..5b1ba4ac 100644 --- a/src/explorer/newFileWizard/NewFileWizard.test.tsx +++ b/src/explorer/newFileWizard/NewFileWizard.test.tsx @@ -5,8 +5,9 @@ import { fireEvent, waitFor } from '@testing-library/dom'; import { cleanup } from '@testing-library/react'; import React from 'react'; import { testRender } from '../../../test'; +import { Hub } from '../../components/hubPicker'; import NewFileWizard from './NewFileWizard'; -import { Hub, newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; +import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; afterEach(() => { cleanup(); diff --git a/src/explorer/newFileWizard/NewFileWizard.tsx b/src/explorer/newFileWizard/NewFileWizard.tsx index 37947720..54981736 100644 --- a/src/explorer/newFileWizard/NewFileWizard.tsx +++ b/src/explorer/newFileWizard/NewFileWizard.tsx @@ -1,17 +1,12 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -import { - Button, - Classes, - Dialog, - FormGroup, - Radio, - RadioGroup, -} from '@blueprintjs/core'; +import { Button, Classes, Dialog, FormGroup } from '@blueprintjs/core'; import React, { useCallback, useRef, useState } from 'react'; import { useId } from 'react-aria'; import { useDispatch } from 'react-redux'; +import { Hub } from '../../components/hubPicker'; +import { HubPicker } from '../../components/hubPicker/HubPicker'; import { useFileStorageMetadata } from '../../fileStorage/hooks'; import { FileNameValidationResult, @@ -20,7 +15,7 @@ import { } from '../../pybricksMicropython/lib'; import { useSelector } from '../../reducers'; import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup'; -import { Hub, newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; +import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions'; import { I18nId, useI18n } from './i18n'; // This should be set to the most commonly used hub. @@ -75,19 +70,7 @@ const NewFileWizard: React.VoidFunctionComponent = () => { onChange={setFileName} /> - setHubType(e.currentTarget.value as Hub)} - > - BOOST Move Hub - City Hub - Technic Hub - SPIKE Prime - SPIKE Essential - - MINDSTORMS Robot Inventor - - +

diff --git a/src/explorer/newFileWizard/actions.ts b/src/explorer/newFileWizard/actions.ts index 2ce71e67..b8d9ab20 100644 --- a/src/explorer/newFileWizard/actions.ts +++ b/src/explorer/newFileWizard/actions.ts @@ -2,28 +2,13 @@ // Copyright (c) 2022 The Pybricks Authors import { createAction } from '../../actions'; +import { Hub } from '../../components/hubPicker'; import { pythonFileExtension } from '../../pybricksMicropython/lib'; /** Supported file extensions. */ type SupportedFileExtension = typeof pythonFileExtension; -/** Supported hub types. */ -export enum Hub { - /** BOOST Move hub */ - Move = 'movehub', - /** City hub */ - City = 'cityhub', - /** Technic hub */ - Technic = 'technichub', - /** MINDSTORMS Robot Inventor hub */ - Inventor = 'inventorhub', - /** SPIKE Prime hub */ - Prime = 'primehub', - /** SPIKE Essential hub */ - Essential = 'essentialhub', -} - /** * Requests to show the new file wizard dialog. */ diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts index 2cfdf7f7..528618d7 100644 --- a/src/explorer/sagas.test.ts +++ b/src/explorer/sagas.test.ts @@ -6,6 +6,7 @@ import { FileWithHandle } from 'browser-fs-access'; import { mock } from 'jest-mock-extended'; import { AsyncSaga, uuid } from '../../test'; import { alertsShowAlert } from '../alerts/actions'; +import { Hub } from '../components/hubPicker'; import { editorActivateFile, editorCloseFile, @@ -72,7 +73,6 @@ import { } from './duplicateFileDialog/actions'; import { ExplorerError, ExplorerErrorName } from './error'; import { - Hub, newFileWizardDidAccept, newFileWizardDidCancel, newFileWizardShow, diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index e441c44c..793db68c 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -345,3 +345,45 @@ function didFailToFinishCreator( * @param total The total number of bytes to be flashed. */ export const didFailToFinish = createAction(didFailToFinishCreator); + +/** + * Action that triggers the install Pybricks firmware saga. + */ +export const firmwareInstallPybricks = createAction(() => ({ + type: 'firmware.action.installPybricks', +})); + +/** + * Action that indicates {@link firmwareInstallPybricks} succeeded. + */ +export const firmwareDidInstallPybricks = createAction(() => ({ + type: 'firmware.action.didInstallPybricks', +})); + +/** + * Action that indicates {@link firmwareInstallPybricks} failed. + */ +export const firmwareDidFailToInstallPybricks = createAction(() => ({ + type: 'firmware.action.didFailToInstallPybricks', +})); + +/** + * Action that triggers the restore LEGO firmware saga. + */ +export const firmwareRestoreLego = createAction(() => ({ + type: 'firmware.action.restoreLego', +})); + +/** + * Action that indicates {@link firmwareRestoreLego} succeeded. + */ +export const firmwareDidRestoreLego = createAction(() => ({ + type: 'firmware.action.didRestoreLego', +})); + +/** + * Action that indicates {@link firmwareRestoreLego} failed. + */ +export const firmwareDidFailToRestoreLego = createAction(() => ({ + type: 'firmware.action.didFailToRestoreLego', +})); diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx new file mode 100644 index 00000000..8b581277 --- /dev/null +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import './installPybricksDialog.scss'; +import { + Button, + Checkbox, + Classes, + ControlGroup, + DialogStep, + FormGroup, + IRef, + Icon, + InputGroup, + Intent, + MultistepDialog, + NonIdealState, + Spinner, + Switch, +} from '@blueprintjs/core'; +import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2'; +import classNames from 'classnames'; +import React, { useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { appName } from '../../app/constants'; +import HelpButton from '../../components/HelpButton'; +import { + Hub, + hubHasBluetoothButton, + hubHasExternalFlash, + hubHasUSB, +} from '../../components/hubPicker'; +import { HubPicker } from '../../components/hubPicker/HubPicker'; +import { useSelector } from '../../reducers'; +import { + firmwareInstallPybricksDialogAccept, + firmwareInstallPybricksDialogCancel, +} from './actions'; +import { useFirmware } from './hooks'; +import { I18nId, useI18n } from './i18n'; +import { validateHubName } from '.'; + +const dialogBody = classNames( + Classes.DIALOG_BODY, + 'pb-firmware-installPybricksDialog-body', +); + +type SelectHubPanelProps = { + hubType: Hub; + onChange: (hubType: Hub) => void; +}; + +const SelectHubPanel: React.VoidFunctionComponent = ({ + hubType, + onChange, +}) => { + const i18n = useI18n(); + + return ( +
+

{i18n.translate(I18nId.SelectHubPanelMessage)}

+ + +

+ {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsTitle, + )} +

+
    +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsRcx, + )} +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsNxt, + )} +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoMindstormsEv3, + )} +
  • +
+

+ {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpTitle, + )} +

+
    +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpWedo2, + )} + * +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain, + )} + * +
  • +
  • + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpMario, + )} +
  • +
+ + + *{' '} + {i18n.translate( + I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpFootnote, + )} + +
+ } + renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => ( + + )} + /> +
+ ); +}; + +type AcceptLicensePanelProps = { + hubType: Hub; + licenseAccepted: boolean; + onLicenseAcceptedChanged: (accepted: boolean) => void; +}; + +const AcceptLicensePanel: React.VoidFunctionComponent = ({ + hubType, + licenseAccepted, + onLicenseAcceptedChanged, +}) => { + const { data, error } = useFirmware(hubType); + const i18n = useI18n(); + + return ( +
+
+
+ {data &&
{data.licenseText}
} + {!data && ( + + {(error && + i18n.translate( + I18nId.LicensePanelLicenseTextError, + )) || } + + )} +
+ onLicenseAcceptedChanged(e.currentTarget.checked)} + disabled={!data} + /> +
+
+ ); +}; + +type SelectOptionsPanelProps = { + hubType: Hub; + hubName: string; + includeProgram: boolean; + onChangeHubName(hubName: string): void; + onChangeIncludeProgram(includeProgram: boolean): void; +}; + +const ConfigureOptionsPanel: React.VoidFunctionComponent = ({ + hubType, + hubName, + includeProgram, + onChangeHubName, + onChangeIncludeProgram, +}) => { + const i18n = useI18n(); + const isHubNameValid = validateHubName(hubName); + + return ( +
+ + + onChangeHubName(e.currentTarget.value)} + onMouseOver={(e) => e.preventDefault()} + onMouseDown={(e) => e.stopPropagation()} + intent={isHubNameValid ? Intent.NONE : Intent.DANGER} + placeholder="Pybricks Hub" + rightElement={ + isHubNameValid ? undefined : ( + + ) + } + /> + + + + + {(hubHasExternalFlash(hubType) && ( +

+ {i18n.translate( + I18nId.OptionsPanelCustomMainNotApplicableMessage, + )} +

+ )) || ( + + + onChangeIncludeProgram( + (e.target as HTMLInputElement).checked, + ) + } + /> + + + )} +
+
+ ); +}; + +type BootloaderModePanelProps = { + hubType: Hub; +}; + +const BootloaderModePanel: React.VoidFunctionComponent = ({ + hubType, +}) => { + const i18n = useI18n(); + + const { button, light, lightPattern } = useMemo(() => { + return { + button: i18n.translate( + hubHasBluetoothButton(hubType) + ? I18nId.BootloaderPanelButtonBluetooth + : I18nId.BootloaderPanelButtonPower, + ), + light: i18n.translate( + hubHasBluetoothButton(hubType) + ? I18nId.BootloaderPanelLightBluetooth + : I18nId.BootloaderPanelLightStatus, + ), + lightPattern: i18n.translate( + hubHasBluetoothButton(hubType) + ? I18nId.BootloaderPanelLightPatternBluetooth + : I18nId.BootloaderPanelLightPatternStatus, + ), + }; + }, [i18n, hubType]); + + return ( +
+

{i18n.translate(I18nId.BootloaderPanelInstruction1)}

+
    + {hubHasUSB(hubType) && ( +
  1. {i18n.translate(I18nId.BootloaderPanelStepDisconnectUsb)}
  2. + )} + +
  3. {i18n.translate(I18nId.BootloaderPanelStepPowerOff)}
  4. + + {/* City hub has power issues and requires disconnecting motors/sensors */} + {hubType === Hub.City && ( +
  5. {i18n.translate(I18nId.BootloaderPanelStepDisconnectIo)}
  6. + )} + +
  7. + {i18n.translate(I18nId.BootloaderPanelStepHoldButton, { button })} +
  8. + + {hubHasUSB(hubType) && ( +
  9. {i18n.translate(I18nId.BootloaderPanelStepConnectUsb)}
  10. + )} + +
  11. + {i18n.translate(I18nId.BootloaderPanelStepWaitForLight, { + button, + light, + lightPattern, + })} +
  12. + +
  13. + {i18n.translate( + /* hubs with USB will keep the power on, but other hubs won't */ + hubHasUSB(hubType) + ? I18nId.BootloaderPanelStepReleaseButton + : I18nId.BootloaderPanelStepKeepHolding, + { + button, + }, + )} +
  14. +
+

+ {i18n.translate(I18nId.BootloaderPanelInstruction2, { + flashFirmware: ( + + {i18n.translate(I18nId.FlashFirmwareButtonLabel)} + + ), + })} +

+
+ ); +}; + +const defaultHubType = Hub.Technic; + +export const InstallPybricksDialog: React.VoidFunctionComponent = () => { + const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog); + const dispatch = useDispatch(); + const [hubType, setHubType] = useState(defaultHubType); + const [hubName, setHubName] = useState(''); + const [includeProgram, setIncludeProgram] = useState(false); + const [licenseAccepted, setLicenseAccepted] = useState(false); + const { data } = useFirmware(hubType); + const i18n = useI18n(); + + return ( + dispatch(firmwareInstallPybricksDialogCancel())} + finalButtonProps={{ + text: i18n.translate(I18nId.FlashFirmwareButtonLabel), + onClick: () => + dispatch( + firmwareInstallPybricksDialogAccept( + data?.firmwareZip ?? new ArrayBuffer(0), + undefined, + hubName, + ), + ), + }} + > + } + nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }} + /> + + } + backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }} + nextButtonProps={{ + disabled: !licenseAccepted, + text: i18n.translate(I18nId.NextButtonLabel), + }} + /> + + } + backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }} + nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }} + /> + } + backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }} + /> + + ); +}; diff --git a/src/firmware/installPybricksDialog/actions.ts b/src/firmware/installPybricksDialog/actions.ts new file mode 100644 index 00000000..7b9e6722 --- /dev/null +++ b/src/firmware/installPybricksDialog/actions.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { createAction } from '../../actions'; + +/** Actions that request the install Pybricks firmware dialog to be shown. */ +export const firmwareInstallPybricksDialogShow = createAction(() => ({ + type: 'firmware.installPybricksDialog.action.show', +})); + +/** Actions that indicates the user accepted the install Pybricks firmware dialog. */ +export const firmwareInstallPybricksDialogAccept = createAction( + (firmwareZip: ArrayBuffer, customProgram: string | undefined, hubName: string) => ({ + type: 'firmware.installPybricksDialog.action.accept', + firmwareZip, + customProgram, + hubName, + }), +); + +/** Actions that indicates the user canceled the install Pybricks firmware dialog. */ +export const firmwareInstallPybricksDialogCancel = createAction(() => ({ + type: 'firmware.installPybricksDialog.action.cancel', +})); diff --git a/src/firmware/installPybricksDialog/hooks.ts b/src/firmware/installPybricksDialog/hooks.ts new file mode 100644 index 00000000..ad0581ed --- /dev/null +++ b/src/firmware/installPybricksDialog/hooks.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors +// based on https://usehooks-ts.com/react-hook/use-fetch + +import { FirmwareReader } from '@pybricks/firmware'; +import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; +import moveHubZip from '@pybricks/firmware/build/movehub.zip'; +import technicHubZip from '@pybricks/firmware/build/technichub.zip'; +import { useEffect, useReducer, useRef } from 'react'; +import { Hub } from '../../components/hubPicker'; + +type FirmwareData = { + firmwareZip: ArrayBuffer; + licenseText: string; +}; + +interface State { + /** The firmware.zip data or undefined if `fetch()` is not complete or on error. */ + data?: FirmwareData; + /** Undefined `fetch()` is not complete yet or was successful, otherwise the error. */ + error?: Error; +} + +type Cache = { [url: string]: FirmwareData }; + +// discriminated union type +type Action = + | { type: 'loading' } + | { type: 'fetched'; payload: FirmwareData } + | { type: 'error'; payload: Error }; + +const firmwareZipMap = new Map([ + [Hub.City, cityHubZip], + [Hub.Technic, technicHubZip], + [Hub.Move, moveHubZip], +]); + +/** + * Gets Pybricks firmware .zip file for the specified hub type. + * @param hubType The hub type. + * @returns The current state. + */ +export function useFirmware(hubType: Hub): State { + const url = firmwareZipMap.get(hubType); + const cache = useRef({}); + + // Used to prevent state update if the component is unmounted + const cancelRequest = useRef(false); + + const initialState: State = { + error: undefined, + data: undefined, + }; + + // Keep state logic separated + const fetchReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'loading': + return { ...initialState }; + case 'fetched': + return { ...initialState, data: action.payload }; + case 'error': + return { ...initialState, error: action.payload }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(fetchReducer, initialState); + + useEffect(() => { + // Do nothing if the url is not given + if (!url) { + return; + } + + cancelRequest.current = false; + + const fetchData = async () => { + dispatch({ type: 'loading' }); + + // If a cache exists for this url, return it + if (cache.current[url]) { + dispatch({ type: 'fetched', payload: cache.current[url] }); + return; + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.statusText); + } + + const firmwareZip = await response.arrayBuffer(); + const reader = await FirmwareReader.load(firmwareZip); + const licenseText = await reader.readReadMeOss(); + const data = { firmwareZip, licenseText }; + + cache.current[url] = data; + if (cancelRequest.current) { + return; + } + + dispatch({ type: 'fetched', payload: data }); + } catch (error) { + if (process.env.NODE_ENV !== 'test') { + console.error(error); + } + + if (cancelRequest.current) { + return; + } + + dispatch({ type: 'error', payload: error as Error }); + } + }; + + void fetchData(); + + // Use the cleanup function for avoiding a possible + // state update after the component was unmounted + return () => { + cancelRequest.current = true; + }; + }, [url]); + + return state; +} diff --git a/src/toolbar/buttons/flash/i18n.test.ts b/src/firmware/installPybricksDialog/i18n.en.test.ts similarity index 75% rename from src/toolbar/buttons/flash/i18n.test.ts rename to src/firmware/installPybricksDialog/i18n.en.test.ts index b8f901e0..3b098b20 100644 --- a/src/toolbar/buttons/flash/i18n.test.ts +++ b/src/firmware/installPybricksDialog/i18n.en.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors -import { lookup } from '../../../../test'; +import { lookup } from '../../../test'; import { I18nId } from './i18n'; import en from './translations/en.json'; diff --git a/src/firmware/installPybricksDialog/i18n.ts b/src/firmware/installPybricksDialog/i18n.ts new file mode 100644 index 00000000..ffd9e779 --- /dev/null +++ b/src/firmware/installPybricksDialog/i18n.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021-2022 The Pybricks Authors +// +// Settings translation keys. + +import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n'; + +export function useI18n(): I18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} + +export enum I18nId { + Title = 'title', + SelectHubPanelTitle = 'selectHubPanel.title', + SelectHubPanelMessage = 'selectHubPanel.message', + SelectHubPanelNotOnListButtonLabel = 'selectHubPanel.notOnListButton.label', + SelectHubPanelNotOnListButtonInfoMindstormsTitle = 'selectHubPanel.notOnListButton.info.mindstorms.title', + SelectHubPanelNotOnListButtonInfoMindstormsRcx = 'selectHubPanel.notOnListButton.info.mindstorms.rcx', + SelectHubPanelNotOnListButtonInfoMindstormsNxt = 'selectHubPanel.notOnListButton.info.mindstorms.nxt', + SelectHubPanelNotOnListButtonInfoMindstormsEv3 = 'selectHubPanel.notOnListButton.info.mindstorms.ev3', + SelectHubPanelNotOnListButtonInfoPoweredUpTitle = 'selectHubPanel.notOnListButton.info.poweredUp.title', + SelectHubPanelNotOnListButtonInfoPoweredUpWedo2 = 'selectHubPanel.notOnListButton.info.poweredUp.wedo2', + SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain = 'selectHubPanel.notOnListButton.info.poweredUp.duploTrain', + SelectHubPanelNotOnListButtonInfoPoweredUpMario = 'selectHubPanel.notOnListButton.info.poweredUp.mario', + SelectHubPanelNotOnListButtonInfoPoweredUpFootnote = 'selectHubPanel.notOnListButton.info.poweredUp.footnote', + LicensePanelTitle = 'licensePanel.title', + LicensePanelLicenseTextError = 'licensePanel.licenseText.error', + LicensePanelAcceptCheckboxLabel = 'licensePanel.acceptCheckbox.label', + OptionsPanelTitle = 'optionsPanel.title', + OptionsPanelHubNameLabel = 'optionsPanel.hubName.label', + OptionsPanelHubNameLabelInfo = 'optionsPanel.hubName.labelInfo', + OptionsPanelHubNameHelp = 'optionsPanel.hubName.help', + OptionsPanelHubNameError = 'optionsPanel.hubName.error', + OptionsPanelCustomMainLabel = 'optionsPanel.customMain.label', + OptionsPanelCustomMainLabelInfo = 'optionsPanel.customMain.labelInfo', + OptionsPanelCustomMainNotApplicableMessage = 'optionsPanel.customMain.notApplicable.message', + OptionsPanelCustomMainIncludeCurrentProgramLabel = 'optionsPanel.customMain.includeCurrentProgram.label', + OptionsPanelCustomMainIncludeCurrentProgramHelp = 'optionsPanel.customMain.includeCurrentProgram.help', + BootloaderPanelTitle = 'bootloaderPanel.title', + BootloaderPanelInstruction1 = 'bootloaderPanel.instruction1', + BootloaderPanelButtonBluetooth = 'bootloaderPanel.button.bluetooth', + BootloaderPanelButtonPower = 'bootloaderPanel.button.power', + BootloaderPanelLightBluetooth = 'bootloaderPanel.light.bluetooth', + BootloaderPanelLightStatus = 'bootloaderPanel.light.status', + BootloaderPanelLightPatternBluetooth = 'bootloaderPanel.lightPattern.bluetooth', + BootloaderPanelLightPatternStatus = 'bootloaderPanel.lightPattern.status', + BootloaderPanelStepDisconnectUsb = 'bootloaderPanel.step.disconnectUsb', + BootloaderPanelStepPowerOff = 'bootloaderPanel.step.powerOff', + BootloaderPanelStepDisconnectIo = 'bootloaderPanel.step.disconnectIo', + BootloaderPanelStepHoldButton = 'bootloaderPanel.step.holdButton', + BootloaderPanelStepConnectUsb = 'bootloaderPanel.step.connectUsb', + BootloaderPanelStepWaitForLight = 'bootloaderPanel.step.waitForLight', + BootloaderPanelStepReleaseButton = 'bootloaderPanel.step.releaseButton', + BootloaderPanelStepKeepHolding = 'bootloaderPanel.step.keepHolding', + BootloaderPanelInstruction2 = 'bootloaderPanel.instruction2', + NextButtonLabel = 'nextButton.label', + BackButtonLabel = 'backButton.label', + FlashFirmwareButtonLabel = 'flashFirmwareButton.label', +} diff --git a/src/firmware/installPybricksDialog/index.ts b/src/firmware/installPybricksDialog/index.ts new file mode 100644 index 00000000..2323fb34 --- /dev/null +++ b/src/firmware/installPybricksDialog/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +const encoder = new TextEncoder(); + +/** + * Validates the hub name. + * @param hubName The hub name. + * @returns True if the name if valid, otherwise false. + */ +export function validateHubName(hubName: string): boolean { + const encoded = encoder.encode(hubName); + + // Technically, the max hub name size is determined by each individual + // firmware file, so we can't check until the firmware has been selected. + // However all firmware currently have 16 bytes allocated (including zero- + // termination), so we can hard code the check here to allow notifying the + // user earlier for better UX. + return encoded.length < 16; +} diff --git a/src/firmware/installPybricksDialog/installPybricksDialog.scss b/src/firmware/installPybricksDialog/installPybricksDialog.scss new file mode 100644 index 00000000..3ea0a378 --- /dev/null +++ b/src/firmware/installPybricksDialog/installPybricksDialog.scss @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +.pb-firmware-installPybricksDialog { + &-body { + min-height: 250px; + } + + &-license { + display: flex; + flex-direction: column; + gap: 10px; + min-height: inherit; + + &-text { + flex-grow: 1; + min-height: 0; + max-height: 200px; + overflow: auto; + } + } +} diff --git a/src/firmware/installPybricksDialog/reducers.ts b/src/firmware/installPybricksDialog/reducers.ts new file mode 100644 index 00000000..afa2694a --- /dev/null +++ b/src/firmware/installPybricksDialog/reducers.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Reducer, combineReducers } from '@reduxjs/toolkit'; +import { + firmwareInstallPybricksDialogAccept, + firmwareInstallPybricksDialogCancel, + firmwareInstallPybricksDialogShow, +} from './actions'; + +/** Controls the flash Pybricks firmware dialog open state. */ +const isOpen: Reducer = (state = false, action) => { + if (firmwareInstallPybricksDialogShow.matches(action)) { + return true; + } + + if (firmwareInstallPybricksDialogAccept.matches(action)) { + return false; + } + + if (firmwareInstallPybricksDialogCancel.matches(action)) { + return false; + } + + return state; +}; + +export default combineReducers({ isOpen }); diff --git a/src/firmware/installPybricksDialog/translations/en.json b/src/firmware/installPybricksDialog/translations/en.json new file mode 100644 index 00000000..d8a7e673 --- /dev/null +++ b/src/firmware/installPybricksDialog/translations/en.json @@ -0,0 +1,90 @@ +{ + "title": "Install Pybricks Firmware", + "selectHubPanel": { + "title": "Select hub type", + "message": "Which kind of hub do you want to use?", + "notOnListButton": { + "label": "My hub is not in the list.", + "info": { + "mindstorms": { + "title": "MINDSTORMS Programmable Bricks", + "rcx": "RCX - not enough memory to run Pybricks", + "nxt": "NXT - maybe some day", + "ev3": "EV3 - supported using VS Code instead of Pybricks Code" + }, + "poweredUp": { + "title": "Unsupported Powered Up Hubs", + "wedo2": "WeDo 2.0 Smart hub", + "duploTrain": "Duplo Train hub", + "mario": "Mario/Luigi/Peach", + "footnote": "firmware cannot be updated" + } + } + } + }, + "licensePanel": { + "title": "Accept licenses", + "licenseText": { + "error": "There was a problem while getting the firmware file." + }, + "acceptCheckbox": { + "label": "I have read and agree to the license terms and conditions." + } + }, + "optionsPanel": { + "title": "Configure options", + "hubName": { + "label": "Hub name", + "labelInfo": "(optional)", + "help": "Enter a name here to customize the hub name when flashing the firmware. This name will be used in the Bluetooth advertising data and can be used to identify the hub when connecting.", + "error": "The name is too long." + }, + "customMain": { + "label": "Custom program", + "labelInfo": "(optional)", + "notApplicable": { + "message": "This hub has external flash memory so including a custom program when flashing firmware is not needed." + }, + "includeCurrentProgram": { + "label": "Include current program", + "help": "Enable to include your program when flashing the firmware or disable to use the default program. Flashing your program along with the firmware will allow you to run your program without being connected to {appName}" + } + } + }, + "bootloaderPanel": { + "title": "Place hub in bootloader mode", + "instruction1": "To flash the firmware, the hub must be placed in bootloader mode. Follow the steps below to do this:", + "button": { + "bluetooth": "Bluetooth button", + "power": "power button" + }, + "light": { + "bluetooth": "Bluetooth light", + "status": "hub status light" + }, + "lightPattern": { + "bluetooth": "pink-green-blue-off", + "status": "light purple" + }, + "step": { + "disconnectUsb": "Disconnect the USB cable from the hub.", + "powerOff": "Turn off the hub.", + "disconnectIo": "Disconnect all motors and sensors from the I/O ports on the hub.", + "holdButton": "Press and hold the {button} on the hub.", + "connectUsb": "Connect the USB cable.", + "waitForLight": "Keep holding the {button} and wait for the {light} to start flashing {lightPattern}. This takes about 5 seconds.", + "releaseButton": "Release the {button}", + "keepHolding": "Keep holding the {button}." + }, + "instruction2": "Then click the {flashFirmware} button below to connect to the hub and flash the firmware." + }, + "backButton": { + "label": "Back" + }, + "nextButton": { + "label": "Next" + }, + "flashFirmwareButton": { + "label": "Flash Firmware" + } +} diff --git a/src/firmware/reducers.test.ts b/src/firmware/reducers.test.ts index a910f8e0..c264aa26 100644 --- a/src/firmware/reducers.test.ts +++ b/src/firmware/reducers.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { AnyAction } from 'redux'; import { @@ -17,6 +17,9 @@ test('initial state', () => { expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { "flashing": false, + "installPybricksDialog": Object { + "isOpen": false, + }, "progress": null, } `); diff --git a/src/firmware/reducers.ts b/src/firmware/reducers.ts index 97bd6c12..19ad63c1 100644 --- a/src/firmware/reducers.ts +++ b/src/firmware/reducers.ts @@ -3,6 +3,7 @@ import { Reducer, combineReducers } from 'redux'; import { didFailToFinish, didFinish, didProgress, didStart } from './actions'; +import installPybricksDialog from './installPybricksDialog/reducers'; const flashing: Reducer = (state = false, action) => { if (didStart.matches(action)) { @@ -28,4 +29,4 @@ const progress: Reducer = (state = null, action) => { return state; }; -export default combineReducers({ flashing, progress }); +export default combineReducers({ installPybricksDialog, flashing, progress }); diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 878e047c..2da5bcdb 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -62,8 +62,14 @@ import { didFinish, didProgress, didStart, + firmwareInstallPybricks, flashFirmware, } from './actions'; +import { + firmwareInstallPybricksDialogAccept, + firmwareInstallPybricksDialogCancel, + firmwareInstallPybricksDialogShow, +} from './installPybricksDialog/actions'; const firmwareZipMap = new Map([ [HubType.CityHub, cityHubZip], @@ -477,6 +483,23 @@ function* handleFlashFirmware(action: ReturnType): Generat } } +function* handleInstallPybricks(): Generator { + yield* put(firmwareInstallPybricksDialogShow()); + const { accepted, canceled } = yield* race({ + accepted: take(firmwareInstallPybricksDialogAccept), + canceled: take(firmwareInstallPybricksDialogCancel), + }); + + if (canceled) { + return; + } + + defined(accepted); + + yield* put(flashFirmware(accepted.firmwareZip, false, accepted.hubName)); +} + export default function* (): Generator { yield* takeEvery(flashFirmware, handleFlashFirmware); + yield* takeEvery(firmwareInstallPybricks, handleInstallPybricks); } diff --git a/src/settings/Settings.test.tsx b/src/settings/Settings.test.tsx index 59e2f381..5f19301d 100644 --- a/src/settings/Settings.test.tsx +++ b/src/settings/Settings.test.tsx @@ -4,6 +4,7 @@ import { cleanup, getByLabelText, waitFor } from '@testing-library/react'; import React from 'react'; import { testRender } from '../../test'; +import { firmwareInstallPybricks, firmwareRestoreLego } from '../firmware/actions'; import Settings from './Settings'; afterEach(() => { @@ -41,41 +42,27 @@ describe('darkMode setting switch', () => { }); }); -describe('flashCurrentProgram setting switch', () => { - it('should toggle the setting', async () => { - const [user, settings] = testRender(); +describe('firmware', () => { + it('should dispatch action when install Pybricks firmware button is clicked', async () => { + const [user, settings, dispatch] = testRender(); - expect(localStorage.getItem('setting.flashCurrentProgram')).toBe(null); + const button = settings.getByRole('button', { + name: 'Install Pybricks Firmware', + }); + await user.click(button); - await user.click(settings.getByLabelText('Include current program')); - expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('true'); - - await user.click(settings.getByLabelText('Include current program')); - expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('false'); - }); -}); - -describe('hubName setting', () => { - it('should migrate old settings', () => { - // old settings did not use json format, so lack quotes - localStorage.setItem('setting.hubName', 'old name'); - - const [, settings] = testRender(); - - const textBox = settings.getByLabelText('Hub name'); - - expect(textBox).toHaveValue('old name'); + expect(dispatch).toHaveBeenCalledWith(firmwareInstallPybricks()); }); - it('should update the setting', async () => { - const [user, settings] = testRender(); + it('should dispatch action when restore official LEGO firmware button is clicked', async () => { + const [user, settings, dispatch] = testRender(); - expect(localStorage.getItem('setting.hubName')).toBe(null); + const button = settings.getByRole('button', { + name: 'Restore Official LEGO® Firmware', + }); + await user.click(button); - const textBox = settings.getByLabelText('Hub name'); - await user.type(textBox, 'test name'); - - expect(localStorage.getItem('setting.hubName')).toBe('"test name"'); + expect(dispatch).toHaveBeenCalledWith(firmwareRestoreLego()); }); }); diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index ccd28ac9..67c5aca2 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -6,10 +6,6 @@ import { ButtonGroup, ControlGroup, FormGroup, - Icon, - InputGroup, - Intent, - Label, Switch, } from '@blueprintjs/core'; import React, { useState } from 'react'; @@ -18,7 +14,6 @@ import { useTernaryDarkMode } from 'usehooks-ts'; import AboutDialog from '../about/AboutDialog'; import { appCheckForUpdate, appReload, appShowInstallPrompt } from '../app/actions'; import { - appName, pybricksBugReportsUrl, pybricksGitterUrl, pybricksProjectsUrl, @@ -26,15 +21,13 @@ import { } from '../app/constants'; import { Button } from '../components/Button'; import HelpButton from '../components/HelpButton'; +import { firmwareInstallPybricks, firmwareRestoreLego } from '../firmware/actions'; +import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog'; import { pseudolocalize } from '../i18n'; import { useSelector } from '../reducers'; import ExternalLinkIcon from '../utils/ExternalLinkIcon'; import { isMacOS } from '../utils/os'; -import { - useSettingFlashCurrentProgram, - useSettingHubName, - useSettingIsShowDocsEnabled, -} from './hooks'; +import { useSettingIsShowDocsEnabled } from './hooks'; import { I18nId, useI18n } from './i18n'; import './settings.scss'; @@ -44,8 +37,6 @@ const Settings: React.VoidFunctionComponent = () => { const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false); const { isDarkMode, setTernaryDarkMode } = useTernaryDarkMode(); - const [isFlashCurrentProgramEnabled, setIsFlashCurrentProgramEnabled] = - useSettingFlashCurrentProgram(); const isServiceWorkerRegistered = useSelector( (s) => s.app.isServiceWorkerRegistered, ); @@ -56,7 +47,6 @@ const Settings: React.VoidFunctionComponent = () => { ); const promptingInstall = useSelector((s) => s.app.promptingInstall); const readyForOfflineUse = useSelector((s) => s.app.readyForOfflineUse); - const { hubName, isHubNameValid, setHubName } = useSettingHubName(); const dispatch = useDispatch(); @@ -107,52 +97,19 @@ const Settings: React.VoidFunctionComponent = () => { - - - setIsFlashCurrentProgramEnabled( - (e.target as HTMLInputElement).checked, - ) - } - /> - - - - - setHubName(e.currentTarget.value)} - onMouseOver={(e) => e.preventDefault()} - onMouseDown={(e) => e.stopPropagation()} - intent={isHubNameValid ? Intent.NONE : Intent.DANGER} - placeholder="Pybricks Hub" - rightElement={ - isHubNameValid ? undefined : ( - - ) - } - /> - - + diff --git a/src/ble/alerts/OldFirmware.tsx b/src/ble/alerts/OldFirmware.tsx new file mode 100644 index 00000000..a396bbc0 --- /dev/null +++ b/src/ble/alerts/OldFirmware.tsx @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import './index.scss'; +import { Button, Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +type OldFirmwareProps = { + onFlashFirmware: () => void; +}; + +const OldFirmware: React.VoidFunctionComponent = ({ + onFlashFirmware, +}) => { + const i18n = useI18n(); + + return ( + <> +

{i18n.translate(I18nId.OldFirmwareMessage)}

+
+ +
+ + ); +}; + +export const oldFirmware: CreateToast = ( + onAction, +) => { + return { + message: onAction('flashFirmware')} />, + icon: 'info-sign', + intent: Intent.PRIMARY, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/ble/alerts/i18n.ts b/src/ble/alerts/i18n.ts index 81045edd..20fca0a1 100644 --- a/src/ble/alerts/i18n.ts +++ b/src/ble/alerts/i18n.ts @@ -24,4 +24,6 @@ export enum I18nId { NoHubSuggestion2 = 'noHub.suggestion2', NoHubFlashFirmwareButton = 'noHub.flashFirmwareButton', NoHubTroubleshootButton = 'noHub.troubleshootButton', + OldFirmwareMessage = 'oldFirmware.message', + OldFirmwareFlashFirmwareLabel = 'oldFirmware.flashFirmware.label', } diff --git a/src/ble/alerts/noHub.scss b/src/ble/alerts/index.scss similarity index 86% rename from src/ble/alerts/noHub.scss rename to src/ble/alerts/index.scss index c011e9a3..7771c733 100644 --- a/src/ble/alerts/noHub.scss +++ b/src/ble/alerts/index.scss @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -.pb-ble-alerts-noHub { +.pb-ble-alerts { &-buttons { display: flex; gap: 10px; diff --git a/src/ble/alerts/index.ts b/src/ble/alerts/index.ts index 2e5ec4dd..22b7ffc0 100644 --- a/src/ble/alerts/index.ts +++ b/src/ble/alerts/index.ts @@ -6,6 +6,14 @@ import { missingService } from './MissingService'; import { noGatt } from './NoGatt'; import { noHub } from './NoHub'; import { noWebBluetooth } from './NoWebBluetooth'; +import { oldFirmware } from './OldFirmware'; // gathers all of the alert creation functions for passing up to the top level -export default { bluetoothNotAvailable, missingService, noGatt, noHub, noWebBluetooth }; +export default { + bluetoothNotAvailable, + missingService, + noGatt, + noHub, + noWebBluetooth, + oldFirmware, +}; diff --git a/src/ble/alerts/translations/en.json b/src/ble/alerts/translations/en.json index 94c9f34d..fe9bfd18 100644 --- a/src/ble/alerts/translations/en.json +++ b/src/ble/alerts/translations/en.json @@ -23,5 +23,11 @@ "suggestion2": "If you have flashed the Pybricks firmware to the hub already and you are still having problems connecting, please visit the troubleshooting guide.", "flashFirmwareButton": "Flash Firmware", "troubleshootButton": "Troubleshooting Tips" + }, + "oldFirmware": { + "message": "A new firmware version is available for this hub. Please install the latest version to use all new features.", + "flashFirmware": { + "label": "Flash firmware now" + } } } diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts index f58f2086..5b973691 100644 --- a/src/ble/sagas.test.ts +++ b/src/ble/sagas.test.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -import { HubType } from '@pybricks/firmware'; import { MockProxy, mock } from 'jest-mock-extended'; import { AsyncSaga } from '../../test'; import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions'; @@ -17,6 +16,7 @@ import { softwareRevisionStringUUID, } from '../ble-device-info-service/protocol'; import { encodeInfo } from '../ble-device-info-service/protocol.test'; +import { HubType } from '../ble-lwp3-service/protocol'; import { nordicUartRxCharUUID, nordicUartServiceUUID, @@ -204,6 +204,8 @@ async function runConnectUntil(saga: AsyncSaga, point: ConnectRunPoint): Promise bleDIServiceDidReceiveFirmwareRevision('3.2.0b2'), ); + await expect(saga.take()).resolves.toEqual(alertsShowAlert('ble', 'oldFirmware')); + if (point === ConnectRunPoint.DidReceiveFirmwareRevision) { return; } diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index 87d43e1f..f291af2a 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -6,7 +6,9 @@ // 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, @@ -14,6 +16,7 @@ import { fork, put, select, + spawn, take, takeEvery, } from 'typed-redux-saga/macro'; @@ -54,6 +57,7 @@ import { import { firmwareInstallPybricks } from '../firmware/actions'; import { RootState } from '../reducers'; import { ensureError } from '../utils'; +import { pythonVersionToSemver } from '../utils/version'; import { bleConnectPybricks as bleConnectPybricks, bleDidConnectPybricks, @@ -219,6 +223,35 @@ function* handleBleConnectPybricks(): Generator { ); 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> + >( + 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), ); diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index e8fd902d..eceeb4b2 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -39,5 +39,4 @@ export enum I18nId { ServiceWorkerUpdateMessage = 'serviceWorker.update.message', ServiceWorkerUpdateAction = 'serviceWorker.update.action', MpyError = 'mpy.error', - CheckFirmwareTooOld = 'check.firmwareTooOld', } diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index ed48626c..4ad88de6 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -2,16 +2,11 @@ // Copyright (c) 2021-2022 The Pybricks Authors import { IToaster } from '@blueprintjs/core'; -import { - FirmwareReaderError, - FirmwareReaderErrorCode, - firmwareVersion, -} from '@pybricks/firmware'; +import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware'; import { I18nManager } from '@shopify/react-i18n'; import { AnyAction } from 'redux'; import { AsyncSaga, uuid } from '../../test'; import { appDidCheckForUpdate } from '../app/actions'; -import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; import { editorDidFailToOpenFile } from '../editor/actions'; import { EditorError } from '../editor/error'; import { @@ -91,7 +86,6 @@ test.each([ didFailToFinish(FailToFinishReasonType.FirmwareSize), didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')), appDidCheckForUpdate(false), - bleDIServiceDidReceiveFirmwareRevision('3.0.0'), fileStorageDidFailToInitialize(new Error('test error')), explorerDidFailToImportFiles(new Error('test error')), explorerDidFailToCreateNewFile(new Error('test error')), @@ -118,7 +112,6 @@ test.each([ didFailToFinish(FailToFinishReasonType.FailedToConnect), serviceWorkerDidSucceed(), appDidCheckForUpdate(true), - bleDIServiceDidReceiveFirmwareRevision(firmwareVersion), explorerDidFailToImportFiles(new DOMException('test message', 'AbortError')), explorerDidFailToCreateNewFile(new DOMException('test message', 'AbortError')), explorerDidFailToDuplicateFile( diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 33636225..89a84665 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -4,16 +4,13 @@ // Saga for managing notifications (toasts) import { ActionProps, IToaster, IconName, Intent, LinkProps } from '@blueprintjs/core'; -import { firmwareVersion } from '@pybricks/firmware'; import { Replacements } from '@shopify/react-i18n'; import React from 'react'; import { channel } from 'redux-saga'; -import * as semver from 'semver'; import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro'; import { getAlertProps } from '../alerts'; import { appDidCheckForUpdate, appReload } from '../app/actions'; import { appName } from '../app/constants'; -import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions'; import { editorDidFailToOpenFile } from '../editor/actions'; import { EditorError } from '../editor/error'; import { @@ -31,7 +28,6 @@ import { } from '../lwp3-bootloader/actions'; import { didCompile, didFailToCompile } from '../mpy/actions'; import { serviceWorkerDidUpdate } from '../service-worker/actions'; -import { pythonVersionToSemver } from '../utils/version'; import NotificationAction from './NotificationAction'; import NotificationMessage from './NotificationMessage'; import { add as addNotification } from './actions'; @@ -309,21 +305,6 @@ function* showNoUpdateInfo(action: ReturnType): Gen }); } -function* checkVersion( - action: ReturnType, -): Generator { - // ensure the actual hub firmware version is the same as the shipped - // firmware version or newer - if ( - !semver.satisfies( - pythonVersionToSemver(action.version), - `>=${pythonVersionToSemver(firmwareVersion)}`, - ) - ) { - yield* showSingleton(Level.Error, I18nId.CheckFirmwareTooOld); - } -} - function* showFileStorageFailToInitialize( action: ReturnType, ): Generator { @@ -405,7 +386,6 @@ export default function* (): Generator { yield* takeEvery(addNotification, handleAddNotification); yield* takeEvery(serviceWorkerDidUpdate, showServiceWorkerUpdate); yield* takeEvery(appDidCheckForUpdate, showNoUpdateInfo); - yield* takeEvery(bleDIServiceDidReceiveFirmwareRevision, checkVersion); yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize); yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles); yield* takeEvery(explorerDidFailToCreateNewFile, showExplorerFailToCreateFile); diff --git a/src/notifications/translations/en.json b/src/notifications/translations/en.json index f2d956aa..5e5de875 100644 --- a/src/notifications/translations/en.json +++ b/src/notifications/translations/en.json @@ -43,8 +43,5 @@ "message": "A new version of {appName} is available. Click {action} to start using the new version.", "action": "Restart" } - }, - "check": { - "firmwareTooOld": "A new firmware version is available for this hub. Please install the latest version to use all new features." } } From 709c3c554b9424f3293969056c4b4c2e6e97ef5b Mon Sep 17 00:00:00 2001 From: David Lechner Date: Wed, 20 Jul 2022 18:01:35 -0500 Subject: [PATCH 22/26] bump @pybricks/firmware to 5.0.0 This contains a breaking change where main.py is now optional so we have to handle that case. --- package.json | 2 +- src/firmware/sagas.ts | 8 +++++++- yarn.lock | 10 +++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index d98e71f8..5baf750a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@blueprintjs/popover2": "^1.4.2", "@blueprintjs/select": "^4.4.2", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", - "@pybricks/firmware": "4.17.0", + "@pybricks/firmware": "5.0.0", "@pybricks/ide-docs": "2.2.0", "@pybricks/mpy-cross-v5": "^2.0.0", "@pybricks/mpy-cross-v6": "^2.0.0", diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index aae9e780..62d1f483 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -201,11 +201,17 @@ function* loadFirmware( const firmwareBase = yield* call(() => reader.readFirmwareBase()); const metadata = yield* call(() => reader.readMetadata()); - // if a user program was not given, then use main.py from the frimware.zip + // if a user program was not given, then use main.py from the firmware.zip if (program === undefined) { program = yield* call(() => reader.readMainPy()); } + // REVISIT: the firmware may eventually be changed to allow no main.py + // for now, ensure there is a program even if it does nothing + if (!program) { + program = ''; + } + if (![5, 6].includes(metadata['mpy-abi-version'])) { yield* put( didFailToFinish( diff --git a/yarn.lock b/yarn.lock index 43188e5b..6d92e8a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2280,12 +2280,12 @@ __metadata: languageName: node linkType: hard -"@pybricks/firmware@npm:4.17.0": - version: 4.17.0 - resolution: "@pybricks/firmware@npm:4.17.0" +"@pybricks/firmware@npm:5.0.0": + version: 5.0.0 + resolution: "@pybricks/firmware@npm:5.0.0" dependencies: jszip: ^3.7.1 - checksum: 68d35727d101024f0c58bef0e7a8f245c7c5cbc8b38b2d3c638efc56a1ae84e03e52c596e3bfcdf0d22c1c9add4c2037d8fe4bf54e69b78763ec8c1169cc8517 + checksum: 049dd90e988aa574cfa0ead1e62bcb74e6fdfc9b709bc1c40874ddf3abb63cd35555d22806c91184bc2e982912a001e0ef94ef72ef66217eb0319bcaf45a7cb3 languageName: node linkType: hard @@ -2319,7 +2319,7 @@ __metadata: "@blueprintjs/popover2": ^1.4.2 "@blueprintjs/select": ^4.4.2 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.7 - "@pybricks/firmware": 4.17.0 + "@pybricks/firmware": 5.0.0 "@pybricks/ide-docs": 2.2.0 "@pybricks/mpy-cross-v5": ^2.0.0 "@pybricks/mpy-cross-v6": ^2.0.0 From bb6f86ff18d1874ffc7ab88a27695e971e9a43dc Mon Sep 17 00:00:00 2001 From: David Lechner Date: Wed, 20 Jul 2022 18:51:45 -0500 Subject: [PATCH 23/26] firmware/installPybricksDialog: add bootloader discrimination Also fix license not showing for newer hubs. --- src/components/hubPicker/index.ts | 14 ++++++++++++++ .../InstallPybricksDialog.tsx | 2 ++ src/firmware/installPybricksDialog/actions.ts | 11 ++++++++++- src/firmware/installPybricksDialog/hooks.ts | 7 ++++++- src/firmware/sagas.ts | 14 +++++++++++--- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/components/hubPicker/index.ts b/src/components/hubPicker/index.ts index 95f65b2b..a4200da3 100644 --- a/src/components/hubPicker/index.ts +++ b/src/components/hubPicker/index.ts @@ -57,3 +57,17 @@ export function hubHasExternalFlash(hub: Hub): boolean { return false; } } + +/** Gets the bootloader type for the hub. */ +export function hubBootloaderType(hub: Hub) { + switch (hub) { + case Hub.Prime: + case Hub.Essential: + case Hub.Inventor: + return 'usb-lego-dfu'; + case Hub.Move: + case Hub.City: + case Hub.Technic: + return 'ble-lwp3-bootloader'; + } +} diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx index 664ff4f2..3236c55e 100644 --- a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -28,6 +28,7 @@ import { appName } from '../../app/constants'; import HelpButton from '../../components/HelpButton'; import { Hub, + hubBootloaderType, hubHasBluetoothButton, hubHasExternalFlash, hubHasUSB, @@ -426,6 +427,7 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => { onClick: () => dispatch( firmwareInstallPybricksDialogAccept( + hubBootloaderType(hubType), data?.firmwareZip ?? new ArrayBuffer(0), selectedIncludeFile?.path, hubName, diff --git a/src/firmware/installPybricksDialog/actions.ts b/src/firmware/installPybricksDialog/actions.ts index 9b3495ce..6653de68 100644 --- a/src/firmware/installPybricksDialog/actions.ts +++ b/src/firmware/installPybricksDialog/actions.ts @@ -8,15 +8,24 @@ export const firmwareInstallPybricksDialogShow = createAction(() => ({ type: 'firmware.installPybricksDialog.action.show', })); +type FlashMethod = 'ble-lwp3-bootloader' | 'usb-lego-dfu'; + /** * Action that indicates the user accepted the install Pybricks firmware dialog. + * @param flashMethod The connection method and protocol used for flashing. * @param firmwareZip The firmware.zip raw data. * @param customProgram Optional path of custom program to include when flashing firmware. * @param hubName The hub name to use when flashing firmware. */ export const firmwareInstallPybricksDialogAccept = createAction( - (firmwareZip: ArrayBuffer, customProgram: string | undefined, hubName: string) => ({ + ( + flashMethod: FlashMethod, + firmwareZip: ArrayBuffer, + customProgram: string | undefined, + hubName: string, + ) => ({ type: 'firmware.installPybricksDialog.action.accept', + flashMethod, firmwareZip, customProgram, hubName, diff --git a/src/firmware/installPybricksDialog/hooks.ts b/src/firmware/installPybricksDialog/hooks.ts index ad0581ed..42535940 100644 --- a/src/firmware/installPybricksDialog/hooks.ts +++ b/src/firmware/installPybricksDialog/hooks.ts @@ -4,7 +4,9 @@ import { FirmwareReader } from '@pybricks/firmware'; import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; +import essentialHubZip from '@pybricks/firmware/build/essentialhub.zip'; import moveHubZip from '@pybricks/firmware/build/movehub.zip'; +import primeHubZip from '@pybricks/firmware/build/primehub.zip'; import technicHubZip from '@pybricks/firmware/build/technichub.zip'; import { useEffect, useReducer, useRef } from 'react'; import { Hub } from '../../components/hubPicker'; @@ -30,9 +32,12 @@ type Action = | { type: 'error'; payload: Error }; const firmwareZipMap = new Map([ + [Hub.Move, moveHubZip], [Hub.City, cityHubZip], [Hub.Technic, technicHubZip], - [Hub.Move, moveHubZip], + [Hub.Prime, primeHubZip], + [Hub.Essential, essentialHubZip], + [Hub.Inventor, primeHubZip], ]); /** diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 62d1f483..128314e3 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -525,9 +525,17 @@ function* handleInstallPybricks(): Generator { defined(accepted); - yield* put( - flashFirmware(accepted.firmwareZip, accepted.customProgram, accepted.hubName), - ); + switch (accepted.flashMethod) { + case 'ble-lwp3-bootloader': + yield* put( + flashFirmware( + accepted.firmwareZip, + accepted.customProgram, + accepted.hubName, + ), + ); + break; + } } export default function* (): Generator { From 28f63d5d855f52ec2d1e53415354296e58b24127 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 21 Jul 2022 14:27:27 -0500 Subject: [PATCH 24/26] utils/math: add crc32 function This matches the STM32 hardware CRC. --- src/utils/math.test.ts | 16 ++++++++++++++-- src/utils/math.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/utils/math.test.ts b/src/utils/math.test.ts index eaa60641..aa53d699 100644 --- a/src/utils/math.test.ts +++ b/src/utils/math.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors -import { fmod, sumComplement32, xor8 } from './math'; +import { crc32, fmod, sumComplement32, xor8 } from './math'; describe('fmod', () => { test('positive numbers', () => { @@ -22,6 +22,18 @@ describe('sumComplement32', () => { }); }); +describe('crc32', () => { + test('trivial', () => { + expect(crc32([0])).toBe(0); + }); + test('trivial2', () => { + expect(crc32([0xffffffff])).toBe(0); + }); + test('basic', () => { + expect(crc32([1, 2, 3, 4, 5])).toBe(-2048796416); + }); +}); + describe('xor8', () => { test('basic', () => { expect(xor8([0])).toBe(0xff); diff --git a/src/utils/math.ts b/src/utils/math.ts index 0b2c7445..9af478e7 100644 --- a/src/utils/math.ts +++ b/src/utils/math.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors /** * Compute modulo using floored division @@ -28,6 +28,32 @@ export function sumComplement32(data: Iterable): number { // checksum is two's complement of total return ~total + 1; } +// thanks https://stackoverflow.com/a/33152544/1976323 + +const crc32Table: ReadonlyArray = [ + 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, 0x130476dc, 0x17c56b6b, 0x1a864db2, + 0x1e475005, 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, 0x350c9b64, 0x31cd86d3, + 0x3c8ea00a, 0x384fbdbd, +]; + +/** + * Calculates the 32-bit CRC32 checksum. + * @data an iterable of 32-bit integers + * @returns the checksum + */ +export function crc32(data: Iterable): number { + let crc = 0xffffffff; + + for (const word of data) { + crc ^= word; + + for (let i = 0; i < 8; i++) { + crc = (crc << 4) ^ crc32Table[crc >> 28]; + } + } + + return crc; +} /** * Calculates the 8-bit "xor" checksum From 6d07713eb52b4bb8d371924eec1e9e1bae45e322 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 21 Jul 2022 18:35:42 -0500 Subject: [PATCH 25/26] firmware: implement flashing via USB DFU --- CHANGELOG.md | 1 + package.json | 4 +- src/alerts.ts | 2 + src/app/constants.ts | 3 + src/firmware/actions.ts | 29 ++++ src/firmware/alerts/FirmwareMismatch.tsx | 21 +++ src/firmware/alerts/NoDfuHub.tsx | 43 ++++++ src/firmware/alerts/NoDfuInterface.tsx | 21 +++ src/firmware/alerts/NoWebUsb.tsx | 26 ++++ src/firmware/alerts/i18n.test.ts | 12 ++ src/firmware/alerts/i18n.ts | 22 +++ src/firmware/alerts/index.ts | 14 ++ src/firmware/alerts/translations/en.json | 21 +++ src/firmware/sagas.ts | 188 ++++++++++++++++++++++- src/usb/index.ts | 33 ++++ yarn.lock | 25 +++ 16 files changed, 458 insertions(+), 7 deletions(-) create mode 100644 src/firmware/alerts/FirmwareMismatch.tsx create mode 100644 src/firmware/alerts/NoDfuHub.tsx create mode 100644 src/firmware/alerts/NoDfuInterface.tsx create mode 100644 src/firmware/alerts/NoWebUsb.tsx create mode 100644 src/firmware/alerts/i18n.test.ts create mode 100644 src/firmware/alerts/i18n.ts create mode 100644 src/firmware/alerts/index.ts create mode 100644 src/firmware/alerts/translations/en.json create mode 100644 src/usb/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92535742..9f1aa2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Added - Added better error message when no files to backup ([support#681]). - Added multi-step firmware flashing dialog. +- Added support for flashing firmware via USB DFU. ### Fixed - Fixed deleting files that are not open in the editor. diff --git a/package.json b/package.json index 5baf750a..8d229669 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/react-splitter-layout": "^3.0.2", "@types/redux-logger": "^3.0.9", "@types/semver": "^7.3.10", + "@types/w3c-web-usb": "^1.0.6", "@types/web-bluetooth": "^0.0.15", "@types/web-locks-api": "^0.0.2", "@types/wicg-file-system-access": "^2020.9.5", @@ -54,6 +55,7 @@ "dexie": "^3.2.2", "dexie-observable": "^4.0.0-beta.13", "dexie-react-hooks": "^1.1.1", + "dfu": "^0.1.5", "dotenv": "^16.0.1", "dotenv-expand": "^8.0.3", "fake-indexeddb": "^4.0.0", @@ -192,7 +194,7 @@ "^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": "/config/jest/fileTransform.mjs" }, "transformIgnorePatterns": [ - "[/\\\\]node_modules[/\\\\](?!(monaco-editor|react-monaco-editor)[/\\\\]).+\\.(js|jsx|mjs|cjs|ts|tsx)$", + "[/\\\\]node_modules[/\\\\](?!(monaco-editor|react-monaco-editor|nanoevents)[/\\\\]).+\\.(js|jsx|mjs|cjs|ts|tsx)$", "^.+\\.module\\.(css|sass|scss)$" ], "modulePaths": [], diff --git a/src/alerts.ts b/src/alerts.ts index 4e372ef0..a203bbff 100644 --- a/src/alerts.ts +++ b/src/alerts.ts @@ -5,6 +5,7 @@ import { IToastProps } from '@blueprintjs/core'; import alerts from './alerts/alerts'; import ble from './ble/alerts'; import explorer from './explorer/alerts'; +import firmware from './firmware/alerts'; import { CreateToast } from './i18nToaster'; /** This collects alerts from all of the subsystems of the app */ @@ -12,6 +13,7 @@ const alertDomains = { alerts, ble, explorer, + firmware, }; /** Gets the type of available alert domains. */ diff --git a/src/app/constants.ts b/src/app/constants.ts index f145da7d..7c0a22af 100644 --- a/src/app/constants.ts +++ b/src/app/constants.ts @@ -35,6 +35,9 @@ export const pybricksGitterUrl = 'https://gitter.im/pybricks/community'; export const pybricksBluetoothTroubleshootingUrl = 'https://github.com/pybricks/support/discussions/270'; +export const pybricksUsbDfuTroubleshootingUrl = + 'https://github.com/pybricks/support/discussions/688'; + /** Pybricks copyright statement. */ export const pybricksCopyright = 'Copyright (c) 2020-2022 The Pybricks Authors'; diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index 6526d0fc..ab2343e8 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -346,6 +346,35 @@ function didFailToFinishCreator( */ export const didFailToFinish = createAction(didFailToFinishCreator); +/** + * Low-level action to flash firmware using LEGO's DFU over USB. + * @param data The firmware zip file data. + * @param hubName A custom hub name or an empty string to use the default name. + */ +export const firmwareFlashUsbDfu = createAction( + (data: ArrayBuffer, hubName: string) => ({ + type: 'firmware.action.flashUsbDfu', + data, + hubName, + }), +); + +/** + * Low-level action that indicates {@link firmwareFlashUsbDfu} succeeded. + */ +export const firmwareDidFlashUsbDfu = createAction(() => ({ + type: 'firmware.action.didFlashUsbDfu', +})); + +/** + * Low-level action that indicates {@link firmwareFlashUsbDfu} failed. + */ +export const firmwareDidFailToFlashUsbDfu = createAction(() => ({ + type: 'firmware.action.didFailToFlashUsbDfu', +})); + +// High-level actions + /** * Action that triggers the install Pybricks firmware saga. */ diff --git a/src/firmware/alerts/FirmwareMismatch.tsx b/src/firmware/alerts/FirmwareMismatch.tsx new file mode 100644 index 00000000..ba9522df --- /dev/null +++ b/src/firmware/alerts/FirmwareMismatch.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +const FirmwareMismatch: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return

{i18n.translate(I18nId.FirmwareMismatchMessage)}

; +}; + +export const firmwareMismatch: CreateToast = (onAction) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/NoDfuHub.tsx b/src/firmware/alerts/NoDfuHub.tsx new file mode 100644 index 00000000..a065da10 --- /dev/null +++ b/src/firmware/alerts/NoDfuHub.tsx @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { AnchorButton, Intent } from '@blueprintjs/core'; +import React from 'react'; +import { pybricksUsbDfuTroubleshootingUrl } from '../../app/constants'; +import { CreateToast } from '../../i18nToaster'; +import ExternalLinkIcon from '../../utils/ExternalLinkIcon'; +import { isLinux, isWindows } from '../../utils/os'; +import { I18nId, useI18n } from './i18n'; + +const NoDfuHub: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + + return ( + <> +

{i18n.translate(I18nId.NoDfuHubMessage)}

+ + {isWindows() &&

{i18n.translate(I18nId.NoDfuHubSuggestion1Windows)}

} + {isLinux() &&

{i18n.translate(I18nId.NoDfuHubSuggestion1Linux)}

} + +

{i18n.translate(I18nId.NoDfuHubSuggestion2)}

+ + + {i18n.translate(I18nId.NoDfuHubTroubleshootButton)} + + + + ); +}; + +export const noDfuHub: CreateToast = (onAction) => { + return { + message: , + icon: 'info-sign', + intent: Intent.PRIMARY, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/NoDfuInterface.tsx b/src/firmware/alerts/NoDfuInterface.tsx new file mode 100644 index 00000000..9751bbdb --- /dev/null +++ b/src/firmware/alerts/NoDfuInterface.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +const NoDfuInterface: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return

{i18n.translate(I18nId.NoDfuInterfaceMessage)}

; +}; + +export const noDfuInterface: CreateToast = (onAction) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/NoWebUsb.tsx b/src/firmware/alerts/NoWebUsb.tsx new file mode 100644 index 00000000..3303c023 --- /dev/null +++ b/src/firmware/alerts/NoWebUsb.tsx @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { CreateToast } from '../../i18nToaster'; +import { I18nId, useI18n } from './i18n'; + +const NoWebUsb: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return ( + <> +

{i18n.translate(I18nId.NoWebUsbMessage)}

+

{i18n.translate(I18nId.NoWebUsbSuggestion)}

+ + ); +}; + +export const noWebUsb: CreateToast = (onAction) => { + return { + message: , + icon: 'error', + intent: Intent.DANGER, + onDismiss: () => onAction('dismiss'), + }; +}; diff --git a/src/firmware/alerts/i18n.test.ts b/src/firmware/alerts/i18n.test.ts new file mode 100644 index 00000000..e706ba28 --- /dev/null +++ b/src/firmware/alerts/i18n.test.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { lookup } from '../../../test'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; + +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { + expect(lookup(en, id)).toBeDefined(); + }); +}); diff --git a/src/firmware/alerts/i18n.ts b/src/firmware/alerts/i18n.ts new file mode 100644 index 00000000..35dfee63 --- /dev/null +++ b/src/firmware/alerts/i18n.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n'; + +export function useI18n(): I18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} + +export enum I18nId { + NoWebUsbMessage = 'noWebUsb.message', + NoWebUsbSuggestion = 'noWebUsb.suggestion', + NoDfuHubMessage = 'noDfuHub.message', + NoDfuHubSuggestion1Windows = 'noDfuHub.suggestion1.windows', + NoDfuHubSuggestion1Linux = 'noDfuHub.suggestion1.linux', + NoDfuHubSuggestion2 = 'noDfuHub.suggestion2', + NoDfuHubTroubleshootButton = 'noDfuHub.troubleshootButton', + NoDfuInterfaceMessage = 'noDfuInterface.message', + FirmwareMismatchMessage = 'firmwareMismatch.message', +} diff --git a/src/firmware/alerts/index.ts b/src/firmware/alerts/index.ts new file mode 100644 index 00000000..82ea36ab --- /dev/null +++ b/src/firmware/alerts/index.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { firmwareMismatch } from './FirmwareMismatch'; +import { noDfuHub } from './NoDfuHub'; +import { noDfuInterface } from './NoDfuInterface'; +import { noWebUsb } from './NoWebUsb'; + +export default { + firmwareMismatch, + noDfuHub, + noDfuInterface, + noWebUsb, +}; diff --git a/src/firmware/alerts/translations/en.json b/src/firmware/alerts/translations/en.json new file mode 100644 index 00000000..df5a2aa4 --- /dev/null +++ b/src/firmware/alerts/translations/en.json @@ -0,0 +1,21 @@ +{ + "noWebUsb": { + "message": "This browser does not support Web USB or Web USB is not enabled.", + "suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge." + }, + "noDfuHub": { + "message": "Could not find your hub?", + "suggestion1": { + "windows": "You may need to manually install a USB driver before you can connect to your hub.", + "linux": "You may need to add udev rules before you can connect to your hub." + }, + "suggestion2": "Click the button below for more information.", + "troubleshootButton": "Troubleshooting Tips" + }, + "noDfuInterface": { + "message": "This is very unusual. The USB device did not contain the expected interface." + }, + "firmwareMismatch": { + "message": "Cannot flash firmware. The firmware file is for a different kind of hub." + } +} diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 128314e3..42cfd915 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -10,6 +10,7 @@ import { import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; import moveHubZip from '@pybricks/firmware/build/movehub.zip'; import technicHubZip from '@pybricks/firmware/build/technichub.zip'; +import { WebDFU } from 'dfu'; import { AnyAction } from 'redux'; import { ActionPattern } from 'redux-saga/effects'; import { @@ -25,6 +26,7 @@ import { take, takeEvery, } from 'typed-redux-saga/macro'; +import { alertsShowAlert } from '../alerts/actions'; import { fileStorageDidFailToReadFile, fileStorageDidReadFile, @@ -55,8 +57,9 @@ import { MaxProgramFlashSize, Result } from '../lwp3-bootloader/protocol'; import { BootloaderConnectionState } from '../lwp3-bootloader/reducers'; import { compile, didCompile, didFailToCompile } from '../mpy/actions'; import { RootState } from '../reducers'; +import { LegoUsbProductId, legoUsbVendorId } from '../usb'; import { defined, ensureError, hex, maybe } from '../utils'; -import { fmod, sumComplement32 } from '../utils/math'; +import { crc32, fmod, sumComplement32 } from '../utils/math'; import { isAndroid } from '../utils/os'; import { FailToFinishReasonType, @@ -66,6 +69,9 @@ import { didFinish, didProgress, didStart, + firmwareDidFailToFlashUsbDfu, + firmwareDidFlashUsbDfu, + firmwareFlashUsbDfu, firmwareInstallPybricks, flashFirmware, } from './actions'; @@ -193,7 +199,12 @@ function* loadFirmware( } else { yield* put(didFailToFinish(FailToFinishReasonType.Unknown, readerErr)); } + + // FIXME: we should return error/throw instead yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); } defined(reader); @@ -220,7 +231,12 @@ function* loadFirmware( MetadataProblem.NotSupported, ), ); + + // FIXME: we should return error/throw instead yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); } yield* put( @@ -232,8 +248,12 @@ function* loadFirmware( }); if (mpyFail) { + // FIXME: we should return error/throw instead yield* put(didFailToFinish(FailToFinishReasonType.FailedToCompile)); yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); } defined(mpy); @@ -246,8 +266,12 @@ function* loadFirmware( const firmwareView = new DataView(firmware.buffer); if (firmware.length > metadata['max-firmware-size']) { + // FIXME: we should return error/throw instead yield* put(didFailToFinish(FailToFinishReasonType.FirmwareSize)); yield* disconnectAndCancel(); + + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); } firmware.set(firmwareBase); @@ -262,7 +286,23 @@ function* loadFirmware( } } - if (metadata['checksum-type'] !== 'sum') { + const checksum = (function () { + switch (metadata['checksum-type']) { + case 'sum': + return sumComplement32( + firmwareIterator(firmwareView, metadata['max-firmware-size']), + ); + case 'crc32': + return crc32( + firmwareIterator(firmwareView, metadata['max-firmware-size']), + ); + default: + return undefined; + } + })(); + + if (!checksum) { + // FIXME: we should return error/throw instead yield* put( didFailToFinish( FailToFinishReasonType.BadMetadata, @@ -271,11 +311,10 @@ function* loadFirmware( ), ); yield* disconnectAndCancel(); - } - const checksum = sumComplement32( - firmwareIterator(firmwareView, metadata['max-firmware-size']), - ); + // istanbul ignore next: needed for typescript flow + throw new Error('unreachable'); + } firmwareView.setUint32(checksumOffset, checksum, true); @@ -512,6 +551,139 @@ function* handleFlashFirmware(action: ReturnType): Generat } } +/** Maps USB Product ID to LWP3 hub type ID */ +const productIdMap: ReadonlyMap = new Map([ + [LegoUsbProductId.SpikePrimeBootloader, HubType.PrimeHub], + [LegoUsbProductId.SpikeEssentialBootloader, HubType.EssentialHub], + [LegoUsbProductId.MindstormsRobotInventorBootloader, HubType.PrimeHub], +]); + +// currently all hubs use the same start address +const dfuFirmwareStartAddress = 0x08008000; + +function* handleFlashUsbDfu(action: ReturnType): Generator { + const defer = new Array<() => void>(); + + try { + // not all web browsers support Web USB + if (!navigator.usb) { + yield* put(alertsShowAlert('firmware', 'noWebUsb')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + const device = yield* call(() => + navigator.usb + .requestDevice({ + filters: [ + { + vendorId: legoUsbVendorId, + productId: LegoUsbProductId.SpikePrimeBootloader, + }, + { + vendorId: legoUsbVendorId, + productId: LegoUsbProductId.SpikeEssentialBootloader, + }, + { + vendorId: legoUsbVendorId, + productId: + LegoUsbProductId.MindstormsRobotInventorBootloader, + }, + ], + }) + .catch((err) => { + if ( + err instanceof DOMException && + err.code === DOMException.NOT_FOUND_ERR + ) { + // user clicked cancel button + return undefined; + } + + throw err; + }), + ); + + if (!device) { + yield* put(alertsShowAlert('firmware', 'noDfuHub')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + const dfu = new WebDFU( + device, + // forceInterfacesName is needed to get the flash layout map + { forceInterfacesName: true }, + { + info: console.debug, + warning: console.warn, + progress: (progress, total) => { + // TODO: bind to eventChannel and dispatch progress actions + console.log(progress, total); + }, + }, + ); + + yield* call(() => dfu.init()); + + // we want the interface with alt=0 + const ifaceIndex = dfu.interfaces.findIndex( + (i) => i.alternate.alternateSetting === 0, + ); + + if (ifaceIndex === -1) { + yield* put(alertsShowAlert('firmware', 'noDfuInterface')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + yield* call(() => dfu.connect(ifaceIndex)); + + defer.push(() => dfu.close()); + + const { firmware, deviceId } = yield* loadFirmware( + action.data, + undefined, + action.hubName, + ); + + if (deviceId !== productIdMap.get(device.productId)) { + yield* put(alertsShowAlert('firmware', 'firmwareMismatch')); + yield* put(firmwareDidFailToFlashUsbDfu()); + return; + } + + dfu.dfuseStartAddress = dfuFirmwareStartAddress; + const writeProc = dfu.write(1024, firmware, true); + + writeProc.events.on('error', console.error); + + // REVISIT: we could possibly race the 'write/end' and 'error' events + // here instead of waiting for disconnect + + // this is a bit of a hack, but the hub resets when flashing is done + // so we get a disconnect event unless there was an error, so the user + // will probably see the timeout error instead of the underlying error + yield* call(() => dfu.waitDisconnected(30000)); + + yield* put(firmwareDidFlashUsbDfu()); + } catch (err) { + if (process.env.NODE_ENV !== 'test') { + console.error(err); + } + + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { error: ensureError(err) }), + ); + + yield* put(firmwareDidFailToFlashUsbDfu()); + } finally { + while (defer.length !== 0) { + defer.pop()?.(); + } + } +} + function* handleInstallPybricks(): Generator { yield* put(firmwareInstallPybricksDialogShow()); const { accepted, canceled } = yield* race({ @@ -535,10 +707,14 @@ function* handleInstallPybricks(): Generator { ), ); break; + case 'usb-lego-dfu': + yield* put(firmwareFlashUsbDfu(accepted.firmwareZip, accepted.hubName)); + break; } } export default function* (): Generator { yield* takeEvery(flashFirmware, handleFlashFirmware); + yield* takeEvery(firmwareFlashUsbDfu, handleFlashUsbDfu); yield* takeEvery(firmwareInstallPybricks, handleInstallPybricks); } diff --git a/src/usb/index.ts b/src/usb/index.ts new file mode 100644 index 00000000..93fb1f4a --- /dev/null +++ b/src/usb/index.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +// https://github.com/pybricks/technical-info/blob/master/assigned-numbers.md#usb + +/** Official LEGO USB Vendor ID (VID) */ +export const legoUsbVendorId = 0x0694; + +/** Official LEGO USB Product IDs (PID) */ +export enum LegoUsbProductId { + /** MINDSTORMS RCX IR Tower. */ + RcxIrTower = 0x0001, + /** MINDSTORMS NXT */ + Nxt = 0x0002, + /** WeDo USB hub. */ + WedoUsb = 0x0003, + /** MINDSTORMS EV3 */ + Ev3 = 0x0005, + /** MINDSTORMS EV3 in firmware update (bootloader) mode. */ + Ev3Bootloader = 0x0006, + /** SPIKE Prime hub in DFU (bootloader) mode. */ + SpikePrimeBootloader = 0x0008, + /** SPIKE Prime hub. */ + SpikePrime = 0x0009, + /** SPIKE Essential hub in DFU (bootloader) mode. */ + SpikeEssentialBootloader = 0x000c, + /** SPIKE Essential hub. */ + SpikeEssential = 0x000d, + /** MINDSTORMS Robot inventor hub. */ + MindstormsRobotInventor = 0x0010, + /** MINDSTORMS Robot inventor hub in DFU (bootloader) mode. */ + MindstormsRobotInventorBootloader = 0x0011, +} diff --git a/yarn.lock b/yarn.lock index 6d92e8a3..e4e286d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2338,6 +2338,7 @@ __metadata: "@types/react-splitter-layout": ^3.0.2 "@types/redux-logger": ^3.0.9 "@types/semver": ^7.3.10 + "@types/w3c-web-usb": ^1.0.6 "@types/web-bluetooth": ^0.0.15 "@types/web-locks-api": ^0.0.2 "@types/wicg-file-system-access": ^2020.9.5 @@ -2362,6 +2363,7 @@ __metadata: dexie: ^3.2.2 dexie-observable: ^4.0.0-beta.13 dexie-react-hooks: ^1.1.1 + dfu: ^0.1.5 dotenv: ^16.0.1 dotenv-expand: ^8.0.3 eslint: ^8.20.0 @@ -4682,6 +4684,13 @@ __metadata: languageName: node linkType: hard +"@types/w3c-web-usb@npm:^1.0.6": + version: 1.0.6 + resolution: "@types/w3c-web-usb@npm:1.0.6" + checksum: 9f30948cb84174fa290066b08274bdfb034d38c6db0976e9a826508732fba04d81e3300bca41ea23b737f1424c51adec5ae810cdf85d5b5a158d5840914f0417 + languageName: node + linkType: hard + "@types/web-bluetooth@npm:^0.0.15": version: 0.0.15 resolution: "@types/web-bluetooth@npm:0.0.15" @@ -7111,6 +7120,15 @@ __metadata: languageName: node linkType: hard +"dfu@npm:^0.1.5": + version: 0.1.5 + resolution: "dfu@npm:0.1.5" + dependencies: + nanoevents: ^6.0.0 + checksum: 7fa8aa1578518be4eb8981f67878ecb6f68e64ccf91ed278f35708a6d4e3f6e822e68ab8653c6b3d69e08ddf105c550698b88db363bdab8f8c2a7a582ac96001 + languageName: node + linkType: hard + "didyoumean@npm:^1.2.2": version: 1.2.2 resolution: "didyoumean@npm:1.2.2" @@ -10810,6 +10828,13 @@ __metadata: languageName: node linkType: hard +"nanoevents@npm:^6.0.0": + version: 6.0.2 + resolution: "nanoevents@npm:6.0.2" + checksum: 73d8c8f584b850bae6705820710a20c19be61145a4a6ad3b157caf1fea52d46f48e0d1f1c1452019c0e84869f226debad2245218f0b0c3f9dffb7afc9b42e663 + languageName: node + linkType: hard + "nanoid@npm:^3.3.4": version: 3.3.4 resolution: "nanoid@npm:3.3.4" From e063627c737484d2d186dac415205479eed3dc07 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 22 Jul 2022 10:50:39 -0500 Subject: [PATCH 26/26] yarn: update blueprints packages --- package.json | 6 +++--- yarn.lock | 46 +++++++++++++++++++++++----------------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 8d229669..09b9448f 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ }, "dependencies": { "@babel/core": "^7.18.9", - "@blueprintjs/core": "^4.5.0", - "@blueprintjs/popover2": "^1.4.2", - "@blueprintjs/select": "^4.4.2", + "@blueprintjs/core": "^4.6.1", + "@blueprintjs/popover2": "^1.4.3", + "@blueprintjs/select": "^4.5.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", "@pybricks/firmware": "5.0.0", "@pybricks/ide-docs": "2.2.0", diff --git a/yarn.lock b/yarn.lock index e4e286d9..4ff83035 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1508,12 +1508,12 @@ __metadata: languageName: node linkType: hard -"@blueprintjs/core@npm:^4.5.0, @blueprintjs/core@npm:^4.6.0": - version: 4.6.0 - resolution: "@blueprintjs/core@npm:4.6.0" +"@blueprintjs/core@npm:^4.6.1": + version: 4.6.1 + resolution: "@blueprintjs/core@npm:4.6.1" dependencies: "@blueprintjs/colors": ^4.1.4 - "@blueprintjs/icons": ^4.3.1 + "@blueprintjs/icons": ^4.4.0 "@juggle/resize-observer": ^3.3.1 "@types/dom4": ^2.0.1 classnames: ^2.2 @@ -1529,26 +1529,26 @@ __metadata: bin: upgrade-blueprint-2.0.0-rename: scripts/upgrade-blueprint-2.0.0-rename.sh upgrade-blueprint-3.0.0-rename: scripts/upgrade-blueprint-3.0.0-rename.sh - checksum: 1a471553e8f6d34fa959c12da0f23d95cb38fae637ab45afb1198af014a896cb51fe4710b0e9c0721fdeed2c13eb7e7adb69fa2d4f5b00dcfea4564997a64503 + checksum: 1bd2a770821c485c86cfa823c142e753d628257106f8d380dab020e8718cfed7d9ff828b729fed5e8ae97dd0aac7135095c286b3393d7e962a09517d682c7f41 languageName: node linkType: hard -"@blueprintjs/icons@npm:^4.3.1": - version: 4.3.1 - resolution: "@blueprintjs/icons@npm:4.3.1" +"@blueprintjs/icons@npm:^4.4.0": + version: 4.4.0 + resolution: "@blueprintjs/icons@npm:4.4.0" dependencies: change-case: ^4.1.2 classnames: ^2.2 tslib: ~2.3.1 - checksum: 7f28f5c55529964d0584ec3f10f9960001fdb17e401c217b80d08e00c2ebddb4880a8a3cc9a5c793b8eaf6a48b2b9585c8cb50e0e89093d944c3a1cab0a27ff8 + checksum: 6f3878047b8856d9020fb3598b83bf944ebe4fe972fadc58f58c90d7300116ad30d287984c3f0e94dcc508cf9f1d847586a577281f7af073cdc87764683611f1 languageName: node linkType: hard -"@blueprintjs/popover2@npm:^1.4.2": - version: 1.4.2 - resolution: "@blueprintjs/popover2@npm:1.4.2" +"@blueprintjs/popover2@npm:^1.4.3": + version: 1.4.3 + resolution: "@blueprintjs/popover2@npm:1.4.3" dependencies: - "@blueprintjs/core": ^4.6.0 + "@blueprintjs/core": ^4.6.1 "@juggle/resize-observer": ^3.3.1 "@popperjs/core": ^2.5.4 classnames: ^2.2 @@ -1558,22 +1558,22 @@ __metadata: peerDependencies: react: ^16.8 || 17 || 18 react-dom: ^16.8 || 17 || 18 - checksum: 0cbe2b04732c8913c6dad3121e60d24f5d1f15d9b0d8b173573ca5efc322f67db2d604603641142aa4aa5f66d7aa4379e4316a36c1a0e19fabd1e9c4071eb78a + checksum: 285bdda43cc6803552003b70e79215943a134510d6c76116d7cf5cb901ff83bd73da3a4d7486b5580e72eb18d62d488778248156faf1af4d9ffe43618a537b9c languageName: node linkType: hard -"@blueprintjs/select@npm:^4.4.2": - version: 4.4.2 - resolution: "@blueprintjs/select@npm:4.4.2" +"@blueprintjs/select@npm:^4.5.0": + version: 4.5.0 + resolution: "@blueprintjs/select@npm:4.5.0" dependencies: - "@blueprintjs/core": ^4.6.0 - "@blueprintjs/popover2": ^1.4.2 + "@blueprintjs/core": ^4.6.1 + "@blueprintjs/popover2": ^1.4.3 classnames: ^2.2 tslib: ~2.3.1 peerDependencies: react: ^16.8 || 17 || 18 react-dom: ^16.8 || 17 || 18 - checksum: eb41a9ea513d477fd6336e4875a605b467d174b3fa6bfe7bd4ccd5f6247a3d528802757cc13e39be40312f494640d5aa02c0378a4964558a1cfeb94a98c1dc20 + checksum: 837049e6d7d1063f65aaa9d94d4b9b74a2b0a3a227243ceeaeb4302a743960f4a1c9fbceb51ee20f3216ba71becfd1fa4e405950c0f6d75288ba96f2a8e9be53 languageName: node linkType: hard @@ -2315,9 +2315,9 @@ __metadata: resolution: "@pybricks/pybricks-code@workspace:." dependencies: "@babel/core": ^7.18.9 - "@blueprintjs/core": ^4.5.0 - "@blueprintjs/popover2": ^1.4.2 - "@blueprintjs/select": ^4.4.2 + "@blueprintjs/core": ^4.6.1 + "@blueprintjs/popover2": ^1.4.3 + "@blueprintjs/select": ^4.5.0 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.7 "@pybricks/firmware": 5.0.0 "@pybricks/ide-docs": 2.2.0