From 8d4680ba82bcf106078153b39e1598b483bca407 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 27 Dec 2021 12:34:32 -0600 Subject: [PATCH] status-bar: add connected hub indicator This adds an indicator to the status bar that shows the hub name. When clicked, a popover will be displayed that lists more detailed information about the connected hub. --- CHANGELOG.md | 3 ++ src/ble-device-info-service/protocol.ts | 46 +++++++++++++++++ src/ble/reducers.test.ts | 56 +++++++++++++++++++++ src/ble/reducers.ts | 44 ++++++++++++++++- src/index.scss | 4 ++ src/status-bar/StatusBar.test.tsx | 8 ++- src/status-bar/StatusBar.tsx | 66 ++++++++++++++++++++++++- src/status-bar/i18n.en.json | 7 +++ src/status-bar/i18n.test.ts | 12 +++++ src/status-bar/i18n.ts | 10 ++++ src/status-bar/status-bar.scss | 5 ++ src/variables.scss | 2 +- 12 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 src/status-bar/i18n.en.json create mode 100644 src/status-bar/i18n.test.ts create mode 100644 src/status-bar/i18n.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aff43c2..180aac0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### Added +- Status bar indicator for connected hub. + ### Changed - Saving file now uses proper save dialog in Chromium browser ([support#84]). - Toolbar buttons now scale with screen size ([support#300]). diff --git a/src/ble-device-info-service/protocol.ts b/src/ble-device-info-service/protocol.ts index b1dfb101..191e7d9e 100644 --- a/src/ble-device-info-service/protocol.ts +++ b/src/ble-device-info-service/protocol.ts @@ -5,6 +5,13 @@ // Refer to Device Information Service (DIS) at https://www.bluetooth.com/specifications/specs/ // and assigned numbers at https://www.bluetooth.com/specifications/assigned-numbers/ +import { + HubType, + LegoCompanyId, + TechnicLargeHubVariant, + TechnicSmallHubVariant, +} from '../ble-lwp3-service/protocol'; + /** Device Information service UUID. */ export const serviceUUID = 0x180a; @@ -54,3 +61,42 @@ export function decodePnpId(data: DataView): PnpId { productVersion: data.getUint16(5, true), }; } + +/** + * Returns hub type as a string suitable for display to users. + * @param pnpId The PnP ID + */ +export function getHubTypeName(pnpId: PnpId): string { + if (pnpId.vendorIdSource !== PnpIdVendorIdSource.BluetoothSig) { + return 'USB'; + } + + if (pnpId.vendorId !== LegoCompanyId) { + return 'non-LEGO'; + } + + switch (pnpId.productId) { + case HubType.MoveHub: + return 'Move hub'; + case HubType.CityHub: + return 'City hub'; + case HubType.TechnicHub: + return 'Technic hub'; + case HubType.TechnicLargeHub: + switch (pnpId.productVersion) { + case TechnicLargeHubVariant.SpikePrimeHub: + return 'Prime hub'; + case TechnicLargeHubVariant.MindstormsInventorHub: + return 'Inventor hub'; + } + break; + case HubType.TechnicSmallHub: + switch (pnpId.productVersion) { + case TechnicSmallHubVariant.SpikeEssentialHub: + return 'Essential hub'; + } + break; + } + + return 'Unsupported'; +} diff --git a/src/ble/reducers.test.ts b/src/ble/reducers.test.ts index 0a3c4215..8427caff 100644 --- a/src/ble/reducers.test.ts +++ b/src/ble/reducers.test.ts @@ -2,6 +2,12 @@ // Copyright (c) 2021 The Pybricks Authors import { Action } from '../actions'; +import { + bleDIServiceDidReceiveFirmwareRevision, + bleDIServiceDidReceivePnPId, +} from '../ble-device-info-service/actions'; +import { PnpIdVendorIdSource } from '../ble-device-info-service/protocol'; +import { HubType, LegoCompanyId } from '../ble-lwp3-service/protocol'; import { BleDeviceDidFailToConnectReason, connect, @@ -19,6 +25,9 @@ test('initial state', () => { expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` Object { "connection": "ble.connection.state.disconnected", + "deviceFirmwareVersion": "", + "deviceName": "", + "deviceType": "", } `); }); @@ -57,3 +66,50 @@ test('connection', () => { ).connection, ).toBe(BleConnectionState.Connected); }); + +test('deviceName', () => { + const testId = 'test-id'; + const testName = 'Test Name'; + + expect( + reducers({ deviceName: '' } as State, didConnect(testId, testName)).deviceName, + ).toBe(testName); + + expect( + reducers({ deviceName: testName } as State, didDisconnect()).deviceName, + ).toBe(''); +}); + +test('deviceType', () => { + expect( + reducers( + { deviceType: '' } as State, + bleDIServiceDidReceivePnPId({ + vendorIdSource: PnpIdVendorIdSource.BluetoothSig, + vendorId: LegoCompanyId, + productId: HubType.MoveHub, + productVersion: 0, + }), + ).deviceType, + ).toBe('Move hub'); + + expect( + reducers({ deviceType: 'Move hub' } as State, didDisconnect()).deviceType, + ).toBe(''); +}); + +test('deviceFirmwareVersion', () => { + const testVersion = '3.0.0'; + + expect( + reducers( + { deviceFirmwareVersion: '' } as State, + bleDIServiceDidReceiveFirmwareRevision(testVersion), + ).deviceFirmwareVersion, + ).toBe(testVersion); + + expect( + reducers({ deviceFirmwareVersion: testVersion } as State, didDisconnect()) + .deviceFirmwareVersion, + ).toBe(''); +}); diff --git a/src/ble/reducers.ts b/src/ble/reducers.ts index 6dbc3758..449fe567 100644 --- a/src/ble/reducers.ts +++ b/src/ble/reducers.ts @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2021 The Pybricks Authors // // Manages state for the Bluetooth Low Energy connection. // This assumes that there is only one global connection to a single device. import { Reducer, combineReducers } from 'redux'; import { Action } from '../actions'; +import { BleDIServiceActionType } from '../ble-device-info-service/actions'; +import { getHubTypeName } from '../ble-device-info-service/protocol'; import { BleDeviceActionType } from './actions'; /** @@ -50,4 +52,42 @@ const connection: Reducer = ( } }; -export default combineReducers({ connection }); +const deviceName: Reducer = (state = '', action) => { + switch (action.type) { + case BleDeviceActionType.DidDisconnect: + return ''; + case BleDeviceActionType.DidConnect: + return action.name; + default: + return state; + } +}; + +const deviceType: Reducer = (state = '', action) => { + switch (action.type) { + case BleDeviceActionType.DidDisconnect: + return ''; + case BleDIServiceActionType.DidReceivePnPId: + return getHubTypeName(action.pnpId); + default: + return state; + } +}; + +const deviceFirmwareVersion: Reducer = (state = '', action) => { + switch (action.type) { + case BleDeviceActionType.DidDisconnect: + return ''; + case BleDIServiceActionType.DidReceiveFirmwareRevision: + return action.version; + default: + return state; + } +}; + +export default combineReducers({ + connection, + deviceName, + deviceType, + deviceFirmwareVersion, +}); diff --git a/src/index.scss b/src/index.scss index 9274c26f..89805e82 100644 --- a/src/index.scss +++ b/src/index.scss @@ -30,6 +30,10 @@ body { position: absolute; } +.no-wrap { + white-space: nowrap; +} + // global style tweaks .#{$ns}-toast { diff --git a/src/status-bar/StatusBar.test.tsx b/src/status-bar/StatusBar.test.tsx index 8cbfba70..2719de2c 100644 --- a/src/status-bar/StatusBar.test.tsx +++ b/src/status-bar/StatusBar.test.tsx @@ -1,12 +1,18 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; import { Store } from 'redux'; +import { BleConnectionState } from '../ble/reducers'; import StatusBar from './StatusBar'; it('should prevent browser context menu', () => { const store = { - getState: jest.fn(), + getState: jest.fn(() => ({ + ble: { connection: BleConnectionState.Disconnected, deviceName: '' }, + })), dispatch: jest.fn(), subscribe: jest.fn(), } as unknown as Store; diff --git a/src/status-bar/StatusBar.tsx b/src/status-bar/StatusBar.tsx index 7304ead7..08a4830a 100644 --- a/src/status-bar/StatusBar.tsx +++ b/src/status-bar/StatusBar.tsx @@ -1,18 +1,82 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2021 The Pybricks Authors +import { Button } from '@blueprintjs/core'; +import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2'; +import { useI18n } from '@shopify/react-i18n'; import React from 'react'; +import { useSelector } from 'react-redux'; +import { BleConnectionState } from '../ble/reducers'; +import { RootState } from '../reducers'; +import { MessageId } from './i18n'; +import en from './i18n.en.json'; import './status-bar.scss'; +const HubInfoButton: React.VFC = (_props) => { + const deviceName = useSelector((state: RootState) => state.ble.deviceName); + const deviceType = useSelector((state: RootState) => state.ble.deviceType); + const deviceFirmwareVersion = useSelector( + (state: RootState) => state.ble.deviceFirmwareVersion, + ); + + const [i18n] = useI18n({ id: 'statusBar', translations: { en }, fallback: en }); + + return ( + + + + + + {i18n.translate(MessageId.HubInfoConnectedTo)} + + + {deviceName} + + + + + {i18n.translate(MessageId.HubInfoHubType)} + + + {deviceType} + + + + + {i18n.translate(MessageId.HubInfoFirmware)} + + + v{deviceFirmwareVersion} + + + + } + > + + + ); +}; + const StatusBar: React.VFC = (_props) => { + const connection = useSelector((state: RootState) => state.ble.connection); + return (
e.preventDefault()} - >
+ > + {connection === BleConnectionState.Connected && } + ); }; diff --git a/src/status-bar/i18n.en.json b/src/status-bar/i18n.en.json new file mode 100644 index 00000000..1222b2ff --- /dev/null +++ b/src/status-bar/i18n.en.json @@ -0,0 +1,7 @@ +{ + "hubInfo": { + "connectedTo": "Connected to:", + "hubType": "Hub type:", + "firmware": "Firmware:" + } +} diff --git a/src/status-bar/i18n.test.ts b/src/status-bar/i18n.test.ts new file mode 100644 index 00000000..d5939dd8 --- /dev/null +++ b/src/status-bar/i18n.test.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020-2021 The Pybricks Authors + +import { lookup } from '../../test'; +import { MessageId } from './i18n'; +import en from './i18n.en.json'; + +describe('Ensure .json file has matches for MessageIds', () => { + test.each(Object.values(MessageId))('%s', (id) => { + expect(lookup(en, id)).toBeDefined(); + }); +}); diff --git a/src/status-bar/i18n.ts b/src/status-bar/i18n.ts new file mode 100644 index 00000000..5e128865 --- /dev/null +++ b/src/status-bar/i18n.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors +// +// Status bar translation keys. + +export enum MessageId { + HubInfoConnectedTo = 'hubInfo.connectedTo', + HubInfoHubType = 'hubInfo.hubType', + HubInfoFirmware = 'hubInfo.firmware', +} diff --git a/src/status-bar/status-bar.scss b/src/status-bar/status-bar.scss index 2da14ea7..52be658d 100644 --- a/src/status-bar/status-bar.scss +++ b/src/status-bar/status-bar.scss @@ -14,3 +14,8 @@ display: flex; align-items: center; } + +// make status bar items right-aligned +.pb-status-bar :first-child { + margin-left: auto; +} diff --git a/src/variables.scss b/src/variables.scss index b6b0f9da..9f77601c 100644 --- a/src/variables.scss +++ b/src/variables.scss @@ -11,7 +11,7 @@ $pt-font-size-large: $pt-grid-size * 1.8; $pt-font-size-small: $pt-grid-size * 1.4; $pb-toolbar-height: 72px; -$pb-status-bar-height: 24px; +$pb-status-bar-height: 30px; $pb-pybricks-blue: #0088ce; $pt-app-background-color: #e8e8e8;