Skip to content

fix(agent): rearm control capture after device reconnect - #450

Merged
davidbudnick merged 1 commit into
AprilNEA:masterfrom
Phecda:fix/bluetooth-capture-reconnect
Aug 10, 2026
Merged

fix(agent): rearm control capture after device reconnect#450
davidbudnick merged 1 commit into
AprilNEA:masterfrom
Phecda:fix/bluetooth-capture-reconnect

Conversation

@Phecda

@Phecda Phecda commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Restore programmable controls after a Logitech device reconnects or switches between Bluetooth and Unifying, without requiring an agent restart.

Changes

  • openlogi-hidpp / openlogi-hid
    • Expose HID transport liveness and stop stale capture sessions after confirmed disconnects.
    • Keep cached Unifying capabilities separate from current device reachability.
    • Confirm Unifying reachability with a root ping after battery refresh errors.
    • Retain live cached HID channels when the OS temporarily omits their vendor collection from enumeration.
    • Abandon stale firmware state on disconnect while preserving normal restoration for ordinary route or configuration changes.
  • openlogi-agent-core / openlogi-agent
    • Re-arm control capture when the selected device reconnects.
    • Re-arm capture after system wake.
    • Add regression coverage for reconnect and capture lifecycle behavior.

Testing

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --locked -- -D warnings
  • cargo test --workspace --locked
  • Hardware-tested on macOS with an MX Master 3:
    • Bluetooth → Unifying → Bluetooth switching
    • Auxiliary and gesture actions recovered on both transports
  • Captured a Bluetooth-direct HID++ enumeration gap in agent and macOS logs. The retention fix is regression-tested and running in the debug agent, but the intermittent gap has not recurred during post-fix hardware testing.
  • The transient Unifying battery-error fallback was covered by an automated HID++ channel test but was not reproduced on hardware.

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR restores HID++ control capture (gesture button, DPI toggle, thumb wheel) after a Logitech device reconnects or switches transports, without requiring an agent restart. It does so through a coordinated set of changes across five crates: a capture_rearm_generation counter in SharedRuntime that the orchestrator bumps on reconnect/wake; a CaptureTarget struct in the gesture watcher that carries the generation so a same-route reconnect triggers a Revoked stop rather than a Graceful one; a 1-second is_connected() poll in the capture session that self-terminates on physical disconnect; a Unifying probe refactor that separates cached capabilities from live reachability (adding a root ping fallback); and a retained_nodes guard that keeps cached Bluetooth HID channels alive through transient IOHIDManager enumeration gaps.

  • Reconnect re-arm path: Orchestrator::on_inventory_update computes selected_needs_capture_rearm before the changed branch so the generation increment fires regardless of whether the device list itself changed (e.g., online-flag-only transitions). The existing reapply_targets logic is reused to identify the relevant device indices.
  • Disconnect detection: AsyncHidChannel gains a connected: AtomicBool cleared by mark_disconnected() on either read_report or write_report receiving HidError::Disconnected. On Windows the trait default returns true, preserving previous behavior. The wait_for_capture_exit helper replaces the bare shutdown.await, polling the flag every second alongside the stop oneshot.
  • Unifying liveness: probe_unifying_features replaces the old probe_or_reuse call in probe_unifying_slot and adds a Device::new root-ping for the CacheOutcome::Seen case (cache hit but no successful battery refresh), preventing a sleeping or BT-switched device from staying "online" through stale capabilities indefinitely.

Confidence Score: 5/5

  • Safe to merge. The reconnect and disconnect paths are narrow and well-contained; the fallback on every changed code path is either a logged warning or a session that fails fast and retries on the next 1-second poll.
  • All changed paths have clear invariants and matching unit tests. The generation counter correctly uses the existing reapply_targets logic to detect reconnects, the stop_for_target_change function handles every (want, current, connection) combination correctly, and the is_connected flag uses appropriate memory ordering (AcqRel/Acquire). The Unifying liveness change is a targeted fix with a documented trade-off (one extra root-ping per tick for devices without battery). The Windows path is unaffected by design (default is_connected → true). Hardware testing on both transports with an MX Master 3 was performed.
  • No files require special attention. The most complex logic — stop_for_target_change in watchers/gesture.rs and probe_unifying_features in inventory/probe.rs — is covered by existing unit and integration tests.

Important Files Changed

Filename Overview
crates/openlogi-agent-core/src/orchestrator.rs Adds capture_rearm_generation: Arc<AtomicU64> to SharedRuntime and increments it when the selected device reconnects or the system wakes, triggering re-arm in the gesture watcher. Refactors the !changed early-return into an if/else so the generation bump runs unconditionally after both branches. Well-tested with three new unit tests.
crates/openlogi-agent-core/src/watchers/gesture.rs Introduces CaptureTarget struct (route + thumbwheel arm + gesture divert + rearm_generation) and the targeted stop_for_target_change function that issues Revoked on same-route/new-generation changes and Graceful otherwise. The manage loop is updated to carry the new struct and consume the generation signal correctly.
crates/openlogi-hid/src/gesture.rs Adds wait_for_capture_exit that polls chan.is_connected() every second alongside the shutdown oneshot, enabling the session to self-terminate on physical disconnect. Teardown is now an explicit match that skips restore writes on Revoked or Disconnected.
crates/openlogi-hid/src/inventory.rs Adds retained_nodes (union of enumerated nodes and live cached nodes) and append_live_cached_channels to prevent a transient IOHIDManager enumeration gap from evicting a still-connected Bluetooth channel. The retained set is passed to registry.retain_nodes so the registry is consistent.
crates/openlogi-hid/src/inventory/probe.rs Extracts probe_unifying_features (cached capabilities + live reachability check) and assemble_unifying_device to decouple feature caching from device liveness. Devices with a CacheOutcome::Seen result (no battery feature or battery refresh failed) now perform a HID++ root ping before being marked offline, fixing the stale-online problem.
crates/openlogi-hid/src/transport.rs Adds connected: AtomicBool to AsyncHidChannel. Both read_report and write_report call mark_disconnected() on HidError::Disconnected. is_connected() exposes the flag upward and is gated to non-Windows. AcqRel/Acquire ordering is correct for the flag.
crates/openlogi-hidpp/src/channel.rs Adds is_connected() to the RawHidChannel trait with a default of true (preserves existing behaviour for transports that cannot detect disconnects, e.g. Windows) and exposes it on HidppChannel.
crates/openlogi-agent/src/main.rs Passes shared.capture_rearm_generation through to spawn_with_registry. One-line change, correct wiring.
crates/openlogi-agent/src/pairing.rs Initialises capture_rearm_generation: Arc::new(0.into()) in the test SharedRuntime stub. Correct.
crates/openlogi-hid/src/inventory/tests.rs Adds live_cached_channel_survives_a_transient_enumeration_gap unit test for retained_nodes, verifying that a connected cached channel is kept and a disconnected one is dropped.

Sequence Diagram

sequenceDiagram
    participant OS as OS / async_hid
    participant Transport as AsyncHidChannel
    participant Inventory as Enumerator
    participant Orchestrator as Orchestrator
    participant Watcher as GestureWatcher (manage)
    participant Session as CaptureSession

    Note over Session: Device connected, capture active
    OS->>Transport: Disconnected error (read/write)
    Transport->>Transport: "mark_disconnected() → connected=false"

    loop Every CAPTURE_HEALTH_POLL (1s)
        Session->>Transport: chan.is_connected()
        Transport-->>Session: false
        Session->>Session: CaptureExit::Disconnected
    end
    Session->>Session: drop listener, clear capture slot
    Session-->>Watcher: done_tx.send(epoch) [Err::ChannelDisconnected logged]
    Watcher->>Watcher: "should_rearm() → true → current=None"

    Note over Inventory: Next inventory tick
    Inventory->>Transport: is_connected() on cached channels
    Transport-->>Inventory: false → excluded from retained_nodes
    Inventory->>Orchestrator: on_inventory_update (device offline)
    Orchestrator->>Orchestrator: selected_needs_capture_rearm → false (still offline)

    Note over OS: Device reconnects
    OS->>Inventory: enumerate() includes node again
    Inventory->>Inventory: append_live_cached_channels (or fresh channel)
    Inventory->>Orchestrator: on_inventory_update (device online)
    Orchestrator->>Orchestrator: reapply_volatile_settings
    Orchestrator->>Orchestrator: capture_rearm_generation.fetch_add(1)

    loop Next TARGET_POLL tick
        Watcher->>Watcher: load capture_rearm_generation → new value
        Watcher->>Watcher: "CaptureTarget { rearm_generation: new }"
        Watcher->>Watcher: stop_for_target_change → Revoked (same route, new gen)
        Watcher->>Session: stop.send(Revoked)
        Session->>Session: disarm skipped, slot cleared
        Watcher->>Watcher: spawn new capture session
    end
Loading

Reviews (5): Last reviewed commit: "fix(agent): rearm control capture after ..." | Re-trigger Greptile

@Phecda
Phecda force-pushed the fix/bluetooth-capture-reconnect branch from f192bb3 to 598386f Compare July 23, 2026 03:33
@davidbudnick
davidbudnick force-pushed the fix/bluetooth-capture-reconnect branch from 598386f to 8c04bff Compare August 2, 2026 00:50
@davidbudnick davidbudnick added type: bug Something is broken or behaves incorrectly platform: all Cross-platform issue labels Aug 2, 2026
@davidbudnick davidbudnick added this to the v0.6.24 milestone Aug 10, 2026
@davidbudnick
davidbudnick force-pushed the fix/bluetooth-capture-reconnect branch from 8c04bff to ad19141 Compare August 10, 2026 03:48
@davidbudnick
davidbudnick merged commit 6f2d1d5 into AprilNEA:master Aug 10, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

platform: all Cross-platform issue type: bug Something is broken or behaves incorrectly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants