From 92ce5c39b0f2e33f59fb22f6679d2e43112ca4ec Mon Sep 17 00:00:00 2001 From: Karel Tucek Date: Thu, 8 Oct 2026 14:29:15 +0200 Subject: [PATCH] feat: detect broken bonds and offer re-pairing Show a top notification panel when the bond between the keyboard halves is broken (GetDeviceState byte 2 bit 4) and offer to re-pair them. After a successful re-pair keep the progress state until the device reports a healthy bond or 5 seconds elapse. On failure navigate to the advanced settings page where the pairing log is visible. Report the dongle bond health too (IsPaired response byte 2), re-checked periodically, and treat a bond known to only one of the devices as broken. The dongle pairing panel shows a re-pair message in these cases. Co-Authored-By: Claude Opus 5.5 --- .../uhk-agent/src/services/device.service.ts | 36 ++++++++++++++++++- .../src/models/device-connection-state.ts | 4 +++ packages/uhk-common/src/models/dongle.ts | 5 +++ packages/uhk-usb/src/models/bond-state.ts | 7 ++++ packages/uhk-usb/src/models/device-state.ts | 1 + packages/uhk-usb/src/models/index.ts | 1 + packages/uhk-usb/src/uhk-hid-device.ts | 19 ++++++++-- packages/uhk-web/src/app/app.component.html | 6 ++++ packages/uhk-web/src/app/app.component.scss | 1 + packages/uhk-web/src/app/app.component.ts | 17 ++++++++- .../dongle-pairing-panel.component.html | 6 +++- .../dongle-pairing-panel.component.ts | 1 + .../halves-pairing-panel.component.html | 9 +++++ .../halves-pairing-panel.component.scss | 21 +++++++++++ .../halves-pairing-panel.component.ts | 17 +++++++++ .../src/app/models/dongle-pairing-state.ts | 1 + .../src/app/models/halves-pairing-state.ts | 4 +++ packages/uhk-web/src/app/models/index.ts | 1 + packages/uhk-web/src/app/shared.module.ts | 2 ++ .../store/actions/advance-settings.action.ts | 6 ++++ .../effects/advanced-settings.effects.ts | 27 +++++++++++++- packages/uhk-web/src/app/store/index.ts | 25 ++++++++++++- .../reducers/advanced-settings.reducer.ts | 31 +++++++++++++--- .../uhk-web/src/app/store/reducers/device.ts | 4 +++ 24 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 packages/uhk-usb/src/models/bond-state.ts create mode 100644 packages/uhk-web/src/app/components/device/halves-pairing-panel/halves-pairing-panel.component.html create mode 100644 packages/uhk-web/src/app/components/device/halves-pairing-panel/halves-pairing-panel.component.scss create mode 100644 packages/uhk-web/src/app/components/device/halves-pairing-panel/halves-pairing-panel.component.ts create mode 100644 packages/uhk-web/src/app/models/halves-pairing-state.ts diff --git a/packages/uhk-agent/src/services/device.service.ts b/packages/uhk-agent/src/services/device.service.ts index 5a3cdfc7154..472e8ee2ed7 100644 --- a/packages/uhk-agent/src/services/device.service.ts +++ b/packages/uhk-agent/src/services/device.service.ts @@ -100,6 +100,9 @@ import { import { QueueManager } from './queue-manager'; import { ZephyrLogService } from './zephyr-log.service'; +// the poller iterates at most every 250ms, so it is at least 10 seconds +const DONGLE_BOND_POLL_ITERATIONS = 40; + /** * IpcMain pair of the UHK Communication * Functionality: @@ -1231,6 +1234,8 @@ export class DeviceService { private async uhkDevicePoller(): Promise { let deviceProtocolVersion: string; let iterationCount = 0; + // set only while a dongle that can be queried is connected + let dongleBond: { deviceBleAddress: number[], isBondBroken: boolean }; while (true) { if (this._pollerAllowed) { @@ -1241,6 +1246,7 @@ export class DeviceService { const state = await this.device.getDeviceConnectionStateAsync(); if (!isEqual(state, this.savedState)) { const newState = cloneDeep(state); + dongleBond = undefined; if (state.hasPermission && state.communicationInterfaceAvailable) { state.hardwareModules = await this.getHardwareModules(false); @@ -1264,7 +1270,10 @@ export class DeviceService { dongleUhkDevice = new UhkHidDevice(this.logService, this.options, this.rootDir, dongle); const dongleBleAddress = await dongleUhkDevice.getBleAddress(); state.dongle.bleAddress = convertBleAddressArrayToString(dongleBleAddress); - state.dongle.isPairedWithKeyboard = await dongleUhkDevice.isPairedWith(deviceBleAddress); + const dongleBondState = await dongleUhkDevice.getBondState(deviceBleAddress); + state.dongle.isPairedWithKeyboard = dongleBondState.isPaired; + state.dongle.isBondBroken = dongleBondState.isBondBroken; + dongleBond = { deviceBleAddress, isBondBroken: dongleBondState.isBondBroken }; state.isPairedWithDongle = await this.device.isPairedWith(dongleBleAddress); const dongleOperations = new UhkOperations(this.logService, dongleUhkDevice); state.dongle.versionInfo = await dongleOperations.getDeviceVersionInfo(); @@ -1299,6 +1308,13 @@ export class DeviceService { this.logService.misc('[DeviceService] Device connection state changed to:', JSON.stringify(state, null, 2)); } + else if (dongleBond && iterationCount % DONGLE_BOND_POLL_ITERATIONS === 0) { + // The dongle is queried only when the connection state changes, but its bond can break at any time. + const isBondBroken = await this.isDongleBondBroken(dongleBond.deviceBleAddress); + if (isBondBroken !== dongleBond.isBondBroken) { + this.savedState = undefined; + } + } if (state.isMacroStatusDirty) { this._checkStatusBuffer = true; @@ -1327,6 +1343,24 @@ export class DeviceService { } } + private async isDongleBondBroken(deviceBleAddress: number[]): Promise { + await this.dongleZephyrLogService.disable(); + let dongleUhkDevice: UhkHidDevice; + try { + const dongle = await getCurrentUhkDongleHID(); + dongleUhkDevice = new UhkHidDevice(this.logService, this.options, this.rootDir, dongle); + const bondState = await dongleUhkDevice.getBondState(deviceBleAddress); + + return bondState.isBondBroken; + } + finally { + if (dongleUhkDevice) { + await dongleUhkDevice.close(); + } + await this.dongleZephyrLogService.enable(); + } + } + private async pollDebugInfo(iterationCount: number): Promise { if(!this.isI2cDebuggingEnabled) { return; diff --git a/packages/uhk-common/src/models/device-connection-state.ts b/packages/uhk-common/src/models/device-connection-state.ts index f64705651af..e875737d0fb 100644 --- a/packages/uhk-common/src/models/device-connection-state.ts +++ b/packages/uhk-common/src/models/device-connection-state.ts @@ -18,6 +18,10 @@ export interface DeviceConnectionState { dongle: Dongle; leftHalfBootloaderActive: boolean; hasPermission: boolean; + /** + * The left half rejected the bond of the right half (GetDeviceState byte 2 bit 4). + */ + isHalvesBondBroken: boolean; bootloaderActive: boolean; isMacroStatusDirty: boolean; isZephyrLogAvailable: boolean; diff --git a/packages/uhk-common/src/models/dongle.ts b/packages/uhk-common/src/models/dongle.ts index ca9904deae2..aacb209cf16 100644 --- a/packages/uhk-common/src/models/dongle.ts +++ b/packages/uhk-common/src/models/dongle.ts @@ -5,6 +5,11 @@ export interface Dongle { bootloaderActive: boolean; + /** + * The keyboard rejected the bond of the dongle. + */ + isBondBroken?: boolean; + isPairedWithKeyboard?: boolean; /** * True if more than 1 UHK dongle connected. diff --git a/packages/uhk-usb/src/models/bond-state.ts b/packages/uhk-usb/src/models/bond-state.ts new file mode 100644 index 00000000000..b2909966d20 --- /dev/null +++ b/packages/uhk-usb/src/models/bond-state.ts @@ -0,0 +1,7 @@ +export interface BondState { + isPaired: boolean; + /** + * The peer rejected the bond (IsPaired response byte 2). + */ + isBondBroken: boolean; +} diff --git a/packages/uhk-usb/src/models/device-state.ts b/packages/uhk-usb/src/models/device-state.ts index 952a8d5e387..9ef8a6fa6d8 100644 --- a/packages/uhk-usb/src/models/device-state.ts +++ b/packages/uhk-usb/src/models/device-state.ts @@ -1,6 +1,7 @@ export interface DeviceState { isEepromBusy: boolean; isMacroStatusDirty: boolean; + isPeerBondBroken: boolean; isZephyrLogAvailable: boolean; areHalvesMerged: boolean; isLeftHalfConnected: boolean; diff --git a/packages/uhk-usb/src/models/index.ts b/packages/uhk-usb/src/models/index.ts index 75cbf676657..d56a86fb5f0 100644 --- a/packages/uhk-usb/src/models/index.ts +++ b/packages/uhk-usb/src/models/index.ts @@ -1,3 +1,4 @@ +export * from './bond-state.js'; export * from './debug-info.js'; export * from './device-state.js'; export * from './duration.js'; diff --git a/packages/uhk-usb/src/uhk-hid-device.ts b/packages/uhk-usb/src/uhk-hid-device.ts index 26922d87970..9522d6462ac 100644 --- a/packages/uhk-usb/src/uhk-hid-device.ts +++ b/packages/uhk-usb/src/uhk-hid-device.ts @@ -43,6 +43,7 @@ import { UsbCommand } from './constants.js'; import { + BondState, DeviceState, PairingInfo, ReenumerateOption, @@ -229,6 +230,12 @@ export class UhkHidDevice { } public async isPairedWith(address: number[]): Promise { + const bondState = await this.getBondState(address); + + return bondState.isPaired; + } + + public async getBondState(address: number[]): Promise { await this.assertDeviceSupportWirelessUSBCommands(); this.logService.usbOps('[UhkHidDevice] USB[T]: is paired with'); @@ -237,10 +244,13 @@ export class UhkHidDevice { ...address, ]); const responseBuffer = await this.write(buffer); - // 1st byte is the status code we skip it - const response = responseBuffer.readUInt8(1); - return response === 1; + // 1st byte is the status code we skip it + return { + isPaired: responseBuffer.readUInt8(1) === 1, + // older firmwares always send 0 + isBondBroken: responseBuffer.readUInt8(2) === 1, + }; } public async pairCentral(): Promise { @@ -328,6 +338,7 @@ export class UhkHidDevice { rightModuleSlot: RightSlotModules.NoModule }, hardwareModules: {}, + isHalvesBondBroken: false, isMacroStatusDirty: false, isZephyrLogAvailable: false, leftHalfDetected: false, @@ -410,6 +421,7 @@ export class UhkHidDevice { const deviceState = await this.getDeviceState(); result.activeKeymapIndex = deviceState.activeKeymapIndex; result.halvesInfo = calculateHalvesState(deviceState); + result.isHalvesBondBroken = deviceState.isPeerBondBroken; result.isMacroStatusDirty = deviceState.isMacroStatusDirty; result.isZephyrLogAvailable = deviceState.isZephyrLogAvailable; @@ -635,6 +647,7 @@ export class UhkHidDevice { leftModuleSlot: MODULE_ID_TO_STRING[buffer[4]], newPairedDevice: isBitSet(buffer[2], 2), isZephyrLogAvailable: isBitSet(buffer[2], 3), + isPeerBondBroken: isBitSet(buffer[2], 4), rightModuleSlot: MODULE_ID_TO_STRING[buffer[5]] }; } diff --git a/packages/uhk-web/src/app/app.component.html b/packages/uhk-web/src/app/app.component.html index 5e7fd7c7f92..e5805258583 100644 --- a/packages/uhk-web/src/app/app.component.html +++ b/packages/uhk-web/src/app/app.component.html @@ -13,8 +13,14 @@ + { + this.halvesPairingState = data; + this.cdRef.markForCheck(); + }); this.newPairedDevicesStateSubscription = this.store.select(getNewPairedDevicesState) .subscribe(data => { this.newPairedDevicesState = data; @@ -248,6 +257,7 @@ export class MainAppComponent implements OnDestroy { ngOnDestroy(): void { this.actionsSubscription.unsubscribe(); this.donglePairingStateSubscription.unsubscribe(); + this.halvesPairingStateSubscription.unsubscribe(); this.newPairedDevicesStateSubscription.unsubscribe(); this.errorPanelHeightSubscription.unsubscribe(); this.saveToKeyboardStateSubscription.unsubscribe(); @@ -338,6 +348,7 @@ export class MainAppComponent implements OnDestroy { return this.showFirmwareUpgradePanel || this.showUpdateAvailable || this.donglePairingState?.showDonglePairingPanel + || this.halvesPairingState?.showHalvesPairingPanel || this.newPairedDevicesState?.showNewPairedDevicesPanel || !!this.sharedConfigChange; } @@ -356,6 +367,10 @@ export class MainAppComponent implements OnDestroy { this.store.dispatch(new UpdateFirmwareAction(false)); } + startHalvesPairing(): void { + this.store.dispatch(new StartLeftHalfPairingAction()); + } + startDonglePairing(): void { this.store.dispatch(new StartDonglePairingAction()); } diff --git a/packages/uhk-web/src/app/components/device/dongle-pairing-panel/dongle-pairing-panel.component.html b/packages/uhk-web/src/app/components/device/dongle-pairing-panel/dongle-pairing-panel.component.html index 373f2f77ec8..9fda0c2c946 100644 --- a/packages/uhk-web/src/app/components/device/dongle-pairing-panel/dongle-pairing-panel.component.html +++ b/packages/uhk-web/src/app/components/device/dongle-pairing-panel/dongle-pairing-panel.component.html @@ -1,5 +1,9 @@