ble/sagas: show alert when Pybricks Profile is newer

If the user sideloads a firmware that has communication features that
are newer than what is supported by the app, then show a warning message.
This commit is contained in:
David Lechner
2023-04-24 17:38:53 -05:00
committed by David Lechner
parent 618f7840da
commit 1219a377d4
7 changed files with 165 additions and 13 deletions
+3
View File
@@ -4,6 +4,9 @@
## [Unreleased]
### Added
- Added warning message when hub has newer Pybricks Profile version than supported version.
## [2.2.0-beta.3] - 2023-04-24
### Added
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
import { Toast } from '@blueprintjs/core';
import { act } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../../test';
import { newPybricksProfile } from './NewPybricksProfile';
it('should dismiss when close is clicked', async () => {
const callback = jest.fn();
const toast = newPybricksProfile(callback, undefined as never);
const [user, message] = testRender(<Toast {...toast} />);
await act(() => user.click(message.getByRole('button', { name: /close/i })));
expect(callback).toHaveBeenCalledWith('dismiss');
});
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2023 The Pybricks Authors
import { Intent } from '@blueprintjs/core';
import React from 'react';
import { appName } from '../../app/constants';
import type { CreateToast } from '../../toasterTypes';
import { useI18n } from './i18n';
type NewPybricksProfileProps = {
/** The Pybricks Profile version reported by the hub. */
hubVersion: string;
/** The supported Pybricks Profile version. */
supportedVersion: string;
};
const NewPybricksProfile: React.VoidFunctionComponent<NewPybricksProfileProps> = ({
hubVersion,
supportedVersion,
}) => {
const i18n = useI18n();
return (
<>
<p>{i18n.translate('newPybricksProfile.message')}</p>
<p>
{i18n.translate('newPybricksProfile.versions', {
hubVersion: `v${hubVersion}`,
app: appName,
appVersion: `v${supportedVersion}`,
})}
</p>
<p>
{i18n.translate('newPybricksProfile.suggestion', {
app: appName,
})}
</p>
</>
);
};
export const newPybricksProfile: CreateToast<NewPybricksProfileProps> = (
onAction,
props,
) => ({
message: <NewPybricksProfile {...props} />,
icon: 'warning-sign',
intent: Intent.WARNING,
timeout: 15000, // long message, need more time to read
onDismiss: () => onAction('dismiss'),
});
+2
View File
@@ -4,6 +4,7 @@
import { bluetoothNotAvailable } from './BluetoothNotAvailable';
import { disconnected } from './Disconnected';
import { missingService } from './MissingService';
import { newPybricksProfile } from './NewPybricksProfile';
import { noGatt } from './NoGatt';
import { noHub } from './NoHub';
import { noWebBluetooth } from './NoWebBluetooth';
@@ -14,6 +15,7 @@ export default {
bluetoothNotAvailable,
disconnected,
missingService,
newPybricksProfile,
noGatt,
noHub,
noWebBluetooth,
+5
View File
@@ -30,6 +30,11 @@
"label": "Update Pybricks firmware"
}
},
"newPybricksProfile": {
"message": "The connected hub uses a newer Pybricks communication profile version. Some features may not work correctly.",
"versions": "The hub has {hubVersion} while {app} supports {appVersion}.",
"suggestion": "Downgrade the hub firmware or upgrade {app} to avoid problems."
},
"disconnected": {
"message": "The hub is no longer connected. Please reconnect and try again."
}
+68 -12
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// Copyright (c) 2022-2023 The Pybricks Authors
import { MockProxy, mock } from 'jest-mock-extended';
import { AsyncSaga } from '../../test';
@@ -23,9 +23,13 @@ import {
nordicUartServiceUUID,
nordicUartTxCharUUID,
} from '../ble-nordic-uart-service/protocol';
import { blePybricksServiceDidNotReceiveHubCapabilities } from '../ble-pybricks-service/actions';
import {
blePybricksServiceDidNotReceiveHubCapabilities,
blePybricksServiceDidReceiveHubCapabilities,
} from '../ble-pybricks-service/actions';
import {
pybricksControlEventCharacteristicUUID,
pybricksHubCapabilitiesCharacteristicUUID,
pybricksServiceUUID,
} from '../ble-pybricks-service/protocol';
import { firmwareInstallPybricks } from '../firmware/actions';
@@ -38,7 +42,7 @@ import {
toggleBluetooth,
} from './actions';
import { BleConnectionState } from './reducers';
import ble from './sagas';
import ble, { supportedPybricksProfileVersion } from './sagas';
const encoder = new TextEncoder();
@@ -106,10 +110,22 @@ function createMocks(): Mocks {
pybricksChar.startNotifications.mockResolvedValue(pybricksChar);
pybricksChar.stopNotifications.mockResolvedValue(pybricksChar);
const hubCapabilitiesChar = mock<BluetoothRemoteGATTCharacteristic>();
hubCapabilitiesChar.readValue.mockResolvedValue(
new DataView(
new Uint8Array([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]).buffer,
),
);
const pybricksService = mock<BluetoothRemoteGATTService>();
pybricksService.getCharacteristic
.calledWith(pybricksControlEventCharacteristicUUID)
.mockResolvedValue(pybricksChar);
pybricksService.getCharacteristic
.calledWith(pybricksHubCapabilitiesCharacteristicUUID)
.mockResolvedValue(hubCapabilitiesChar);
const uartRxChar = mock<BluetoothRemoteGATTCharacteristic>();
@@ -188,6 +204,13 @@ enum ConnectRunPoint {
DidConnect,
}
const defaultPnpId: PnpId = {
productId: 0x80,
productVersion: 0,
vendorId: 919,
vendorIdSource: 1,
};
/**
* Run the "success" path of the connect saga until a given point.
*
@@ -221,21 +244,16 @@ async function runConnectUntil(saga: AsyncSaga, point: ConnectRunPoint): Promise
return;
}
const pnpId: PnpId = {
productId: 0x80,
productVersion: 0,
vendorId: 919,
vendorIdSource: 1,
};
await expect(saga.take()).resolves.toEqual(bleDIServiceDidReceivePnPId(pnpId));
await expect(saga.take()).resolves.toEqual(
bleDIServiceDidReceivePnPId(defaultPnpId),
);
if (point === ConnectRunPoint.DidReceivePnpId) {
return;
}
await expect(saga.take()).resolves.toEqual(
blePybricksServiceDidNotReceiveHubCapabilities(pnpId, '3.2.0b2'),
blePybricksServiceDidNotReceiveHubCapabilities(defaultPnpId, '3.2.0b2'),
);
if (point === ConnectRunPoint.DidNotReceiveHubCapabilities) {
@@ -411,6 +429,44 @@ describe('connect action is dispatched', () => {
expect(mocks.gatt.disconnect).toHaveBeenCalled();
});
it('should alert if pybricks profile is newer than supported', async () => {
// increase supported minor version by 1 to get hub version
const hubVersionParts = supportedPybricksProfileVersion.split('.');
hubVersionParts[1] = String(Number(hubVersionParts[1]) + 1);
const hubVersion = hubVersionParts.join('.');
mocks.softwareRevisionChar.readValue.mockResolvedValue(
new DataView(encoder.encode(hubVersion).buffer),
);
await runConnectUntil(saga, ConnectRunPoint.DidReceiveFirmwareRevision);
await expect(saga.take()).resolves.toEqual(
bleDIServiceDidReceiveSoftwareRevision(hubVersion),
);
await expect(saga.take()).resolves.toEqual(
alertsShowAlert('ble', 'newPybricksProfile', {
hubVersion: hubVersion,
supportedVersion: supportedPybricksProfileVersion,
}),
);
// then continue as normal
await expect(saga.take()).resolves.toEqual(
bleDIServiceDidReceivePnPId(defaultPnpId),
);
await expect(saga.take()).resolves.toEqual(
blePybricksServiceDidReceiveHubCapabilities(0, 0, 0),
);
await expect(saga.take()).resolves.toEqual(
bleDidConnectPybricks('test-id', 'test name'),
);
});
it('should fail if reading pnp id characteristic fails', async () => {
const testError = new Error('test error');
mocks.pnpIdChar.readValue.mockRejectedValue(testError);
+18 -1
View File
@@ -72,6 +72,9 @@ import {
} from './actions';
import { BleConnectionState } from './reducers';
/** The version of the Pybricks Profile version currently implemented by this file. */
export const supportedPybricksProfileVersion = '1.3.0';
const decoder = new TextDecoder();
function* handlePybricksControlValueChanged(data: DataView): Generator {
@@ -265,6 +268,21 @@ function* handleBleConnectPybricks(): Generator {
);
yield* put(bleDIServiceDidReceiveSoftwareRevision(softwareRevision));
// notify user if newer Pybricks Profile on hub
if (
semver.gte(
softwareRevision,
new semver.SemVer(supportedPybricksProfileVersion).inc('minor'),
)
) {
yield* put(
alertsShowAlert('ble', 'newPybricksProfile', {
hubVersion: softwareRevision,
supportedVersion: supportedPybricksProfileVersion,
}),
);
}
const pnpIdChar = yield* call(() =>
deviceInfoService.getCharacteristic(pnpIdUUID).catch((err) => {
if (err instanceof DOMException && err.name === 'NotFoundError') {
@@ -274,7 +292,6 @@ function* handleBleConnectPybricks(): Generator {
throw err;
}),
);
if (!pnpIdChar) {
// possible with firmware < v3.1.0
throw new Error('missing PnP ID characteristic');