From d1ef4925d278ad8259f3ae8b9c27660583936897 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:41:00 +0000 Subject: [PATCH 1/5] Fix C/Capture swap, read factory stick calibration, hardening (v1.3.0) Protocol (sources cited in code and README): - Byte 4 of the input report: Capture is 0x02 and C is 0x10, not the reverse. Agreed by ndeadly/switch2_controller_research (hid_reports.md), esp-cpp/espp PR #765 (switch2_pro_report.hpp) and the hardware capture in issue #14. Also fixes Capture -> DSU Touch. - Read the factory stick calibration over the command channel (649d4ac9-...f005 / replies c765a961-...) and light player LED 1. Frame format, SPI addresses 0x130A8/0x130E8 and block layout adapted from kennethreitz/switch2bridge-macos@975f329 (issue #14, MIT), based on darthcloud/BlueRetro#1249; addresses match SDL's Switch 2 driver. Best effort: a silent controller keeps the nominal 2048 range. - README: 7492866c-...c0f8 is notify-only, document the command channel. Hardening: - mappings.json written atomically (temp file + os.replace) - "dsu.enabled" must be a boolean (bool("false") was True) - warn when the DSU server is bound to a non-loopback address - DSU socket: drop SO_REUSEADDR (useless for UDP; on Linux it let a second server silently share the port) - worker -> UI messages taken atomically; stick hysteresis read under lock - asyncio.get_running_loop(); logging set up at startup, not import Build: Python 3.10+ (bleak 3 requires it), major-version upper bounds, py2app moved to requirements-build.txt, CI matrix 3.10/3.12/3.13, DMG README explains the macOS 15+ Gatekeeper "Open Anyway" flow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SGsFjHBorX2mfsanZhFyzs --- .github/workflows/ci.yml | 9 +- README.md | 52 ++++-- Switch2Bridge.py | 344 +++++++++++++++++++++++++++++++++++---- build_dmg.sh | 12 +- dsu_server.py | 3 +- requirements-build.txt | 3 + requirements.txt | 10 +- tests/test_bridge.py | 168 ++++++++++++++++++- 8 files changed, 540 insertions(+), 61 deletions(-) create mode 100644 requirements-build.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0bbc22..d5e6ea0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,14 +8,19 @@ on: jobs: test: - name: Headless tests + name: Headless tests (Python ${{ matrix.python-version }}) runs-on: macos-14 + strategy: + fail-fast: false + matrix: + # oldest supported (bleak 3 needs 3.10+), the one the DMG ships, newest + python-version: ["3.10", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} cache: pip - name: Install dependencies diff --git a/README.md b/README.md index c00634f..31517e7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Python menubar app that connects to the Switch 2 Pro Controller via BLE and tr [![CI](https://github.com/mlstr0m/switch2bridge-macos/actions/workflows/ci.yml/badge.svg)](https://github.com/mlstr0m/switch2bridge-macos/actions/workflows/ci.yml) [![macOS](https://img.shields.io/badge/macOS-Ventura%2B-blue?logo=apple)](https://www.apple.com/macos) -[![Python](https://img.shields.io/badge/Python-3.9%2B-green?logo=python)](https://python.org) +[![Python](https://img.shields.io/badge/Python-3.10%2B-green?logo=python)](https://python.org) [![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE) --- @@ -24,11 +24,13 @@ A Python menubar app that connects to the Switch 2 Pro Controller via BLE and tr - ✅ **Full button mapping** — all buttons, triggers, D-pad working - ✅ **Analog sticks** — read with 12-bit precision (converted to 8 directions, see Limitations) +- ✅ **Factory stick calibration** — read from the controller at connect, so sticks reach full deflection without drift - ✅ **Grip buttons** — Switch 2 exclusive GL/GR buttons supported - ✅ **Ryujinx compatible** — keyboard bridge for emulator support - ✅ **No pairing required** — bypasses macOS Bluetooth limitations - ✅ **Auto-reconnect** — if the controller sleeps or drops, the bridge retries for 60 s -- ✅ **C button** — the Switch 2's new C button can be mapped (experimental) +- ✅ **C button** — the Switch 2's new C button can be mapped +- ✅ **Player LED** — player 1 light is set on connect (best effort) - ✅ **DSU server (cemuhook)** — true **analog sticks** in Dolphin, Cemu & other DSU clients, no driver needed - ✅ **Start at Login** — one click in the menubar (bundled .app, macOS 13+) @@ -47,7 +49,7 @@ This bridge connects via BLE using the `bleak` library, reads the raw input data ## 📋 Requirements - macOS Ventura (13.0) or later -- Python 3.9+ +- Python 3.10+ (bleak 3 requires it) - Nintendo Switch 2 Pro Controller ## 🔧 Run from source @@ -79,6 +81,7 @@ chmod +x build_dmg.sh The installer lands at `dist/Switch2Bridge-Installer.dmg`. Manual build: ```bash +pip install -r requirements-build.txt # adds py2app python setup_app.py py2app # → dist/Switch2 Bridge.app ``` @@ -157,9 +160,11 @@ switch2bridge-macos/ ├── dsu_server.py # DSU (cemuhook) server — analog output for emulators ├── setup_app.py # py2app configuration ├── build_dmg.sh # Automated build script (.app + DMG) -├── requirements.txt # Python dependencies +├── requirements.txt # Runtime dependencies +├── requirements-build.txt # + py2app, for the .app / DMG ├── tests/ -│ └── test_bridge.py # Headless tests (mappings, key dispatch, BLE lifecycle) +│ ├── test_bridge.py # Headless tests (mappings, key dispatch, BLE lifecycle, calibration) +│ └── test_dsu.py # DSU server over real UDP ├── AppIcon.icns # Application icon (used by py2app) ├── LICENSE └── README.md @@ -185,8 +190,29 @@ switch2bridge-macos/ | UUID | Purpose | |------|---------| -| `7492866c-ec3e-4619-8258-32755ffcc0f9` | Input reports (notifications) | -| `7492866c-ec3e-4619-8258-32755ffcc0f8` | Output (LED, rumble — not working) | +| `7492866c-ec3e-4619-8258-32755ffcc0f9` | Input reports (notifications) — what the bridge reads | +| `7492866c-ec3e-4619-8258-32755ffcc0f8` | Also notify-only (read, notify) — it can't be written, which is why LED/rumble never worked through it | +| `649d4ac9-8eb7-4e6c-af44-1ea54fe5f005` | Command channel (write without response) | +| `c765a961-d9d8-4d36-a20a-5315b111836a` | Command replies (notifications) | + +UUIDs from [ndeadly/switch2_controller_research](https://github.com/ndeadly/switch2_controller_research) (`bluetooth_interface.md`) and the hardware findings in [#14](https://github.com/mlstr0m/switch2bridge-macos/issues/14). + +### Input report + +| Byte | Content | +|------|---------| +| 2 | `0x01` B, `0x02` A, `0x04` Y, `0x08` X, `0x10` R, `0x20` ZR, `0x40` +, `0x80` RS | +| 3 | `0x01` Down, `0x02` Right, `0x04` Left, `0x08` Up, `0x10` L, `0x20` ZL, `0x40` −, `0x80` LS | +| 4 | `0x01` Home, `0x02` Capture, `0x04` GR, `0x08` GL, `0x10` C | +| 5–7 / 8–10 | Left / right stick, two packed 12-bit values each | + +Byte 4 follows ndeadly's `hid_reports.md`, [espp's Pro Controller 2 report](https://github.com/esp-cpp/espp/pull/765) and the capture in #14 — up to v1.2.4 the bridge had C and Capture swapped. + +### Stick calibration + +At connect the bridge reads the controller's factory calibration over the command channel (SPI reads of `0x130A8` for the left stick and `0x130E8` for the right, 9 bytes each: centre, +travel, −travel as packed 12-bit pairs), then lights the player 1 LED. Real travel is only ~1500–1770 counts rather than the nominal 2048, so without it a fully pushed stick tops out around 0.8 in DSU. If the controller doesn't answer, the bridge silently keeps the nominal range; the menubar shows *sticks calibrated* when it worked. + +Frame format and addresses come from [kennethreitz's fork](https://github.com/kennethreitz/switch2bridge-macos/blob/975f329592a4ef56bd8ac2947d4d148eaf808fe0/controller_commands.py) (issue #14, MIT), itself based on [BlueRetro #1249](https://github.com/darthcloud/BlueRetro/issues/1249); the addresses match [SDL's Switch 2 driver](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c). Not every controller exposes that input UUID (see [#15](https://github.com/mlstr0m/switch2bridge-macos/issues/15)), so the bridge treats it as a first guess only: @@ -210,10 +236,10 @@ Every connection also logs the full GATT table (`GATT: N characteristic(s): …` | Feature | Status | Notes | |---------|--------|-------| | Buttons | ✅ Working | All buttons mapped | -| C button | 🧪 Experimental | Parsed as byte 4, bit `0x02` — please report if it doesn't fire | -| Analog Sticks | ✅ Analog via DSU | Full 12-bit analog through the DSU server (Dolphin/Cemu). The keyboard bridge remains digital: thresholded (with hysteresis) to 8 directions (WASD/IJKL). | -| LED Control | ❌ Not working | Output characteristic doesn't respond | -| Rumble | ❌ Not working | Same issue | +| C button | ✅ Fixed in v1.3.0 | Byte 4, bit `0x10` (was wrongly read as `0x02`, i.e. Capture, up to v1.2.4) | +| Analog Sticks | ✅ Analog via DSU | Full 12-bit analog through the DSU server (Dolphin/Cemu), scaled with the controller's factory calibration. The keyboard bridge remains digital: thresholded (with hysteresis) to 8 directions (WASD/IJKL). | +| LED Control | 🧪 Player 1 only | Set once on connect through the command channel | +| Rumble | ❌ Not implemented | Goes through the command/vibration channels, not `…c0f8` | | Motion/Gyro | ⚠️ Plumbing ready | DSU motion fields are sent (as zeros) — the gyro bytes in the BLE report are not decoded yet | | Native HID | ❌ Not possible | Would require DriverKit (kernel-level) | @@ -221,7 +247,7 @@ Every connection also logs the full GATT table (`GATT: N characteristic(s): …` Contributions welcome! Areas that need work: -1. **LED/Rumble** — figure out the output protocol (likely a Joy-Con-style handshake) +1. **Rumble** — the command channel is now used for calibration and LEDs; rumble is the next step (see #14) 2. **Motion controls** — decode gyro/accelerometer data 3. **True analog** — virtual HID device via DriverKit 4. **Cross-platform** — Linux/Windows ports @@ -232,6 +258,8 @@ Contributions welcome! Areas that need work: - **Claude (Anthropic)** — development assistance - Inspired by [SPro2Win](https://github.com/SquareDonut1/SPro2Win) (Windows) - Protocol reference from [Nintendo Switch Reverse Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering) +- Switch 2 BLE protocol: [ndeadly/switch2_controller_research](https://github.com/ndeadly/switch2_controller_research), [darthcloud & german77 (BlueRetro #1249)](https://github.com/darthcloud/BlueRetro/issues/1249) +- C/Capture fix, stick calibration and command channel findings: [issue #14](https://github.com/mlstr0m/switch2bridge-macos/issues/14) and [kennethreitz's fork](https://github.com/kennethreitz/switch2bridge-macos/tree/real-gamepad-output) ## 📄 License diff --git a/Switch2Bridge.py b/Switch2Bridge.py index 2cb9ca8..70017d1 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -11,11 +11,14 @@ """ import asyncio +import ipaddress import json import logging +import os import re import subprocess import sys +import tempfile import threading import time from logging.handlers import RotatingFileHandler @@ -67,7 +70,7 @@ # ============================================================ APP_NAME = "Switch2 Bridge" -APP_VERSION = "1.2.4" # single source of truth — read by setup_app.py & build_dmg.sh +APP_VERSION = "1.3.0" # single source of truth — read by setup_app.py & build_dmg.sh INPUT_CHAR_UUID = "7492866c-ec3e-4619-8258-32755ffcc0f9" # Not every controller revision exposes that UUID (issue #15), so it is only the @@ -85,6 +88,34 @@ ISSUES_URL = "https://github.com/mlstr0m/switch2bridge-macos/issues" +# Command channel (stick calibration read, player LEDs). The characteristic +# the README used to call "output" (…c0f8) is notify-only; commands go here. +# UUIDs: https://github.com/ndeadly/switch2_controller_research +# (bluetooth_interface.md) +# Frame format, SPI read and player-light commands, factory calibration +# addresses and block layout adapted from the fork described in +# https://github.com/mlstr0m/switch2bridge-macos/issues/14 — +# https://github.com/kennethreitz/switch2bridge-macos/blob/975f329592a4ef56bd8ac2947d4d148eaf808fe0/controller_commands.py +# (MIT), itself based on https://github.com/darthcloud/BlueRetro/issues/1249. +# The addresses match SDL's driver (0x13080 + 0x28, 0x130C0 + 0x28): +# https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c +COMMAND_CHAR_UUID = "649d4ac9-8eb7-4e6c-af44-1ea54fe5f005" +RESPONSE_CHAR_UUID = "c765a961-d9d8-4d36-a20a-5315b111836a" +CMD_FRAME_LEN = 16 +CMD_REQUEST = 0x91 +REPORT_SPI, CMD_SPI_READ = 0x02, 0x04 +REPORT_PLAYER_LIGHTS, CMD_SET_PLAYER_LIGHTS = 0x09, 0x07 +FACTORY_STICK_LEFT = 0x000130A8 +FACTORY_STICK_RIGHT = 0x000130E8 +STICK_BLOCK_LEN = 9 +COMMAND_REPLY_TIMEOUT = 0.8 # per SPI read +PLAYER_LIGHT_PATTERN = 0x01 # one bit per LED: player 1 + +# Sticks report 12-bit values; without a factory calibration we keep the +# nominal centre/half-range the bridge always used. +STICK_RAW_CENTER = 2048 +STICK_RAW_HALF_RANGE = 2048 + # 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) @@ -104,15 +135,124 @@ MAPPINGS_FILE = CONFIG_DIR / "mappings.json" LOG_DIR = Path.home() / "Library" / "Logs" / "Switch2Bridge" -LOG_DIR.mkdir(parents=True, exist_ok=True) -_log_handler = RotatingFileHandler( - LOG_DIR / "bridge.log", maxBytes=1_000_000, backupCount=2 -) -_log_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) -logging.basicConfig(level=logging.INFO, handlers=[_log_handler]) log = logging.getLogger(__name__) +def setup_logging(): + """Log to ~/Library/Logs — called at startup, not at import, so importing + the module (tests, tools) has no filesystem side effects.""" + LOG_DIR.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + LOG_DIR / "bridge.log", maxBytes=1_000_000, backupCount=2 + ) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + logging.basicConfig(level=logging.INFO, handlers=[handler]) + + +# ============================================================ +# CONTROLLER PROTOCOL — command frames + stick calibration +# (sources: see COMMAND_CHAR_UUID above) +# ============================================================ + +def _command_frame(report_type, command, payload=b""): + """ 0x91 0x00 0x00 0x08 0x00 0x00 , 16 bytes.""" + head = bytes([report_type, CMD_REQUEST, 0x00, command, 0x00, 0x08, 0x00, 0x00]) + return (head + payload).ljust(CMD_FRAME_LEN, b"\x00") + + +def spi_read_command(address, length): + payload = bytes([length, 0x7E, 0x00, 0x00]) + address.to_bytes(4, "little") + return _command_frame(REPORT_SPI, CMD_SPI_READ, payload) + + +def player_lights_command(pattern): + return _command_frame( + REPORT_PLAYER_LIGHTS, CMD_SET_PLAYER_LIGHTS, bytes([pattern & 0x0F]) + ) + + +def parse_spi_reply(reply): + """(address, data) for an SPI read reply, else None. + + The result byte (reply[5]) differs between transports/firmwares (0xF8 or + 0x78), so replies are matched on the echoed address instead. + """ + if len(reply) < CMD_FRAME_LEN or reply[0] != REPORT_SPI or reply[3] != CMD_SPI_READ: + return None + length = reply[8] + address = int.from_bytes(reply[12:16], "little") + return address, bytes(reply[CMD_FRAME_LEN:CMD_FRAME_LEN + length]) + + +def unpack_12bit_pair(chunk): + """Three bytes -> two 12-bit values (the packing the stick reports use).""" + return (chunk[0] | ((chunk[1] & 0x0F) << 8), + ((chunk[1] & 0xF0) >> 4) | (chunk[2] << 4)) + + +def decode_stick_block(data): + """Factory calibration block -> (centre, +travel, -travel) as (x, y) pairs. + + Returns None when the block is missing, erased (all 0xFF) or implausible, + so a controller without stored calibration keeps the defaults. + """ + if data is None or len(data) < STICK_BLOCK_LEN: + return None + block = data[:STICK_BLOCK_LEN] + if all(b == 0xFF for b in block): + return None + centre = unpack_12bit_pair(block[0:3]) + travel_pos = unpack_12bit_pair(block[3:6]) + travel_neg = unpack_12bit_pair(block[6:9]) + if not all(1200 < c < 2900 for c in centre): + return None + if not all(600 < t <= 2048 for t in travel_pos + travel_neg): + return None + return centre, travel_pos, travel_neg + + +class StickCalibration: + """Per-axis centre and per-direction travel (travel is asymmetric).""" + + AXES = ("lx", "ly", "rx", "ry") + + def __init__(self): + self.reset() + + def reset(self): + self.centers = {a: STICK_RAW_CENTER for a in self.AXES} + self.travel_pos = {a: STICK_RAW_HALF_RANGE for a in self.AXES} + self.travel_neg = {a: STICK_RAW_HALF_RANGE for a in self.AXES} + self.from_factory = False + + def apply_factory(self, left, right): + """Adopt decoded blocks (None = keep defaults for that stick).""" + applied = [] + for side, block in (("l", left), ("r", right)): + if block is None: + continue + centre, travel_pos, travel_neg = block + for i, coord in enumerate(("x", "y")): + axis = side + coord + self.centers[axis] = centre[i] + self.travel_pos[axis] = travel_pos[i] + self.travel_neg[axis] = travel_neg[i] + applied.append(axis) + if applied: + self.from_factory = True + log.info("factory stick calibration applied: %s", ", ".join( + f"{a}=c{self.centers[a]}/+{self.travel_pos[a]}/-{self.travel_neg[a]}" + for a in applied + )) + return bool(applied) + + def value(self, axis, raw): + """Raw 12-bit reading -> [-1.0, 1.0].""" + delta = raw - self.centers[axis] + span = self.travel_pos[axis] if delta >= 0 else self.travel_neg[axis] + return max(-1.0, min(1.0, delta / float(span))) + + # ============================================================ # MAPPINGS — load/save user-editable JSON # ============================================================ @@ -193,10 +333,26 @@ def __init__(self): def ensure_default_file(cls): CONFIG_DIR.mkdir(parents=True, exist_ok=True) if not MAPPINGS_FILE.exists(): - with open(MAPPINGS_FILE, "w") as f: - json.dump(cls.DEFAULT, f, indent=2) + cls._write_json(cls.DEFAULT) log.info("wrote default mappings to %s", MAPPINGS_FILE) + @staticmethod + def _write_json(cfg): + """Atomic write: a crash mid-write can never leave a truncated file.""" + fd, tmp = tempfile.mkstemp( + dir=MAPPINGS_FILE.parent, prefix=".mappings-", suffix=".json" + ) + try: + with os.fdopen(fd, "w") as f: + json.dump(cfg, f, indent=2) + os.replace(tmp, MAPPINGS_FILE) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + def load(self): """Load mappings from disk, falling back to defaults on error. @@ -276,8 +432,20 @@ def _apply(self, cfg): dsu = cfg.get("dsu", {}) if not isinstance(dsu, dict): raise ValueError('"dsu" must be an object') - self.dsu_enabled = bool(dsu.get("enabled", True)) + enabled = dsu.get("enabled", True) + if not isinstance(enabled, bool): + # bool("false") is True — never guess, fall back to the default + warnings.append( + f'"dsu.enabled" must be true or false, got {enabled!r}; using true' + ) + enabled = True + self.dsu_enabled = enabled self.dsu_host = str(dsu.get("host", "127.0.0.1")) + if not self._is_loopback(self.dsu_host): + warnings.append( + f"DSU server listens on {self.dsu_host}: other machines on the " + "network can read the controller. Use 127.0.0.1 unless intended." + ) try: port = int(dsu.get("port", 26760)) except (TypeError, ValueError): @@ -321,11 +489,19 @@ def _persist(self, section, key, value, what): target = cfg[section] = {} target[key] = value try: - with open(MAPPINGS_FILE, "w") as f: - json.dump(cfg, f, indent=2) + self._write_json(cfg) except Exception: log.exception("could not write mappings.json to persist %s", what) + @staticmethod + def _is_loopback(host): + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False # a hostname: can't tell, so warn + @classmethod def _parse_uuid(cls, value, warnings): """None/empty means auto-detect; anything not a UUID is warned about.""" @@ -373,13 +549,17 @@ def __init__(self, mappings: Mappings, dsu: DSUServer = None): self.is_reconnecting = False self.controller_name = None self.packet_count = 0 - # Set by worker, read & cleared by the main-thread UI tick - self.last_error = None - self.last_notice = None + self.calibration = StickCalibration() + # Set by worker, consumed by the main-thread UI tick via take_*(); + # the lock makes read-and-clear atomic so no message is lost. + self._msg_lock = threading.Lock() + self._last_error = None + self._last_notice = None # Key state: which key each input source holds, and how many sources # hold each key (two buttons mapped to the same key must not release - # it while one of them is still down). - self._key_lock = threading.Lock() + # it while one of them is still down). Reentrant: _set_stick_key + # holds it across its read of _source_keys and the _set_key call. + self._key_lock = threading.RLock() self._source_keys = {} # source name -> key currently held self._key_refs = {} # key -> number of sources holding it self._client = None @@ -388,6 +568,36 @@ def __init__(self, mappings: Mappings, dsu: DSUServer = None): self._loop = None self._task = None + # --- worker -> UI messages --- + + @property + def last_error(self): + return self._last_error + + @last_error.setter + def last_error(self, value): + with self._msg_lock: + self._last_error = value + + @property + def last_notice(self): + return self._last_notice + + @last_notice.setter + def last_notice(self, value): + with self._msg_lock: + self._last_notice = value + + def take_error(self): + with self._msg_lock: + value, self._last_error = self._last_error, None + return value + + def take_notice(self): + with self._msg_lock: + value, self._last_notice = self._last_notice, None + return value + # --- key dispatch --- def _press_ref(self, key): @@ -443,8 +653,9 @@ def _set_stick_key(self, source, key, value): Avoids key chatter when the stick hovers right at the threshold. """ t = self.mappings.stick_threshold - held = source in self._source_keys - self._set_key(source, key, value > (t * 0.8 if held else t)) + with self._key_lock: + held = source in self._source_keys + self._set_key(source, key, value > (t * 0.8 if held else t)) # --- BLE input parser --- @@ -477,12 +688,15 @@ def _on_data(self, sender, data: bytes): self._set_key('-', b.get('-'), b3 & 0x40) self._set_key('LS', b.get('LS'), b3 & 0x80) - # special (byte 4) — 0x02 is believed to be the new C button + # special (byte 4): 0x01 Home, 0x02 Capture, 0x04 GR, 0x08 GL, 0x10 C + # Sources (all agree): ndeadly/switch2_controller_research + # hid_reports.md; esp-cpp/espp PR #765 switch2_pro_report.hpp; + # hardware capture in mlstr0m/switch2bridge-macos issue #14. self._set_key('HOME', b.get('HOME'), b4 & 0x01) - self._set_key('C', b.get('C'), b4 & 0x02) + self._set_key('CAPT', b.get('CAPT'), b4 & 0x02) self._set_key('GR', b.get('GR'), b4 & 0x04) self._set_key('GL', b.get('GL'), b4 & 0x08) - self._set_key('CAPT', b.get('CAPT'), b4 & 0x10) + self._set_key('C', b.get('C'), b4 & 0x10) # sticks: 12-bit packed across bytes 5-10 lx_raw = data[5] | ((data[6] & 0x0F) << 8) @@ -490,10 +704,12 @@ def _on_data(self, sender, data: bytes): rx_raw = data[8] | ((data[9] & 0x0F) << 8) ry_raw = ((data[9] & 0xF0) >> 4) | (data[10] << 4) - lx = (lx_raw - 2048) / 2048.0 - ly = (ly_raw - 2048) / 2048.0 - rx = (rx_raw - 2048) / 2048.0 - ry = (ry_raw - 2048) / 2048.0 + # factory calibration when it could be read, nominal range otherwise + cal = self.calibration + lx = cal.value('lx', lx_raw) + ly = cal.value('ly', ly_raw) + rx = cal.value('rx', rx_raw) + ry = cal.value('ry', ry_raw) ls = self.mappings.left_stick rs = self.mappings.right_stick @@ -517,7 +733,7 @@ def _on_data(self, sender, data: bytes): 'DDOWN': b3 & 0x01, 'DRIGHT': b3 & 0x02, 'DLEFT': b3 & 0x04, 'DUP': b3 & 0x08, 'L': b3 & 0x10, 'ZL': b3 & 0x20, '-': b3 & 0x40, 'LS': b3 & 0x80, - 'HOME': b4 & 0x01, 'CAPT': b4 & 0x10, + 'HOME': b4 & 0x01, 'CAPT': b4 & 0x02, }, lx, ly, rx, ry, ) @@ -604,7 +820,7 @@ def _probe_candidates(cls, client): async def _probe_input_char(self, client, uuid): """Subscribe briefly: True if it streams reports we can decode.""" - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() got_report = asyncio.Event() def _sniff(_sender, data): @@ -680,6 +896,62 @@ async def _resolve_input_char(self, client): ) return None + # --- command channel: calibration + player LED --- + + async def _configure_controller(self, client): + """Read the factory stick calibration and light player LED 1. + + Best effort: a controller that doesn't answer keeps the nominal + stick range, and nothing here can fail the connection. + """ + replies = asyncio.Queue() + + def on_reply(_sender, payload): + replies.put_nowait(bytes(payload)) + + try: + await client.start_notify(RESPONSE_CHAR_UUID, on_reply) + except Exception as e: + log.info("command channel unavailable (%s); nominal stick range", e) + return + + async def spi_read(address, length): + while not replies.empty(): + replies.get_nowait() + await client.write_gatt_char( + COMMAND_CHAR_UUID, spi_read_command(address, length), response=False + ) + for _ in range(3): # only the reply echoing our address counts + try: + reply = await asyncio.wait_for( + replies.get(), COMMAND_REPLY_TIMEOUT + ) + except asyncio.TimeoutError: + return None + parsed = parse_spi_reply(reply) + if parsed and parsed[0] == address: + return parsed[1] + return None + + try: + left = decode_stick_block( + await spi_read(FACTORY_STICK_LEFT, STICK_BLOCK_LEN)) + right = decode_stick_block( + await spi_read(FACTORY_STICK_RIGHT, STICK_BLOCK_LEN)) + if not self.calibration.apply_factory(left, right): + log.info("no usable factory stick calibration; nominal range") + await client.write_gatt_char( + COMMAND_CHAR_UUID, player_lights_command(PLAYER_LIGHT_PATTERN), + response=False, + ) + except Exception: # CancelledError is a BaseException: still propagates + log.exception("controller configuration failed; continuing") + finally: + try: + await client.stop_notify(RESPONSE_CHAR_UUID) + except Exception: + pass + # --- main async routine --- async def _session(self, address, name, reconnected=False): @@ -708,6 +980,7 @@ async def _session(self, address, name, reconnected=False): if input_char is None: return False # last_error already set + self.calibration.reset() # a different controller may reconnect try: await client.start_notify(input_char, self._on_data) notifying = input_char @@ -726,6 +999,8 @@ async def _session(self, address, name, reconnected=False): if reconnected: self.last_notice = f"Reconnected to {name}" + await self._configure_controller(client) + while not self._stop_event.is_set() and client.is_connected: await asyncio.sleep(0.1) return True @@ -1009,16 +1284,14 @@ def _tick(self, _): self._check_accessibility() self._surface_mappings_messages() - if self.bridge.last_error: - err = self.bridge.last_error - self.bridge.last_error = None + err = self.bridge.take_error() + if err: 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: - notice = self.bridge.last_notice - self.bridge.last_notice = None + notice = self.bridge.take_notice() + if notice: log.info("user-visible bridge notice: %s", notice) self._notify("Controller", notice) @@ -1041,6 +1314,8 @@ def _tick(self, _): rate = max(0.0, (count - self._rate_prev_count) / elapsed) if elapsed > 0 else 0.0 self._rate_prev_count, self._rate_prev_time = count, now detail = f" {count} pkts · {rate:.0f}/s" + if self.bridge.calibration.from_factory: + detail += " · sticks calibrated" if self.dsu.running: n = self.dsu.client_count() if n: @@ -1280,5 +1555,6 @@ def _notify(self, subtitle, message): print(" App is running in the menu bar.") print(f" Mappings: {MAPPINGS_FILE}") print(f" Logs: {LOG_DIR / 'bridge.log'}\n") + setup_logging() log.info("starting %s", APP_NAME) Switch2BridgeApp().run() diff --git a/build_dmg.sh b/build_dmg.sh index 1b69795..7e4750e 100755 --- a/build_dmg.sh +++ b/build_dmg.sh @@ -38,7 +38,7 @@ fi source venv/bin/activate pip install --upgrade pip -q -pip install -r requirements.txt -q +pip install -r requirements-build.txt -q echo " ✓ Dependencies installed" @@ -87,8 +87,11 @@ Switch2 Bridge - Installation 1. Drag "Switch2 Bridge" to "Applications" -2. Launch from Applications - (Right-click → Open the first time) +2. Launch from Applications. The app is not notarized, so the + first launch is blocked by Gatekeeper: + - macOS 15 (Sequoia) and later: System Settings → + Privacy & Security → "Open Anyway", then confirm + - macOS 13-14: Right-click → Open 3. Grant Bluetooth and Accessibility permissions when macOS prompts you @@ -103,6 +106,9 @@ Ryujinx Configuration: A→Z B→X X→C Y→V L→Q R→E ZL→1 ZR→3 Sticks: WASD / IJKL + + Default mapping — edit it in + ~/Library/Application Support/Switch2Bridge/mappings.json README # Build DMG diff --git a/dsu_server.py b/dsu_server.py index 9a747d3..c2f0ea7 100644 --- a/dsu_server.py +++ b/dsu_server.py @@ -86,7 +86,8 @@ def start(self): self._stop.clear() try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # No SO_REUSEADDR: UDP has no TIME_WAIT to work around, and on + # Linux it would let a second server silently share the port. sock.bind((self.host, self.port)) sock.settimeout(1.0) except OSError as e: diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..34ea5a6 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,3 @@ +# Switch2 Bridge - build-only dependencies (.app / DMG) +-r requirements.txt +py2app>=0.28.0 diff --git a/requirements.txt b/requirements.txt index 365f879..ff5944c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -# Switch2 Bridge - Dependencies +# Switch2 Bridge - runtime dependencies (Python 3.10+) +# Upper bounds on major versions: a new major must be tested before it ships. -bleak>=0.21.0 # BLE connection -pynput>=1.7.6 # Keyboard simulation -rumps>=0.4.0 # Menubar app -py2app>=0.28.0 # Build .app +bleak>=0.21.0,<4 # BLE connection (3.x requires Python 3.10+) +pynput>=1.7.6,<2 # Keyboard simulation +rumps>=0.4.0,<0.5 # Menubar app pyobjc-framework-ServiceManagement>=9.0 # "Start at Login" (SMAppService) diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 86b32c0..ccaf7e1 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -138,9 +138,25 @@ def packet(b2=0, b3=0, b4=0, lx=2048, ly=2048, rx=2048, ry=2048): br2._on_data(None, packet(b2=0x00)) # A released check("A release -> z up", ("release", "z") in events, events) +# byte 4: 0x01 Home, 0x02 Capture, 0x04 GR, 0x08 GL, 0x10 C — per ndeadly's +# hid_reports.md, espp PR #765 and the hardware capture in issue #14 +for bit, name, key in ((0x01, "HOME", "h"), (0x02, "CAPT", "o"), + (0x04, "GR", "0"), (0x08, "GL", "9")): + events.clear() + br2._on_data(None, packet(b4=bit)) + br2._on_data(None, packet()) + check(f"byte4 {bit:#04x} -> {name}", events == [("press", key), ("release", key)], events) + +events.clear() +br2._on_data(None, packet(b4=0x10)) # C pressed, unmapped by default +br2._on_data(None, packet()) +check("unmapped C (0x10) -> nothing", events == [], events) +mm.buttons["C"] = "c" +br2._on_data(None, packet(b4=0x10)) +check("mapped C fires on 0x10", events == [("press", "c")], events) +br2._on_data(None, packet()) +mm.buttons["C"] = None events.clear() -br2._on_data(None, packet(b4=0x02)) # C pressed, unmapped by default -check("unmapped C -> nothing", events == [], events) events.clear() br2._on_data(None, packet(ly=4000)) # left stick up @@ -151,7 +167,7 @@ def packet(b2=0, b3=0, b4=0, lx=2048, ly=2048, rx=2048, ry=2048): events.clear() br2._on_data(None, b"\x00\x01\x02") # short packet -check("short packet ignored", events == [] and br2.packet_count == 5) +check("short packet ignored", events == [] and br2.packet_count == 16) # dpad + stick sharing same key mm.left_stick["up"] = S2B.Key.up # same as DUP @@ -225,10 +241,13 @@ class MockClient: instances = [] services_template = None # set per test; None -> known_gatt() streaming = {} # uuid -> payload emitted while subscribed + flash = {} # SPI address -> bytes answered on the command channel def __init__(self, address, timeout=None): self.address = address self._connected = False self.notify_cb = None + self.response_cb = None + self.writes = [] self.subscribed = [] self.services = ( known_gatt() if MockClient.services_template is None @@ -244,11 +263,27 @@ async def connect(self): async def disconnect(self): self._connected = False async def start_notify(self, uuid, cb): - self.notify_cb = cb + if uuid == S2B.RESPONSE_CHAR_UUID: + self.response_cb = cb + else: + self.notify_cb = cb self.subscribed.append(uuid) payload = MockClient.streaming.get(uuid) if payload is not None: self._feeds[uuid] = asyncio.ensure_future(self._feed(uuid, cb, payload)) + async def write_gatt_char(self, uuid, data, response=True): + self.writes.append((uuid, bytes(data))) + # emulate SPI read replies from MockClient.flash (address -> bytes) + if uuid == S2B.COMMAND_CHAR_UUID and data[0] == S2B.REPORT_SPI: + address = int.from_bytes(data[12:16], "little") + block = MockClient.flash.get(address) + if block is not None and self.response_cb: + # layout of the real reply quoted in the fork's PROTOCOL.md + # (result byte 0x78 over BLE) + head = bytes([0x02, 0x01, 0x00, 0x04, 0x10, 0x78, 0, 0, + len(block), 0, 0, 0]) + address.to_bytes(4, "little") + reply = head + block + asyncio.get_running_loop().call_soon(self.response_cb, uuid, reply) async def stop_notify(self, uuid): feed = self._feeds.pop(uuid, None) if feed is not None: @@ -260,6 +295,7 @@ async def _feed(uuid, cb, payload): cb(uuid, payload) S2B.BleakClient = MockClient +S2B.COMMAND_REPLY_TIMEOUT = 0.05 # mock answers instantly or never class Adv: manufacturer_data = {0x057E: b"\x01\x69\x20\xff"} @@ -488,6 +524,130 @@ async def _failing_connect(self): check("corrupt file untouched", S2B.MAPPINGS_FILE.read_text() == "{broken json") check("in-memory toggle still applied", m4.dsu_enabled is False) +# ============ command channel: factory stick calibration ============ +print("== stick calibration ==") +# Real blocks read from a Pro Controller 2, published in +# https://github.com/kennethreitz/switch2bridge-macos/blob/975f329592a4ef56bd8ac2947d4d148eaf808fe0/docs/PROTOCOL.md +LEFT_BLOCK = bytes.fromhex("b347837616612e6664") +RIGHT_BLOCK = bytes.fromhex("60e884f1f56551f665") +check("left block decoded", + S2B.decode_stick_block(LEFT_BLOCK) == ((1971, 2100), (1654, 1553), (1582, 1606)), + S2B.decode_stick_block(LEFT_BLOCK)) +check("right block decoded", + S2B.decode_stick_block(RIGHT_BLOCK) == ((2144, 2126), (1521, 1631), (1617, 1631)), + S2B.decode_stick_block(RIGHT_BLOCK)) +check("erased block rejected", S2B.decode_stick_block(b"\xff" * 9) is None) +check("short block rejected", S2B.decode_stick_block(b"\x00" * 4) is None) +check("implausible block rejected", S2B.decode_stick_block(b"\x00" * 9) is None) + +# request frame bytes quoted in PROTOCOL.md (SPI read of 0x1FC040, 11 bytes) +check("spi read frame", + S2B.spi_read_command(0x1FC040, 0x0B) + == bytes.fromhex("0291000400080000" "0b7e000040c01f00"), + S2B.spi_read_command(0x1FC040, 0x0B).hex()) +check("player lights frame", + S2B.player_lights_command(0x01) == bytes.fromhex("0991000700080000" "0100000000000000")) +reply = bytes.fromhex("0201000410780000" "0b00000040c01f00") + b"\x11" * 11 +check("spi reply parsed (0x78 result byte)", + S2B.parse_spi_reply(reply) == (0x1FC040, b"\x11" * 11), S2B.parse_spi_reply(reply)) +check("foreign reply ignored", S2B.parse_spi_reply(b"\x09" + reply[1:]) is None) + +cal = S2B.StickCalibration() +check("nominal range unchanged without calibration", + cal.value("lx", 2048) == 0.0 and cal.value("lx", 4095) == 2047 / 2048 + and cal.value("lx", 0) == -1.0) +cal.apply_factory(S2B.decode_stick_block(LEFT_BLOCK), None) +check("full deflection reaches 1.0", cal.value("lx", 1971 + 1654) == 1.0) +check("asymmetric travel", cal.value("lx", 1971 - 1582) == -1.0) +check("factory centre reads 0", cal.value("ly", 2100) == 0.0) +check("clamped past travel", cal.value("lx", 4095) == 1.0) +check("right stick untouched", cal.value("rx", 2048) == 0.0 and cal.from_factory) + +# end-to-end: session reads both blocks, then lights player 1 +MockClient.flash = {S2B.FACTORY_STICK_LEFT: LEFT_BLOCK, S2B.FACTORY_STICK_RIGHT: RIGHT_BLOCK} +brc = S2B.ControllerBridge(mm) +async def run_session(): + task = asyncio.ensure_future(brc._session("XX", "Pro Controller")) + await asyncio.sleep(0.3) + brc._stop_event.set() + return await task +check("session with calibration streams", asyncio.run(run_session()) is True) +cl = MockClient.instances[-1] +check("calibration applied from controller", brc.calibration.from_factory + and brc.calibration.centers["rx"] == 2144, brc.calibration.centers) +check("player LED written", cl.writes and cl.writes[-1] + == (S2B.COMMAND_CHAR_UUID, S2B.player_lights_command(0x01)), cl.writes) +check("response channel unsubscribed", S2B.RESPONSE_CHAR_UUID in cl.subscribed) +# a later session with a silent controller falls back to the nominal range +MockClient.flash = {} +brc._stop_event.clear() +check("silent controller still streams", asyncio.run(run_session()) is True) +check("silent controller -> nominal range", not brc.calibration.from_factory) + +# DSU feed carries Capture from bit 0x02 (it is DSU's Touch button) +class FakeDSU: + running = True + def __init__(self): self.pushed = [] + def push(self, buttons, *axes): self.pushed.append(buttons) +fdsu = FakeDSU() +brd = S2B.ControllerBridge(mm, fdsu) +brd._on_data(None, packet(b4=0x02)) +brd._on_data(None, packet(b4=0x10)) +check("DSU gets Capture on 0x02", fdsu.pushed[0]["CAPT"] and not fdsu.pushed[1]["CAPT"], + fdsu.pushed) +brd.release_all_keys() + +# ============ config hardening ============ +print("== config hardening ==") +mh = M() +cfg3 = json.loads(json.dumps(M.DEFAULT)) +cfg3["dsu"]["enabled"] = "false" +mh._apply(cfg3) +check("string enabled not truthy", mh.dsu_enabled is True + and "dsu.enabled" in (mh.last_warning or ""), mh.last_warning) +cfg3 = json.loads(json.dumps(M.DEFAULT)) +cfg3["dsu"]["enabled"] = False +mh.last_warning = None # load() clears it before _apply() +mh._apply(cfg3) +check("bool enabled honoured", mh.dsu_enabled is False and mh.last_warning is None, mh.last_warning) +cfg3["dsu"]["host"] = "0.0.0.0" +mh._apply(cfg3) +check("exposed DSU host warned", "network" in (mh.last_warning or ""), mh.last_warning) +for host in ("127.0.0.1", "localhost", "::1"): + cfg3["dsu"]["host"] = host + mh.last_warning = None + mh._apply(cfg3) + check(f"loopback {host} not warned", mh.last_warning is None, mh.last_warning) + +# atomic write leaves no temp file behind and a valid file +S2B.MAPPINGS_FILE.write_text(json.dumps(M.DEFAULT)) +M().set_dsu_enabled(False) +leftovers = [p.name for p in S2B.MAPPINGS_FILE.parent.iterdir() if p.name.startswith(".mappings-")] +check("atomic write: no temp leftovers", leftovers == [], leftovers) +check("atomic write: valid json", json.loads(S2B.MAPPINGS_FILE.read_text())["dsu"]["enabled"] is False) + +# a failing write must keep the previous file intact +orig_dump = S2B.json.dump +def _boom(*a, **k): + raise OSError("disk full") +S2B.json.dump = _boom +M().set_dsu_enabled(True) +S2B.json.dump = orig_dump +check("failed write keeps old file", json.loads(S2B.MAPPINGS_FILE.read_text())["dsu"]["enabled"] is False) +leftovers = [p.name for p in S2B.MAPPINGS_FILE.parent.iterdir() if p.name.startswith(".mappings-")] +check("failed write cleans temp file", leftovers == [], leftovers) + +# worker -> UI messages: take_* returns once, then clears +brm = S2B.ControllerBridge(mm) +brm.last_error = "e1" +brm.last_notice = "n1" +check("take_error", brm.take_error() == "e1" and brm.take_error() is None) +check("take_notice", brm.take_notice() == "n1" and brm.last_notice is None) + +# importing the module must not touch ~/Library/Logs +check("no logging handler installed at import", + not any(isinstance(h, S2B.RotatingFileHandler) for h in S2B.logging.getLogger().handlers)) + print() if FAILURES: print(f"FAILED: {len(FAILURES)} -> {FAILURES}") From 7c155e763dbd139c3e56d353788e630da46e4a1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:23:30 +0000 Subject: [PATCH 2/5] Add a manual Release workflow and the v1.3.0 release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actions → Release → Run workflow (tag input): checks the tag matches APP_VERSION and doesn't exist yet, runs the tests, builds the DMG, then creates the tag and the GitHub release with the DMG attached, using .github/release-notes/.md as the body with {{SHA256}} replaced by the DMG checksum. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SGsFjHBorX2mfsanZhFyzs --- .github/release-notes/v1.3.0.md | 32 +++++++++++++++ .github/workflows/release.yml | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 .github/release-notes/v1.3.0.md create mode 100644 .github/workflows/release.yml diff --git a/.github/release-notes/v1.3.0.md b/.github/release-notes/v1.3.0.md new file mode 100644 index 0000000..2cfb059 --- /dev/null +++ b/.github/release-notes/v1.3.0.md @@ -0,0 +1,32 @@ +## What's new + +### 🎮 C and Capture were swapped — fixed +Byte 4 of the input report is `0x01` Home, `0x02` **Capture**, `0x04` GR, `0x08` GL, `0x10` **C**. Up to v1.2.4 the bridge read them the other way round, so pressing Capture fired the (unmapped by default) C button, and C fired Capture. This affected the keyboard bridge and the DSU Touch button. Three independent sources agree on the correct layout: [ndeadly/switch2_controller_research](https://github.com/ndeadly/switch2_controller_research), [espp #765](https://github.com/esp-cpp/espp/pull/765), and the hardware capture in #14. + +### 🕹️ Factory stick calibration +At connect, the bridge now reads each controller's own stick calibration over the BLE command channel, then lights the player 1 LED. Real stick travel is only ~1500–1770 counts rather than the nominal 2048, so previously a fully pushed stick topped out around 0.8 in DSU and the resting centre could drift by up to ~110 counts. The menubar shows *sticks calibrated* when this works. If the controller doesn't answer, nothing changes compared to v1.2.4. +Protocol details from #14 and [kennethreitz's fork](https://github.com/kennethreitz/switch2bridge-macos/tree/real-gamepad-output), based on [BlueRetro #1249](https://github.com/darthcloud/BlueRetro/issues/1249). Thanks! + +### 🔧 Hardening +- `mappings.json` is now written atomically, so a crash can no longer leave a truncated file. +- `"dsu": { "enabled": "false" }` (a string) no longer turns the server on. A warning is shown instead. +- A warning appears when the DSU server listens on a non-local address. +- DSU socket: dropped `SO_REUSEADDR`, which is useless for UDP and let two servers share a port on some systems. +- No more lost error/notice messages between the BLE thread and the menubar. + +### 📦 Build & requirements +- **Python 3.10+** is now required to run from source (bleak 3 needs it). +- Dependencies are capped at their current major versions, and `py2app` has moved to `requirements-build.txt`. +- CI now tests Python 3.10, 3.12 and 3.13. +- The v1.2.4 input-characteristic auto-detection (#15), which was never released on its own, ships here as well. + +## Notes +- 178 automated checks (bridge + DSU) green on CI. +- ⚠️ The C/Capture fix and the calibration read are backed by the sources above but **haven't been verified on our own hardware yet**. If something's off, please open an issue with your `~/Library/Logs/Switch2Bridge/bridge.log`. + +**Install**: download the DMG below and drag the app to Applications. The first launch is blocked by Gatekeeper (the app isn't notarized): on **macOS 15+** go to *System Settings → Privacy & Security → Open Anyway*; on macOS 13–14, right-click → Open. Then grant Bluetooth and Accessibility. + +SHA-256 `Switch2Bridge-v1.3.0.dmg`: +`{{SHA256}}` + +**Full Changelog**: https://github.com/mlstr0m/switch2bridge-macos/compare/v1.2.3...v1.3.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ac7af9a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +# Manual: Actions → Release → Run workflow, with the tag (e.g. v1.3.0). +# Builds the DMG from the selected branch, then creates the tag and the +# GitHub release, using .github/release-notes/.md as the body +# ({{SHA256}} is replaced by the DMG checksum). +on: + workflow_dispatch: + inputs: + tag: + description: "Tag to create, e.g. v1.3.0 (must match APP_VERSION)" + required: true + title: + description: "Release title (defaults to the tag)" + required: false + +permissions: + contents: write + +jobs: + release: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Check tag, version and notes + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + run: | + VERSION=$(sed -n 's/^APP_VERSION = "\(.*\)".*/\1/p' Switch2Bridge.py) + if [ "$TAG" != "v$VERSION" ]; then + echo "::error::tag $TAG does not match APP_VERSION $VERSION"; exit 1 + fi + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::error::release $TAG already exists"; exit 1 + fi + test -f ".github/release-notes/$TAG.md" \ + || { echo "::error::missing .github/release-notes/$TAG.md"; exit 1; } + + - name: Tests + run: | + pip install -r requirements.txt + python tests/test_bridge.py + python tests/test_dsu.py + + - name: Build DMG + run: ./build_dmg.sh + + - name: Publish release + env: + TAG: ${{ inputs.tag }} + TITLE: ${{ inputs.title }} + GH_TOKEN: ${{ github.token }} + run: | + DMG="dist/Switch2Bridge-$TAG.dmg" + mv dist/Switch2Bridge-Installer.dmg "$DMG" + SHA=$(shasum -a 256 "$DMG" | cut -d' ' -f1) + echo "SHA-256 $SHA" + sed "s/{{SHA256}}/$SHA/" ".github/release-notes/$TAG.md" > notes.md + gh release create "$TAG" "$DMG" \ + --target "$GITHUB_SHA" \ + --title "${TITLE:-$TAG}" \ + --notes-file notes.md From 8870366f5f52d8135c97e1b6fc8ce87646008b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:46:20 +0000 Subject: [PATCH 3/5] Tests: keep setup_logging out of ~/Library/Logs; drop unused set_address Follow-up to #17: - the setup_logging tests now point LOG_DIR at a temp dir and detach their handler afterwards, instead of creating controller2.log in the real ~/Library/Logs/Switch2Bridge and leaving a handler on bridge.log - Mappings.set_address() had no caller outside its own test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SGsFjHBorX2mfsanZhFyzs --- Switch2Bridge.py | 5 ----- tests/test_bridge.py | 17 ++++++++++------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Switch2Bridge.py b/Switch2Bridge.py index dc4e89a..2bdcfbf 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -499,11 +499,6 @@ 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: diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 3a9c10c..178243d 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -506,10 +506,6 @@ async def run(): 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 @@ -716,7 +712,8 @@ def _boom(*a, **k): except SystemExit: check("unknown flag rejected", True) -# setup_logging +# setup_logging — in a temp dir, never the real ~/Library/Logs +S2B.LOG_DIR = Path(tempfile.mkdtemp()) log_default = S2B.setup_logging() check("default log is bridge.log", log_default == S2B.LOG_DIR / "bridge.log") @@ -726,8 +723,14 @@ def _boom(*a, **k): 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() +check("log files only in the temp dir", + sorted(p.name for p in S2B.LOG_DIR.iterdir()) == ["bridge.log", "controller2.log"], + list(S2B.LOG_DIR.iterdir())) + +# detach the test handler so nothing keeps logging to a file +S2B.logging.getLogger().removeHandler(S2B._log_handler) +S2B._log_handler.close() +S2B._log_handler = None print() if FAILURES: From 5c42829f71d8183666e137b7c0d25da863f434aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:13:57 +0000 Subject: [PATCH 4/5] Tidy the repo: lint in CI, cleaner .gitignore, README dev docs - ruff (pyflakes, bugbear, pyupgrade) configured in ruff.toml and run as a CI job; fixed its four findings: `raise ... from None` in config validation, `socket.timeout` -> `TimeoutError` (alias since 3.10) - .gitignore: drop duplicated entries, English comments, ignore .ruff_cache - README: project structure lists .github/ (CI, release workflow, notes) and ruff.toml; new Development section (tests, lint, release steps); DriverKit is a system extension needing a paid developer account, not "kernel-level" / "not possible" Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SGsFjHBorX2mfsanZhFyzs --- .github/workflows/ci.yml | 11 +++++++++++ .gitignore | 24 ++++++++++-------------- README.md | 24 +++++++++++++++++++++--- Switch2Bridge.py | 4 ++-- dsu_server.py | 2 +- ruff.toml | 5 +++++ tests/test_dsu.py | 2 +- 7 files changed, 51 insertions(+), 21 deletions(-) create mode 100644 ruff.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e6ea0..17d254c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,17 @@ on: workflow_dispatch: jobs: + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install "ruff>=0.15,<1" + - run: ruff check . + test: name: Headless tests (Python ${{ matrix.python-version }}) runs-on: macos-14 diff --git a/.gitignore b/.gitignore index a02fb39..21d1b08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,22 @@ -# Build & distribution -dist/ +# Build output (py2app / build_dmg.sh) build/ +dist/ *.spec build_py2app.log -# On ignore tous les fichiers de cache Python -**/__pycache__/ -*.pyc - -# Fichiers système macOS -.DS_Store -.AppleDouble -.LSOverride - -# Fichiers Python (si tu l'utilises) +# Python __pycache__/ *.py[cod] -*$py.class venv/ .venv/ env/ +.ruff_cache/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride -# IDE (Visual Studio Code, etc.) +# Editors .vscode/ .idea/ diff --git a/README.md b/README.md index d6bc466..931e948 100644 --- a/README.md +++ b/README.md @@ -192,14 +192,32 @@ switch2bridge-macos/ ├── build_dmg.sh # Automated build script (.app + DMG) ├── requirements.txt # Runtime dependencies ├── requirements-build.txt # + py2app, for the .app / DMG +├── ruff.toml # Lint config ├── tests/ -│ ├── test_bridge.py # Headless tests (mappings, key dispatch, BLE lifecycle, calibration) +│ ├── test_bridge.py # Headless tests (mappings, key dispatch, BLE lifecycle, calibration, CLI) │ └── test_dsu.py # DSU server over real UDP +├── .github/ +│ ├── workflows/ci.yml # Lint + tests on every PR, DMG build on main +│ ├── workflows/release.yml # Manual release: build, tag, publish +│ └── release-notes/ # One vX.Y.Z.md per release ├── AppIcon.icns # Application icon (used by py2app) ├── LICENSE └── README.md ``` +## 🛠️ Development + +```bash +pip install -r requirements.txt +python tests/test_bridge.py # headless: BLE and keyboard are mocked +python tests/test_dsu.py # real UDP round-trips on localhost +pip install ruff && ruff check . +``` + +CI runs the same on every pull request (macOS, Python 3.10 / 3.12 / 3.13). + +**Releasing** — bump `APP_VERSION` in `Switch2Bridge.py` (the single source of truth), add `.github/release-notes/vX.Y.Z.md` (`{{SHA256}}` is replaced by the DMG checksum), merge, then run **Actions → Release → Run workflow** with the tag. It refuses to publish if the tag doesn't match `APP_VERSION` or the release already exists. + ## 🔬 Technical Details ### How It Works @@ -271,7 +289,7 @@ Every connection also logs the full GATT table (`GATT: N characteristic(s): …` | LED Control | 🧪 Player 1 only | Set once on connect through the command channel | | Rumble | ❌ Not implemented | Goes through the command/vibration channels, not `…c0f8` | | Motion/Gyro | ⚠️ Plumbing ready | DSU motion fields are sent (as zeros) — the gyro bytes in the BLE report are not decoded yet | -| Native HID | ❌ Not possible | Would require DriverKit (kernel-level) | +| Native HID | ❌ Not implemented | Would need a DriverKit system extension (virtual gamepad), which requires a paid Apple Developer account to sign | ## 🤝 Contributing @@ -279,7 +297,7 @@ Contributions welcome! Areas that need work: 1. **Rumble** — the command channel is now used for calibration and LEDs; rumble is the next step (see #14) 2. **Motion controls** — decode gyro/accelerometer data -3. **True analog** — virtual HID device via DriverKit +3. **Native gamepad** — virtual HID device via DriverKit, so games see a real controller (analog already works through DSU) 4. **Cross-platform** — Linux/Windows ports ## 📜 Credits diff --git a/Switch2Bridge.py b/Switch2Bridge.py index 2bdcfbf..b6c2ca6 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -431,7 +431,7 @@ def _apply(self, cfg): try: threshold = float(sticks.get("threshold", 0.5)) except (TypeError, ValueError): - raise ValueError('"sticks.threshold" must be a number') + raise ValueError('"sticks.threshold" must be a number') from None clamped = min(max(threshold, self.THRESHOLD_MIN), self.THRESHOLD_MAX) if clamped != threshold: warnings.append(f"Stick threshold {threshold} out of range, using {clamped}") @@ -474,7 +474,7 @@ def _apply(self, cfg): try: port = int(dsu.get("port", 26760)) except (TypeError, ValueError): - raise ValueError('"dsu.port" must be an integer') + raise ValueError('"dsu.port" must be an integer') from None if not (1024 <= port <= 65535): warnings.append(f"DSU port {port} out of range, using 26760") port = 26760 diff --git a/dsu_server.py b/dsu_server.py index c2f0ea7..9f0fd38 100644 --- a/dsu_server.py +++ b/dsu_server.py @@ -184,7 +184,7 @@ def _serve(self): while not self._stop.is_set(): try: data, addr = self._sock.recvfrom(1024) - except socket.timeout: + except TimeoutError: # the 1 s recv timeout continue except OSError: break # socket closed diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..61ab791 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,5 @@ +target-version = "py310" + +[lint] +# pyflakes, syntax errors, bugbear, pyupgrade +select = ["F", "E9", "B", "UP"] diff --git a/tests/test_dsu.py b/tests/test_dsu.py index 4b8cc50..bc2c17e 100644 --- a/tests/test_dsu.py +++ b/tests/test_dsu.py @@ -137,7 +137,7 @@ def crc_ok(pkt): cli.settimeout(0.5) cli.recvfrom(1024) check("stale client pruned", False, "still receiving") -except socket.timeout: +except TimeoutError: check("stale client pruned", True) check("client_count zero", srv.client_count() == 0) From 3be573eaf98a77bfdc1db06e2556a41c764c3abe Mon Sep 17 00:00:00 2001 From: maelstr0m <96124638+mlstr0m@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:50:19 +0200 Subject: [PATCH 5/5] =?UTF-8?q?Promote=20=E2=80=A6f8=20to=20a=20known=20in?= =?UTF-8?q?put=20characteristic=20(issue=20#15=20field=20report)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reporter's controller adopted 7492866c-ec3e-4619-8258-32755ffcc0f8 and has no …f9 at all. So …f8 is now a known candidate, probed before any blind vendor/SIG scanning, which makes detection immediate on that revision instead of luck-of-the-ordering. Being less sure of its role than …f9, it is probe-validated rather than trusted outright, and characteristics that don't advertise notify/indicate are skipped everywhere — including a pin or a known UUID that turns out to be write-only, which now falls through to probing instead of failing the session. (cherry picked from commit c349a71f95fff040dbeeb1243bbd6bc54624fa7c) Ported onto v1.3.0: the README keeps v1.3.0's characteristics table and limitations (…f8 is notify-only; ndeadly lists it as the Pro Controller input) and folds in the #15 facts; the code comment no longer calls …f8 the output characteristic. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SGsFjHBorX2mfsanZhFyzs --- README.md | 20 +++++++++--------- Switch2Bridge.py | 50 ++++++++++++++++++++++++++++++++------------ tests/test_bridge.py | 39 +++++++++++++++++++++++++++++++--- 3 files changed, 83 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 931e948..eb8ea2f 100644 --- a/README.md +++ b/README.md @@ -238,13 +238,21 @@ CI runs the same on every pull request (macOS, Python 3.10 / 3.12 / 3.13). | UUID | Purpose | |------|---------| -| `7492866c-ec3e-4619-8258-32755ffcc0f9` | Input reports (notifications) — what the bridge reads | -| `7492866c-ec3e-4619-8258-32755ffcc0f8` | Also notify-only (read, notify) — it can't be written, which is why LED/rumble never worked through it | +| `7492866c-ec3e-4619-8258-32755ffcc0f9` | Input reports (notifications) — absent on some units | +| `7492866c-ec3e-4619-8258-32755ffcc0f8` | Notify-only (read, notify) — **carries the input reports on units without `…f9`** ([#15](https://github.com/mlstr0m/switch2bridge-macos/issues/15)); it can't be written, which is why LED/rumble never worked through it | | `649d4ac9-8eb7-4e6c-af44-1ea54fe5f005` | Command channel (write without response) | | `c765a961-d9d8-4d36-a20a-5315b111836a` | Command replies (notifications) | UUIDs from [ndeadly/switch2_controller_research](https://github.com/ndeadly/switch2_controller_research) (`bluetooth_interface.md`) and the hardware findings in [#14](https://github.com/mlstr0m/switch2bridge-macos/issues/14). +**The input characteristic isn't fixed.** An AU-market controller in [#15](https://github.com/mlstr0m/switch2bridge-macos/issues/15) has no `…f9` at all and streams its input reports from `…f8` (which ndeadly also lists as the Pro Controller input). So the bridge resolves it at connect time rather than assuming it: + +1. the UUID pinned in `mappings.json` (`ble.input_char`), if the device exposes it and it can notify; +2. otherwise `…f9`; +3. otherwise it **probes** the remaining notifiable characteristics — other known UUIDs (`…f8`) first, then the same Nintendo vendor block, then other vendor UUIDs, SIG-assigned ones (battery…) last. Each candidate is subscribed for up to 3 s and only adopted once it streams a report of at least 11 bytes, enough to decode buttons and both sticks. The winner is written back to `ble.input_char`, so later connections skip the probing. + +Every connection also logs the full GATT table (`GATT: N characteristic(s): …`) to `~/Library/Logs/Switch2Bridge/bridge.log` — that line is what a bug report needs when a controller can't be identified. + ### Input report | Byte | Content | @@ -262,14 +270,6 @@ At connect the bridge reads the controller's factory calibration over the comman Frame format and addresses come from [kennethreitz's fork](https://github.com/kennethreitz/switch2bridge-macos/blob/975f329592a4ef56bd8ac2947d4d148eaf808fe0/controller_commands.py) (issue #14, MIT), itself based on [BlueRetro #1249](https://github.com/darthcloud/BlueRetro/issues/1249); the addresses match [SDL's Switch 2 driver](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c). -Not every controller exposes that input UUID (see [#15](https://github.com/mlstr0m/switch2bridge-macos/issues/15)), so the bridge treats it as a first guess only: - -1. the UUID pinned in `mappings.json` (`ble.input_char`), if it is present on the device; -2. otherwise the documented UUID above; -3. otherwise it **probes** every other notifiable characteristic — vendor UUIDs first, SIG-assigned ones last — subscribing for 3 s each and keeping the first one that streams reports of at least 11 bytes (enough to decode buttons and both sticks). The winner is written back to `ble.input_char`, so later connections skip the probing. - -Every connection also logs the full GATT table (`GATT: N characteristic(s): …`) to `~/Library/Logs/Switch2Bridge/bridge.log` — that line is what a bug report needs when a controller can't be identified. - ## 🩺 Troubleshooting - **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 (🔍 → 🟢). diff --git a/Switch2Bridge.py b/Switch2Bridge.py index b6c2ca6..28774a8 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -73,6 +73,13 @@ APP_NAME = "Switch2 Bridge" APP_VERSION = "1.3.0" # single source of truth — read by setup_app.py & build_dmg.sh INPUT_CHAR_UUID = "7492866c-ec3e-4619-8258-32755ffcc0f9" +# …f8 streams the input reports of an AU-market controller that has no …f9 +# at all (issue #15); ndeadly/switch2_controller_research also lists it as the +# Pro Controller input. Unlike …f9 it is probe-validated, not trusted outright. +KNOWN_INPUT_CHAR_UUIDS = ( + INPUT_CHAR_UUID, + "7492866c-ec3e-4619-8258-32755ffcc0f8", +) # Not every controller revision exposes that UUID (issue #15), so it is only the # first candidate: when it is absent we probe the other notifiable @@ -841,24 +848,37 @@ def _log_gatt(self, client): except Exception: log.exception("could not dump the GATT table") + @staticmethod + def _can_notify(char): + props = {str(p).lower() for p in (getattr(char, "properties", ()) or ())} + return bool(props & {"notify", "indicate"}) + + @classmethod + def _notifiable_uuids(cls, client): + return { + str(char.uuid).lower() + for _svc, char in cls._gatt_chars(client) if cls._can_notify(char) + } + @classmethod def _probe_candidates(cls, client): """Notifiable characteristics, likeliest input reporter first.""" - vendor_block, vendor, standard = [], [], [] + known, vendor_block, vendor, standard = [], [], [], [] for _svc, char in cls._gatt_chars(client): - props = {str(p).lower() for p in (getattr(char, "properties", ()) or ())} - if not props & {"notify", "indicate"}: + if not cls._can_notify(char): continue uuid = str(char.uuid).lower() if uuid == INPUT_CHAR_UUID: - continue # already tried before probing - if uuid.endswith(BLE_BASE_UUID_SUFFIX): + continue # trusted without probing, before we get here + if uuid in KNOWN_INPUT_CHAR_UUIDS: + known.append(uuid) + elif uuid.endswith(BLE_BASE_UUID_SUFFIX): standard.append(uuid) elif uuid.startswith(VENDOR_UUID_PREFIX): vendor_block.append(uuid) else: vendor.append(uuid) - return (vendor_block + vendor + standard)[:MAX_INPUT_PROBES] + return (known + vendor_block + vendor + standard)[:MAX_INPUT_PROBES] async def _probe_input_char(self, client, uuid): """Subscribe briefly: True if it streams reports we can decode.""" @@ -892,20 +912,24 @@ def _sniff(_sender, data): async def _resolve_input_char(self, client): """UUID of the characteristic streaming input reports, or None. - Order: the UUID pinned in mappings.json, then the known one, then a - probe of every other notifiable characteristic — controller revisions - renumber it (issue #15). Sets last_error when nothing works. + Order: the UUID pinned in mappings.json, then the documented one, then + a probe of every other notifiable characteristic (other known UUIDs + first) — controller revisions renumber it (issue #15). Sets last_error + when nothing works. """ - present = {str(char.uuid).lower() for _svc, char in self._gatt_chars(client)} + notifiable = self._notifiable_uuids(client) pinned = self.mappings.input_char if pinned: - if pinned in present: + if pinned in notifiable: log.info("using pinned input characteristic %s", pinned) return pinned - log.warning("pinned input characteristic %s absent, re-detecting", pinned) + log.warning( + "pinned input characteristic %s absent or not notifiable, " + "re-detecting", pinned, + ) - if INPUT_CHAR_UUID in present: + if INPUT_CHAR_UUID in notifiable: return INPUT_CHAR_UUID candidates = self._probe_candidates(client) diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 178243d..0ae8fd4 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -387,6 +387,9 @@ class Named: BATTERY_CHAR = "00002a19" + S2B.BLE_BASE_UUID_SUFFIX ALT_INPUT_CHAR = "7492866c-ec3e-4619-8258-32755ffcc0fa" ODD_VENDOR_CHAR = "ab0828b1-198e-4351-b779-901fa0e0371e" +# …f8 is documented as the output characteristic but streams input on the +# controller of issue #15, which has no …f9 at all +KNOWN_ALT_CHAR = "7492866c-ec3e-4619-8258-32755ffcc0f8" def resolve(services, pinned=None, streaming=None): """Run _resolve_input_char against a fabricated GATT table.""" @@ -435,6 +438,33 @@ async def run(): brr.last_notice) check("no error on success", brr.last_error is None, brr.last_error) +# issue #15's controller: no …f9, input streams from the documented output UUID +uuid, brr, client = resolve( + [MockService("svc", [MockChar(BATTERY_CHAR), MockChar(KNOWN_ALT_CHAR)])], + streaming={KNOWN_ALT_CHAR: report}, +) +check("known alternate adopted", uuid == KNOWN_ALT_CHAR, uuid) +check("known alternate probed first", client.subscribed[0] == KNOWN_ALT_CHAR, + client.subscribed) + +# …f9 present but write-only: fall through to probing instead of failing +uuid, brr, _ = resolve( + [MockService("svc", [ + MockChar(S2B.INPUT_CHAR_UUID, ("write",)), MockChar(KNOWN_ALT_CHAR), + ])], + streaming={KNOWN_ALT_CHAR: report}, +) +check("non-notifiable known char skipped", uuid == KNOWN_ALT_CHAR, uuid) + +# a pin that exists but can't notify is ignored too +uuid, _, _ = resolve( + [MockService("svc", [ + MockChar(ALT_INPUT_CHAR, ("write",)), MockChar(S2B.INPUT_CHAR_UUID), + ])], + pinned=ALT_INPUT_CHAR, +) +check("non-notifiable pin ignored", uuid == S2B.INPUT_CHAR_UUID, uuid) + # short reports must not be mistaken for input (battery streams 1 byte) uuid, brr, _ = resolve( [MockService("svc", [MockChar(BATTERY_CHAR)])], @@ -445,13 +475,16 @@ async def run(): brr.last_error and "bridge.log" in brr.last_error and "issues" in brr.last_error, brr.last_error) -# probe order: same vendor block, then other vendor UUIDs, then SIG-assigned +# probe order: other known UUIDs, then same vendor block, then other vendor +# UUIDs, then SIG-assigned ones MockClient.services_template = [MockService("svc", [ MockChar(BATTERY_CHAR), MockChar(ODD_VENDOR_CHAR), MockChar(ALT_INPUT_CHAR), - MockChar("write-only-char", ("write",)), + MockChar(KNOWN_ALT_CHAR), MockChar("write-only-char", ("write",)), ])] order = S2B.ControllerBridge._probe_candidates(MockClient("XX")) -check("probe order", order == [ALT_INPUT_CHAR, ODD_VENDOR_CHAR, BATTERY_CHAR], order) +check("probe order", + order == [KNOWN_ALT_CHAR, ALT_INPUT_CHAR, ODD_VENDOR_CHAR, BATTERY_CHAR], + order) check("non-notifiable skipped", "write-only-char" not in order, order) MockClient.services_template = None