From bb687bf62dd103de988093b27e8b41d6c962e1ee Mon Sep 17 00:00:00 2001 From: Karel Tucek Date: Wed, 5 Aug 2026 15:02:38 +0200 Subject: [PATCH 1/5] Host connections rehaul. --- package.json | 4 +- .../config-items/host-connection.ts | 2 + .../config-items/key-action/helper.ts | 2 + .../key-action/keystroke-action.ts | 2 + .../config-items/key-action/mouse-action.ts | 2 + .../key-action/play-macro-action.ts | 2 + .../key-action/switch-keymap-action.ts | 2 + .../key-action/switch-layer-action.ts | 2 + .../config-serializer/config-items/keymap.ts | 2 + .../config-serializer/config-items/layer.ts | 2 + .../macro-action/command-macro-action.ts | 2 + .../macro-action/delay-macro-action.ts | 2 + .../macro-action/key-macro-action.ts | 2 + .../macro-action/mouse-button-macro-action.ts | 2 + .../macro-action/move-mouse-macro-action.ts | 2 + .../macro-action/scroll-mouse-macro-action.ts | 2 + .../macro-action/text-macro-action.ts | 2 + .../config-serializer/config-items/macro.ts | 2 + .../config-items/module-configuration.ts | 2 + .../config-serializer/config-items/module.ts | 2 + .../config-items/rgb-color.ts | 2 + .../config-items/user-configuration.test.ts | 4 +- .../config-items/user-configuration.ts | 45 ++++++++ .../src/models/device-connection-state.ts | 5 +- packages/uhk-common/src/models/index.ts | 1 + .../src/models/new-paired-device.ts | 15 +++ packages/uhk-common/src/util/index.ts | 1 + ...rotocol-support-new-pairings-with-slots.ts | 7 ++ packages/uhk-common/user-config-80.json | 4 +- packages/uhk-usb/src/constants.ts | 3 +- packages/uhk-usb/src/uhk-hid-device.ts | 57 +++++++++- .../host-connections.component.html | 85 ++++++++++++++- .../host-connections.component.scss | 33 ++++++ .../host-connections.component.ts | 102 +++++++++++++++++- .../app/models/user-configuration-value.ts | 2 +- .../app/store/reducers/user-configuration.ts | 54 ++++++++-- 36 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 packages/uhk-common/src/models/new-paired-device.ts create mode 100644 packages/uhk-common/src/util/is-device-protocol-support-new-pairings-with-slots.ts diff --git a/package.json b/package.json index 7ab4cf71fbb..0c5ae1b68f1 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "electron/dist/electron-main.js", "version": "10.1.0", "firmwareVersion": "17.2.0", - "deviceProtocolVersion": "4.17.0", - "userConfigVersion": "14.0.0", + "deviceProtocolVersion": "4.19.0", + "userConfigVersion": "15.0.0", "hardwareConfigVersion": "1.0.0", "description": "Agent is the configuration application of the Ultimate Hacking Keyboard.", "repository": { diff --git a/packages/uhk-common/src/config-serializer/config-items/host-connection.ts b/packages/uhk-common/src/config-serializer/config-items/host-connection.ts index 58e6ce7afa1..d8aca12dd5e 100644 --- a/packages/uhk-common/src/config-serializer/config-items/host-connection.ts +++ b/packages/uhk-common/src/config-serializer/config-items/host-connection.ts @@ -77,6 +77,7 @@ export class HostConnection { case 12: case 13: case 14: + case 15: return this.fromJsonObjectV9(jsonObject, serialisationInfo); default: @@ -94,6 +95,7 @@ export class HostConnection { case 12: case 13: case 14: + case 15: return this.fromJsonBinaryV9(buffer, serialisationInfo); default: diff --git a/packages/uhk-common/src/config-serializer/config-items/key-action/helper.ts b/packages/uhk-common/src/config-serializer/config-items/key-action/helper.ts index 5f9a9152c37..f2fb061ec4e 100644 --- a/packages/uhk-common/src/config-serializer/config-items/key-action/helper.ts +++ b/packages/uhk-common/src/config-serializer/config-items/key-action/helper.ts @@ -42,6 +42,7 @@ export class Helper { case 12: case 13: case 14: + case 15: return this.fromUhkBufferV1(buffer, macros, serialisationInfo); default: @@ -133,6 +134,7 @@ export class Helper { case 12: case 13: case 14: + case 15: return this.fromJSONObjectV1(keyAction, macros, serialisationInfo); default: diff --git a/packages/uhk-common/src/config-serializer/config-items/key-action/keystroke-action.ts b/packages/uhk-common/src/config-serializer/config-items/key-action/keystroke-action.ts index 973c4e7d37b..a978282e753 100644 --- a/packages/uhk-common/src/config-serializer/config-items/key-action/keystroke-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/key-action/keystroke-action.ts @@ -87,6 +87,7 @@ export class KeystrokeAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV6(jsonObject, serialisationInfo); break; @@ -115,6 +116,7 @@ export class KeystrokeAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromBinaryV6(buffer, serialisationInfo); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/key-action/mouse-action.ts b/packages/uhk-common/src/config-serializer/config-items/key-action/mouse-action.ts index 3e7e21d68c8..41dffd5f912 100644 --- a/packages/uhk-common/src/config-serializer/config-items/key-action/mouse-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/key-action/mouse-action.ts @@ -67,6 +67,7 @@ export class MouseAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV6(jsonObject, serialisationInfo); break; @@ -95,6 +96,7 @@ export class MouseAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromBinaryV6(buffer, serialisationInfo); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/key-action/play-macro-action.ts b/packages/uhk-common/src/config-serializer/config-items/key-action/play-macro-action.ts index 68653614b06..23b7ed89dbe 100644 --- a/packages/uhk-common/src/config-serializer/config-items/key-action/play-macro-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/key-action/play-macro-action.ts @@ -100,6 +100,7 @@ export class PlayMacroAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV6(jsonObject, macros, serialisationInfo); break; @@ -128,6 +129,7 @@ export class PlayMacroAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromBinaryV6(buffer, macros, serialisationInfo); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/key-action/switch-keymap-action.ts b/packages/uhk-common/src/config-serializer/config-items/key-action/switch-keymap-action.ts index 863ef5397bd..525f9ebf82f 100644 --- a/packages/uhk-common/src/config-serializer/config-items/key-action/switch-keymap-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/key-action/switch-keymap-action.ts @@ -46,6 +46,7 @@ export class SwitchKeymapAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV6(jsonObject, serialisationInfo); break; @@ -127,6 +128,7 @@ export class UnresolvedSwitchKeymapAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromBinaryV6(buffer, serialisationInfo); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/key-action/switch-layer-action.ts b/packages/uhk-common/src/config-serializer/config-items/key-action/switch-layer-action.ts index d4bc1134fac..1c81554e7e3 100644 --- a/packages/uhk-common/src/config-serializer/config-items/key-action/switch-layer-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/key-action/switch-layer-action.ts @@ -76,6 +76,7 @@ export class SwitchLayerAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV6(jsonObject, serialisationInfo); break; @@ -104,6 +105,7 @@ export class SwitchLayerAction extends KeyAction { case 12: case 13: case 14: + case 15: this.fromBinaryV6(buffer, serialisationInfo); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/keymap.ts b/packages/uhk-common/src/config-serializer/config-items/keymap.ts index 301a523567b..4caceff1b40 100644 --- a/packages/uhk-common/src/config-serializer/config-items/keymap.ts +++ b/packages/uhk-common/src/config-serializer/config-items/keymap.ts @@ -45,6 +45,7 @@ export class Keymap { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject, macros, serialisationInfo); break; @@ -72,6 +73,7 @@ export class Keymap { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer, macros, serialisationInfo); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/layer.ts b/packages/uhk-common/src/config-serializer/config-items/layer.ts index 6da0fe9ef1a..e43ac3dea53 100644 --- a/packages/uhk-common/src/config-serializer/config-items/layer.ts +++ b/packages/uhk-common/src/config-serializer/config-items/layer.ts @@ -44,6 +44,7 @@ export class Layer { case 12: case 13: case 14: + case 15: this.fromJsonObjectV5(jsonObject, macros, serialisationInfo); break; @@ -72,6 +73,7 @@ export class Layer { case 12: case 13: case 14: + case 15: this.fromBinaryV5(buffer, macros, serialisationInfo); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/macro-action/command-macro-action.ts b/packages/uhk-common/src/config-serializer/config-items/macro-action/command-macro-action.ts index 911563cf00b..9e718f3e5ae 100644 --- a/packages/uhk-common/src/config-serializer/config-items/macro-action/command-macro-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/macro-action/command-macro-action.ts @@ -30,6 +30,7 @@ export class CommandMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject); break; @@ -55,6 +56,7 @@ export class CommandMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/macro-action/delay-macro-action.ts b/packages/uhk-common/src/config-serializer/config-items/macro-action/delay-macro-action.ts index 21f882402b4..0ba8ce9211b 100644 --- a/packages/uhk-common/src/config-serializer/config-items/macro-action/delay-macro-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/macro-action/delay-macro-action.ts @@ -31,6 +31,7 @@ export class DelayMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject); break; @@ -56,6 +57,7 @@ export class DelayMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/macro-action/key-macro-action.ts b/packages/uhk-common/src/config-serializer/config-items/macro-action/key-macro-action.ts index 12db006db08..9c0adde8b6b 100644 --- a/packages/uhk-common/src/config-serializer/config-items/macro-action/key-macro-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/macro-action/key-macro-action.ts @@ -62,6 +62,7 @@ export class KeyMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject); break; @@ -87,6 +88,7 @@ export class KeyMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/macro-action/mouse-button-macro-action.ts b/packages/uhk-common/src/config-serializer/config-items/macro-action/mouse-button-macro-action.ts index 510b795e5cc..50239bc21b7 100644 --- a/packages/uhk-common/src/config-serializer/config-items/macro-action/mouse-button-macro-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/macro-action/mouse-button-macro-action.ts @@ -44,6 +44,7 @@ export class MouseButtonMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject); break; @@ -69,6 +70,7 @@ export class MouseButtonMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/macro-action/move-mouse-macro-action.ts b/packages/uhk-common/src/config-serializer/config-items/macro-action/move-mouse-macro-action.ts index 2995f7b1596..4f10fdbd649 100644 --- a/packages/uhk-common/src/config-serializer/config-items/macro-action/move-mouse-macro-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/macro-action/move-mouse-macro-action.ts @@ -34,6 +34,7 @@ export class MoveMouseMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject); break; @@ -59,6 +60,7 @@ export class MoveMouseMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/macro-action/scroll-mouse-macro-action.ts b/packages/uhk-common/src/config-serializer/config-items/macro-action/scroll-mouse-macro-action.ts index 085496a519b..3e1b58125a7 100644 --- a/packages/uhk-common/src/config-serializer/config-items/macro-action/scroll-mouse-macro-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/macro-action/scroll-mouse-macro-action.ts @@ -34,6 +34,7 @@ export class ScrollMouseMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject); break; @@ -59,6 +60,7 @@ export class ScrollMouseMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/macro-action/text-macro-action.ts b/packages/uhk-common/src/config-serializer/config-items/macro-action/text-macro-action.ts index 2ce86583331..4a26ec51035 100644 --- a/packages/uhk-common/src/config-serializer/config-items/macro-action/text-macro-action.ts +++ b/packages/uhk-common/src/config-serializer/config-items/macro-action/text-macro-action.ts @@ -30,6 +30,7 @@ export class TextMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject); break; @@ -55,6 +56,7 @@ export class TextMacroAction extends MacroAction { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/macro.ts b/packages/uhk-common/src/config-serializer/config-items/macro.ts index 0c508a84507..dd44f0dd10d 100644 --- a/packages/uhk-common/src/config-serializer/config-items/macro.ts +++ b/packages/uhk-common/src/config-serializer/config-items/macro.ts @@ -43,6 +43,7 @@ export class Macro { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject, serialisationInfo); break; @@ -68,6 +69,7 @@ export class Macro { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer, serialisationInfo); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/module-configuration.ts b/packages/uhk-common/src/config-serializer/config-items/module-configuration.ts index 98d00ad3e95..34e2d721894 100644 --- a/packages/uhk-common/src/config-serializer/config-items/module-configuration.ts +++ b/packages/uhk-common/src/config-serializer/config-items/module-configuration.ts @@ -98,6 +98,7 @@ export class ModuleConfiguration { case 12: case 13: case 14: + case 15: this.fromJsonObjectV7(jsonObject); break; @@ -125,6 +126,7 @@ export class ModuleConfiguration { case 12: case 13: case 14: + case 15: this.fromBinaryV7(buffer); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/module.ts b/packages/uhk-common/src/config-serializer/config-items/module.ts index a6cb3dcaa0f..ab908e6f70d 100644 --- a/packages/uhk-common/src/config-serializer/config-items/module.ts +++ b/packages/uhk-common/src/config-serializer/config-items/module.ts @@ -47,6 +47,7 @@ export class Module { case 12: case 13: case 14: + case 15: this.fromJsonObjectV1(jsonObject, macros, serialisationInfo); break; @@ -72,6 +73,7 @@ export class Module { case 12: case 13: case 14: + case 15: this.fromBinaryV1(buffer, macros, serialisationInfo); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/rgb-color.ts b/packages/uhk-common/src/config-serializer/config-items/rgb-color.ts index 6d39bee9310..4b34d0e4a3e 100644 --- a/packages/uhk-common/src/config-serializer/config-items/rgb-color.ts +++ b/packages/uhk-common/src/config-serializer/config-items/rgb-color.ts @@ -40,6 +40,7 @@ export class RgbColor { case 12: case 13: case 14: + case 15: this.fromJsonV6(jsonObject); break; @@ -67,6 +68,7 @@ export class RgbColor { case 12: case 13: case 14: + case 15: this.fromBinaryV6(buffer); break; diff --git a/packages/uhk-common/src/config-serializer/config-items/user-configuration.test.ts b/packages/uhk-common/src/config-serializer/config-items/user-configuration.test.ts index 996f3a32783..b4a471b6b75 100644 --- a/packages/uhk-common/src/config-serializer/config-items/user-configuration.test.ts +++ b/packages/uhk-common/src/config-serializer/config-items/user-configuration.test.ts @@ -11,7 +11,7 @@ describe('user-configuration', () => { it('should transform an empty config', ({ assert }) => { jsonTester( assert, { - userConfigMajorVersion: 14, + userConfigMajorVersion: 15, userConfigMinorVersion: 0, userConfigPatchVersion: 0, lastSaveAgentTag: '', @@ -65,6 +65,8 @@ describe('user-configuration', () => { keyBacklightFadeOutBatteryTimeout: 0, keyBacklightBrightnessChargingDefault: 50, batteryChargingMode: 'Full', + bluetoothAlwaysAdvertise: false, + bluetoothKeepConnectionsAlive: false, hostConnections: [ { type: 'UsbRight', diff --git a/packages/uhk-common/src/config-serializer/config-items/user-configuration.ts b/packages/uhk-common/src/config-serializer/config-items/user-configuration.ts index 0c106ab3a6b..7cb5977ddad 100644 --- a/packages/uhk-common/src/config-serializer/config-items/user-configuration.ts +++ b/packages/uhk-common/src/config-serializer/config-items/user-configuration.ts @@ -177,6 +177,10 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo @assertUInt16 keystrokeDelay: number; + bluetoothAlwaysAdvertise: boolean; + + bluetoothKeepConnectionsAlive: boolean; + hostConnections: HostConnection[] = []; moduleConfigurations: ModuleConfiguration[] = []; @@ -230,6 +234,7 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo case 12: case 13: case 14: + case 15: this.fromJsonObjectV9(jsonObject); break; @@ -254,6 +259,7 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo this.migrateToV12_1(); this.migrateToV13(); this.migrateToV14(); + this.migrateToV15(); this.recalculateConfigurationLength(); @@ -291,6 +297,7 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo case 12: case 13: case 14: + case 15: this.fromBinaryV9(buffer); break; @@ -363,6 +370,10 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo this.userConfigurationLength = 0; } + if (this.migrateToV15()) { + this.userConfigurationLength = 0; + } + if (this.userConfigurationLength === 0) { this.recalculateConfigurationLength(); } @@ -429,6 +440,9 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo keyBacklightBrightnessChargingDefault: this.keyBacklightBrightnessChargingDefault, batteryChargingMode: BatteryChargingMode[this.batteryChargingMode], + bluetoothAlwaysAdvertise: this.bluetoothAlwaysAdvertise, + bluetoothKeepConnectionsAlive: this.bluetoothKeepConnectionsAlive, + hostConnections: this.hostConnections.map(hostConnection => hostConnection.toJsonObject()), moduleConfigurations: this.moduleConfigurations.map(moduleConfiguration => moduleConfiguration.toJsonObject()), keymaps: this.keymaps.map(keymap => keymap.toJsonObject(this.getSerialisationInfo(), this.macros)), @@ -493,6 +507,8 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo buffer.writeBoolean(this.secondaryRoleAdvancedStrategyTriggerFromSameHalf); buffer.writeUInt8(this.secondaryRoleAdvancedStrategyMinimumHoldTime); buffer.writeUInt8(this.secondaryRoleAdvancedStrategyTimeoutType); + buffer.writeBoolean(this.bluetoothAlwaysAdvertise); + buffer.writeBoolean(this.bluetoothKeepConnectionsAlive); for(let i = 0; i < HOST_CONNECTION_COUNT_MAX; i++) { const hostConnection = this.hostConnections[i]; @@ -847,6 +863,11 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo this.secondaryRoleAdvancedStrategyTimeoutType = buffer.readUInt8(); } + if (isSerialisationInfoGte(serialisationInfo, '15.0.0')) { + this.bluetoothAlwaysAdvertise = buffer.readBoolean(); + this.bluetoothKeepConnectionsAlive = buffer.readBoolean(); + } + this.hostConnections = []; for (let i = 0; i < HOST_CONNECTION_COUNT_MAX; i++) { const hostConnection = new HostConnection().fromBinary(buffer, serialisationInfo); @@ -1137,6 +1158,11 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo this.batteryChargingMode = BatteryChargingMode[jsonObject.batteryChargingMode as string]; } + if (isSerialisationInfoGte(serialisationInfo, '15.0.0')) { + this.bluetoothAlwaysAdvertise = jsonObject.bluetoothAlwaysAdvertise; + this.bluetoothKeepConnectionsAlive = jsonObject.bluetoothKeepConnectionsAlive; + } + this.hostConnections = jsonObject.hostConnections.map((hostConnection: any, index: number) => { const connection = new HostConnection().fromJsonObject(hostConnection, serialisationInfo); connection.index = index; @@ -1505,6 +1531,25 @@ export class UserConfiguration implements AdvancedSecondaryRoleConfiguration, Mo this.secondaryRoleAdvancedStrategyTriggerFromSameHalf = true; this.secondaryRoleAdvancedStrategyMinimumHoldTime = 0; this.secondaryRoleAdvancedStrategyTimeoutType = SecondaryRoleAdvancedStrategyTimeoutType.Active; + + return true; + } + + private migrateToV15(): boolean { + if (this.userConfigMajorVersion > 14) { + return false; + } + + this.userConfigMajorVersion = 15; + this.userConfigMinorVersion = 0; + this.userConfigPatchVersion = 0; + + // Pre-v15 firmware operated in multipoint mode, but singlepoint is the new default, + // so migrated configurations deliberately change behaviour here. + this.bluetoothAlwaysAdvertise = false; + this.bluetoothKeepConnectionsAlive = false; + + return true; } private getSerialisationInfo(): SerialisationInfo { diff --git a/packages/uhk-common/src/models/device-connection-state.ts b/packages/uhk-common/src/models/device-connection-state.ts index a719d68c370..f64705651af 100644 --- a/packages/uhk-common/src/models/device-connection-state.ts +++ b/packages/uhk-common/src/models/device-connection-state.ts @@ -1,6 +1,7 @@ import { Dongle } from './dongle.js'; import { HalvesInfo } from './halves-info.js'; import { HardwareModules } from './hardware-modules.js'; +import { NewPairedDevice } from './new-paired-device.js'; import { UdevRulesInfo } from './udev-rules-info.js'; import { UhkDeviceProduct } from './uhk-products.js'; @@ -29,8 +30,8 @@ export interface DeviceConnectionState { halvesInfo: HalvesInfo; hardwareModules?: HardwareModules; /** - * The BLE addresses of the devices that paired with the keyboard but not is the hostConnections of the user configuration. + * The devices that paired with the keyboard but not is the hostConnections of the user configuration. */ - newPairedDevices: string[]; + newPairedDevices: NewPairedDevice[]; udevRulesInfo: UdevRulesInfo; } diff --git a/packages/uhk-common/src/models/index.ts b/packages/uhk-common/src/models/index.ts index dec9dcd6082..923b5a78e3f 100644 --- a/packages/uhk-common/src/models/index.ts +++ b/packages/uhk-common/src/models/index.ts @@ -25,6 +25,7 @@ export * from './app-start-info.js'; export * from './configuration-reply.js'; export * from './version-information.js'; export * from './device-connection-state.js'; +export * from './new-paired-device.js'; export * from './module-firmware-upgrade-progress.js'; export * from './module-firmware-upgrade-skip-info.js'; export * from './module-slot-to-i2c-adress.js'; diff --git a/packages/uhk-common/src/models/new-paired-device.ts b/packages/uhk-common/src/models/new-paired-device.ts new file mode 100644 index 00000000000..2a80d2495cd --- /dev/null +++ b/packages/uhk-common/src/models/new-paired-device.ts @@ -0,0 +1,15 @@ +/** + * A Bluetooth bond that exists on the keyboard but has no host connection in the user configuration yet. + */ +export interface NewPairedDevice { + /** + * The BLE address of the paired device. + */ + address: string; + /** + * The host connection slot the firmware associates with this bond, 0 based index into the + * serialized `hostConnections` array. Undefined when the device protocol is older than the + * version that reports slots, in which case the first empty slot has to be used. + */ + slot?: number; +} diff --git a/packages/uhk-common/src/util/index.ts b/packages/uhk-common/src/util/index.ts index 3b0104be028..ba1b3704faf 100644 --- a/packages/uhk-common/src/util/index.ts +++ b/packages/uhk-common/src/util/index.ts @@ -15,6 +15,7 @@ export * from './is-bit-set.js'; export * from './is-device-protocol-support-active-keymap-index.js'; export * from './is-device-protocol-support-firmware-checksum.js'; export * from './is-device-protocol-support-git-info.js'; +export * from './is-device-protocol-support-new-pairings-with-slots.js'; export * from './is-device-protocol-support-status-error.js'; export * from './is-equal-array.js'; export * from './is-official-uhk-firmware.js'; diff --git a/packages/uhk-common/src/util/is-device-protocol-support-new-pairings-with-slots.ts b/packages/uhk-common/src/util/is-device-protocol-support-new-pairings-with-slots.ts new file mode 100644 index 00000000000..9b1e0f2bbf5 --- /dev/null +++ b/packages/uhk-common/src/util/is-device-protocol-support-new-pairings-with-slots.ts @@ -0,0 +1,7 @@ +import { isVersionGte } from './version-helpers.js'; + +const DEVICE_PROTOCOL_VERSION_THAT_SUPPORT_NEW_PAIRINGS_WITH_SLOTS = '4.19.0'; + +export function isDeviceProtocolSupportNewPairingsWithSlots (deviceProtocolVersion: string): boolean { + return isVersionGte(deviceProtocolVersion, DEVICE_PROTOCOL_VERSION_THAT_SUPPORT_NEW_PAIRINGS_WITH_SLOTS); +} diff --git a/packages/uhk-common/user-config-80.json b/packages/uhk-common/user-config-80.json index 015515c04fb..ae2d061df12 100644 --- a/packages/uhk-common/user-config-80.json +++ b/packages/uhk-common/user-config-80.json @@ -1,5 +1,5 @@ { - "userConfigMajorVersion": 14, + "userConfigMajorVersion": 15, "userConfigMinorVersion": 0, "userConfigPatchVersion": 0, "lastSaveAgentTag": "", @@ -89,6 +89,8 @@ "keyBacklightFadeOutBatteryTimeout": 10, "keyBacklightBrightnessChargingDefault": 50, "batteryChargingMode": "Full", + "bluetoothAlwaysAdvertise": false, + "bluetoothKeepConnectionsAlive": false, "hostConnections": [ { "type": "UsbRight", diff --git a/packages/uhk-usb/src/constants.ts b/packages/uhk-usb/src/constants.ts index 0a42af6a93d..02f5932f58d 100644 --- a/packages/uhk-usb/src/constants.ts +++ b/packages/uhk-usb/src/constants.ts @@ -62,7 +62,8 @@ export enum DevicePropertyIds { BleAddress = 9, PairedRightPeerBleAddress = 10, PairingStatus = 11, - NewPairings = 12 + NewPairings = 12, + NewPairingsWithSlots = 13 } export enum EnumerationModes { diff --git a/packages/uhk-usb/src/uhk-hid-device.ts b/packages/uhk-usb/src/uhk-hid-device.ts index 5ee44b6e104..26922d87970 100644 --- a/packages/uhk-usb/src/uhk-hid-device.ts +++ b/packages/uhk-usb/src/uhk-hid-device.ts @@ -12,12 +12,14 @@ import { FIRMWARE_UPGRADE_METHODS, HalvesInfo, isBitSet, + isDeviceProtocolSupportNewPairingsWithSlots, isEqualArray, isVersionLt, LeftSlotModules, LogService, mapI2cAddressToModuleName, ModuleSlotToI2cAddress, + NewPairedDevice, ProtocolVersions, RightSlotModules, UdevRulesInfo, @@ -75,6 +77,7 @@ export const BOOTLOADER_TIMEOUT_MS = 5000; export const UHK_HID_DEVICE_NOT_CONNECTED = '[UhkHidDevice] Device is not connected'; const MAX_BLE_ADDRESSES_IN_NEW_PAIRING_RESPONSE = 10; +const MAX_BLE_ADDRESSES_IN_NEW_PAIRING_WITH_SLOTS_RESPONSE = 8; /** * HID API wrapper to support unified logging and async write */ @@ -636,10 +639,16 @@ export class UhkHidDevice { }; } - async getPairedDevices(): Promise { + async getPairedDevices(): Promise { + const protocolVersions = await this.getProtocolVersions(); + + if (isDeviceProtocolSupportNewPairingsWithSlots(protocolVersions.deviceProtocolVersion)) { + return this.getPairedDevicesWithSlots(); + } + this.logService.misc('[UhkHidDevice] Read paired devices'); let iteration = 0; - const result: string[] = []; + const result: NewPairedDevice[] = []; while (true) { this.logService.usb('[UhkHidDevice] USB[T]: Read paired devices'); @@ -658,7 +667,7 @@ export class UhkHidDevice { for (let i = 0; i < BLE_ADDRESS_LENGTH; i++) { address.push(uhkBuffer.readUInt8()); } - result.push(convertBleAddressArrayToString(address)); + result.push({ address: convertBleAddressArrayToString(address) }); } if (remainingNewConnections <= MAX_BLE_ADDRESSES_IN_NEW_PAIRING_RESPONSE) { @@ -671,6 +680,48 @@ export class UhkHidDevice { return result; } + /** + * Same paging shape as {@link DevicePropertyIds.NewPairings}, but every entry also carries the host + * connection slot the firmware wants the bond to end up in, and a page holds 8 entries instead of 10. + */ + private async getPairedDevicesWithSlots(): Promise { + this.logService.misc('[UhkHidDevice] Read paired devices with slots'); + let iteration = 0; + const result: NewPairedDevice[] = []; + + while (true) { + this.logService.usb('[UhkHidDevice] USB[T]: Read paired devices with slots'); + const command = Buffer.from([UsbCommand.GetProperty, DevicePropertyIds.NewPairingsWithSlots, iteration]); + const buffer = await this.write(command); + const uhkBuffer = UhkBuffer.fromArray(convertBufferToIntArray(buffer)); + // skip the first byte + uhkBuffer.readUInt8(); + + const remainingNewConnections = uhkBuffer.readUInt8(); + + const count = Math.min(remainingNewConnections, MAX_BLE_ADDRESSES_IN_NEW_PAIRING_WITH_SLOTS_RESPONSE); + + for (let i = 0; i < count; i++) { + const address: number[] = []; + for (let j = 0; j < BLE_ADDRESS_LENGTH; j++) { + address.push(uhkBuffer.readUInt8()); + } + result.push({ + address: convertBleAddressArrayToString(address), + slot: uhkBuffer.readUInt8(), + }); + } + + if (remainingNewConnections <= MAX_BLE_ADDRESSES_IN_NEW_PAIRING_WITH_SLOTS_RESPONSE) { + break; + } + + iteration += 1; + } + + return result; + } + async getProtocolVersions(): Promise { if (this._protocolVersions) { return this._protocolVersions; diff --git a/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.html b/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.html index f7ef98ab829..1e52a29705a 100644 --- a/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.html +++ b/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.html @@ -27,7 +27,8 @@

- + {{ hostConnection.address || '—' }} - - + + + + + + + + +

+ + + + + + +

When turned off, the UHK operates in singlepoint mode: it talks to one Bluetooth host at + a time, and connecting to another host drops the current connection. This is what most Bluetooth + keyboards do.

+ When turned on, the UHK keeps several Bluetooth hosts connected at once, so switching between them + is instant. +
+

+ +
+
+ + + + +

Keeps every connected Bluetooth host connected instead of dropping the old connection when + you switch to another host. This is what makes switching instant.

+ Required by Always advertise. +
+
+ +
+ + + + +

Keeps the UHK discoverable even while it is already connected, so a further host can connect + without you having to free up a connection first.

+
+
+
diff --git a/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.scss b/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.scss index c17991040f4..e7110a9062a 100644 --- a/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.scss +++ b/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.scss @@ -15,6 +15,39 @@ table { } } } + + tr.empty-host-connection td { + opacity: 0.5; + } + + tfoot td { + border-bottom: none; + } + + .fold-toggle { + text-decoration: none; + } +} + +.multipoint-title { + display: flex; + align-items: center; + gap: 0.5rem; + + // Keep the switch at control size rather than letting it scale with the heading font. + .form-check { + margin-bottom: 0; + font-size: 1rem; + } + + label { + margin-bottom: 0; + cursor: pointer; + } +} + +.multipoint-options { + padding-left: 2.5rem; } // Dragula styles diff --git a/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.ts b/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.ts index d7a020496b3..7b5f7b9c8c5 100644 --- a/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.ts +++ b/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.ts @@ -1,10 +1,17 @@ import { ChangeDetectorRef } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { DragulaService } from 'ng2-dragula'; -import { faCircleExclamation, faCircleNodes, faSpinner, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { + faChevronDown, + faChevronUp, + faCircleExclamation, + faCircleNodes, + faSpinner, + faTrash, +} from '@fortawesome/free-solid-svg-icons'; import { Store } from '@ngrx/store'; import { Subscription } from 'rxjs'; -import { HostConnection } from 'uhk-common'; +import { HostConnection, HostConnections } from 'uhk-common'; import { EraseBleSettingsButtonState } from '../../../models'; import { CheckAreHostConnectionsPairedAction, EraseBleSettingAction } from '../../../store/actions/device'; @@ -13,8 +20,15 @@ import { RenameHostConnectionAction, ReorderHostConnectionsAction, SetHostConnectionSwitchoverAction, + SetUserConfigurationValueAction, } from '../../../store/actions/user-config'; -import { AppState, getEraseBleSettingsButtonState, getHostConnections, getHostConnectionPairState } from '../../../store/index'; +import { + AppState, + getEraseBleSettingsButtonState, + getHostConnections, + getHostConnectionPairState, + getUserConfiguration, +} from '../../../store/index'; @Component({ selector: 'host-connections', @@ -30,15 +44,28 @@ export class HostConnectionsComponent implements OnInit, OnDestroy { faSpinner = faSpinner; faTrash = faTrash; faCircleExclamation = faCircleExclamation; + faChevronDown = faChevronDown; + faChevronUp = faChevronUp; hostConnectionPairState: Record = {}; eraseBleSettingsButtonState: EraseBleSettingsButtonState; hostConnections: HostConnection[] = [] as HostConnection[]; dragAndDropGroup = 'HOST_CONNECTION'; + bluetoothAlwaysAdvertise = false; + bluetoothKeepConnectionsAlive = false; + + /** + * The trailing run of empty slots is folded away by default, otherwise the table is + * always HOST_CONNECTION_COUNT_MAX rows tall. Empty slots between used ones stay visible. + */ + showFoldedSlots = false; + foldedSlotCount = 0; + private hostConnectionPairStateSubscription: Subscription; private eraseBleSettingsSubscription: Subscription; private hostConnectionsSubscription: Subscription; + private userConfigurationSubscription: Subscription; constructor(private dragulaService: DragulaService, private cdRef: ChangeDetectorRef, @@ -79,6 +106,13 @@ export class HostConnectionsComponent implements OnInit, OnDestroy { this.hostConnectionsSubscription = this.store.select(getHostConnections) .subscribe(hostConnections => { this.hostConnections = hostConnections; + this.foldedSlotCount = this.countTrailingEmptySlots(hostConnections); + this.cdRef.markForCheck(); + }); + this.userConfigurationSubscription = this.store.select(getUserConfiguration) + .subscribe(userConfiguration => { + this.bluetoothAlwaysAdvertise = userConfiguration.bluetoothAlwaysAdvertise; + this.bluetoothKeepConnectionsAlive = userConfiguration.bluetoothKeepConnectionsAlive; this.cdRef.markForCheck(); }); } @@ -88,6 +122,7 @@ export class HostConnectionsComponent implements OnInit, OnDestroy { this.eraseBleSettingsSubscription?.unsubscribe(); this.hostConnectionPairStateSubscription?.unsubscribe(); this.hostConnectionsSubscription?.unsubscribe(); + this.userConfigurationSubscription?.unsubscribe(); } deleteHostConnection(index: number, hostConnection: HostConnection): void { @@ -113,7 +148,68 @@ export class HostConnectionsComponent implements OnInit, OnDestroy { this.store.dispatch(new SetHostConnectionSwitchoverAction({index, checked})); } + isEmpty(hostConnection: HostConnection): boolean { + return hostConnection.type === HostConnections.Empty; + } + showNotPairedTooltip(hostConnection: HostConnection): boolean { return hostConnection.hasAddress() && !this.hostConnectionPairState[hostConnection.address]; } + + /** + * The folded slots are always a suffix of the array, so the rendered rows keep lining up + * one-to-one with the drag and drop model indices. + */ + get visibleHostConnectionCount(): number { + if (this.showFoldedSlots) { + return this.hostConnections.length; + } + + return this.hostConnections.length - this.foldedSlotCount; + } + + toggleFoldedSlots(): void { + this.showFoldedSlots = !this.showFoldedSlots; + } + + get multipointEnabled(): boolean { + return this.bluetoothKeepConnectionsAlive; + } + + setMultipointEnabled(enabled: boolean): void { + // Both options together make up multipoint operation, so the toggle drives both. Either one + // can be turned back off afterwards, except that always advertise keeps them alive. + this.setUserConfigurationValue('bluetoothKeepConnectionsAlive', enabled); + this.setUserConfigurationValue('bluetoothAlwaysAdvertise', enabled); + } + + setBluetoothAlwaysAdvertise(checked: boolean): void { + this.setUserConfigurationValue('bluetoothAlwaysAdvertise', checked); + + // Advertising while connected is only meaningful if the existing connections are kept. + if (checked) { + this.setUserConfigurationValue('bluetoothKeepConnectionsAlive', true); + } + } + + private setUserConfigurationValue(propertyName: string, value: boolean): void { + if (this[propertyName] === value) { + return; + } + + this.store.dispatch(new SetUserConfigurationValueAction({ + propertyName, + value, + })); + } + + private countTrailingEmptySlots(hostConnections: HostConnection[]): number { + let count = 0; + + for (let i = hostConnections.length - 1; i >= 0 && this.isEmpty(hostConnections[i]); i--) { + count++; + } + + return count; + } } diff --git a/packages/uhk-web/src/app/models/user-configuration-value.ts b/packages/uhk-web/src/app/models/user-configuration-value.ts index 3cea484d002..0b8cd49beb6 100644 --- a/packages/uhk-web/src/app/models/user-configuration-value.ts +++ b/packages/uhk-web/src/app/models/user-configuration-value.ts @@ -1,4 +1,4 @@ export interface UserConfigurationValue { propertyName: string; - value: number; + value: number | boolean; } diff --git a/packages/uhk-web/src/app/store/reducers/user-configuration.ts b/packages/uhk-web/src/app/store/reducers/user-configuration.ts index c5245003472..1d33d280282 100644 --- a/packages/uhk-web/src/app/store/reducers/user-configuration.ts +++ b/packages/uhk-web/src/app/store/reducers/user-configuration.ts @@ -30,6 +30,7 @@ import { Module, ModuleConfiguration, MODULES_NONE_CONFIGS, + NewPairedDevice, NoneAction, PlayMacroAction, RgbColor, @@ -89,7 +90,7 @@ export interface State { lastEditedKey: LastEditedKey; layerOptions: Map; halvesInfo: HalvesInfo; - newPairedDevices: string[]; + newPairedDevices: NewPairedDevice[]; newPairedDevicesAdding: boolean; newerUserConfiguration?: NewerUserConfiguration; selectedLayerOption: LayerOption; @@ -148,20 +149,38 @@ export function reducer( case UserConfig.ActionTypes.AddNewPairedDevicesToHostConnections: { const userConfiguration: UserConfiguration = Object.assign(new UserConfiguration(), state.userConfiguration); - userConfiguration.hostConnections = state.userConfiguration.hostConnections.filter(hostConnection => hostConnection.type !== HostConnections.Empty); - for (const bleAddress of state.newPairedDevices) { - const hostConnection = new HostConnection(); - hostConnection.type = HostConnections.BLE; - hostConnection.address = bleAddress; - hostConnection.name = generateHostConnectionName(userConfiguration.hostConnections, 'Bluetooth device'); + const hostConnections = state.userConfiguration.hostConnections.map(hostConnection => new HostConnection(hostConnection)); - userConfiguration.hostConnections.push(hostConnection); + // The slots the firmware reports are indices into the full array, so it must stay + // addressable by index and the existing connections must keep their positions. + for (let i = hostConnections.length; i < HOST_CONNECTION_COUNT_MAX; i++) { + hostConnections.push(emptyHostConnection()); } - for (let i = userConfiguration.hostConnections.length; i < HOST_CONNECTION_COUNT_MAX; i++) { - userConfiguration.hostConnections.push(emptyHostConnection()); + for (const newPairedDevice of state.newPairedDevices) { + const hostConnection = new HostConnection(); + hostConnection.type = HostConnections.BLE; + hostConnection.address = newPairedDevice.address; + hostConnection.name = generateHostConnectionName(hostConnections, 'Bluetooth device'); + + const slot = findHostConnectionSlot(hostConnections, newPairedDevice.slot); + + if (slot === -1) { + // Unreachable while any slot is still empty, so this only happens when the + // keyboard really has more than HOST_CONNECTION_COUNT_MAX hosts. Overflowing the + // array is what puts the UI into the TooMuchHostConnections state, which asks + // the user to delete connections. + hostConnection.index = hostConnections.length; + hostConnections.push(hostConnection); + } + else { + hostConnection.index = slot; + hostConnections[slot] = hostConnection; + } } + userConfiguration.hostConnections = hostConnections; + return { ...state, userConfiguration, @@ -1822,6 +1841,21 @@ function addNewMacroToState(state: State): State { }; } +/** + * Returns the slot a newly paired device should occupy: the one the firmware asked for when it is + * free, otherwise the first empty slot, or -1 when every slot is taken. + */ +function findHostConnectionSlot(hostConnections: HostConnection[], preferredSlot: number | undefined): number { + const isSlotEmpty = (slot: number): boolean => + slot >= 0 && slot < hostConnections.length && hostConnections[slot].type === HostConnections.Empty; + + if (preferredSlot !== undefined && isSlotEmpty(preferredSlot)) { + return preferredSlot; + } + + return hostConnections.findIndex(hostConnection => hostConnection.type === HostConnections.Empty); +} + function generateHostConnectionName(hostConnections: HostConnection[], baseName: string): string { let iter: number = 1; let name = baseName; From 37a41868329d52221e72dcb223e9e93ff36b1ca1 Mon Sep 17 00:00:00 2001 From: Karel Tucek Date: Wed, 5 Aug 2026 16:35:39 +0200 Subject: [PATCH 2/5] Reference slots, not hosts. --- .../host-connections.component.html | 9 ++++- .../host-connections.component.scss | 5 +++ .../app/store/reducers/user-configuration.ts | 34 ++----------------- 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.html b/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.html index 1e52a29705a..40bba3426f9 100644 --- a/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.html +++ b/packages/uhk-web/src/app/components/device/host-connections/host-connections.component.html @@ -8,6 +8,10 @@

+ + Slot + + Type @@ -34,6 +38,9 @@

class="uhk-icon uhk-icon-vertical-grip-icon movable" aria-hidden="true"> + + {{ index + 1 }} + {{hostConnection.type | hostConnectionTypeLabelPipe}} @@ -82,7 +89,7 @@

- + -

- - - - +

+ Multipoint operation -

When turned off, the UHK operates in singlepoint mode: it talks to one Bluetooth host at - a time, and connecting to another host drops the current connection. This is what most Bluetooth - keyboards do.

- When turned on, the UHK keeps several Bluetooth hosts connected at once, so switching between them +

With neither option enabled, the UHK operates in singlepoint mode: it talks to one + Bluetooth host at a time, and connecting to another host drops the current connection. This is + what most Bluetooth keyboards do.

+ Otherwise the UHK keeps several Bluetooth hosts connected at once, so switching between them is instant.

-
+