Skip to content

feat(hid): Unifying receiver support#181

Open
cserby wants to merge 33 commits into
AprilNEA:masterfrom
cserby:story/linux-hid/unifying-receiver-support
Open

feat(hid): Unifying receiver support#181
cserby wants to merge 33 commits into
AprilNEA:masterfrom
cserby:story/linux-hid/unifying-receiver-support

Conversation

@cserby

@cserby cserby commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

OpenLogi previously only enumerated Logi Bolt receivers (PID 0xC548). Users with a Unifying receiver (PID 0xC52B / 0xC532) saw no devices in the GUI or CLI.

This PR adds full Unifying receiver support:

  • Protocol layer (openlogi-hidpp): implement the missing HID++ 1.0 methods on UnifyingReceivercount_pairings, trigger_device_arrival, get_device_pairing_information, device-connection event listener (0x41 sub_id), DeviceKind enum (shifted vs Bolt at values ≥5).
  • Transport filter (openlogi-hid): the hid-logitech-dj kernel driver creates a virtual hidraw node per paired Unifying device; these matched the HID++ filter and caused 5-second probe timeouts every enumeration tick. Added is_receiver_child_node() (Linux) that checks the sysfs path and filters them before probing.
  • Inventory (openlogi-hid): split probe_one to handle Receiver::Unifying separately. Added probe_unifying_receiver / probe_unifying_slot that build PairedDevice entries from device-arrival events. Added UNIFYING_SLOT_PROBE (3.5 s) per-slot budget so a slow wireless HID++ 2.0 round-trip cannot consume the shared PROBE_BUDGET.
  • Routing (openlogi-hid): add DeviceRoute::Unifying variant; add DeviceRoute::device_route_for() constructor that picks the right variant from the receiver's product ID; consolidate BOLT_PIDS / UNIFYING_PIDS as the single source of truth (previously duplicated in three places).
  • App stack (openlogi-agent-core, openlogi-gui, openlogi-cli): update all routing call sites, fix "Bolt receiver" label, add "Unifying receiver" label, surface devices that lack HID++ 2.0 model info in the carousel (wpid-based config key), hide the mouse-model Buttons tab for keyboards without an asset.

Verified on hardware

  • Logitech Unifying receiver (C52B) — K540/K545 keyboard (slot 1) and M510 mouse (slot 2) both appear in the carousel
  • Logitech MX Anywhere 3 over Bluetooth — still appears correctly
  • Device tab shows "Unifying receiver" (not "Bolt receiver")
  • K540 keyboard shows only the Device tab (no spurious mouse Buttons panel)
  • M510 mouse shows Buttons + Device tabs

Known limitations

  • Offline Unifying devices (paired but sleeping) are not surfaced — the 0xB5/0x5N pairing-info register returns InvalidValue; the sub-register format differs from Bolt and needs further investigation.
  • K540 model info is absent on the first few enumeration ticks — the K540 takes ~3 s for the HID++ 2.0 version ping (slow wireless round-trip); the 3.5 s per-slot budget covers it, but feature enumeration may still miss on very slow hardware.
  • The CacheKey::Bolt sentinel [0,0,0,slot] is reused for Unifying slot caching (no unit_id available without working pairing-info register); documented in the code.

Test plan

  • cargo test --workspace passes (18 new unit tests across 5 crates)
  • cargo clippy --workspace -- -D warnings clean
  • Run ./target/debug/openlogi-agent + ./target/debug/openlogi-gui — all three devices appear in the carousel
  • Device tab on Unifying devices shows "Unifying receiver"
  • K540 shows only Device tab; M510 shows Buttons + Device tabs

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds full Logi Unifying receiver support (PIDs 0xC52B/0xC532) across the whole stack, from the HID++ 1.0 protocol layer up to the GUI carousel and settings window. It also resolves a long-standing Linux issue where the hid-logitech-dj kernel driver's virtual per-device hidraw nodes caused 5-second probe timeouts on every enumeration tick.

  • Protocol layer (openlogi-hidpp): UnifyingReceiver gains count_pairings, trigger_device_arrival, get_device_pairing_information, and a 0x41 sub_id event listener; the BoltReceiver is simultaneously refactored to the Arc<ListenerDropGuard> pattern that fixes the Clone/Drop listener-handle bug identified in the previous review.
  • Transport/inventory (openlogi-hid): is_receiver_child_node() filters out virtual DJ child nodes; probe_unifying_receiver/probe_unifying_slot probe online slots concurrently with a 3.5 s per-slot budget; CacheKey::UnifyingSlot uses the full receiver serial to prevent cross-receiver cache collisions (addressed from the prior review); the per-slot timeout fallback now correctly returns cached data rather than an empty default.
  • App stack (openlogi-agent-core, openlogi-gui): routing consolidated into DeviceRoute::device_route_for(); Buttons tab suppressed for keyboards lacking an asset; Linux systemd user-unit autostart reconcile added.

Confidence Score: 5/5

Safe to merge; all three issues flagged in the previous review round are addressed in this version, and the new code is well-tested with 18 new unit tests.

All previous blocking findings (Clone/Drop listener handle, cache key collisions, timeout fallback ignoring the cache) have been resolved with the Arc pattern, CacheKey::UnifyingSlot using the full serial, and the corrected cached.map_or_else fallback. The two remaining notes are both minor style/performance observations with no impact on correctness.

crates/openlogi-gui/src/platform/permissions.rs — the Linux permission probe is called from a render closure; caching the result would prevent repeated filesystem I/O during UI redraws.

Important Files Changed

Filename Overview
crates/openlogi-hidpp/src/receiver/unifying.rs New UnifyingReceiver implementation with HID++ 1.0 register access; Clone issue from previous review resolved via Arc
crates/openlogi-hidpp/src/receiver/mod.rs BoltReceiver refactored to Arc pattern, fixing the Clone/Drop listener handle bug from prior review; Unifying variant added to Receiver enum
crates/openlogi-hid/src/inventory.rs probe_unifying_receiver / probe_unifying_slot added; CacheKey::UnifyingSlot uses full receiver UID to avoid cross-receiver collisions; timeout fallback correctly returns cached data
crates/openlogi-hid/src/route.rs DeviceRoute::Unifying variant added; device_route_for() consolidates routing logic into a single canonical constructor; BOLT_PIDS/UNIFYING_PIDS now the single source of truth
crates/openlogi-hid/src/transport.rs is_receiver_child_node() / is_receiver_child_sysfs_path() added to filter out hid-logitech-dj virtual child nodes on Linux; logic tested and correctly identifies terminal vs. parent sysfs components
crates/openlogi-agent/src/launch_agent.rs Linux systemd user-unit reconcile added; escape_systemd_exec handles %, $, spaces and double-quotes; idempotent read-compare-write logic with best-effort systemctl calls
crates/openlogi-gui/src/platform/permissions.rs Linux input-device access probe added (uinput + hidraw); classify() logic is well-tested; probe_logitech_hidraw reads /dev on every call which is invoked from a render closure
crates/openlogi-gui/src/windows/settings.rs Permissions page split into macOS/Linux variants; permission_field function is #[cfg(target_os = "macos")]-gated but contains a redundant inner cfg block
crates/openlogi-gui/src/app.rs Buttons tab now gated on can_show_mouse_model to suppress mouse-silhouette UI for keyboards without an asset; Unifying receiver label added to route_label()
crates/openlogi-agent-core/src/device_order.rs DeviceRoute::Unifying matched alongside Bolt in DeviceStableId::from_parts; intentional shared stable-ID variant documented and unit-tested

Sequence Diagram

sequenceDiagram
    participant Enum as Enumerator
    participant Trans as transport
    participant Chan as HidppChannel
    participant UR as UnifyingReceiver
    participant Slot as probe_unifying_slot

    Enum->>Trans: enumerate_hidpp_devices()
    Trans-->>Enum: [hidraw nodes] (child nodes filtered)
    Enum->>Chan: open_hidpp_channel(dev)
    Chan-->>Enum: channel
    Enum->>UR: receiver::detect() → Receiver::Unifying
    Enum->>UR: get_unique_id()
    UR-->>Enum: serial (receiver_uid)
    Enum->>UR: count_pairings()
    UR-->>Enum: count
    Enum->>UR: listen() → rx
    Enum->>UR: trigger_device_arrival()
    UR-->>Enum: DeviceConnection events (drain 1.5s)
    loop per online slot (concurrent, 3.5s budget each)
        Enum->>Slot: probe_unifying_slot(channel, event, receiver_uid)
        Slot->>Chan: Device::new + enumerate_features
        Chan-->>Slot: ProbedFeatures
        Slot-->>Enum: (PairedDevice, CacheOutcome)
    end
    Enum-->>Enum: "DeviceInventory { receiver: Unifying, paired: [...] }"
Loading

Fix All in Codex Fix All in Claude Code

Reviews (9): Last reviewed commit: "fix(hid): fall back to PID when Unifying..." | Re-trigger Greptile

Comment thread crates/openlogi-hidpp/src/receiver/unifying.rs
Comment thread crates/openlogi-hid/src/inventory.rs Outdated
@cserby cserby force-pushed the story/linux-hid/unifying-receiver-support branch from 1aa4cd0 to 0cdcc83 Compare June 9, 2026 07:20
Comment thread crates/openlogi-hid/src/inventory.rs
cserby and others added 6 commits June 10, 2026 08:42
Reconcile the agent's autostart state on Linux by writing/removing a
systemd user unit at $XDG_CONFIG_HOME/systemd/user/openlogi-agent.service.
Mirrors the macOS LaunchAgent semantics exactly:

- Restart=on-failure (crash respawns; clean exit(0) stays stopped)
- WantedBy=graphical-session.target (takes effect at next login)
- ExecStart escaped for systemd (% doubled, spaces quoted)
- Idempotent write/remove — only touches disk when content changes
- systemctl --user daemon-reload + enable/disable best-effort

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Accessibility footer in the main window hidden on Linux
- "Open" button in permission rows gated to macOS only
- Launch-at-login description no longer says "log in to macOS"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added missing translations for the Linux permission row label introduced
in this PR. Follows the same pattern as 'Input Monitoring'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-hid/unifying-receiver-support branch 2 times, most recently from 5f21ed0 to 276f6db Compare June 10, 2026 07:10
cserby and others added 17 commits June 10, 2026 09:14
… scope

- Add 'Unifying receiver' and 'Input device access' keys to en.yml
  (all locale files must match en.yml for the i18n test)
- Add 'Ricevitore Unifying' to it.yml for key parity
- Move InteractiveElement import outside #[cfg(target_os = "macos")]
  so on_action() calls for CloseWindow/Minimize/Zoom work on Linux
- Fix rustfmt line-wrap in launch_agent::unit_path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- udev/70-openlogi.rules: TAG+="uaccess" for hidraw (Logitech VID 046d)
  and uinput; includes non-systemd GROUP="input" fallback instructions
- systemd/openlogi-agent.service: packaged user-unit template for
  /usr/lib/systemd/user/ (complements the per-user unit written by
  launch_at_login)
- desktop/openlogi.desktop: XDG application launcher
- install.sh / uninstall.sh: POSIX sh, set -eu; copies binaries + all
  artifacts, reloads udevadm, best-effort systemd and icon cache updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs/INSTALL-linux.md covers: prerequisites, build from source, udev
rules (uaccess + non-systemd fallback), install.sh usage, autostart via
systemctl, verification steps, and known limitations table.

README.md gets a Linux subsection under ## Install with the minimal
quick-start (build + udev) and a link to the full guide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- install.sh: rewrite ExecStart in the systemd unit via sed so the
  installed service always points to $BINDIR/openlogi-agent, not the
  hardcoded /usr/bin path (which mismatches the default /usr/local prefix)
- uninstall.sh: add udevadm trigger after reload so hidraw and uinput
  nodes lose the uaccess tag immediately, not only after re-plug/reboot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- install.sh: escape sed replacement string for BINDIR metacharacters
  (& \ |) so paths like /opt/my&pkg don't corrupt the unit file
- install.sh: best-effort daemon-reload via sudo -u $SUDO_USER after
  writing the unit so reinstalls pick up the new ExecStart immediately
- uninstall.sh: use SUDO_USER to target the real user's systemd session
  when the script is run under sudo, preventing the disable from silently
  targeting root's session while the user's agent keeps running

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bluetooth HID devices go through the uhid virtual bus, which has no
idVendor sysfs attribute — ATTRS{idVendor}=="046d" doesn't match them.
Add a second rule matching on the HID kernel name format
"bus:VID:PID.iface", whose VID field (046D) covers both BT and USB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sudo -u strips the environment, so systemctl --user cannot locate the
user's D-Bus socket without XDG_RUNTIME_DIR. Without it, disable --now
in uninstall.sh silently fails (exit code 1, swallowed by || true),
leaving the agent enabled even after the binary is removed.
daemon-reload in install.sh has the same problem — it would silently
skip the reload, requiring a manual reload before the updated unit takes
effect.

Fix both by computing REAL_UID / INSTALL_USER and passing
XDG_RUNTIME_DIR=/run/user/<uid> explicitly to the sudo -u invocation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both install.sh (PREFIX/bin) and nfpm postinstall (/usr/bin) now
expand the placeholder explicitly, removing the implicit assumption
that the template's hardcoded path matches any particular installer.
nfpm.yaml maps the three binaries + udev rules + systemd user unit +
desktop entry + icon into .deb/.rpm. VERSION env var is templated in
at build time by the xtask.

nfpm-scripts/postinstall.sh: reload udevadm, refresh icon and desktop
caches, print enable-agent instructions.
nfpm-scripts/preremove.sh: reload udevadm to revoke uaccess tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
xtask/src/linux.rs: PackageLinux command builds release binaries (unless
--no-build), then invokes nfpm for both deb and rpm. Reads the workspace
version from Cargo.toml and passes it as VERSION to nfpm.

.github/workflows/release.yml: new linux-packages job (ubuntu-latest)
installs nfpm via the goreleaser apt repo, runs `cargo run -p xtask --
package-linux`, and uploads .deb/.rpm as an artifact. The publish job now
depends on both dmg and linux-packages, downloads both artifact sets, and
attaches .deb/.rpm to the GitHub Release alongside the DMGs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace workspace_version() file-I/O + fragile TOML line-scanner with
  env!("CARGO_PKG_VERSION") — Cargo bakes the workspace version in at
  compile time, zero I/O and always correct
- Merge two separate apt-get update + install steps in the linux-packages
  CI job into one (add goreleaser repo first, then single update + install)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- nfpm.yaml: drop v prefix from version field — dpkg/rpm expect bare
  semver (0.6.0 not v0.6.0); v prefix can cause upgrade ordering issues
- nfpm.yaml: fix maintainer to Name <email> format required by Debian
- release.yml: replace trusted=yes with a proper GPG key pin for the
  goreleaser apt repo to prevent supply-chain substitution attacks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the goreleaser apt repo (which has no GPG key and requires
trusted=yes) with a direct download of a pinned nfpm release from
GitHub. The SHA256 is checked before install, preventing a compromised
CDN or MITM from substituting a malicious nfpm binary in the release
pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
postinstall.sh / preremove.sh: udevadm trigger is asynchronous — it
queues uevent changes but returns before udev finishes processing them.
Without udevadm settle, a user who immediately runs
`systemctl --user enable --now openlogi-agent` after package install
may find /dev/hidraw* and /dev/uinput still denying access because
the uaccess tags haven't propagated yet. Add `udevadm settle || true`
after the trigger calls in both scripts.

release.yml: extend the minisign signing loop from dist/*.dmg to also
cover dist/*.deb and dist/*.rpm, so all release artifacts have a .minisig
sidecar and SHA256SUMS is consistent across all platforms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
xtask/src/linux.rs: PackageLinux command builds release binaries (unless
--no-build), then invokes nfpm for both deb and rpm. Reads the workspace
version from Cargo.toml and passes it as VERSION to nfpm.

.github/workflows/release.yml: new linux-packages job (ubuntu-latest)
installs nfpm via the goreleaser apt repo, runs `cargo run -p xtask --
package-linux`, and uploads .deb/.rpm as an artifact. The publish job now
depends on both dmg and linux-packages, downloads both artifact sets, and
attaches .deb/.rpm to the GitHub Release alongside the DMGs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cserby and others added 8 commits June 10, 2026 09:17
OpenLogi previously only enumerated Logi Bolt receivers (PID 0xC548).
Users with a Unifying receiver (PID 0xC52B / 0xC532) saw no devices.

openlogi-hidpp/receiver/unifying.rs
  Add the missing HID++ 1.0 methods needed for device enumeration:
  count_pairings(), trigger_device_arrival(), get_device_pairing_information(),
  get_unique_id(). Add EventEmitter<Event> + message listener for 0x41
  device-connection notifications (same pattern as BoltReceiver). Add
  DeviceKind enum (shifted vs Bolt at values 5+: Remote=5, Trackball=6,
  Touchpad=7). Update receiver/mod.rs to call get_unique_id() directly.

openlogi-hid/transport.rs
  Add is_receiver_child_node() (Linux): the hid-logitech-dj kernel driver
  creates a virtual hidraw node per paired Unifying device; these matched
  our HID++ filter and caused 5-second probe timeouts every tick. Detect
  them by checking whether any known receiver PID appears as a *parent*
  directory component in the device's sysfs path, and filter them out
  before probing. Extract the path-matching logic to is_receiver_child_sysfs_path()
  for testability without filesystem access.
  Add BOLT_PIDS and UNIFYING_PIDS as the canonical PID lists; pairing.rs
  now derives from them so a new receiver PID needs editing in one place.

openlogi-hid/inventory.rs
  probe_one: replace Bolt-only match with explicit Bolt / Unifying / direct
  arms. Add probe_unifying_receiver (uses device-arrival events to surface
  online paired devices) and probe_unifying_slot (builds PairedDevice from
  the event; falls back gracefully on timeout). Add UNIFYING_SLOT_PROBE
  (3.5 s) per-slot budget so a slow wireless HID++ 2.0 round-trip on one
  device cannot consume the shared PROBE_BUDGET on behalf of others. Add
  drain_device_arrival_unifying and map_unifying_kind.

openlogi-hid/route.rs
  Add DeviceRoute::Unifying { receiver_uid, slot } — same structure as Bolt
  but distinct so the label and write path are correct. Add device_route_for()
  constructor that picks Unifying/Bolt/Direct from the receiver's product ID
  using the canonical PID lists. Update open_route_channel to handle the
  Unifying arm. Add BOLT_PIDS and UNIFYING_PIDS constants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consumers of the HID layer all constructed DeviceRoute::Bolt for any
receiver-backed device. This caused three problems for Unifying devices:
1. open_route_channel only tried Receiver::Bolt — writes silently failed.
2. The Device tab showed "Bolt receiver" for Unifying devices.
3. The Buttons tab showed the generic mouse hotspot layout for keyboards
   (K540 has ReprogControls so capabilities.buttons=true, but no asset).
4. Paired devices without HID++ 2.0 model info were skipped entirely by
   build_device_list, so the K540 never appeared in the carousel.

openlogi-agent-core
  device_order.rs: DeviceStableId::from_parts folds Unifying into the Bolt
  variant (same slot-based sort key regardless of receiver family, so the
  GUI carousel and agent agree on "first device").
  orchestrator.rs: call DeviceRoute::device_route_for() directly.

openlogi-cli/diag
  Use DeviceRoute::device_route_for() at both diag call sites.

openlogi-gui/app.rs
  route_label: add "Unifying receiver" arm for DeviceRoute::Unifying.
  tabs_for: gate the Buttons tab on `can_show_mouse_model` — a pointer-type
  device (Mouse/Trackball) or one with a resolved asset. A keyboard that
  exposes ReprogControls but has no asset would otherwise show the generic
  mouse hotspot layout (Middle Click, DPI Toggle, …) which is wrong.
  Update tabs_follow_capabilities_not_kind test to use kind=Mouse (the
  0x0005 kind-correction fix from AprilNEA#127 corrects mislabeled mice at probe
  time; the test scenario of kind=Keyboard + mouse-caps no longer arises).
  Add keyboard_without_asset_hides_buttons_tab test.

openlogi-gui/state/devices.rs
  build_device_list: remove the hard model_info guard. Devices without
  HID++ 2.0 model info (HID++ 1.0 devices, or probes that timed out)
  are now surfaced with a wpid-based config_key ("wpid{:04x}") so their
  settings persist across sessions; slot is the last-resort fallback.
  Call DeviceRoute::device_route_for() directly (one-liner wrapper removed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
inventory.rs — probe_unifying_slot now receives the receiver's unique_id
and incorporates its first 3 ASCII bytes into the CacheKey so two
Unifying receivers with a device on the same slot number use distinct
cache entries. The previous [0,0,0,slot] key was receiver-agnostic and
would hand receiver B's slot-1 query the cached ProbedFeatures built for
receiver A's slot-1 device (different model, different capabilities).

route.rs — device_route_for: make the Bolt-default design explicit in
the doc comment and add a tracing::debug for receivers whose PID is in
neither BOLT_PIDS nor UNIFYING_PIDS. Behaviour is unchanged — returning
None for unknown PIDs would silently drop writes for future Bolt variants
with new PIDs, which is worse than routing them as Bolt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
unifying.rs — #[derive(Clone)] copied msg_listener_hdl (u32) verbatim,
so dropping any clone called remove_msg_listener with the shared handle
and silently deregistered the listener for all surviving clones. The
next drain_device_arrival_unifying would return empty, making Unifying
devices vanish from inventory.

Replace the bare u32 with Arc<ListenerDropGuard>: every Receiver clone
shares the Arc; remove_msg_listener is called exactly once, when the
Arc's refcount hits zero (last clone dropped). The manual Drop impl is
removed — ListenerDropGuard::drop owns the cleanup.

inventory.rs — CacheKey::Bolt { unit_id: [uid[0..3], slot] } used only
3 ASCII bytes of the receiver serial as a prefix, so two receivers whose
serials share the same first three characters (common in Logitech batches,
e.g. "DA2699E1" and "DA2604F2") still collided on the same slot.

Add CacheKey::UnifyingSlot { receiver_uid: String, slot: u8 } keyed on
the full serial string + slot. Two Unifying receivers with a device on
the same slot number now have provably distinct cache entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added missing translations for the route label introduced in this PR.
Placed alongside 'Bolt receiver' following the same pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BoltReceiver had the same Clone+Drop bug fixed for UnifyingReceiver in
the previous commit: #[derive(Clone)] copied msg_listener_hdl (u32)
verbatim, so the first drop of any clone deregistered the HID++ listener
for all surviving copies — subsequent drain_device_arrival calls would
return empty and Bolt devices would vanish from inventory.

Move ListenerDropGuard to receiver/mod.rs (pub(super)) so it is shared
by both bolt.rs and unifying.rs without duplication. Apply the Arc-wrap
fix to BoltReceiver identically: store Arc<ListenerDropGuard> instead of
a bare u32, remove the manual impl Drop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On timeout the fallback returned ProbedFeatures::default() even when a
prior successful probe was in the cache, causing battery, model name,
and capability flags to disappear from the carousel for that device.
Return the cached probe when available and emit Seen so the entry is not
counted toward eviction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-hid/unifying-receiver-support branch from 276f6db to af664c5 Compare June 10, 2026 07:20
cserby and others added 2 commits June 10, 2026 09:30
The function is only called from a #[cfg(target_os = "linux")] context,
making it dead code on macOS/Windows. Gate it with
#[cfg(any(target_os = "linux", test))] so the tests remain cross-platform
while silencing the dead_code lint in production builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Empty string fallback causes CacheKey collisions when two receivers
share the same slot and both fail get_unique_id(). Product ID is a
weaker but non-empty discriminant; a warning is logged so the
degraded isolation is visible.
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