Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@ pip install -r requirements.txt
python Switch2Bridge.py
```

On first launch, macOS will prompt for:
1. **Bluetooth** — to connect to the controller
2. **Accessibility** — to simulate keyboard input
macOS will ask for two permissions:
1. **Accessibility** — prompted at first launch (to simulate keyboard input)
2. **Bluetooth** — prompted the first time you click **Connect Controller** (not at launch!)

Grant both in `System Settings → Privacy & Security`.
⚠️ **When running from source, the Bluetooth permission belongs to _Terminal_ (or your Python interpreter), not to the app.** If no prompt ever appears, add/enable Terminal manually in `System Settings → Privacy & Security → Bluetooth`, then relaunch. The app detects a denied permission and offers to open the right settings pane.

## 📦 Build a standalone .app + DMG

Expand Down Expand Up @@ -188,7 +188,9 @@ switch2bridge-macos/

## 🩺 Troubleshooting

- **"Controller not found"** — make sure the Switch 2 Pro Controller is on and not paired with a console. Hold the small pair button on the back until the LEDs cycle, then click **Connect Controller** again.
- **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 (🔍 → 🟢).
- **"Controller not found"** — make sure the controller is **not paired with a console nearby** (unpair it or put the console to sleep far away). Click **Connect Controller** *first* — the search now runs for 30 s — *then* hold the small pair button on the back until the LEDs sweep back and forth.
- **No Bluetooth prompt ever appeared (run-from-source)** — the permission belongs to Terminal/Python, not the app. Check `System Settings → Privacy & Security → Bluetooth` and enable Terminal, then relaunch. Without it, scans silently find nothing.
- **Menubar says 🟢 connected but inputs don't reach the emulator** — macOS Accessibility permission is missing. Grant it in *System Settings → Privacy & Security → Accessibility*, then relaunch the app. (The app should also pop an alert about this on first launch.)
- **Logs** — written to `~/Library/Logs/Switch2Bridge/bridge.log`. Open a terminal and `tail -f` it to watch what's happening in real time.

Expand Down
85 changes: 72 additions & 13 deletions Switch2Bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
# ============================================================

APP_NAME = "Switch2 Bridge"
APP_VERSION = "1.2.0" # single source of truth — read by setup_app.py & build_dmg.sh
APP_VERSION = "1.2.1" # 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
Expand All @@ -76,6 +76,9 @@

SCAN_TIMEOUT = 5.0
CONNECT_TIMEOUT = 15.0
# Keep scanning this long on the first search — pairing-mode advertising is
# easy to miss with a single short window
INITIAL_SCAN_WINDOW = 30.0
# After an unexpected drop, keep trying to reconnect for this long
RECONNECT_WINDOW = 60.0

Expand Down Expand Up @@ -540,9 +543,23 @@ async def _session(self, address, name, reconnected=False):
except Exception as e:
log.warning("BLE disconnect error: %s", e)

@staticmethod
def _scan_error_message(exc):
"""Turn a bleak scan failure into an actionable message."""
text = str(exc).lower()
if "unauthorized" in text or "not authorized" in text or "denied" in text:
return (
"macOS refused Bluetooth access. Grant it in System Settings → "
"Privacy & Security → Bluetooth.\nWhen running from source, the "
"permission belongs to Terminal (or your Python), not the app."
)
if "turned off" in text or "powered off" in text:
return "Bluetooth is turned off. Enable it in Control Center and retry."
return f"Bluetooth scan failed: {exc}"

async def _connect_async(self):
was_connected = False
deadline = None
deadline = time.monotonic() + INITIAL_SCAN_WINDOW
try:
while not self._stop_event.is_set():
self.is_searching = True
Expand All @@ -551,16 +568,15 @@ async def _connect_async(self):
address, name = await self._find_controller()
except Exception as e:
log.exception("BLE scan failed")
self.last_error = f"Bluetooth scan failed: {e}"
self.last_error = self._scan_error_message(e)
return
finally:
self.is_searching = False

if self._stop_event.is_set():
return

streamed = False
if address:
self.is_searching = False
streamed = await self._session(
address, name, reconnected=was_connected
)
Expand All @@ -578,22 +594,28 @@ async def _connect_async(self):
log.info("connection dropped, entering reconnect loop")
continue

if not was_connected:
# First attempt failed outright — surface and stop.
if not address:
self.last_error = (
"Controller not found. Make sure it's on and in pairing mode."
)
if address and not was_connected:
# Found it but couldn't connect — surface and stop.
return

if deadline is not None and time.monotonic() > deadline:
self.last_error = "Could not reconnect to the controller."
if time.monotonic() > deadline:
self.last_error = (
"Could not reconnect to the controller."
if was_connected else
"Controller not found after 30 s. Hold the small pair "
"button on the back until the LEDs sweep, while the "
"search is running.\nNote: the controller will never "
"appear in System Settings → Bluetooth — watch the "
"menubar instead."
)
return

# keep is_searching set during the pause so the UI doesn't flicker
await asyncio.sleep(1.0)
except asyncio.CancelledError:
log.info("bridge task cancelled")
finally:
self.is_searching = False
self.is_reconnecting = False

# --- public API ---
Expand Down Expand Up @@ -814,6 +836,8 @@ def _tick(self, _):
def _on_action(self, _):
state = self._current_state()
if state == 'idle':
if not self._check_bluetooth():
return
self.bridge.connect()
elif state != 'stopping':
self.bridge.disconnect()
Expand Down Expand Up @@ -973,6 +997,41 @@ def _check_accessibility(self):
"?Privacy_Accessibility",
])

def _check_bluetooth(self):
"""Return False (and explain) when Bluetooth permission is denied.

CBManagerAuthorization: 0 not determined (prompt will appear on first
scan), 1 restricted, 2 denied, 3 allowed.
"""
try:
from CoreBluetooth import CBManager # ships with bleak's backend
auth = int(CBManager.authorization())
except Exception:
return True # can't check — let the scan surface any error
if auth in (1, 2):
log.warning("Bluetooth permission denied (auth=%s)", auth)
clicked_ok = rumps.alert(
title="Bluetooth Permission Required",
message=(
f"macOS is blocking Bluetooth access for {APP_NAME}.\n\n"
"Grant it in:\n"
"System Settings → Privacy & Security → Bluetooth\n\n"
"⚠️ When running from source, the permission belongs to "
"Terminal (or your Python interpreter) — enable that entry, "
"then relaunch."
),
ok="Open System Settings",
cancel="Later",
)
if clicked_ok == 1:
subprocess.Popen([
"open",
"x-apple.systempreferences:com.apple.preference.security"
"?Privacy_Bluetooth",
])
return False
return True

def _surface_mappings_messages(self):
if self.mappings.last_error:
err = self.mappings.last_error
Expand Down
26 changes: 25 additions & 1 deletion tests/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,18 @@ def packet(b2=0, b3=0, b4=0, lx=2048, ly=2048, rx=2048, ry=2048):
class MockScanner:
delay = 0.3
result = {}
queue = [] # optional per-round results, popped first
@staticmethod
async def discover(timeout=None, return_adv=False):
await asyncio.sleep(MockScanner.delay)
if MockScanner.queue:
return MockScanner.queue.pop(0)
return MockScanner.result

S2B.BleakScanner = MockScanner

# not found path
# not found path (shrink the 30 s initial window for the test)
S2B.INITIAL_SCAN_WINDOW = 0.0
br3 = S2B.ControllerBridge(mm)
br3.connect()
br3._thread.join(3)
Expand Down Expand Up @@ -251,6 +255,26 @@ class Dev:
check("no error after user disconnect", br5.last_error is None, br5.last_error)
check("client disconnected", not MockClient.instances[-1].is_connected)

# initial search keeps scanning: nothing on round 1, found on round 2
print("== initial scan retry ==")
S2B.INITIAL_SCAN_WINDOW = 10.0
MockScanner.queue = [{}]
br6 = S2B.ControllerBridge(mm)
br6.connect()
time.sleep(2.0) # scan (0.05) + 1 s pause + scan + connect
check("found on second scan round", br6.is_connected)
check("no error during retry", br6.last_error is None, br6.last_error)
br6.disconnect(wait=True, timeout=3.0)

# scan error -> actionable messages
print("== scan error messages ==")
msg = S2B.ControllerBridge._scan_error_message(Exception("CBCentralManager is not authorized"))
check("unauthorized -> permission hint", "Privacy & Security" in msg and "Terminal" in msg, msg)
msg = S2B.ControllerBridge._scan_error_message(Exception("Bluetooth device is turned off"))
check("powered off -> enable hint", "turned off" in msg, msg)
msg = S2B.ControllerBridge._scan_error_message(Exception("boom"))
check("generic passthrough", "boom" in msg, msg)

print()
if FAILURES:
print(f"FAILED: {len(FAILURES)} -> {FAILURES}")
Expand Down
Loading