diff --git a/CHANGELOG.md b/CHANGELOG.md index f4742c05..d22b2e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [Unreleased] +### Added +- Show error message if connected hub is running old firmware ([support#482]). + +[support#482]: https://github.com/pybricks/support/issues/482 + ## [1.1.0-beta.5] - 2021-08-30 ### Changed diff --git a/package.json b/package.json index d2f93e1f..265a6f0b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/react-redux": "^7.1.18", "@types/react-splitter-layout": "^3.0.2", "@types/redux-logger": "^3.0.9", + "@types/semver": "^7.3.8", "@types/web-bluetooth": "^0.0.11", "@types/zen-push": "^0.1.1", "babel-plugin-macros": "^3.0.1", @@ -49,6 +50,7 @@ "redux": "^4.0.5", "redux-logger": "^3.0.6", "redux-saga": "^1.1.3", + "semver": "^7.3.5", "spdx-satisfies": "^5.0.0", "typed-redux-saga": "^1.3.1", "typescript": "~4.4.3", diff --git a/src/ble-uart/sagas.ts b/src/ble-uart/sagas.ts index f2c1e8de..30728564 100644 --- a/src/ble-uart/sagas.ts +++ b/src/ble-uart/sagas.ts @@ -206,9 +206,6 @@ function* connect(_action: BleDeviceConnectAction): Generator { return; } - // TODO: save firmware version for later use - console.log(`Hub firmware version: ${firmwareVersion}`); - let softwareVersionChar: BluetoothRemoteGATTCharacteristic; try { softwareVersionChar = yield* call( @@ -422,7 +419,7 @@ function* connect(_action: BleDeviceConnectAction): Generator { tasks.push(yield* takeEvery(BleUartActionType.Write, writeUart, uartRxChar)); - yield* put(didConnect()); + yield* put(didConnect(firmwareVersion)); // wait for disconnection yield* takeMaybe(disconnectChannel); diff --git a/src/ble/actions.ts b/src/ble/actions.ts index f11b1ef2..27751ddd 100644 --- a/src/ble/actions.ts +++ b/src/ble/actions.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2021 The Pybricks Authors // // Actions for managing Bluetooth Low Energy connections. @@ -44,13 +44,16 @@ export function connect(): BleDeviceConnectAction { return { type: BleDeviceActionType.Connect }; } -export type BleDeviceDidConnectAction = Action; +export type BleDeviceDidConnectAction = Action & { + firmwareVersion: string; +}; /** * Creates an action that indicates a device was connected. + * @param firmwareVersion The firmware version of the hub (e.g. 3.0.0a1) */ -export function didConnect(): BleDeviceDidConnectAction { - return { type: BleDeviceActionType.DidConnect }; +export function didConnect(firmwareVersion: string): BleDeviceDidConnectAction { + return { type: BleDeviceActionType.DidConnect, firmwareVersion }; } export enum BleDeviceFailToConnectReasonType { diff --git a/src/ble/reducers.test.ts b/src/ble/reducers.test.ts index 51389e75..88daf64a 100644 --- a/src/ble/reducers.test.ts +++ b/src/ble/reducers.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors +import { firmwareVersion } from '@pybricks/firmware'; import { Action } from '../actions'; import { BleDeviceDidFailToConnectReason, @@ -29,8 +30,10 @@ test('connection', () => { .connection, ).toBe(BleConnectionState.Connecting); expect( - reducers({ connection: BleConnectionState.Connecting } as State, didConnect()) - .connection, + reducers( + { connection: BleConnectionState.Connecting } as State, + didConnect(firmwareVersion), + ).connection, ).toBe(BleConnectionState.Connected); expect( reducers( diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index 4b1e2dc0..ba2153b7 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors +import { firmwareVersion } from '@pybricks/firmware'; import { Action } from '../actions'; import { statusReportEvent } from '../ble-pybricks-service/actions'; import { Status, statusToFlag } from '../ble-pybricks-service/protocol'; @@ -27,8 +28,10 @@ test('initial state', () => { describe('runtime', () => { test('', () => { expect( - reducers({ runtime: HubRuntimeState.Disconnected } as State, didConnect()) - .runtime, + reducers( + { runtime: HubRuntimeState.Disconnected } as State, + didConnect(firmwareVersion), + ).runtime, ).toBe(HubRuntimeState.Unknown); }); diff --git a/src/notifications/i18n.en.json b/src/notifications/i18n.en.json index 0ed65d37..15afa237 100644 --- a/src/notifications/i18n.en.json +++ b/src/notifications/i18n.en.json @@ -39,5 +39,8 @@ "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." } } diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index 361e915a..08ac380c 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2021 The Pybricks Authors // // Notification translation keys. @@ -29,4 +29,5 @@ export enum MessageId { 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 bf6157ea..512430bc 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -2,13 +2,18 @@ // Copyright (c) 2021 The Pybricks Authors import { IToaster } from '@blueprintjs/core'; -import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware'; +import { + FirmwareReaderError, + FirmwareReaderErrorCode, + firmwareVersion, +} from '@pybricks/firmware'; import { AsyncSaga } from '../../test'; import { Action } from '../actions'; import { didCheckForUpdate } from '../app/actions'; import { BleDeviceFailToConnectReasonType, didFailToConnect as bleDidFailToConnect, + didConnect, } from '../ble/actions'; import { storageChanged } from '../editor/actions'; import { @@ -76,6 +81,7 @@ test.each([ didFailToFinish(FailToFinishReasonType.FirmwareSize), didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')), didCheckForUpdate(false), + didConnect('3.0.0'), ])('actions that should show notification: %o', async (action: Action) => { const getToasts = jest.fn().mockReturnValue([]); const show = jest.fn(); @@ -106,6 +112,7 @@ test.each([ didFailToFinish(FailToFinishReasonType.FailedToConnect), didSucceed({} as ServiceWorkerRegistration), didCheckForUpdate(true), + didConnect(firmwareVersion), ])('actions that should not show a notification: %o', async (action: Action) => { const getToasts = jest.fn().mockReturnValue([]); const show = jest.fn(); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 75297607..cafa303f 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -10,14 +10,17 @@ import { IconName, Intent, } 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 { AppActionType, AppDidCheckForUpdateAction, reload } from '../app/actions'; import { appName } from '../app/constants'; import { BleDeviceActionType, + BleDeviceDidConnectAction, BleDeviceDidFailToConnectAction, BleDeviceFailToConnectReasonType, } from '../ble/actions'; @@ -37,6 +40,7 @@ import { ServiceWorkerAction, ServiceWorkerActionType, } from '../service-worker/actions'; +import { pythonVersionToSemver } from '../utils/version'; import NotificationAction from './NotificationAction'; import NotificationMessage from './NotificationMessage'; import UnexpectedErrorNotification from './UnexpectedErrorNotification'; @@ -382,6 +386,19 @@ function* showNoUpdateInfo(action: AppDidCheckForUpdateAction): Generator { }); } +function* checkVersion(action: BleDeviceDidConnectAction): Generator { + // ensure the actual hub firmware version is the same as the shipped + // firmware version or newer + if ( + !semver.satisfies( + pythonVersionToSemver(action.firmwareVersion), + `>=${pythonVersionToSemver(firmwareVersion)}`, + ) + ) { + yield* showSingleton(Level.Error, MessageId.CheckFirmwareTooOld); + } +} + export default function* (): Generator { yield* takeEvery( BleDeviceActionType.DidFailToConnect, @@ -398,4 +415,5 @@ export default function* (): Generator { yield* takeEvery(NotificationActionType.Add, addNotification); yield* takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate); yield* takeEvery(AppActionType.DidCheckForUpdate, showNoUpdateInfo); + yield* takeEvery(BleDeviceActionType.DidConnect, checkVersion); } diff --git a/src/utils/version.test.ts b/src/utils/version.test.ts new file mode 100644 index 00000000..1757f301 --- /dev/null +++ b/src/utils/version.test.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { pythonVersionToSemver } from './version'; + +describe('pythonVersionToSemver', () => { + test.each([ + ['v1.0.0', 'v1.0.0'], + ['v1.0.0a1', 'v1.0.0-alpha.1'], + ['v1.0.0b2', 'v1.0.0-beta.2'], + ['v1.0.0c3', 'v1.0.0-candidate.3'], + ['v1.0.0f4', 'v1.0.0-final.4'], + ])('valid version %s', (version, expected) => { + expect(pythonVersionToSemver(version)).toBe(expected); + }); + + test('invalid version', () => { + expect(() => pythonVersionToSemver('not a version')).toThrow(); + }); +}); diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 00000000..468acde0 --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import * as semver from 'semver'; + +/** + * Converts a Python short version string (e.g. '1.0.0b1') to a valid semver + * string (e.g. 1.0.0-beta.1). + * + * @param version The Python version string. + * @returns A modified version string that is a valid semver. + */ +export function pythonVersionToSemver(version: string): string { + const newVersion = version + .replace('a', '-alpha.') + .replace('b', '-beta.') + .replace('c', '-candidate.') + .replace('f', '-final.'); + + if (!semver.valid(newVersion)) { + throw new Error('invalid version'); + } + + return newVersion; +} diff --git a/yarn.lock b/yarn.lock index 54fddf2b..254c6a09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2146,6 +2146,11 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== +"@types/semver@^7.3.8": + version "7.3.8" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.8.tgz#508a27995498d7586dcecd77c25e289bfaf90c59" + integrity sha512-D/2EJvAlCEtYFEYmmlGwbGXuK886HzyCc3nZX/tkFTQdEU8jZDAgiv08P162yB17y4ZXZoq7yFAnW4GDBb9Now== + "@types/source-list-map@*": version "0.1.2" resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9"