From 38db0d016cc59ee927467e9a1c2593b40ae16e69 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 8 Aug 2025 22:56:24 +0000 Subject: [PATCH] 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. --- src/ble/reducers.test.ts | 97 ------------------------------ src/ble/reducers.ts | 74 +---------------------- src/hub/reducers.test.ts | 99 ++++++++++++++++++++++++++++++- src/hub/reducers.ts | 72 +++++++++++++++++++++- src/status-bar/StatusBar.test.tsx | 11 ++-- src/status-bar/StatusBar.tsx | 10 ++-- 6 files changed, 177 insertions(+), 186 deletions(-) diff --git a/src/ble/reducers.test.ts b/src/ble/reducers.test.ts index deba2a9a..b9e89ff1 100644 --- a/src/ble/reducers.test.ts +++ b/src/ble/reducers.test.ts @@ -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(); -}); diff --git a/src/ble/reducers.ts b/src/ble/reducers.ts index 808a42fa..753b3180 100644 --- a/src/ble/reducers.ts +++ b/src/ble/reducers.ts @@ -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 = ( return state; }; -const deviceName: Reducer = (state = '', action) => { - if (bleDidDisconnectPybricks.matches(action)) { - return ''; - } - - if (bleDidConnectPybricks.matches(action)) { - return action.name; - } - - return state; -}; - -const deviceType: Reducer = (state = '', action) => { - if (bleDidDisconnectPybricks.matches(action)) { - return ''; - } - - if (bleDIServiceDidReceivePnPId.matches(action)) { - return getHubTypeName(action.pnpId); - } - - return state; -}; - -const deviceFirmwareVersion: Reducer = (state = '', action) => { - if (bleDidDisconnectPybricks.matches(action)) { - return ''; - } - - if (bleDIServiceDidReceiveFirmwareRevision.matches(action)) { - return action.version; - } - - return state; -}; - -const deviceLowBatteryWarning: Reducer = (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 = (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, }); diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index 7e7bbf83..87b081a2 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -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; 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( diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index 73824d23..eba4109e 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -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 = ( return state; }; +const deviceName: Reducer = (state = '', action) => { + if (bleDidDisconnectPybricks.matches(action)) { + return ''; + } + + if (bleDidConnectPybricks.matches(action)) { + return action.name; + } + + return state; +}; + +const deviceType: Reducer = (state = '', action) => { + if (bleDidDisconnectPybricks.matches(action)) { + return ''; + } + + if (bleDIServiceDidReceivePnPId.matches(action)) { + return getHubTypeName(action.pnpId); + } + + return state; +}; + +const deviceFirmwareVersion: Reducer = (state = '', action) => { + if (bleDidDisconnectPybricks.matches(action)) { + return ''; + } + + if (bleDIServiceDidReceiveFirmwareRevision.matches(action)) { + return action.version; + } + + return state; +}; + +const deviceLowBatteryWarning: Reducer = (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 = (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 = (state = null, action) => { if (didStartDownload.matches(action)) { return 0; @@ -299,6 +364,11 @@ const selectedSlot: Reducer = (state = 0, action) => { export default combineReducers({ runtime, + deviceName, + deviceType, + deviceFirmwareVersion, + deviceLowBatteryWarning, + deviceBatteryCharging, downloadProgress, maxBleWriteSize, maxUserProgramSize, diff --git a/src/status-bar/StatusBar.test.tsx b/src/status-bar/StatusBar.test.tsx index f5ae875d..fee0bcc6 100644 --- a/src/status-bar/StatusBar.test.tsx +++ b/src/status-bar/StatusBar.test.tsx @@ -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(, { - 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(, { - 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'))); diff --git a/src/status-bar/StatusBar.tsx b/src/status-bar/StatusBar.tsx index 1756eb7c..c14c8885 100644 --- a/src/status-bar/StatusBar.tsx +++ b/src/status-bar/StatusBar.tsx @@ -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 ( { 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 (