From 3a09170126638dd395378a2fc8e50f883c0ed073 Mon Sep 17 00:00:00 2001 From: Jamieson Pryor Date: Sun, 6 Sep 2026 22:04:32 -0700 Subject: [PATCH] Fix config flag input. --- README.md | 34 ++++++++++++++- Switch2Bridge.py | 99 +++++++++++++++++++++++++++++++++++++------- tests/test_bridge.py | 81 ++++++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 16 deletions(-) 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}")