Skip to content

fix: hotplug-driven inventory and phantom device-card suppression#346

Open
davidbudnick wants to merge 5 commits into
AprilNEA:masterfrom
davidbudnick:fix/device-lifecycle
Open

fix: hotplug-driven inventory and phantom device-card suppression#346
davidbudnick wants to merge 5 commits into
AprilNEA:masterfrom
davidbudnick:fix/device-lifecycle

Conversation

@davidbudnick

Copy link
Copy Markdown
Contributor

Context

Stale device cards pile up when the same models are used at two locations (work + home G513 / MX Master 3S), and a just-plugged keyboard waits seconds for its configured lighting. Root causes: persisted known_identities resurrect unreachable devices as placeholders with model-key dedup defeated by the flaky extended-model byte (0b034 vs 2b034, #271/#280), and the inventory watcher is a pure 2 s poll — the "async-hid has no listener API" rationale predates async-hid 0.5's watch().

Demo

  • Plug in a wired keyboard: configured lighting applies within ~1 s (hotplug wake + one confirming re-apply that absorbs the boot race).
  • Unplug a Bolt receiver: its paired devices no longer linger as offline cards; a live MX Master 3S no longer renders next to a phantom same-model card.
  • cargo test --workspace passes; clippy/fmt clean.

Changes

  • Expose OS hotplug events from the shared backend: hotplug.rs, transport.rs (openlogi-hid)
  • Wake the inventory watcher on hotplug events, keep the 2 s tick as reconciliation: watchers/inventory.rs
  • Re-apply volatile settings once more after a first sighting: orchestrator.rs
  • Suppress offline placeholders whose receiver is absent and dedup same-model cards by wire PID: state/devices.rs

Notes

  • Verified on macOS (unit tests + hotplug stream smoke test); real plug/unplug hardware pass still pending — hence draft.
  • Follow-up worth discussing: keying device identity on the HID++ 0x0003 serial/unit id with the route as an attribute, so one physical unit keeps one config entry across transports.

@davidbudnick davidbudnick marked this pull request as ready for review July 2, 2026 21:20
@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR wires up OS hotplug events from async-hid 0.5 to wake the inventory watcher early, and adds three suppression gates in the GUI device-list builder to eliminate phantom offline cards for absent receivers and same-model duplicates.

  • Hotplug integration (hotplug.rs, transport.rs, inventory.rs): a new watch_hotplug() API returns a HidBackend-backed stream; the watcher's select! loop wakes on events and drains the burst before a single re-enumerate, with the 2 s tick kept as a reconciliation fallback.
  • Boot-race confirming re-apply (orchestrator.rs): plan_reapply queues a one-shot follow-up after a device's first sighting so a second write absorbs the window where the device's firmware hasn't finished initialising.
  • Phantom-card suppression (state/devices.rs): offline placeholders are now filtered if their receiver: UID is absent from the current inventory, and collapsed by wire PID to avoid showing two cards for one physical model when the flaky extended-model byte produced two config keys.

Confidence Score: 5/5

Safe to merge once hardware validation is confirmed — all logic paths are unit-tested and the fallback to pure polling on any hotplug failure keeps the existing behaviour intact.

The hotplug integration, confirming re-apply, and phantom-card suppression are all independently well-tested. The watch_hotplug() call is correctly placed inside block_on, the burst-drain loop is sound, and the three-gate dedup in append_offline_known is verified end-to-end including the flaky extended-model byte edge case. The only observations are a missing reconnect path when the hotplug stream closes and a minor linear-scan guard — neither changes correctness for the scenarios this PR targets.

No files require special attention — the draft status is about pending hardware validation, not code correctness.

Important Files Changed

Filename Overview
crates/openlogi-hid/src/hotplug.rs New file: thin bridge that maps async_hid::DeviceEvent to the public HotplugEvent enum via the shared HID_BACKEND. Correct and minimal.
crates/openlogi-hid/src/transport.rs Adds hid_backend() crate-local accessor for the existing LazyLock<HidBackend>; no logic changes to enumeration or channel opening.
crates/openlogi-agent-core/src/watchers/inventory.rs Replaces bare thread::sleep(period) with a rt.block_on select that wakes on hotplug events. watch_hotplug() correctly placed inside block_on; burst-drain poll_once loop is sound.
crates/openlogi-agent-core/src/orchestrator.rs Introduces plan_reapply adding a one-shot confirming re-apply for first-sighted devices; three new unit tests cover first-sighting confirmation, transition exclusion, and offline followup skip.
crates/openlogi-gui/src/state/devices.rs Adds three suppression gates to append_offline_known: receiver-absent skip, wire-PID dedup, key/model dedup. Degenerate zero-PID gap is documented in code and accepted.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant OS as OS HID Layer
    participant HP as hotplug stream
    participant IW as InventoryWatcher (thread)
    participant ORC as Orchestrator
    participant GUI as GUI DeviceList

    OS->>HP: DeviceEvent::Connected(_)
    HP->>IW: HotplugEvent::Connected
    IW->>IW: "sleep(HOTPLUG_SETTLE=400ms)"
    IW->>IW: drain burst (poll_once loop)
    IW->>IW: enumerator.enumerate()
    IW->>ORC: InventoryEvent::Snapshot(devices)
    ORC->>ORC: plan_reapply() → targets + next_followup
    ORC->>ORC: reapply_volatile_settings(first-sighting device)
    Note over ORC: next_followup queued for confirming re-apply
    Note over IW: ~2 s periodic tick (or next hotplug)
    IW->>ORC: InventoryEvent::Snapshot(devices)
    ORC->>ORC: followup fires → second reapply_volatile_settings
    ORC->>GUI: refresh_inventory → build_device_list
    GUI->>GUI: collect present_receivers UIDs
    GUI->>GUI: append_offline_known (3 gates)
    Note over GUI: Gate 1: key/model already live
    Note over GUI: Gate 2: receiver UID absent
    Note over GUI: Gate 3: wire PID already in blocked_pids
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant OS as OS HID Layer
    participant HP as hotplug stream
    participant IW as InventoryWatcher (thread)
    participant ORC as Orchestrator
    participant GUI as GUI DeviceList

    OS->>HP: DeviceEvent::Connected(_)
    HP->>IW: HotplugEvent::Connected
    IW->>IW: "sleep(HOTPLUG_SETTLE=400ms)"
    IW->>IW: drain burst (poll_once loop)
    IW->>IW: enumerator.enumerate()
    IW->>ORC: InventoryEvent::Snapshot(devices)
    ORC->>ORC: plan_reapply() → targets + next_followup
    ORC->>ORC: reapply_volatile_settings(first-sighting device)
    Note over ORC: next_followup queued for confirming re-apply
    Note over IW: ~2 s periodic tick (or next hotplug)
    IW->>ORC: InventoryEvent::Snapshot(devices)
    ORC->>ORC: followup fires → second reapply_volatile_settings
    ORC->>GUI: refresh_inventory → build_device_list
    GUI->>GUI: collect present_receivers UIDs
    GUI->>GUI: append_offline_known (3 gates)
    Note over GUI: Gate 1: key/model already live
    Note over GUI: Gate 2: receiver UID absent
    Note over GUI: Gate 3: wire PID already in blocked_pids
Loading

Reviews (2): Last reviewed commit: "fix(agent): run hotplug watch under runt..." | Re-trigger Greptile

Comment thread crates/openlogi-agent-core/src/watchers/inventory.rs Outdated
Comment thread crates/openlogi-agent-core/src/watchers/inventory.rs Outdated
Comment thread crates/openlogi-gui/src/state/devices.rs
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.

1 participant