Skip to content
Merged
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 (🔍 → 🟢).
Expand Down
50 changes: 37 additions & 13 deletions Switch2Bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 36 additions & 3 deletions tests/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)])],
Expand All @@ -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

Expand Down
Loading