From 765bc9eb375c2b35490aafc116c5cb643e40a812 Mon Sep 17 00:00:00 2001 From: maelstr0m <96124638+mlstr0m@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:09:33 +0200 Subject: [PATCH] Widen Nintendo BLE discovery + scan diagnostics + in-menu errors (v1.2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report follow-up: scans run clean for 30 s but never see the controller, and error notifications are invisible in run-from-source. - Match both Nintendo company IDs in manufacturer data: 0x0553 (the actual Bluetooth SIG assigned ID, used by other Switch 2 community tools) and 0x057E (Nintendo's USB VID, matched previously). A controller advertising 0x0553 with no device name was undetectable. - When a scan round finds nothing, log every device seen (name, rssi, manufacturer data hex, capped at 20) — turns "not found" reports into actionable data. - Show the last connection error in the dropdown while idle (⚠️ line): rumps notifications don't show when running from source, so failures were completely silent in the UI. - Tests: discovery filter matrix (0x0553 / 0x057E / name fallback / unrelated device) — 56 bridge checks Co-Authored-By: Claude Fable 5 --- Switch2Bridge.py | 35 +++++++++++++++++++++++++++++------ tests/test_bridge.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/Switch2Bridge.py b/Switch2Bridge.py index 7d4fd79..fcd5f90 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -66,11 +66,13 @@ # ============================================================ APP_NAME = "Switch2 Bridge" -APP_VERSION = "1.2.1" # single source of truth — read by setup_app.py & build_dmg.sh +APP_VERSION = "1.2.2" # single source of truth — read by setup_app.py & build_dmg.sh INPUT_CHAR_UUID = "7492866c-ec3e-4619-8258-32755ffcc0f9" -# Bluetooth SIG company identifier for Nintendo -NINTENDO_COMPANY_ID = 0x057e +# Nintendo company identifiers seen in BLE advertisements: +# 0x0553 is the Bluetooth SIG assigned ID, 0x057E is Nintendo's USB VID +# (both observed in the wild depending on firmware) +NINTENDO_COMPANY_IDS = (0x0553, 0x057e) # Switch 2 Pro Controller product ID 0x2069, little-endian as it appears on the wire SWITCH2_PRO_PID_LE = b'\x69\x20' @@ -471,14 +473,28 @@ def _on_data(self, sender, data: bytes): async def _find_controller(self, timeout=SCAN_TIMEOUT): """Returns (address, name) or (None, None).""" devices = await BleakScanner.discover(timeout=timeout, return_adv=True) + seen = [] for address, (device, adv) in devices.items(): + mfr = ", ".join( + f"{cid:#06x}:{bytes(p).hex()}" + for cid, p in adv.manufacturer_data.items() + ) + seen.append( + f"{device.name or '?'} rssi={getattr(adv, 'rssi', '?')} [{mfr}]" + ) # Primary: Nintendo company ID is the dict key in bleak's manufacturer_data - payload = adv.manufacturer_data.get(NINTENDO_COMPANY_ID) - if payload and SWITCH2_PRO_PID_LE in payload: - return address, device.name or "Switch 2 Pro Controller" + for cid in NINTENDO_COMPANY_IDS: + payload = adv.manufacturer_data.get(cid) + if payload and SWITCH2_PRO_PID_LE in payload: + return address, device.name or "Switch 2 Pro Controller" # Fallback: some macOS BLE stacks expose name without manufacturer data if device.name and "Pro Controller" in device.name: return address, device.name + # Diagnostic dump: this is what tells us why a controller wasn't matched + log.info( + "scan: no controller among %d device(s): %s", + len(seen), "; ".join(seen[:20]) or "none", + ) return None, None # --- main async routine --- @@ -730,6 +746,9 @@ def __init__(self): self._last_state = None self._accessibility_checked = False + # Short error shown in the dropdown while idle — notifications are + # unreliable when running from source, the menu is always visible + self._idle_note = None self._apply_state('idle') self._timer = rumps.Timer(self._tick, self.REFRESH_INTERVAL) @@ -800,6 +819,7 @@ def _tick(self, _): err = self.bridge.last_error self.bridge.last_error = None log.warning("user-visible bridge error: %s", err) + self._idle_note = err.splitlines()[0][:70] self._notify("Connection failed", err) if self.bridge.last_notice: @@ -817,6 +837,8 @@ def _tick(self, _): state = self._current_state() if state != self._last_state: self._apply_state(state) + if state == 'idle' and self._idle_note: + self._detail_item.title = f" ⚠️ {self._idle_note}" elif state == 'connected': # in-place title update — no menu rebuild count = self.bridge.packet_count @@ -838,6 +860,7 @@ def _on_action(self, _): if state == 'idle': if not self._check_bluetooth(): return + self._idle_note = None self.bridge.connect() elif state != 'stopping': self.bridge.disconnect() diff --git a/tests/test_bridge.py b/tests/test_bridge.py index f21cb2b..0a9d955 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -275,6 +275,39 @@ class Dev: msg = S2B.ControllerBridge._scan_error_message(Exception("boom")) check("generic passthrough", "boom" in msg, msg) +# discovery filters: both Nintendo company IDs + name fallback +print("== discovery filters ==") +class AdvSIG: + manufacturer_data = {0x0553: b"\x01\x69\x20\x00"} # BT SIG assigned ID +class AdvVID: + manufacturer_data = {0x057E: b"\x01\x69\x20\x00"} # Nintendo USB VID +class AdvOther: + manufacturer_data = {0x004C: b"\x10\x05"} # random Apple device +class NoName: + name = None +class Named: + name = "Pro Controller (S2)" + +MockScanner.queue = [] +MockScanner.delay = 0.01 +br7 = S2B.ControllerBridge(mm) + +MockScanner.result = {"S1": (NoName(), AdvSIG())} +a, n = asyncio.run(br7._find_controller()) +check("SIG id 0x0553 matched (no name)", a == "S1" and n == "Switch 2 Pro Controller", (a, n)) + +MockScanner.result = {"S2": (NoName(), AdvVID())} +a, n = asyncio.run(br7._find_controller()) +check("USB VID 0x057e matched", a == "S2") + +MockScanner.result = {"S3": (Named(), AdvOther())} +a, n = asyncio.run(br7._find_controller()) +check("name fallback", a == "S3" and "Pro Controller" in n) + +MockScanner.result = {"S4": (NoName(), AdvOther())} +a, n = asyncio.run(br7._find_controller()) +check("unrelated device ignored", a is None) + print() if FAILURES: print(f"FAILED: {len(FAILURES)} -> {FAILURES}")