diff --git a/README.md b/README.md index 931e948..eb8ea2f 100644 --- a/README.md +++ b/README.md @@ -238,13 +238,21 @@ CI runs the same on every pull request (macOS, Python 3.10 / 3.12 / 3.13). | UUID | Purpose | |------|---------| -| `7492866c-ec3e-4619-8258-32755ffcc0f9` | Input reports (notifications) — what the bridge reads | -| `7492866c-ec3e-4619-8258-32755ffcc0f8` | Also notify-only (read, notify) — it can't be written, which is why LED/rumble never worked through it | +| `7492866c-ec3e-4619-8258-32755ffcc0f9` | Input reports (notifications) — absent on some units | +| `7492866c-ec3e-4619-8258-32755ffcc0f8` | Notify-only (read, notify) — **carries the input reports on units without `…f9`** ([#15](https://github.com/mlstr0m/switch2bridge-macos/issues/15)); it can't be written, which is why LED/rumble never worked through it | | `649d4ac9-8eb7-4e6c-af44-1ea54fe5f005` | Command channel (write without response) | | `c765a961-d9d8-4d36-a20a-5315b111836a` | Command replies (notifications) | UUIDs from [ndeadly/switch2_controller_research](https://github.com/ndeadly/switch2_controller_research) (`bluetooth_interface.md`) and the hardware findings in [#14](https://github.com/mlstr0m/switch2bridge-macos/issues/14). +**The input characteristic isn't fixed.** An AU-market controller in [#15](https://github.com/mlstr0m/switch2bridge-macos/issues/15) has no `…f9` at all and streams its input reports from `…f8` (which ndeadly also lists as the Pro Controller input). So the bridge resolves it at connect time rather than assuming it: + +1. the UUID pinned in `mappings.json` (`ble.input_char`), if the device exposes it and it can notify; +2. otherwise `…f9`; +3. otherwise it **probes** the remaining notifiable characteristics — other known UUIDs (`…f8`) first, then the same Nintendo vendor block, then other vendor UUIDs, SIG-assigned ones (battery…) last. Each candidate is subscribed for up to 3 s and only adopted once it streams a report of at least 11 bytes, enough to decode buttons and both sticks. The winner is written back to `ble.input_char`, so later connections skip the probing. + +Every connection also logs the full GATT table (`GATT: N characteristic(s): …`) to `~/Library/Logs/Switch2Bridge/bridge.log` — that line is what a bug report needs when a controller can't be identified. + ### Input report | Byte | Content | @@ -262,14 +270,6 @@ At connect the bridge reads the controller's factory calibration over the comman Frame format and addresses come from [kennethreitz's fork](https://github.com/kennethreitz/switch2bridge-macos/blob/975f329592a4ef56bd8ac2947d4d148eaf808fe0/controller_commands.py) (issue #14, MIT), itself based on [BlueRetro #1249](https://github.com/darthcloud/BlueRetro/issues/1249); the addresses match [SDL's Switch 2 driver](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c). -Not every controller exposes that input UUID (see [#15](https://github.com/mlstr0m/switch2bridge-macos/issues/15)), so the bridge treats it as a first guess only: - -1. the UUID pinned in `mappings.json` (`ble.input_char`), if it is present on the device; -2. otherwise the documented UUID above; -3. otherwise it **probes** every other notifiable characteristic — vendor UUIDs first, SIG-assigned ones last — subscribing for 3 s each and keeping the first one that streams reports of at least 11 bytes (enough to decode buttons and both sticks). The winner is written back to `ble.input_char`, so later connections skip the probing. - -Every connection also logs the full GATT table (`GATT: N characteristic(s): …`) to `~/Library/Logs/Switch2Bridge/bridge.log` — that line is what a bug report needs when a controller can't be identified. - ## 🩺 Troubleshooting - **The controller never appears in System Settings → Bluetooth** — that's **expected**, and not a failure. This bridge is a BLE client: there is no system-level pairing, so macOS will never list the controller. The only place to watch is the app's menubar icon (🔍 → 🟢). diff --git a/Switch2Bridge.py b/Switch2Bridge.py index b6c2ca6..28774a8 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -73,6 +73,13 @@ APP_NAME = "Switch2 Bridge" APP_VERSION = "1.3.0" # single source of truth — read by setup_app.py & build_dmg.sh INPUT_CHAR_UUID = "7492866c-ec3e-4619-8258-32755ffcc0f9" +# …f8 streams the input reports of an AU-market controller that has no …f9 +# at all (issue #15); ndeadly/switch2_controller_research also lists it as the +# Pro Controller input. Unlike …f9 it is probe-validated, not trusted outright. +KNOWN_INPUT_CHAR_UUIDS = ( + INPUT_CHAR_UUID, + "7492866c-ec3e-4619-8258-32755ffcc0f8", +) # Not every controller revision exposes that UUID (issue #15), so it is only the # first candidate: when it is absent we probe the other notifiable @@ -841,24 +848,37 @@ def _log_gatt(self, client): except Exception: log.exception("could not dump the GATT table") + @staticmethod + def _can_notify(char): + props = {str(p).lower() for p in (getattr(char, "properties", ()) or ())} + return bool(props & {"notify", "indicate"}) + + @classmethod + def _notifiable_uuids(cls, client): + return { + str(char.uuid).lower() + for _svc, char in cls._gatt_chars(client) if cls._can_notify(char) + } + @classmethod def _probe_candidates(cls, client): """Notifiable characteristics, likeliest input reporter first.""" - vendor_block, vendor, standard = [], [], [] + known, vendor_block, vendor, standard = [], [], [], [] for _svc, char in cls._gatt_chars(client): - props = {str(p).lower() for p in (getattr(char, "properties", ()) or ())} - if not props & {"notify", "indicate"}: + if not cls._can_notify(char): continue uuid = str(char.uuid).lower() if uuid == INPUT_CHAR_UUID: - continue # already tried before probing - if uuid.endswith(BLE_BASE_UUID_SUFFIX): + continue # trusted without probing, before we get here + if uuid in KNOWN_INPUT_CHAR_UUIDS: + known.append(uuid) + elif uuid.endswith(BLE_BASE_UUID_SUFFIX): standard.append(uuid) elif uuid.startswith(VENDOR_UUID_PREFIX): vendor_block.append(uuid) else: vendor.append(uuid) - return (vendor_block + vendor + standard)[:MAX_INPUT_PROBES] + return (known + vendor_block + vendor + standard)[:MAX_INPUT_PROBES] async def _probe_input_char(self, client, uuid): """Subscribe briefly: True if it streams reports we can decode.""" @@ -892,20 +912,24 @@ def _sniff(_sender, data): async def _resolve_input_char(self, client): """UUID of the characteristic streaming input reports, or None. - Order: the UUID pinned in mappings.json, then the known one, then a - probe of every other notifiable characteristic — controller revisions - renumber it (issue #15). Sets last_error when nothing works. + Order: the UUID pinned in mappings.json, then the documented one, then + a probe of every other notifiable characteristic (other known UUIDs + first) — controller revisions renumber it (issue #15). Sets last_error + when nothing works. """ - present = {str(char.uuid).lower() for _svc, char in self._gatt_chars(client)} + notifiable = self._notifiable_uuids(client) pinned = self.mappings.input_char if pinned: - if pinned in present: + if pinned in notifiable: log.info("using pinned input characteristic %s", pinned) return pinned - log.warning("pinned input characteristic %s absent, re-detecting", pinned) + log.warning( + "pinned input characteristic %s absent or not notifiable, " + "re-detecting", pinned, + ) - if INPUT_CHAR_UUID in present: + if INPUT_CHAR_UUID in notifiable: return INPUT_CHAR_UUID candidates = self._probe_candidates(client) diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 178243d..0ae8fd4 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -387,6 +387,9 @@ class Named: BATTERY_CHAR = "00002a19" + S2B.BLE_BASE_UUID_SUFFIX ALT_INPUT_CHAR = "7492866c-ec3e-4619-8258-32755ffcc0fa" ODD_VENDOR_CHAR = "ab0828b1-198e-4351-b779-901fa0e0371e" +# …f8 is documented as the output characteristic but streams input on the +# controller of issue #15, which has no …f9 at all +KNOWN_ALT_CHAR = "7492866c-ec3e-4619-8258-32755ffcc0f8" def resolve(services, pinned=None, streaming=None): """Run _resolve_input_char against a fabricated GATT table.""" @@ -435,6 +438,33 @@ async def run(): brr.last_notice) check("no error on success", brr.last_error is None, brr.last_error) +# issue #15's controller: no …f9, input streams from the documented output UUID +uuid, brr, client = resolve( + [MockService("svc", [MockChar(BATTERY_CHAR), MockChar(KNOWN_ALT_CHAR)])], + streaming={KNOWN_ALT_CHAR: report}, +) +check("known alternate adopted", uuid == KNOWN_ALT_CHAR, uuid) +check("known alternate probed first", client.subscribed[0] == KNOWN_ALT_CHAR, + client.subscribed) + +# …f9 present but write-only: fall through to probing instead of failing +uuid, brr, _ = resolve( + [MockService("svc", [ + MockChar(S2B.INPUT_CHAR_UUID, ("write",)), MockChar(KNOWN_ALT_CHAR), + ])], + streaming={KNOWN_ALT_CHAR: report}, +) +check("non-notifiable known char skipped", uuid == KNOWN_ALT_CHAR, uuid) + +# a pin that exists but can't notify is ignored too +uuid, _, _ = resolve( + [MockService("svc", [ + MockChar(ALT_INPUT_CHAR, ("write",)), MockChar(S2B.INPUT_CHAR_UUID), + ])], + pinned=ALT_INPUT_CHAR, +) +check("non-notifiable pin ignored", uuid == S2B.INPUT_CHAR_UUID, uuid) + # short reports must not be mistaken for input (battery streams 1 byte) uuid, brr, _ = resolve( [MockService("svc", [MockChar(BATTERY_CHAR)])], @@ -445,13 +475,16 @@ async def run(): brr.last_error and "bridge.log" in brr.last_error and "issues" in brr.last_error, brr.last_error) -# probe order: same vendor block, then other vendor UUIDs, then SIG-assigned +# probe order: other known UUIDs, then same vendor block, then other vendor +# UUIDs, then SIG-assigned ones MockClient.services_template = [MockService("svc", [ MockChar(BATTERY_CHAR), MockChar(ODD_VENDOR_CHAR), MockChar(ALT_INPUT_CHAR), - MockChar("write-only-char", ("write",)), + MockChar(KNOWN_ALT_CHAR), MockChar("write-only-char", ("write",)), ])] order = S2B.ControllerBridge._probe_candidates(MockClient("XX")) -check("probe order", order == [ALT_INPUT_CHAR, ODD_VENDOR_CHAR, BATTERY_CHAR], order) +check("probe order", + order == [KNOWN_ALT_CHAR, ALT_INPUT_CHAR, ODD_VENDOR_CHAR, BATTERY_CHAR], + order) check("non-notifiable skipped", "write-only-char" not in order, order) MockClient.services_template = None