Skip to content

fix(logi): recover receiver slots from late activity - #1013

Open
LJAYi wants to merge 2 commits into
Caldis:masterfrom
LJAYi:codex/fix-logi-late-slot-takeover
Open

fix(logi): recover receiver slots from late activity#1013
LJAYi wants to merge 2 commits into
Caldis:masterfrom
LJAYi:codex/fix-logi-late-slot-takeover

Conversation

@LJAYi

@LJAYi LJAYi commented Aug 14, 2026

Copy link
Copy Markdown

Motivation

When a receiver reconnects while a paired mouse is asleep, the initial slot scan can finish with no connected devices. Some devices then resume by sending normal peripheral traffic without a receiver 0x41 connection notification, so Mos never enumerates or takes over that slot.

This was reproduced with a Logitech M720 Triathlon through a Unifying receiver:

  • the receiver was reconnected while the M720 was powered off;
  • the initial scan completed with 0/6 connected slots;
  • after the M720 was powered on, it sent valid HID++ peripheral reports without 0x41;
  • Back, Forward, and MultiPlatform Gesture continued through their native HID paths;
  • restarting Mos while the M720 was awake made the controls work again.

This is separate from #1012: that PR restores diversion for a slot Mos already manages, while this bug leaves the late device entirely unmanaged.

Reproduction

  1. Start Mos with the receiver connected and the M720 bindings configured.
  2. Power off the Logitech M720 Triathlon.
  3. Unplug and reconnect the USB receiver.
  4. Wait at least six seconds, until the log shows Ping complete: 0/6 slots connected and No devices found on receiver.
  5. Power on the M720 and move it, then press Back, Forward, and Gesture.
  6. Before this change, Mos never takes over slot 1 and the controls use their native HID behavior.
  7. Restarting Mos while the M720 is awake makes the controls work again.

Root cause

Receiver peripheral reports were routed only against the current and managed slot sets. Traffic from a slot that the startup scan had marked offline did not update receiverPairedDevices or enter the existing takeover queue. Slot 1 was especially easy to miss because it is also the temporary default routing cursor.

What changed

  • Treat the first valid peripheral report from a previously offline slot, after receiver enumeration has completed, as an implicit connection signal.
  • Mark that slot online and reuse the existing serialized takeover/discovery path.
  • Drop the triggering report because its feature index cannot be interpreted reliably before discovery completes.
  • Ignore subsequent traffic once the slot is online, preventing repeated discovery.

The recovery is event-driven. It adds no polling or persistent timer and does not change BLE behavior or stored settings. On receivers that cannot report device type, an unknown slot may receive the same one-time discovery already used during normal startup; non-mouse controls are then excluded by the existing Left/Right CID check.

Validation

  • scripts/qa/lint-logi-boundary.sh
  • LogiReceiverConnectionStateTests: 31 passed, 0 failed
  • Debug build
  • Real-device validation with the M720 and Unifying receiver:
    • initial enumeration completed with 0/6 slots connected;
    • no receiver 0x41 notification followed;
    • the first M720 peripheral report triggered exactly one slot 1 takeover;
    • discovery completed and re-enabled diversion for Back, Forward, and MultiPlatform Gesture in about 0.5 seconds;
    • all three controls subsequently produced their expected diverted button events;
    • a paired keyboard was discovered once, identified as non-mouse, and remained unmanaged.

Relevant log excerpt

[23:18:03.409] Ping complete: 0/6 slots connected
[23:18:03.409] No devices found on receiver
[23:18:18.120] RX: 11 01 04 00 01 01 01 ...
[23:18:18.121] Inferred slot 1 connection from peripheral activity; candidate=true -> takeover(1)
[23:18:18.122] Slot 1 (re)connected; queued for takeover
[23:18:18.620] CID 0x0056 divert=ON
[23:18:18.623] CID 0x00D0 divert=ON
[23:18:18.626] CID 0x0053 divert=ON
[23:18:18.629] Takeover sweep complete: managed=[1] cursor=1 (mouse)
[23:18:24.400] Button DISPATCH: CID 0x00D0 code=2208 phase=down result=consumed
[23:18:27.382] Button DISPATCH: CID 0x0056 code=1007 phase=down result=consumed
[23:18:28.128] Button DISPATCH: CID 0x0053 code=1006 phase=down result=consumed

The full raw logs contain unrelated paired-device traffic, so they are not committed to the repository. They can be provided to reviewers if needed.

@LJAYi
LJAYi force-pushed the codex/fix-logi-late-slot-takeover branch from 6c081ea to b938b97 Compare August 14, 2026 15:58
@LJAYi
LJAYi marked this pull request as ready for review August 14, 2026 16:03

@Caldis Caldis left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — excellent PR. The bug is real and well characterized (this is exactly the "device doesn't emit 0x41 after resume" scenario the multi-device handoff design anticipated), and the passive inference approach is better than the register-write fallback we had sketched: zero additional outbound HID++ traffic, fully event-driven, red-line compliant. The real-device validation with logs is much appreciated.

Accepting the direction. Two changes requested before merge:

1. Route the inferred takeover through scheduleReceiverSlotTakeover instead of calling handleReceiverSlotTakeover directly.

The 0x41 connection path deliberately goes through a 0.4s debounce to protect against link flapping (rapid connect/disconnect cycles triggering repeated full-slot discovery — we've been bitten by this on real hardware). The inferred-connection path currently bypasses that: a flaky link alternating between 0x41-disconnect and peripheral bursts would trigger an immediate un-debounced discovery per cycle. Routing through the debounced scheduler makes inferred connections behave identically to 0x41 connections — including auto-cancel if a 0x41 disconnect arrives within the window — at the cost of only 0.4s takeover latency.

2. Move the table writes below the !wasConnected guard in handleReceiverPeripheralActivity.

As written, every peripheral report from an already-online slot (i.e. every button event and response on managed slots) performs the firstIndex scan plus isConnected = true; lastError = nil writes before bailing. Reordering to guard first keeps the hot path read-only for online slots, and also removes the side effect of lastError being cleared by arbitrary traffic.

Minor, non-blocking: the wasConnected parameter of receiverPeripheralActivityAction is always false on the production path (the caller already guards it). Fine as-is for testability — just noting it.

Merge coordination note (no action needed from you): a large internal refactor of LogiDeviceSession (component split: ReceiverEnumerator, request pipeline, environment injection) is about to land from a long-running quality branch. Your PR will merge first; we'll adapt it onto the new component boundaries on our side during that merge (receiverPairedDevices writes move behind ReceiverEnumerator, LogiCenter.shared.registry becomes an injected capability).

@LJAYi

LJAYi commented Aug 16, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review — the link-flapping context was especially helpful. Both requested changes make sense, and I’ve pushed them in a8203a2.

The inferred connection path now goes through scheduleReceiverSlotTakeover, so it shares the same 0.4s debounce and disconnect cancellation behavior as the 0x41 path. I also moved the online-slot guard ahead of the state writes, so normal peripheral traffic no longer rewrites isConnected or clears lastError.

I left the wasConnected argument in the pure decision helper for testability, as you noted.

I reran the Logi boundary lint, all 31 LogiReceiverConnectionStateTests, and the full suite: 537 tests executed, 0 failures, with the 3 real-device tests skipped as designed. The Debug arm64 build also completed successfully as part of the test run.

And thanks for the heads-up about the upcoming component split. I’ve kept this follow-up scoped to the current structure and won’t try to anticipate that refactor here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants