diff --git a/README.md b/README.md
index 31517e7..d6bc466 100644
--- a/README.md
+++ b/README.md
@@ -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 @
`).
+> 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.
@@ -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 `.log` when running with `--config`). Open a terminal and `tail -f` it to watch what's happening in real time.
## š§ Limitations
diff --git a/Switch2Bridge.py b/Switch2Bridge.py
index 70017d1..dc4e89a 100644
--- a/Switch2Bridge.py
+++ b/Switch2Bridge.py
@@ -10,6 +10,7 @@
License: MIT
"""
+import argparse
import asyncio
import ipaddress
import json
@@ -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__)
# ============================================================
@@ -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,
},
}
@@ -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
@@ -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)
@@ -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)
@@ -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:
@@ -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."""
@@ -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)
@@ -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
@@ -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()
diff --git a/tests/test_bridge.py b/tests/test_bridge.py
index ccaf7e1..3a9c10c 100644
--- a/tests/test_bridge.py
+++ b/tests/test_bridge.py
@@ -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 ==")
@@ -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}")