hub: move hub info reducers to hub

Most of the hub state doesn't depend on the connection type. To make it
generic, move it out of ble and into hub so that we can share it with
usb.
This commit is contained in:
David Lechner
2025-08-08 21:36:18 -05:00
parent 1fe01b5719
commit 38db0d016c
6 changed files with 177 additions and 186 deletions
-97
View File
@@ -2,14 +2,6 @@
// Copyright (c) 2021-2025 The Pybricks Authors
import { AnyAction } from 'redux';
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 { didReceiveStatusReport } from '../ble-pybricks-service/actions';
import { Status, statusToFlag } from '../ble-pybricks-service/protocol';
import {
bleConnectPybricks,
bleDidConnectPybricks,
@@ -26,11 +18,6 @@ test('initial state', () => {
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
{
"connection": "ble.connection.state.disconnected",
"deviceBatteryCharging": false,
"deviceFirmwareVersion": "",
"deviceLowBatteryWarning": false,
"deviceName": "",
"deviceType": "",
}
`);
});
@@ -73,87 +60,3 @@ test('connection', () => {
).connection,
).toBe(BleConnectionState.Connected);
});
test('deviceName', () => {
const testId = 'test-id';
const testName = 'Test Name';
expect(
reducers({ deviceName: '' } as State, bleDidConnectPybricks(testId, testName))
.deviceName,
).toBe(testName);
expect(
reducers({ deviceName: testName } as State, bleDidDisconnectPybricks())
.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, bleDidDisconnectPybricks())
.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,
bleDidDisconnectPybricks(),
).deviceFirmwareVersion,
).toBe('');
});
test('deviceLowBatteryWarning', () => {
expect(
reducers(
{ deviceLowBatteryWarning: false } as State,
didReceiveStatusReport(statusToFlag(Status.BatteryLowVoltageWarning), 0, 0),
).deviceLowBatteryWarning,
).toBeTruthy();
expect(
reducers(
{ deviceLowBatteryWarning: true } as State,
didReceiveStatusReport(
~statusToFlag(Status.BatteryLowVoltageWarning),
0,
0,
),
).deviceLowBatteryWarning,
).toBeFalsy();
expect(
reducers({ deviceLowBatteryWarning: true } as State, bleDidDisconnectPybricks())
.deviceLowBatteryWarning,
).toBeFalsy();
});
test('deviceBatteryCharging', () => {
expect(
reducers({ deviceBatteryCharging: true } as State, bleDidDisconnectPybricks())
.deviceBatteryCharging,
).toBeFalsy();
});
+1 -73
View File
@@ -1,17 +1,10 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
// Copyright (c) 2020-2025 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 {
bleDIServiceDidReceiveFirmwareRevision,
bleDIServiceDidReceivePnPId,
} from '../ble-device-info-service/actions';
import { getHubTypeName } from '../ble-device-info-service/protocol';
import { didReceiveStatusReport } from '../ble-pybricks-service/actions';
import { Status, statusToFlag } from '../ble-pybricks-service/protocol';
import {
bleConnectPybricks,
bleDidConnectPybricks,
@@ -72,71 +65,6 @@ const connection: Reducer<BleConnectionState> = (
return state;
};
const deviceName: Reducer<string> = (state = '', action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return '';
}
if (bleDidConnectPybricks.matches(action)) {
return action.name;
}
return state;
};
const deviceType: Reducer<string> = (state = '', action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return '';
}
if (bleDIServiceDidReceivePnPId.matches(action)) {
return getHubTypeName(action.pnpId);
}
return state;
};
const deviceFirmwareVersion: Reducer<string> = (state = '', action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return '';
}
if (bleDIServiceDidReceiveFirmwareRevision.matches(action)) {
return action.version;
}
return state;
};
const deviceLowBatteryWarning: Reducer<boolean> = (state = false, action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return false;
}
if (didReceiveStatusReport.matches(action)) {
return Boolean(
action.statusFlags & statusToFlag(Status.BatteryLowVoltageWarning),
);
}
return state;
};
const deviceBatteryCharging: Reducer<boolean> = (state = false, action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return false;
}
// TODO: hub does not currently have a status flag for this
return state;
};
export default combineReducers({
connection,
deviceName,
deviceType,
deviceFirmwareVersion,
deviceLowBatteryWarning,
deviceBatteryCharging,
});
+96 -3
View File
@@ -7,9 +7,13 @@ import {
bleDidDisconnectPybricks,
bleDisconnectPybricks,
} from '../ble/actions';
import { bleDIServiceDidReceiveSoftwareRevision } from '../ble-device-info-service/actions';
import { PnpId } from '../ble-device-info-service/protocol';
import { HubType } from '../ble-lwp3-service/protocol';
import {
bleDIServiceDidReceiveFirmwareRevision,
bleDIServiceDidReceivePnPId,
bleDIServiceDidReceiveSoftwareRevision,
} from '../ble-device-info-service/actions';
import { PnpId, PnpIdVendorIdSource } from '../ble-device-info-service/protocol';
import { HubType, LegoCompanyId } from '../ble-lwp3-service/protocol';
import {
blePybricksServiceDidNotReceiveHubCapabilities,
blePybricksServiceDidReceiveHubCapabilities,
@@ -40,6 +44,11 @@ type State = ReturnType<typeof reducers>;
test('initial state', () => {
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
{
"deviceBatteryCharging": false,
"deviceFirmwareVersion": "",
"deviceLowBatteryWarning": false,
"deviceName": "",
"deviceType": "",
"downloadProgress": null,
"hasRepl": false,
"maxBleWriteSize": 0,
@@ -298,6 +307,90 @@ describe('runtime', () => {
});
});
test('deviceName', () => {
const testId = 'test-id';
const testName = 'Test Name';
expect(
reducers({ deviceName: '' } as State, bleDidConnectPybricks(testId, testName))
.deviceName,
).toBe(testName);
expect(
reducers({ deviceName: testName } as State, bleDidDisconnectPybricks())
.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, bleDidDisconnectPybricks())
.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,
bleDidDisconnectPybricks(),
).deviceFirmwareVersion,
).toBe('');
});
test('deviceLowBatteryWarning', () => {
expect(
reducers(
{ deviceLowBatteryWarning: false } as State,
didReceiveStatusReport(statusToFlag(Status.BatteryLowVoltageWarning), 0, 0),
).deviceLowBatteryWarning,
).toBeTruthy();
expect(
reducers(
{ deviceLowBatteryWarning: true } as State,
didReceiveStatusReport(
~statusToFlag(Status.BatteryLowVoltageWarning),
0,
0,
),
).deviceLowBatteryWarning,
).toBeFalsy();
expect(
reducers({ deviceLowBatteryWarning: true } as State, bleDidDisconnectPybricks())
.deviceLowBatteryWarning,
).toBeFalsy();
});
test('deviceBatteryCharging', () => {
expect(
reducers({ deviceBatteryCharging: true } as State, bleDidDisconnectPybricks())
.deviceBatteryCharging,
).toBeFalsy();
});
describe('maxBleWriteSize', () => {
test.each([100, 1000])('Pybricks Profile >= v1.2.0: %s', (size) => {
expect(
+71 -1
View File
@@ -8,7 +8,12 @@ import {
bleDidDisconnectPybricks,
bleDisconnectPybricks,
} from '../ble/actions';
import { bleDIServiceDidReceiveSoftwareRevision } from '../ble-device-info-service/actions';
import {
bleDIServiceDidReceiveFirmwareRevision,
bleDIServiceDidReceivePnPId,
bleDIServiceDidReceiveSoftwareRevision,
} from '../ble-device-info-service/actions';
import { getHubTypeName } from '../ble-device-info-service/protocol';
import { HubType } from '../ble-lwp3-service/protocol';
import {
blePybricksServiceDidNotReceiveHubCapabilities,
@@ -143,6 +148,66 @@ const runtime: Reducer<HubRuntimeState> = (
return state;
};
const deviceName: Reducer<string> = (state = '', action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return '';
}
if (bleDidConnectPybricks.matches(action)) {
return action.name;
}
return state;
};
const deviceType: Reducer<string> = (state = '', action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return '';
}
if (bleDIServiceDidReceivePnPId.matches(action)) {
return getHubTypeName(action.pnpId);
}
return state;
};
const deviceFirmwareVersion: Reducer<string> = (state = '', action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return '';
}
if (bleDIServiceDidReceiveFirmwareRevision.matches(action)) {
return action.version;
}
return state;
};
const deviceLowBatteryWarning: Reducer<boolean> = (state = false, action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return false;
}
if (didReceiveStatusReport.matches(action)) {
return Boolean(
action.statusFlags & statusToFlag(Status.BatteryLowVoltageWarning),
);
}
return state;
};
const deviceBatteryCharging: Reducer<boolean> = (state = false, action) => {
if (bleDidDisconnectPybricks.matches(action)) {
return false;
}
// TODO: hub does not currently have a status flag for this
return state;
};
const downloadProgress: Reducer<number | null> = (state = null, action) => {
if (didStartDownload.matches(action)) {
return 0;
@@ -299,6 +364,11 @@ const selectedSlot: Reducer<number> = (state = 0, action) => {
export default combineReducers({
runtime,
deviceName,
deviceType,
deviceFirmwareVersion,
deviceLowBatteryWarning,
deviceBatteryCharging,
downloadProgress,
maxBleWriteSize,
maxUserProgramSize,
+4 -7
View File
@@ -4,7 +4,6 @@
import { act, waitFor } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../test';
import { BleConnectionState } from '../ble/reducers';
import { HubRuntimeState } from '../hub/reducers';
import StatusBar from './StatusBar';
@@ -12,15 +11,14 @@ it('should show popover when hub name is clicked', async () => {
const testHubName = 'Test hub';
const [user, statusBar] = testRender(<StatusBar />, {
ble: {
connection: BleConnectionState.Connected,
hub: {
runtime: HubRuntimeState.Idle,
deviceName: testHubName,
deviceType: 'hub type',
deviceFirmwareVersion: 'v0.0.0',
deviceLowBatteryWarning: false,
deviceBatteryCharging: false,
},
hub: { runtime: HubRuntimeState.Idle },
});
await act(() => user.click(statusBar.getByText(testHubName)));
@@ -32,15 +30,14 @@ it('should show popover when battery is clicked', async () => {
const testHubName = 'Test hub';
const [user, statusBar] = testRender(<StatusBar />, {
ble: {
connection: BleConnectionState.Connected,
hub: {
runtime: HubRuntimeState.Idle,
deviceName: testHubName,
deviceType: 'hub type',
deviceFirmwareVersion: 'v0.0.0',
deviceLowBatteryWarning: false,
deviceBatteryCharging: false,
},
hub: { runtime: HubRuntimeState.Idle },
});
await act(() => user.click(statusBar.getByTitle('Battery')));
+5 -5
View File
@@ -72,9 +72,9 @@ const CompletionEngineIndicator: React.FunctionComponent = () => {
const HubInfoButton: React.FunctionComponent = () => {
const i18n = useI18n();
const deviceName = useSelector((s) => s.ble.deviceName);
const deviceType = useSelector((s) => s.ble.deviceType);
const deviceFirmwareVersion = useSelector((s) => s.ble.deviceFirmwareVersion);
const deviceName = useSelector((s) => s.hub.deviceName);
const deviceType = useSelector((s) => s.hub.deviceType);
const deviceFirmwareVersion = useSelector((s) => s.hub.deviceFirmwareVersion);
return (
<Popover
@@ -113,8 +113,8 @@ const HubInfoButton: React.FunctionComponent = () => {
const BatteryIndicator: React.FunctionComponent = () => {
const i18n = useI18n();
const charging = useSelector((s) => s.ble.deviceBatteryCharging);
const lowBatteryWarning = useSelector((s) => s.ble.deviceLowBatteryWarning);
const charging = useSelector((s) => s.hub.deviceBatteryCharging);
const lowBatteryWarning = useSelector((s) => s.hub.deviceLowBatteryWarning);
return (
<Popover