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.
This commit is contained in:
David Lechner
2021-12-27 13:04:13 -06:00
parent 78dd03d80f
commit 8d4680ba82
12 changed files with 258 additions and 5 deletions
+3
View File
@@ -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]).
+46
View File
@@ -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';
}
+56
View File
@@ -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('');
});
+42 -2
View File
@@ -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<BleConnectionState, Action> = (
}
};
export default combineReducers({ connection });
const deviceName: Reducer<string, Action> = (state = '', action) => {
switch (action.type) {
case BleDeviceActionType.DidDisconnect:
return '';
case BleDeviceActionType.DidConnect:
return action.name;
default:
return state;
}
};
const deviceType: Reducer<string, Action> = (state = '', action) => {
switch (action.type) {
case BleDeviceActionType.DidDisconnect:
return '';
case BleDIServiceActionType.DidReceivePnPId:
return getHubTypeName(action.pnpId);
default:
return state;
}
};
const deviceFirmwareVersion: Reducer<string, Action> = (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,
});
+4
View File
@@ -30,6 +30,10 @@ body {
position: absolute;
}
.no-wrap {
white-space: nowrap;
}
// global style tweaks
.#{$ns}-toast {
+7 -1
View File
@@ -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;
+65 -1
View File
@@ -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 (
<Popover2
popoverClassName={Classes2.POPOVER2_CONTENT_SIZING}
rootBoundary="document"
placement="top"
content={
<table className="no-wrap">
<tbody>
<tr>
<td>
<strong>
{i18n.translate(MessageId.HubInfoConnectedTo)}
</strong>
</td>
<td>{deviceName}</td>
</tr>
<tr>
<td>
<strong>
{i18n.translate(MessageId.HubInfoHubType)}
</strong>
</td>
<td>{deviceType}</td>
</tr>
<tr>
<td>
<strong>
{i18n.translate(MessageId.HubInfoFirmware)}
</strong>
</td>
<td>v{deviceFirmwareVersion}</td>
</tr>
</tbody>
</table>
}
>
<Button minimal={true} onMouseDown={(e) => e.preventDefault()}>
{deviceName}
</Button>
</Popover2>
);
};
const StatusBar: React.VFC = (_props) => {
const connection = useSelector((state: RootState) => state.ble.connection);
return (
<div
className="pb-status-bar"
role="status"
aria-live="off"
onContextMenu={(e): void => e.preventDefault()}
></div>
>
{connection === BleConnectionState.Connected && <HubInfoButton />}
</div>
);
};
+7
View File
@@ -0,0 +1,7 @@
{
"hubInfo": {
"connectedTo": "Connected to:",
"hubType": "Hub type:",
"firmware": "Firmware:"
}
}
+12
View File
@@ -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();
});
});
+10
View File
@@ -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',
}
+5
View File
@@ -14,3 +14,8 @@
display: flex;
align-items: center;
}
// make status bar items right-aligned
.pb-status-bar :first-child {
margin-left: auto;
}
+1 -1
View File
@@ -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;