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
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,40 @@ Each value is either a single character (`"a"`, `"5"`, `"."`), `null` to leave a

The Switch 2's new **C button** is supported as `"C"` (unmapped by default — set it to any key to use it).

The `"ble"` section holds one advanced setting, `input_char`: leave it `null` to auto-detect the controller's input characteristic (the bridge fills it in itself once detected), or set a 128-bit UUID to force one. See [BLE Characteristics](#ble-characteristics).
The `"ble"` section holds two advanced settings:
- `input_char`: leave it `null` to auto-detect the controller's input characteristic (the bridge fills it in itself once detected), or set a 128-bit UUID to force one. See [BLE Characteristics](#ble-characteristics).
- `address`: leave it `null` to connect to any available Switch 2 Pro Controller, or set a Bluetooth address/UUID to pin this instance to a specific controller (useful for multi-controller setups).

Invalid JSON falls back to defaults and the menubar surfaces the parse error. Unknown button names or stick directions (typos) are reported via a notification instead of being silently ignored. Two inputs may share the same key: the key is only released once both are released.

### Multi-controller and multiple instances

To run multiple controllers simultaneously, launch separate instances of the app with `--config` pointing to different mapping files:

- **From a bundled .app:**
```bash
open -n -a "Switch2 Bridge" --args --config ~/Library/Application\ Support/Switch2Bridge/pad2.json
```
- **From source:**
```bash
python Switch2Bridge.py --config ~/Library/Application\ Support/Switch2Bridge/pad2.json
```

If the specified config file does not exist, it is created with defaults.

> [!IMPORTANT]
> **Avoid port and controller collisions when running multiple instances:**
> 1. **Per-instance DSU ports:** Every fresh configuration file defaults to DSU port `26760`. Two instances cannot bind to the same UDP port. Edit the second configuration file to use another port (e.g., `"port": 26761`) or disable DSU (`"enabled": false`) on that instance.
> 2. **Controller assignment (`ble.address`):** By default, an instance connects to the first controller it discovers. To deterministically bind each instance to its own gamepad, pin each controller's Bluetooth address/UUID in its config file:
> ```json
> "ble": {
> "input_char": null,
> "address": "B9EA5233-37EF-4DD6-8A31-9EEAE20F78F8"
> }
> ```
> The controller's address is printed in the connection log (`connected to Switch 2 Pro Controller @ <address>`).
> 3. **Per-instance logs:** Each instance names its log file after the configuration file (e.g. `pad2.log`), so instances do not interfere with each other's logs.

## 🕹️ DSU server — true analog sticks (no driver)

The app runs a [cemuhook/DSU](https://github.com/v1993/cemuhook-protocol) server (default `127.0.0.1:26760`), which exposes the controller as a full gamepad over UDP — **analog sticks included**, bypassing the keyboard bridge's 8-direction limitation.
Expand Down Expand Up @@ -229,7 +259,7 @@ Every connection also logs the full GATT table (`GATT: N characteristic(s): …`
- **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.
- **"Characteristic … was not found" / "no readable input characteristic"** — the controller connected but its input-report characteristic isn't where the bridge expects it. Since v1.2.4 the bridge probes the alternatives automatically and remembers what worked, so retry once — and move the sticks while it says it is identifying the controller, in case that revision only reports on change. If it still gives up, the log now contains a `GATT:` line listing every service and characteristic of your controller — attach it to an issue. You can also pin a UUID yourself: `"ble": { "input_char": "…" }` in `mappings.json`.
- **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.
- **Logs** — written to `~/Library/Logs/Switch2Bridge/bridge.log` (or `<config_name>.log` when running with `--config`). Open a terminal and `tail -f` it to watch what's happening in real time.

## 🚧 Limitations

Expand Down
99 changes: 85 additions & 14 deletions Switch2Bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
License: MIT
"""

import argparse
import asyncio
import ipaddress
import json
Expand Down Expand Up @@ -135,18 +136,37 @@
MAPPINGS_FILE = CONFIG_DIR / "mappings.json"

LOG_DIR = Path.home() / "Library" / "Logs" / "Switch2Bridge"
log = logging.getLogger(__name__)
LOG_FILE = LOG_DIR / "bridge.log"
_log_handler = None


def setup_logging():
def setup_logging(config_path=None):
"""Log to ~/Library/Logs — called at startup, not at import, so importing
the module (tests, tools) has no filesystem side effects."""
the module (tests, tools) has no filesystem side effects.
Configure rotating log file handler, named after the config file if specified."""
global _log_handler, LOG_FILE
LOG_DIR.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
LOG_DIR / "bridge.log", maxBytes=1_000_000, backupCount=2
if config_path:
stem = Path(config_path).stem
log_name = f"{stem}.log" if stem != "mappings" else "bridge.log"
else:
log_name = "bridge.log"
LOG_FILE = LOG_DIR / log_name

root = logging.getLogger()
if _log_handler is not None and _log_handler in root.handlers:
root.removeHandler(_log_handler)
_log_handler.close()
_log_handler = RotatingFileHandler(
LOG_FILE, maxBytes=1_000_000, backupCount=2
)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[handler])
_log_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
root.addHandler(_log_handler)
root.setLevel(logging.INFO)
return LOG_FILE


log = logging.getLogger(__name__)


# ============================================================
Expand Down Expand Up @@ -286,10 +306,14 @@ class Mappings:
"host": "127.0.0.1",
"port": 26760,
},
# input_char: null = auto-detect. Set a 128-bit UUID to force the
# input-report characteristic of an unusual controller revision.
# ble:
# input_char: null = auto-detect. Set a 128-bit UUID to force the
# input-report characteristic of an unusual controller revision.
# address: null = any Switch 2 Pro Controller. Set a BLE address /
# UUID to pin this instance to a specific controller.
"ble": {
"input_char": None,
"address": None,
},
}

Expand Down Expand Up @@ -323,6 +347,7 @@ def __init__(self):
self.dsu_host = "127.0.0.1"
self.dsu_port = 26760
self.input_char = None # None = auto-detect the input characteristic
self.ble_address = None # None = connect to any matching controller
# Consumed by the UI tick: error → alert, warning → notification
self.last_error = None
self.last_warning = None
Expand All @@ -331,7 +356,7 @@ def __init__(self):

@classmethod
def ensure_default_file(cls):
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
MAPPINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
if not MAPPINGS_FILE.exists():
cls._write_json(cls.DEFAULT)
log.info("wrote default mappings to %s", MAPPINGS_FILE)
Expand Down Expand Up @@ -459,6 +484,7 @@ def _apply(self, cfg):
if not isinstance(ble, dict):
raise ValueError('"ble" must be an object')
self.input_char = self._parse_uuid(ble.get("input_char"), warnings)
self.ble_address = self._parse_address(ble.get("address"), warnings)

if warnings:
self.last_warning = "\n".join(warnings)
Expand All @@ -473,6 +499,11 @@ def set_input_char(self, uuid):
self.input_char = uuid
self._persist("ble", "input_char", uuid, "input characteristic")

def set_address(self, address):
"""Pin a specific controller address (best effort)."""
self.ble_address = address
self._persist("ble", "address", address, "BLE address")

def _persist(self, section, key, value, what):
"""Write one setting back into mappings.json without touching the rest."""
try:
Expand Down Expand Up @@ -502,6 +533,19 @@ def _is_loopback(host):
except ValueError:
return False # a hostname: can't tell, so warn

@classmethod
def _parse_address(cls, value, warnings):
"""None/empty means auto-detect; non-string warned about."""
if value is None or value == "":
return None
if not isinstance(value, str):
warnings.append(
f'"ble.address" must be a string or null, ignoring: {value!r}'
)
return None
text = value.strip().lower()
return text or None

@classmethod
def _parse_uuid(cls, value, warnings):
"""None/empty means auto-detect; anything not a UUID is warned about."""
Expand Down Expand Up @@ -744,14 +788,17 @@ async def _find_controller(self, timeout=SCAN_TIMEOUT):
"""Returns (address, name) or (None, None)."""
devices = await BleakScanner.discover(timeout=timeout, return_adv=True)
seen = []
target_addr = (self.mappings.ble_address or "").strip().lower()
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}]"
f"{device.name or '?'} [{address}] rssi={getattr(adv, 'rssi', '?')} [{mfr}]"
)
if target_addr and address.lower() != target_addr:
continue
# Primary: Nintendo company ID is the dict key in bleak's manufacturer_data
for cid in NINTENDO_COMPANY_IDS:
payload = adv.manufacturer_data.get(cid)
Expand Down Expand Up @@ -891,7 +938,7 @@ async def _resolve_input_char(self, client):
"Connected, but no characteristic streamed readable input.\n"
"Retry once while moving the sticks — some revisions only report "
"on change.\nIf it keeps failing, the controller's BLE services "
"were listed in ~/Library/Logs/Switch2Bridge/bridge.log: please "
f"were listed in {LOG_FILE}: please "
f"attach that log to a report at {ISSUES_URL}."
)
return None
Expand Down Expand Up @@ -1550,11 +1597,35 @@ def _notify(self, subtitle, message):
# MAIN
# ============================================================

def _parse_args(argv):
parser = argparse.ArgumentParser(
prog="Switch2Bridge",
description=f"{APP_NAME} — map a Switch 2 Pro Controller to keyboard + DSU.",
)
parser.add_argument(
"--config",
metavar="PATH",
help="Path to the mappings JSON to load instead of the default "
f"({MAPPINGS_FILE}). Created with defaults if it does not exist.",
)
# macOS launcher / py2app passes -psn_0_XXXXXX (Process Serial Number).
# Keep -psn_* out while rejecting any other unknown arguments or typos.
clean_argv = [a for a in argv if not (a.startswith("-psn_") or a == "-psn")]
return parser.parse_args(clean_argv)


if __name__ == "__main__":
_args = _parse_args(sys.argv[1:])
if _args.config:
MAPPINGS_FILE = Path(_args.config).expanduser()
CONFIG_DIR = MAPPINGS_FILE.parent
setup_logging(MAPPINGS_FILE)
else:
setup_logging()

print(f"\n🎮 {APP_NAME}")
print(" App is running in the menu bar.")
print(f" Mappings: {MAPPINGS_FILE}")
print(f" Logs: {LOG_DIR / 'bridge.log'}\n")
setup_logging()
print(f" Logs: {LOG_FILE}\n")
log.info("starting %s", APP_NAME)
Switch2BridgeApp().run()
81 changes: 81 additions & 0 deletions tests/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,50 @@ async def run():
check("input_char persisted", saved["ble"]["input_char"] == ALT_INPUT_CHAR, saved.get("ble"))
check("persist keeps other sections", saved["buttons"]["A"] == "z" and "dsu" in saved)

# ============ ble.address config ============
print("== ble.address config ==")
warns_addr = []
check("null address -> None", M._parse_address(None, warns_addr) is None)
check("empty address -> None", M._parse_address("", warns_addr) is None)
check("address normalized",
M._parse_address(" E2C56DB5-DFFB-48D2-B060-D0F5A71096E0 ", warns_addr) == "e2c56db5-dffb-48d2-b060-d0f5a71096e0")
check("invalid address type warned",
M._parse_address(12345, warns_addr) is None and any("ble.address" in w for w in warns_addr))

mcfg_addr = M()
cfg_addr = json.loads(json.dumps(M.DEFAULT))
cfg_addr["ble"]["address"] = "pad-3-addr"
mcfg_addr._apply(cfg_addr)
check("config address pin applied", mcfg_addr.ble_address == "pad-3-addr")

S2B.MAPPINGS_FILE.write_text(json.dumps(M.DEFAULT))
mcfg_addr.set_address("pad-3-addr")
saved_addr = json.loads(S2B.MAPPINGS_FILE.read_text())
check("address persisted", saved_addr["ble"]["address"] == "pad-3-addr")

# multi-controller discovery filter by ble.address
br_multi1 = S2B.ControllerBridge(mcfg_addr) # pinned to pad-3-addr
MockScanner.result = {
"pad-1-addr": (NoName(), AdvSIG()),
"pad-3-addr": (NoName(), AdvVID()),
}
a_multi, _ = asyncio.run(br_multi1._find_controller())
check("pinned address matched specifically", a_multi == "pad-3-addr")

MockScanner.result = {
"pad-1-addr": (NoName(), AdvSIG()),
"pad-2-addr": (NoName(), AdvVID()),
}
a_none, _ = asyncio.run(br_multi1._find_controller())
check("different address skipped", a_none is None)

# unpinned bridge matches first controller
mcfg_unpinned = M()
mcfg_unpinned._apply(json.loads(json.dumps(M.DEFAULT)))
br_unpinned = S2B.ControllerBridge(mcfg_unpinned)
a_first, _ = asyncio.run(br_unpinned._find_controller())
check("unpinned matches first controller", a_first == "pad-1-addr")

# failing reconnect attempts must not spam last_error; only the final
# give-up message surfaces
print("== reconnect error spam ==")
Expand Down Expand Up @@ -648,6 +692,43 @@ def _boom(*a, **k):
check("no logging handler installed at import",
not any(isinstance(h, S2B.RotatingFileHandler) for h in S2B.logging.getLogger().handlers))


# ============ CLI args and logging ============
print("== _parse_args and setup_logging ==")
args = S2B._parse_args(["--config", "custom.json"])
check("parse --config", args.config == "custom.json")

args_psn = S2B._parse_args(["--config", "custom.json", "-psn_0_12345"])
check("ignore -psn with config", args_psn.config == "custom.json")

args_psn_only = S2B._parse_args(["-psn_0_12345"])
check("ignore -psn only", args_psn_only.config is None)

try:
S2B._parse_args(["--conifg", "typo.json"])
check("typo flag rejected", False)
except SystemExit:
check("typo flag rejected", True)

try:
S2B._parse_args(["--unknown"])
check("unknown flag rejected", False)
except SystemExit:
check("unknown flag rejected", True)

# setup_logging
log_default = S2B.setup_logging()
check("default log is bridge.log", log_default == S2B.LOG_DIR / "bridge.log")

log_custom = S2B.setup_logging(Path("controller2.json"))
check("custom config log is controller2.log", log_custom == S2B.LOG_DIR / "controller2.log")

log_mappings = S2B.setup_logging(Path("mappings.json"))
check("mappings.json uses bridge.log", log_mappings == S2B.LOG_DIR / "bridge.log")

# restore default logging
S2B.setup_logging()

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