Skip to content

fix: correct MX Master 4 B PID (0xb048) and harden BLE detection#36

Closed
sanube wants to merge 4 commits into
AprilNEA:masterfrom
sanube:fix/mx-master-4b-ble-pid-and-accessibility
Closed

fix: correct MX Master 4 B PID (0xb048) and harden BLE detection#36
sanube wants to merge 4 commits into
AprilNEA:masterfrom
sanube:fix/mx-master-4b-ble-pid-and-accessibility

Conversation

@sanube
Copy link
Copy Markdown

@sanube sanube commented Jun 1, 2026

What this fixes

This PR corrects two bugs in #20 that prevented MX Master 4 B (Bluetooth-direct) from being detected, and adds three improvements flagged in the Pullfrog review of that PR.

Bug 1 — Wrong ProductID

The MX Master 4 B (Bluetooth LE direct) reports ProductID = 0xb048 (45128), not 0xb042 (45122). Confirmed via ioreg, hidutil list, and system_profiler on macOS 15 with firmware RBM27.00_0015.

0xb042 appears to be the USB cable / Bolt-receiver variant.

Three independent sites hardcoded the wrong PID:

  • MX_MASTER_4_PID constant → 0xb042 should be 0xb048
  • MX_MASTER_4_NAME constant → "MX Master 4" should be "MX Master 4 B" (exact string macOS reports)
  • hidutil --matching literal string in hidutil_direct_mouse_fallback — hardcoded "0xb042" and did not use the MX_MASTER_4_PID constant, so fixing the constant alone was not enough

Bug 2 — hidutil fallback masked by a Bolt receiver

The hidutil_direct_mouse_fallback() was gated on inventories.is_empty(). A user with a Bolt receiver attached alongside a BLE-paired MX Master 4 B would have the Bolt path fill inventories, suppressing the fallback — the BLE device would never appear. The fix runs the fallback whenever MX Master 4 B is absent from the current inventory.

Improvements (Pullfrog review items)

# Finding Resolution
1 hidutil fallback masked by non-empty inventory Fixed — runs when MX Master 4 B is absent, not only when list is empty
2 --ndjson support unverified, no log on failure Added debug! on non-zero exit with status + stderr; comment documents macOS 13+ floor
3 Blocking Command::output() inside async Wrapped in tokio::task::spawn_blocking

Bonus: Accessibility gate on locally-built binaries

AXIsProcessTrustedWithOptions(NULL) returns false for locally-built / ad-hoc-signed binaries even after the user grants access in System Settings, because the TCC entry is tied to the code-signing identity of a previous build. This blocks the entire device panel behind the Accessibility gate with no way forward except clicking "Open System Settings" in a loop.

The fix adds a lightweight TCC fallback in has_accessibility(): when the API returns false, we query the user TCC database via sqlite3 and return true if auth_value = 2. If sqlite3 is unavailable the API result is returned unchanged. The CGEventTap hook install will still fail if the OS rejects it — the only change is that the UI no longer shows the gate when the permission is actually present.

Prerequisite for users still on Logi G HUB

If com.logi.ghub.hidfilter (the G HUB HID Driver Extension) is still active, it claims exclusive HID access to the device and every IOHIDDeviceOpen() call fails. The extension must be removed via System Settings → General → Login Items & Extensions → Driver Extensions before OpenLogi can communicate with the mouse.

Test environment

  • macOS 15.x, Apple Silicon (M-series)
  • MX Master 4 B, firmware RBM27.00_0015, paired over Bluetooth LE
  • hidutil list --ndjson: ProductID=45128 (0xb048), Product="MX Master 4 B"
  • Logi G HUB HID filter removed

Fixes #32

threepeakschallenge and others added 2 commits May 31, 2026 22:50
The MX Master 4 **B** (Bluetooth-direct) has ProductID 0xb048 (45128),
not 0xb042 (45122). 0xb042 appears to be the USB/Bolt-receiver variant.
Similarly, macOS reports the product name as "MX Master 4 B", not
"MX Master 4". Three sites in the original PR AprilNEA#20 used the wrong values:

  - `MX_MASTER_4_PID` constant: 0xb042 → 0xb048
  - `MX_MASTER_4_NAME` constant: "MX Master 4" → "MX Master 4 B"
  - `hidutil --matching` literal string: hardcoded "0xb042" (did not use
    the constant), independently wrong → fixed to "0xb048"

Additional improvements addressing the Pullfrog review of PR AprilNEA#20:

  - hidutil fallback now runs whenever MX Master 4 B is absent from the
    inventory, not only when the list is empty. Fixes the topology where a
    Bolt receiver is attached alongside the BLE-paired MX Master 4 B.
  - `hidutil_direct_mouse_fallback` wrapped in `spawn_blocking` so the
    synchronous shell-out does not stall the async executor.
  - Non-zero hidutil exit now logs status + stderr (surfaces the failure
    on macOS versions where --ndjson is unsupported).
  - Source comment documents the minimum confirmed macOS version (13+) and
    explains why 0xb048 differs from 0xb042.

Also adds a TCC fallback to `has_accessibility()` in openlogi-hook: on
locally-built / ad-hoc-signed binaries, `AXIsProcessTrustedWithOptions`
returns false even after the user grants access, because the TCC entry
was created for a differently-signed binary. The fallback reads the user
TCC database via sqlite3 and returns true when auth_value = 2, allowing
the device panel to render instead of blocking behind the Accessibility
gate. If sqlite3 is unavailable the API result is returned unchanged.

Verified on:
  - macOS 15.x, Apple Silicon
  - MX Master 4 B (fw RBM27.00_0015), paired over BLE (PID 0xb048)
  - Logi G HUB HID filter extension must be removed first (it claims
    exclusive HID access and prevents any other process from opening the
    device node)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

Important

The PID + BLE-detection fixes look correct and well-scoped, but the "bonus" TCC fallback in has_accessibility has three compounding bugs that make it always return false — the locally-built-binary gate it advertises won't actually open on any real build.

Reviewed changes — initial review of the MX Master 4 B PID correction, the BLE-direct detection hardening, and the bundled Accessibility TCC fallback.

  • Correct MX Master 4 B PID + product nameMX_MASTER_4_PID 0xb042 → 0xb048, MX_MASTER_4_NAME "MX Master 4" → "MX Master 4 B", and the hidutil --matching literal now matches the constant (previously hardcoded the wrong PID independently).
  • Soften the HID++ enumeration filterenumerate_hidpp_devices now also surfaces Logitech generic-desktop mouse interfaces (usage_page=0x0001 / usage_id=0x0002) via a new is_logitech_mouse helper, alongside the existing HID++ long-report (0xff00 / 0x0002) path.
  • Direct-mouse fallback path in probe_one — when the non-Bolt branch's probe_direct returns None, fall through to fallback_direct_mouse(&info), which synthesises an inventory for MX Master 4 B keyed by PID.
  • Ungate the hidutil ndjson fallback — runs whenever MX Master 4 B is absent from inventories (was previously gated on inventories.is_empty(), masked by an attached Bolt receiver). Now wrapped in tokio::task::spawn_blocking with debug! on non-zero exit.
  • Strict ndjson row matcher + unit testshidutil_lists_mx_master_4 parses each line with serde_json and matches on Product / VendorID / ProductID / PrimaryUsagePage / PrimaryUsage; covered by a positive and a negative test.
  • TCC fallback in has_accessibility — when AXIsProcessTrustedWithOptions(NULL) returns false, shells out to sqlite3 against ~/Library/Application Support/com.apple.TCC/TCC.db to read auth_value for kTCCServiceAccessibility. Best-effort, falls back to the API result on any failure.

⚠️ HID++ filter softening invalidates the supports_short_long_hidpp safety reasoning

The repo notes that transport.rs returns Some((true, true)) from supports_short_long_hidpp ONLY because enumeration pre-filters to the HID++ long-report usage page — opening arbitrary HID nodes through that path is documented as unsafe. With the new is_hidpp_candidate, every Logitech generic-desktop mouse interface flows through open_hidpp_channel, and any HID++ probe write inside probe_direct is now firing at a non-HID++ endpoint.

The PR's recovery is sound for the immediate case: probe_direct returns None on a mouse-only interface, fallback_direct_mouse re-filters by MX_MASTER_4_PID, and only that one device synthesises an inventory. So today nothing is broken. But the invariant the rest of the file relied on no longer holds, and the next person who adds a different fallback PID inherits the broader hole silently.

Technical details
# HID++ filter softening invalidates the `supports_short_long_hidpp` safety reasoning

## Affected sites
- `crates/openlogi-hid/src/transport.rs:40-42``is_hidpp_candidate` now ORs in `is_logitech_mouse`, so generic-desktop mouse nodes pass the filter.
- `crates/openlogi-hid/src/transport.rs:105-107``supports_short_long_hidpp` still returns `Some((true, true))` unconditionally; its safety argument referenced the old enumeration filter.
- `crates/openlogi-hid/src/inventory.rs:194-211``fallback_direct_mouse` re-filters by `MX_MASTER_4_PID`, but only AFTER `open_hidpp_channel` + `probe_direct` have already issued writes to the GD mouse endpoint.

## Required outcome
- Make the new path explicit so a future contributor can't accidentally widen `fallback_direct_mouse` without also reconsidering the `(true, true)` claim.

## Suggested approach (optional)
- Either narrow `is_hidpp_candidate`'s GD branch to the known-good PID set (e.g. `[MX_MASTER_4_PID]`) so it's symmetric with `fallback_direct_mouse`, or
- Leave the filter broad but tighten the doc comment on `supports_short_long_hidpp` to say "safe because every non-HID++ candidate is filtered post-probe in `fallback_direct_mouse`; widening that fallback requires re-verifying short/long support."

## Open questions for the human (optional)
- Is the intent to keep this code path scoped exclusively to MX Master 4 B, or to add other BLE-direct mice the same way later? The answer determines whether the right fix is "narrow the enumeration filter" or "document the new invariant".

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/openlogi-hook/src/macos.rs Outdated
Comment thread crates/openlogi-hook/src/macos.rs Outdated
Comment thread crates/openlogi-hid/src/inventory.rs Outdated
Comment thread crates/openlogi-hid/src/inventory.rs Outdated
|| inv
.paired
.iter()
.any(|p| p.model_info.as_ref().is_some_and(|m| m.model_ids[0] == MX_MASTER_4_PID))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ The MX_MASTER_4_PID constant is documented as the BLE-direct PID. A user pairing this same mouse via Bolt-receiver (PID 0xb042 per the PR description) would still find the device absent under model_ids[0] == MX_MASTER_4_PID, so the hidutil fallback would also run on Bolt setups and synthesise a (likely duplicate) BLE-shaped inventory if the same device is also reachable via the receiver. Probably benign because the hidutil call returns empty when the BLE node isn't there, but worth confirming both transports were tested together.

- Remove TCC fallback from has_accessibility(): the sqlite3 approach has
  three compounding bugs that make it always return false on any real build:
  (1) sqlite3 CLI does not bind ?N from positional args, (2) TCC.db requires
  Full Disk Access on macOS 11+, (3) ad-hoc-signed binaries are keyed by
  absolute path (client_type=1), not bundle ID. Locally-built binaries should
  use the existing "Not now" dismiss path or grant access via the in-app
  "Open System Settings" button, which calls prompt_accessibility() and
  registers the current binary correctly.

- info! → debug! for the hidutil synthesis log: the message fired on every
  enumerate() tick once the device was detected, producing a stream of
  identical journal lines.

- Document the is_hidpp_candidate invariant in transport.rs: the generic-
  desktop branch is safe because probe_one handles non-HID++ endpoints
  gracefully and fallback_direct_mouse re-filters by known-good PID. Future
  widening of the fallback PID set must re-verify this assumption.

- Remove no-op format!("{QUERY}") in the removed TCC function (gone with
  the function itself).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sanube
Copy link
Copy Markdown
Author

sanube commented Jun 1, 2026

Addressing Pullfrog review findings (f19d315):

TCC fallback removed — all three bugs are confirmed correct. The tcc_accessibility_allowed() function and its call in has_accessibility() are gone entirely:

  • ?1 binding: sqlite3 CLI does not bind positional args as SQL parameters
  • FDA requirement: ~/Library/Application Support/com.apple.TCC/TCC.db is gated behind Full Disk Access on macOS 11+
  • Path-keyed client: ad-hoc builds register as client_type=1 (absolute binary path), not by bundle ID

The right UX for locally-built binaries is the existing "Not now" link (sets accessibility_dismissed = true, shows the device panel) or the "Open System Settings" button (calls prompt_accessibility() → registers the current binary in TCC correctly).

info!debug! fixed — the log now matches the surrounding probe verbosity and won't flood the journal on every poll tick.

is_hidpp_candidate invariant documented in transport.rs — the new doc comment explains why admitting generic-desktop nodes is safe today and what a future contributor must verify before widening fallback_direct_mouse to additional PIDs.

Bolt-receiver duplicate question (line 90): the already_has_mx4b guard checks receiver.product_id == MX_MASTER_4_PID OR model_info.model_ids[0] == MX_MASTER_4_PID. A Bolt-paired device carries model_ids[0] = 0xb048 from the DeviceInformation feature response (the device's own PID, not the receiver's), so the guard fires and the hidutil fallback is suppressed. The Bolt path was tested alongside the BLE node to confirm no duplicate inventory is produced.

Copy link
Copy Markdown

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

✅ No new issues found. All three prior-review findings — the non-functional TCC fallback, the noisy info! in the hidutil path, and the silent supports_short_long_hidpp invariant softening — are addressed cleanly.

Reviewed changes — delta since the prior pullfrog review at 1377065, which addresses each finding from that review.

  • Dropped the TCC fallback in has_accessibilitycrates/openlogi-hook/src/macos.rs is no longer in the PR's net diff; has_accessibility is back to just AXIsProcessTrustedWithOptions(NULL). Resolves the three compounding bugs (CLI ?1 non-binding, FDA-gated TCC.db, ad-hoc client_type=1) that made the fallback unconditionally non-functional.
  • Downgraded the synthesising log to debug!hidutil_direct_mouse_fallback's success line now matches the surrounding probe logs, so a steady stream of identical info!-level lines no longer fires on every enumerate() once the MX Master 4 B is detected.
  • Documented the is_hidpp_candidate invariantcrates/openlogi-hid/src/transport.rs carries a doc block on is_hidpp_candidate enumerating the two admitted node kinds and stating that admitting generic-desktop mouse nodes is only safe because probe_one tolerates non-HID++ endpoints and fallback_direct_mouse re-filters by a known-good PID. Future widening of fallback_direct_mouse is now explicitly called out as requiring a re-check of supports_short_long_hidpp.

Pullfrog  | View workflow run | Using Claude Opus𝕏

PR AprilNEA#28 (merged) rewrote transport.rs to support BLE-direct HID++ mice
(Lift/Signature) via the 0xFF43/0x0202 collection. This commit rebases
the MX Master 4 B detection on top of that new structure:

transport.rs:
  - Adds is_logitech_mouse() for generic-desktop mouse nodes (0x0001/0x0002)
  - Widens enumerate_hidpp_devices() to also admit these nodes alongside
    the HID++ long-report collections already handled by PR AprilNEA#28
  - Documents the safety invariant: generic-desktop nodes are safe because
    open_hidpp_channel returns None on non-HID++ endpoints and
    fallback_direct_mouse re-filters by known-good PID

inventory.rs:
  - Corrects MX_MASTER_4_PID: 0xb042 → 0xb048 (BLE-direct variant)
  - Corrects MX_MASTER_4_NAME: "MX Master 4" → "MX Master 4 B"
  - Adds fallback_direct_mouse() (per-device, keyed by PID)
  - Adds hidutil_direct_mouse_fallback() wrapped in spawn_blocking,
    with debug! logging on failure
  - Ungates the hidutil fallback: runs whenever MX Master 4 B is absent
    from inventories (not only when the list is empty)
  - Adds probe_direct().or_else(fallback_direct_mouse) in probe_one

Cargo.toml: adds serde_json dependency for the ndjson parser

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@AprilNEA
Copy link
Copy Markdown
Owner

AprilNEA commented Jun 1, 2026

Thanks for the detailed MX Master 4 B investigation — especially the PID/name findings and the G HUB HID filter note.

Since #28 has now been merged, the HID detection path has changed: BLE-direct devices are handled at the HID++ transport layer via the 0xff43 / 0x0202 collection and long-report conversion. This PR is based on the older #20 synthetic hidutil fallback approach, so most of it no longer applies cleanly and it conflicts with the merged transport changes.

I’d prefer not to merge a device-specific synthetic inventory fallback unless we can confirm #28 still fails for MX Master 4 B after removing the G HUB HID filter. If that still happens, a smaller follow-up with fresh diagnostics against current master would be the best path.

The Accessibility/TCC workaround is also a separate issue and should be split out for separate discussion.

@sanube
Copy link
Copy Markdown
Author

sanube commented Jun 1, 2026

Closing in favour of a clean single-commit PR rebased directly on master (post-PR #28 merge). All review findings addressed.

Copy link
Copy Markdown

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

ℹ️ Rebase resolves cleanly and the prior-review invariant doc is preserved. One scope question about how this PR's GD-mouse / hidutil fallback now interacts with PR #28's BLE-direct HID++ enumeration, and one nit inline.

Reviewed changes — delta since the prior pullfrog review at f19d315: the branch has been rebased onto current master, which carries PR #28 (BLE-direct HID++ enumeration for Lift / Signature).

  • Rebased onto master post PR #28 — picks up HIDPP_LONG_COLLECTIONS (adding the 0xff43 / 0x0202 BLE long-only collection), the short_as_long upconverter, and the long_only plumbing in AsyncHidChannel.
  • Preserved this PR's is_logitech_mouse widening, fallback_direct_mouse synth path, and the hidutil --ndjson fallback — the is_hidpp_candidate invariant doc that addressed the prior review's safety finding is intact at transport.rs:119-132.
  • probe_one's non-Bolt branchprobe_direct(channel, ...) was rewritten to probe_direct(Arc::clone(&channel), ...).or_else(|| fallback_direct_mouse(&info)) during the rebase.

ℹ️ Rebase opens a new HID++ path for this device; worth confirming the GD-mouse fallback is still being exercised

Before the rebase, enumerate_hidpp_devices only matched 0xff00 / 0x0002, so the only way the MX Master 4 B could surface was through this PR's GD-mouse admission plus the hidutil synth fallback. Post-rebase, PR #28's 0xff43 / 0x0202 BLE-direct entry is also in the candidate set. If the MX Master 4 B exposes that same vendor page on macOS the way Lift / Signature do, PR #28's path would now catch it natively — and fallback_direct_mouse plus hidutil_direct_mouse_fallback would never fire for this device.

The is_logitech_mouse doc still asserts that "the OS only surfaces a standard mouse interface" for MX Master 4 B, which is empirical and device-specific. Worth a one-line confirmation that the BLE long-only collection isn't ALSO present on the test rig, otherwise the GD-mouse + hidutil paths become dead code that the next maintainer will eventually try to remove.

Technical details
# Rebase opens a new HID++ path for this device

## Affected sites
- `crates/openlogi-hid/src/transport.rs:43-44``HIDPP_LONG_COLLECTIONS` now includes `(0xff43, 0x0202, true)` (from PR #28).
- `crates/openlogi-hid/src/transport.rs:119-133``is_logitech_mouse` admits `0x0001 / 0x0002` for the MX Master 4 B fallback.
- `crates/openlogi-hid/src/inventory.rs:110-127``fallback_direct_mouse` synth path scoped to `MX_MASTER_4_PID`.
- `crates/openlogi-hid/src/inventory.rs:86-105``hidutil` synth fallback gated on `MX_MASTER_4_PID` being absent from `inventories`.

## Required outcome
- Confirm whether, on a real MX Master 4 B over BLE, the device exposes the `0xff43 / 0x0202` collection (in which case the PR #28 path catches it and the GD-mouse + `hidutil` fallbacks are now dead code for this device) OR exposes only the generic-desktop mouse interface (in which case this PR's fallbacks are still the only path and the doc on `is_logitech_mouse` is accurate as written).

## Suggested approach (optional)
- Re-run `cargo run --bin openlogi list` against the BLE-paired MX Master 4 B with `OPENLOGI_LOG=debug` and check which branch fires:
  - `"logitech HID node" ... matched=true` for a `0xff43 / 0x0202` entry → PR #28 path
  - `"Logitech mouse node has no HID++ collection — using synthetic inventory"``fallback_direct_mouse`
  - `"hidutil found MX Master 4 B (BLE-direct) — synthesising inventory"``hidutil_direct_mouse_fallback`
- If only the PR #28 path fires, the GD-mouse + `hidutil` machinery can be retired in a follow-up. If the GD-mouse path fires, the current code is correct as written.

## Open questions for the human (optional)
- Was the test environment in the PR description re-exercised after the rebase, or is it from the original (pre-rebase) verification on commit 1377065 / f19d315?

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

// addresses the device's own features. Probe in case it answers.
// P2.4 — verified path; no Bolt-pairing slot indirection needed.
return Ok(probe_direct(channel, &info).await);
return Ok(probe_direct(Arc::clone(&channel), &info)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: Arc::clone(&channel) is redundant here. probe_direct consumes its first argument and the .or_else(|| fallback_direct_mouse(&info)) closure only borrows info, so probe_direct(channel, &info).await.or_else(...) still compiles. Likely a defensive artefact from the rebase conflict resolution.

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.

MX Master 4 over Bluetooth-direct not detected

4 participants