From dea7078292bdb8a236f766b3f819bd19388d0372 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 14:27:33 +0800 Subject: [PATCH 01/24] Modernize for current Linux/Python: prefer btmgmt over hciconfig/hcitool hciconfig and hcitool are deprecated on modern distros (e.g. recent Debian/Ubuntu, Oracle Linux 10) where bluez no longer ships them. The project now prefers btmgmt (bluez-tools) for adapter reset, class setting, and BD address changes, falling back to the legacy tools when present, and to raw HCI sockets via socket.AF_BLUETOOTH as a last resort. Also drop deprecated Python idioms: - pkg_resources -> importlib.resources for the bundled SDP record - asyncio.get_event_loop() -> asyncio.get_running_loop() inside coros, asyncio.run() at entry points; remove the mutable-default loop=asyncio.get_event_loop() arg evaluated at import time - bump python_requires to >=3.9, add crc8 to install_requires Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 27 ++++++++++--- joycontrol/device.py | 80 +++++++++++++++++++++++++++++++++----- joycontrol/server.py | 8 ++-- joycontrol/utils.py | 38 +++++++++++------- run_controller_cli.py | 5 +-- scripts/change_btaddr.sh | 32 +++++++++++---- scripts/dump_spi_flash.py | 23 ++++++----- scripts/joycon_ip_proxy.py | 12 +++--- scripts/relay_joycon.py | 9 ++--- setup.py | 12 ++++-- 10 files changed, 176 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index cac5c9ad..c0859d8f 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,32 @@ Emulation of JOYCON_R, JOYCON_L and PRO_CONTROLLER. Able to send: - nfc for amiibo read & owner registration ## Installation -- Install dependencies - Raspbian: + +Tested on Python 3.9+ and BlueZ 5.55+. The legacy `hciconfig`/`hcitool` tools +are deprecated on modern distributions; this project now prefers `btmgmt` +(part of `bluez-tools`) and falls back to the legacy tools only when present. + +- Install dependencies + Raspbian / Debian / Ubuntu: ```bash -sudo apt install python3-dbus libhidapi-hidraw0 libbluetooth-dev bluez +sudo apt install python3-dbus python3-hid libhidapi-hidraw0 libbluetooth-dev bluez bluez-tools ``` - Python: (a setup.py is present but not yet up to date) - Note that pip here _has_ to be run as root, as otherwise the packages are not available to the root user. + Note: `bluez-tools` provides `btmgmt`. On distributions that no longer ship + `hciconfig`/`hcitool` (e.g. recent Debian/Ubuntu where `bluez` no longer + installs the legacy CLI by default) `btmgmt` is the modern replacement. + + Python: ```bash sudo pip3 install aioconsole hid crc8 ``` - If you are unsure if the packages are properly installed, try running `sudo python3` and import each using `import package_name`. + Or install the project (and its Python deps) directly with: +```bash +sudo pip3 install . +``` + Note that pip here _has_ to be run as root so the packages are available to + the root user (the script must run as root to access raw L2CAP sockets). + If you are unsure if the packages are properly installed, try running + `sudo python3` and import each using `import package_name`. - setup bluetooth - [I shouldn't have to say this, but] make sure you have a working Bluetooth adapter\ diff --git a/joycontrol/device.py b/joycontrol/device.py index a8cd85ff..7157cee6 100644 --- a/joycontrol/device.py +++ b/joycontrol/device.py @@ -1,4 +1,5 @@ import logging +import shutil import uuid import dbus @@ -11,6 +12,10 @@ HID_PATH = '/bluez/switch/hid' +def _has_cmd(name: str) -> bool: + return shutil.which(name) is not None + + class HidDevice: def __init__(self, device_id=None): self._device_id = device_id @@ -44,20 +49,49 @@ async def set_address(self, bt_addr, interactive=True): print(f"Attempting to change the bluetooth MAC to {bt_addr}") print("please choose your method:") print("\t1: bdaddr - ericson, csr, TI, broadcom, zeevo, st") - print("\t2: hcitool - intel chipsets") - print("\t3: hcitool - cypress (raspberri pi 3B+ & 4B)") - print("\tx: abort, dont't change") + print("\t2: btmgmt public-addr - intel chipsets / modern drivers") + print("\t3: hcitool/raw HCI - cypress (raspberry pi 3B+ & 4B)") + print("\tx: abort, don't change") hci_version = " ".join(reversed(list(map(lambda h: '0x' + h, bt_addr.split(":"))))) + adapter_idx = self._adapter_name.replace('hci', '') c = input() if c == '1': + if not _has_cmd('bdaddr'): + logger.error("bdaddr utility not found. Install it from your distro's bluez-tools or build from source.") + return False await utils.run_system_command(f'bdaddr -i {self._adapter_name} {bt_addr}') elif c == '2': - await utils.run_system_command(f'hcitool cmd 0x3f 0x0031 {hci_version}') + # Modern: btmgmt public-addr replaces the vendor HCI command for Intel chipsets + if _has_cmd('btmgmt'): + await utils.run_system_command(f'btmgmt --index {adapter_idx} public-addr {bt_addr}') + elif _has_cmd('hcitool'): + logger.warning('btmgmt not found, falling back to deprecated hcitool') + await utils.run_system_command(f'hcitool cmd 0x3f 0x0031 {hci_version}') + else: + logger.error('Neither btmgmt nor hcitool is available') + return False elif c == '3': - await utils.run_system_command(f'hcitool cmd 0x3f 0x001 {hci_version}') + # Cypress vendor command 0xfc01 — no btmgmt equivalent. Try hcitool, else raw HCI socket. + if _has_cmd('hcitool'): + await utils.run_system_command(f'hcitool cmd 0x3f 0x001 {hci_version}') + else: + logger.warning('hcitool not found, sending vendor HCI command via raw socket') + payload = bytes(int(b, 16) for b in reversed(bt_addr.split(':'))) + await utils.hci_send_cmd(int(adapter_idx or 0), ogf=0x3f, ocf=0x001, data=payload) else: return False - await utils.run_system_command("hciconfig hci0 reset") + + # Reset the adapter. btmgmt is the modern replacement for `hciconfig hci0 reset`. + if _has_cmd('btmgmt'): + await utils.run_system_command(f'btmgmt --index {adapter_idx} power off') + await utils.run_system_command(f'btmgmt --index {adapter_idx} power on') + elif _has_cmd('hciconfig'): + logger.warning('btmgmt not found, falling back to deprecated hciconfig for adapter reset') + await utils.run_system_command(f'hciconfig {self._adapter_name} reset') + else: + logger.warning('Neither btmgmt nor hciconfig found, attempting reset via DBus') + self.powered(False) + self.powered(True) await utils.run_system_command("systemctl restart bluetooth.service") # now we have to reget all dbus-shenanigans because we just restarted it's service. @@ -98,13 +132,39 @@ def pairable(self, boolean=True): async def set_class(self, cls='0x002508'): """ - Sets Bluetooth device class. Requires hciconfig system command. + Sets Bluetooth device class. Prefers btmgmt (modern bluez-tools); + falls back to the deprecated hciconfig if needed. :param cls: default 0x002508 (Gamepad/joystick device class) """ logger.info(f'setting device class to {cls}...') - await utils.run_system_command(f'hciconfig {self._adapter_name} class {cls}') - if self.properties.Get(self.adapter.dbus_interface, "Class") != int(cls, base=0): - logger.error(f"Could not set class to the required {cls}. Connecting probably won't work.") + cls_int = int(cls, base=0) + adapter_idx = self._adapter_name.replace('hci', '') + # Decompose 24-bit class. btmgmt sets bits 0-12 (minor byte + major 5-bit). + # Service class bits 13-23 are derived by bluez from registered profiles + # and discoverability state, so we can't set them directly via btmgmt. + minor = cls_int & 0xFF + major = (cls_int >> 8) & 0x1F + + used_btmgmt = False + if _has_cmd('btmgmt'): + rc, _, _ = await utils.run_system_command(f'btmgmt --index {adapter_idx} class {major} {minor}') + used_btmgmt = (rc == 0) + if not used_btmgmt: + if _has_cmd('hciconfig'): + logger.warning('btmgmt unavailable or failed; falling back to deprecated hciconfig') + await utils.run_system_command(f'hciconfig {self._adapter_name} class {cls}') + else: + logger.error('Neither btmgmt nor hciconfig is available; cannot set device class.') + return + + actual = self.properties.Get(self.adapter.dbus_interface, "Class") + if actual != cls_int: + # Service-class bits often differ when set via btmgmt; only the device-class + # portion (bits 0-12) is required for the Switch to recognize the controller. + if (actual & 0x1FFF) == (cls_int & 0x1FFF): + logger.debug(f"device class set to {hex(actual)} (service bits differ from {cls}, this is expected)") + else: + logger.error(f"Could not set class to the required {cls}. Connecting probably won't work.") async def set_name(self, name: str): """ diff --git a/joycontrol/server.py b/joycontrol/server.py index 8ba5e323..ba114b3d 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -1,16 +1,16 @@ import asyncio import logging import socket +from importlib.resources import files import dbus -import pkg_resources from joycontrol import utils from joycontrol.device import HidDevice from joycontrol.report import InputReport from joycontrol.transport import L2CAP_Transport -PROFILE_PATH = pkg_resources.resource_filename('joycontrol', 'profile/sdp_record_hid.xml') +PROFILE_PATH = str(files('joycontrol').joinpath('profile/sdp_record_hid.xml')) logger = logging.getLogger(__name__) @@ -116,7 +116,7 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= logger.info('Waiting for Switch to connect... Please open the "Change Grip/Order" menu.') - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() client_ctl, ctl_address = await loop.sock_accept(ctl_sock) logger.info(f'Accepted connection at psm {ctl_psm} from {ctl_address}') client_itr, itr_address = await loop.sock_accept(itr_sock) @@ -168,7 +168,7 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= client_ctl.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 0) client_itr.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 0) # create transport for the established connection and activate the HID protocol - transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, client_ctl, 50, capture_file=capture_file) + transport = L2CAP_Transport(asyncio.get_running_loop(), protocol, client_itr, client_ctl, 50, capture_file=capture_file) protocol.connection_made(transport) # HACK: send some empty input reports until the Switch decides to reply diff --git a/joycontrol/utils.py b/joycontrol/utils.py index 803e59ff..ad3b4899 100644 --- a/joycontrol/utils.py +++ b/joycontrol/utils.py @@ -1,5 +1,7 @@ import asyncio import logging +import socket +import struct from contextlib import contextmanager import hid @@ -8,9 +10,9 @@ class AsyncHID(hid.Device): - def __init__(self, *args, loop=asyncio.get_event_loop(), **kwargs): + def __init__(self, *args, loop=None, **kwargs): super().__init__(*args, **kwargs) - self._loop = loop + self._loop = loop if loop is not None else asyncio.get_running_loop() self._write_lock = asyncio.Lock() self._read_lock = asyncio.Lock() @@ -95,14 +97,24 @@ async def aio_chain(*args): for a in args: await a -""" -async def get_bt_mac_address(dev=0): - ret, stdout, stderr = await run_system_command(f'hciconfig hci{dev}') - # TODO: Process error handling - - match = re.search(r'BD Address: (?P\w\w:\w\w:\w\w:\w\w:\w\w:\w\w)', stdout.decode('UTF-8')) - if match: - return list(map(lambda x: int(x, 16), match.group('mac').split(':'))) - else: - raise ValueError(f'BD Address not found in "{stdout}"') -""" +async def hci_send_cmd(adapter_index: int, ogf: int, ocf: int, data: bytes = b'') -> None: + """ + Send a raw HCI command via an HCI raw socket. Modern alternative to `hcitool cmd` + when bluez-tools are not installed. Fire-and-forget — does not parse the event response. + Requires root and the bluetooth kernel module loaded. + :param adapter_index: integer adapter index (0 for hci0) + :param ogf: OpCode Group Field (6 bits) + :param ocf: OpCode Command Field (10 bits) + :param data: command parameters + """ + loop = asyncio.get_running_loop() + sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_RAW, socket.BTPROTO_HCI) + try: + sock.setblocking(False) + await loop.run_in_executor(None, sock.bind, (adapter_index,)) + opcode = ((ogf & 0x3F) << 10) | (ocf & 0x3FF) + # HCI command packet: type=0x01 | opcode (LE) | param_len | params + pkt = struct.pack('/dev/null 2>&1; then + bdaddr -i hci0 "${target_addr}" +elif command -v btmgmt >/dev/null 2>&1; then + btmgmt --index 0 public-addr "${target_addr}" +else + echo "neither bdaddr nor btmgmt is installed; cannot change BD address" >&2 + exit 1 +fi + +# Reset the adapter. Modern: btmgmt power cycle. Fallback: deprecated hciconfig. +if command -v btmgmt >/dev/null 2>&1; then + btmgmt --index 0 power off + btmgmt --index 0 power on +elif command -v hciconfig >/dev/null 2>&1; then + echo "btmgmt not found; falling back to deprecated hciconfig" >&2 + hciconfig hci0 reset +fi + systemctl restart bluetooth.service echo "success" diff --git a/scripts/dump_spi_flash.py b/scripts/dump_spi_flash.py index 4eb24dcd..a50ef86d 100644 --- a/scripts/dump_spi_flash.py +++ b/scripts/dump_spi_flash.py @@ -161,15 +161,14 @@ async def _main(args, loop): # setup logging log.configure() - loop = asyncio.get_event_loop() - task = asyncio.ensure_future(_main(args, loop)) - - try: - loop.run_until_complete(task) - except KeyboardInterrupt: - task.cancel() - with suppress(asyncio.CancelledError): - loop.run_until_complete(task) - finally: - loop.stop() - loop.close() + async def _runner(): + loop = asyncio.get_running_loop() + task = asyncio.ensure_future(_main(args, loop)) + try: + await task + except KeyboardInterrupt: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + asyncio.run(_runner()) diff --git a/scripts/joycon_ip_proxy.py b/scripts/joycon_ip_proxy.py index 09fe0bb9..ab0cc654 100755 --- a/scripts/joycon_ip_proxy.py +++ b/scripts/joycon_ip_proxy.py @@ -37,16 +37,16 @@ async def send_from_queue(queue, dst, printd=False): def read_from_sock(sock): async def internal(): - return await asyncio.get_event_loop().sock_recv(sock, 500) + return await asyncio.get_running_loop().sock_recv(sock, 500) return internal def write_to_sock(sock): async def internal(data): - return await asyncio.get_event_loop().sock_sendall(sock, data) + return await asyncio.get_running_loop().sock_sendall(sock, data) return internal async def connect_bt(bt_addr): - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() ctl = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) itr = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) @@ -66,7 +66,7 @@ async def connect_bt(bt_addr): return ctl, itr async def accept_bt(): - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() ctl_srv = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) itr_srv = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) @@ -157,7 +157,7 @@ async def connectEth(eth, server=False): ip, port = eth.split(':') port = int(port) - t, p = await asyncio.get_event_loop().create_datagram_endpoint(lambda: NoDatagramProtocol((ip, port)), local_addr=('0.0.0.0', port), remote_addr=(ip, port)) + t, p = await asyncio.get_running_loop().create_datagram_endpoint(lambda: NoDatagramProtocol((ip, port)), local_addr=('0.0.0.0', port), remote_addr=(ip, port)) # replaces the syn-ack handshake with just sending a single packet to test the # connection beforehand @@ -169,7 +169,7 @@ async def connectEth(eth, server=False): return p.read, p.write, t.close async def _main(sw_addr, jc_addr, buffer=10): - # loop = asyncio.get_event_loop() + # loop = asyncio.get_running_loop() jc_eth = not re.match("([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", jc_addr) sw_eth = not re.match("([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", sw_addr) diff --git a/scripts/relay_joycon.py b/scripts/relay_joycon.py index 41b37e34..9ee3b47c 100644 --- a/scripts/relay_joycon.py +++ b/scripts/relay_joycon.py @@ -26,7 +26,7 @@ def __init__(self, capture_file=None): self._capture_file = capture_file async def relay_input(self, hid_device, client_itr): - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() while True: data = await hid_device.read(100) @@ -43,7 +43,7 @@ async def relay_input(self, hid_device, client_itr): await asyncio.sleep(0) async def relay_output(self, hid_device, client_itr): - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() while True: data = await loop.sock_recv(client_itr, 50) @@ -82,7 +82,7 @@ async def get_hid_controller(): async def _main(capture_file=None, reconnect_bt_addr=None): - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() if reconnect_bt_addr == None: # Creating l2cap sockets, we have to do this before restarting bluetooth @@ -174,8 +174,7 @@ async def _main(capture_file=None, reconnect_bt_addr=None): log.configure() with utils.get_output(args.log, default=None) as capture_file: - loop = asyncio.get_event_loop() - loop.run_until_complete( + asyncio.run( _main(capture_file=capture_file, reconnect_bt_addr=args.reconnect_bt_addr) ) diff --git a/setup.py b/setup.py index 7743742e..687531f9 100644 --- a/setup.py +++ b/setup.py @@ -2,15 +2,21 @@ from setuptools import setup, find_packages setup(name='joycontrol', - version='0.15', + version='0.16', author='Robert Martin', author_email='martinro@informatik.hu-berlin.de', description='Emulate Nintendo Switch Controllers over Bluetooth', packages=find_packages(), package_data={'joycontrol': ['profile/sdp_record_hid.xml']}, zip_safe=False, + python_requires='>=3.9', install_requires=[ - 'hid', 'aioconsole', 'dbus-python' + 'hid', + 'aioconsole', + 'crc8', + # dbus-python must come from the distro package (python3-dbus) on most + # systems because it links against system libraries — pip-installing it + # often fails. We list it here for completeness; install with apt if pip fails. + 'dbus-python', ] ) - From d1a226d24fa793b6b09eb8b3434933e73d838d35 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 15:06:31 +0800 Subject: [PATCH 02/24] Fix Python 3.12 breakage in protocol writer and disconnect path Two upstream bugs surfaced when running under Python 3.12: 1) `_writer` had a bare `except: break` that silently swallowed every exception, including asyncio.CancelledError (which derives from BaseException since 3.8). The result on 3.12: the writer task exits immediately after starting, the input-report stream stops, and the Switch refuses to accept the controller as a player. Replace with targeted handlers: re-raise CancelledError, log NotConnectedError at info, log every other exception with a traceback before breaking. 2) `connection_lost` called `set_exception()` on `self._controller_state_sender`, which was created via `asyncio.ensure_future(...)` and is therefore a Task. Python 3.10+ disallows `Task.set_exception()` ("Task does not support set_exception operation"). Switch the sender to a plain Future created via `loop.create_future()`, driven by a helper waiter task that mirrors the sig_is_send event onto the future. The Future still supports set_exception(), so disconnect handling works again. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/protocol.py | 51 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index 19758c03..3a15b123 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -171,7 +171,16 @@ async def _writer(self): input_report = self._generate_input_report() try: await self._write(input_report) - except: + except asyncio.CancelledError: + # Propagate cancellation; on Python 3.8+ CancelledError derives + # from BaseException and must not be silently swallowed. + logger.info("writer cancelled") + raise + except NotConnectedError as exc: + logger.info(f"writer: transport not connected ({exc}), exiting") + break + except Exception: + logger.exception("writer: unexpected error during _write, exiting") break # calculate delay self.send_delay = debug.get_delay(self.send_delay) #debug hook @@ -263,8 +272,13 @@ def connection_lost(self, exc: Optional[Exception] = None) -> None: asyncio.ensure_future(self.transport.close()) self.transport = None - if self._controller_state_sender is not None: - self._controller_state_sender.set_exception(NotConnectedError) + sender = self._controller_state_sender + if sender is not None and not sender.done(): + # On Python 3.10+, asyncio.Task.set_exception() was disallowed + # ("Task does not support set_exception operation"). _controller_state_sender + # is now a Future created via loop.create_future() in send_controller_state(), + # which still supports set_exception(). + sender.set_exception(NotConnectedError()) def error_received(self, exc: Exception) -> None: # TODO? @@ -315,10 +329,33 @@ async def send_controller_state(self): else: self._controller_state.sig_is_send.clear() - # wrap into a future to be able to set an exception in case of a disconnect - self._controller_state_sender = asyncio.ensure_future(self._controller_state.sig_is_send.wait()) - await self._controller_state_sender - self._controller_state_sender = None + # Use a plain Future (not a Task) so connection_lost() can call + # set_exception() on it. Drive it from a helper task that waits on + # the sig_is_send event. + loop = asyncio.get_running_loop() + self._controller_state_sender = loop.create_future() + sender = self._controller_state_sender + waiter = asyncio.ensure_future(self._controller_state.sig_is_send.wait()) + + def _on_signaled(_task: asyncio.Task) -> None: + if sender.done(): + return + if _task.cancelled(): + sender.cancel() + return + exc = _task.exception() + if exc is not None: + sender.set_exception(exc) + else: + sender.set_result(None) + + waiter.add_done_callback(_on_signaled) + try: + await sender + finally: + if not waiter.done(): + waiter.cancel() + self._controller_state_sender = None async def wait_for_output_report(self): """ From 8bab5f910448e494c0d3e40b722d32ea67c27cad Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 15:09:13 +0800 Subject: [PATCH 03/24] Fix MyBoundedSemaphore on Python 3.10+: stop using self._loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asyncio.Semaphore stopped accepting/storing a `loop` parameter in Python 3.10, and on 3.12 `self._loop` is None on the subclass. The writer task crashed with `AttributeError: 'NoneType' object has no attribute 'create_future'` on every input report, so the input-report stream never started and the Switch wouldn't accept the controller. Resolve the loop the supported way — `asyncio.get_running_loop()` from inside the coroutine — when constructing the per-request Future. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/my_semaphore.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/joycontrol/my_semaphore.py b/joycontrol/my_semaphore.py index 08ee1b11..4aed7dfc 100644 --- a/joycontrol/my_semaphore.py +++ b/joycontrol/my_semaphore.py @@ -1,9 +1,12 @@ import asyncio class _Request: - def __init__(self, value, loop): + def __init__(self, value): self.value = value - self.future = loop.create_future() + # Use the currently running loop. asyncio.Semaphore no longer exposes + # `self._loop` on Python 3.10+, and even when it did, get_running_loop() + # is the supported way to obtain a loop from inside a coroutine. + self.future = asyncio.get_running_loop().create_future() class MySemaphore(asyncio.Semaphore): @@ -28,7 +31,7 @@ async def acquire(self, count=1): if count < 0: raise ValueError("Semaphore acquire with count < 0") while self._value < count: - r = _Request(count, self._loop) + r = _Request(count) self._waiters.append(r) try: await r.future From 4b8f3cd542a81556e928eff6a01972dd4b4f60cb Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 15:41:28 +0800 Subject: [PATCH 04/24] Suppress CancelledError noise from writer task on clean shutdown When the CLI exits, transport.close() cancels the writer task. The writer correctly propagates CancelledError, but the task's done callback (create_error_check_callback) re-raised it, which asyncio's default handler then logged twice as "Exception in callback". All the other start_asyncio_thread() callers in transport.py already pass ignore=asyncio.CancelledError. The writer thread had been missing this. Match the others. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index 3a15b123..8939fa8a 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -489,6 +489,6 @@ async def _command_set_player_lights(self, input_report, sub_command_data): input_report.set_ack(0x80) input_report.reply_to_subcommand_id(SubCommand.SET_PLAYER_LIGHTS.value) - self._writer_thread = utils.start_asyncio_thread(self._writer()) + self._writer_thread = utils.start_asyncio_thread(self._writer(), ignore=asyncio.CancelledError) self.sig_input_ready.set() return input_report From 8d6481c6c836e834c069c721adaa4575f007ff59 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 16:04:31 +0800 Subject: [PATCH 05/24] Don't log clean shutdown as ERROR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user types `exit`, transport.close() calls connection_lost(None). Per asyncio convention, exc=None means clean shutdown — but the protocol was logging it as ERROR ("Connection lost."), which made every clean exit look like a failure. - protocol.connection_lost(): log INFO when exc is None, ERROR when an exception is provided. - transport.write(): when an OSError/ConnectionResetError forces us into connection_lost(), pass the exception so the log path knows it's a real loss and not a clean close. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/protocol.py | 7 ++++++- joycontrol/transport.py | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index 8939fa8a..8be84a9f 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -268,7 +268,12 @@ def connection_made(self, transport: BaseTransport) -> None: def connection_lost(self, exc: Optional[Exception] = None) -> None: if self.transport is not None: - logger.error('Connection lost.') + # Per asyncio convention, exc=None means a clean shutdown. + # Anything else is unexpected. + if exc is None: + logger.info('Connection closed.') + else: + logger.error(f'Connection lost: {exc}') asyncio.ensure_future(self.transport.close()) self.transport = None diff --git a/joycontrol/transport.py b/joycontrol/transport.py index 8f224563..8ef6b48b 100644 --- a/joycontrol/transport.py +++ b/joycontrol/transport.py @@ -185,10 +185,10 @@ async def write(self, data: Any) -> None: await self._loop.sock_sendall(self._itr_sock, _bytes) except OSError as err: logger.error(err) - self._protocol.connection_lost() + self._protocol.connection_lost(err) except ConnectionResetError as err: logger.error(err) - self._protocol.connection_lost() + self._protocol.connection_lost(err) async def writelines(*data): for d in data: From 6b55fa8d3e128c0e56aa6707523db7e146f39e37 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 16:40:36 +0800 Subject: [PATCH 06/24] Add list_paired_switches.sh to inspect bond history bluez already tracks per-device bond state in /var/lib/bluetooth// /info, and the file's mtime gets bumped whenever the bond is touched (connect / disconnect / link-key update). That makes it a reasonable proxy for "most recently connected" without joycontrol having to track its own connection log. The script lists paired devices for the default adapter, sorted by mtime descending, with the device name and bond timestamp. Useful for finding the address to pass to `run_controller_cli.py -r `. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/list_paired_switches.sh | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100755 scripts/list_paired_switches.sh diff --git a/scripts/list_paired_switches.sh b/scripts/list_paired_switches.sh new file mode 100755 index 00000000..f2e3ff37 --- /dev/null +++ b/scripts/list_paired_switches.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Lists paired Bluetooth devices for the default adapter, most-recent bond first. +# Useful for finding the address to pass to `run_controller_cli.py -r `. +# +# bluez stores per-device bond data under /var/lib/bluetooth///info. +# The file's mtime is bumped on connect / link-key update / disconnect, so sorting +# by mtime gives a reasonable "most recently used" ordering. (Not exact connection +# timestamps — bluez doesn't keep those.) +# +# Requires root because /var/lib/bluetooth is mode 0700. + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo "must be run as root" >&2 + exit 1 +fi + +ADAPTER=$(bluetoothctl show | awk '/Controller/{print $2; exit}') +if [ -z "$ADAPTER" ]; then + echo "no Bluetooth adapter found" >&2 + exit 1 +fi + +DIR=/var/lib/bluetooth/$ADAPTER +if [ ! -d "$DIR" ]; then + echo "no bond directory at $DIR" >&2 + exit 1 +fi + +found=0 +for d in $(ls -1t "$DIR" 2>/dev/null | grep -E '^[0-9A-F:]{17}$'); do + name=$(grep -m1 '^Name=' "$DIR/$d/info" 2>/dev/null | cut -d= -f2-) + ts=$(stat -c %y "$DIR/$d/info" 2>/dev/null | cut -d. -f1) + printf "%s %-20s %s\n" "$d" "${name:-(unknown)}" "$ts" + found=1 +done + +if [ "$found" -eq 0 ]; then + echo "no paired devices for adapter $ADAPTER" +fi From a7b64db461523a9640325b38a44950eb92b82202 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 16:48:13 +0800 Subject: [PATCH 07/24] Quiet routine 'Code is running X s too slow' warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The writer loop targets a 1/60 s cadence in input-report modes 0x30/0x31. Routine sub-100 ms overruns happen on every connection — Linux scheduler jitter and L2CAP flow-control waits where the writer correctly blocks on _write_lock but active_time still accounts for the wait. Logging every one of those at WARNING level was just noise, and drowned out actual stalls worth investigating. Only warn for overruns greater than 100 ms. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/protocol.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index 8be84a9f..b1d46c46 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -187,7 +187,10 @@ async def _writer(self): active_time = time.time() - last_send_time sleep_time = self.send_delay - active_time if sleep_time < 0: - logger.warning(f'Code is running {abs(sleep_time)} s too slow!') + # Only flag genuine stalls. Sub-100ms overruns are routine — + # mostly L2CAP flow-control waits and Linux scheduler jitter. + if abs(sleep_time) > 0.1: + logger.warning(f'Code is running {abs(sleep_time):.3f} s too slow!') sleep_time = 0 try: From f4f7ca0e73de6c68207221f9eb621c5baa17f222 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 16:50:47 +0800 Subject: [PATCH 08/24] Fix '-r auto' picker when multiple paired Switches exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.py iterated `paths.items()` for the interactive picker, but `HidDevice.get_paired_switches()` returns a list, not a dict — so running `run_controller_cli.py -r auto` against a host with more than one paired/cached Switch crashed with AttributeError. Use `enumerate(paths, start=1)` to match the 1-based prompt that follows ("number 1 - N [1]:"). Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index ba114b3d..fcdbfa4b 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -137,7 +137,7 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= elif len(paths) > 1: if interactive: print("found the following paired switches, please choose one:") - for i, p in paths.items(): + for i, p in enumerate(paths, start=1): print(f" {i}: {p}") choice = input(f"number 1 - {len(paths)} [1]:") if not choice: From 6194a3eeb55f720925c22e2e1d382b7d7fd69414 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 16:52:11 +0800 Subject: [PATCH 09/24] Show last-bond timestamp and an abort option in '-r auto' picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `-r auto` finds multiple paired Switches, the picker now shows the last bond mtime alongside each DBus path so you can tell which Switch is the most recent. It also adds a "0: abort" option (or 'q'/'Q') so you can back out of the prompt without picking blindly. Bond mtime is read from /var/lib/bluetooth///info — the same file timestamp used by scripts/list_paired_switches.sh. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/server.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index fcdbfa4b..dbe27716 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -1,6 +1,9 @@ import asyncio import logging +import os import socket +import sys +import time from importlib.resources import files import dbus @@ -138,12 +141,25 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= if interactive: print("found the following paired switches, please choose one:") for i, p in enumerate(paths, start=1): - print(f" {i}: {p}") - choice = input(f"number 1 - {len(paths)} [1]:") + addr = HidDevice.get_address_of_paired_path(p) + info_path = f"/var/lib/bluetooth/{bt_addr}/{addr}/info" + try: + ts = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(os.path.getmtime(info_path)), + ) + except OSError: + ts = "unknown" + print(f" {i}: {p} (last bond: {ts})") + print(" 0: abort") + choice = input(f"number 1 - {len(paths)}, 0 to abort [1]: ") if not choice: path = paths[0] + elif choice.strip() in ('0', 'q', 'Q'): + print("aborted") + sys.exit(0) else: - path = paths[int(choice)-1] + path = paths[int(choice) - 1] else: path = paths[0] logger.warning(f"Automatic reconnect address chose {path} out of {paths}") From d7ed246edcd6fee1a77b6473fbf274bc371aa0f8 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 17:30:03 +0800 Subject: [PATCH 10/24] Switch CLI to prompt_toolkit: live prompt, tab completion, history The old aioconsole.ainput-based prompt had three usability problems: - log lines printed mid-input garbled the `cmd >>` prompt and any text the user had typed - no tab completion - no command history / arrow-key recall across runs Replace it with prompt_toolkit's PromptSession: - patch_stdout(raw=True) buffers writes during the prompt and replays them on the line above, so logs no longer collide with input - WordCompleter built from registered cmd_* methods, dynamic commands, button names (for ControllerCLI), and stick keywords gives Tab completion for the things you actually type - FileHistory at $JOYCONTROL_STATE_DIR (or XDG_STATE_HOME or ~/.local/state)/joycontrol/cli_history persists Up/Down history between runs logging_default now writes to sys.stdout instead of the default stderr, so patch_stdout intercepts log output. Ctrl-D / Ctrl-C at the prompt exit cleanly via the existing return path. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/command_line_interface.py | 183 +++++++++++++++++---------- joycontrol/logging_default.py | 6 +- setup.py | 1 + 3 files changed, 121 insertions(+), 69 deletions(-) diff --git a/joycontrol/command_line_interface.py b/joycontrol/command_line_interface.py index d3b5f908..7814a123 100644 --- a/joycontrol/command_line_interface.py +++ b/joycontrol/command_line_interface.py @@ -1,8 +1,13 @@ import inspect import logging +import os import shlex +from pathlib import Path -from aioconsole import ainput +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.history import FileHistory +from prompt_toolkit.patch_stdout import patch_stdout from joycontrol.controller_state import button_push, ControllerState from joycontrol.transport import NotConnectedError @@ -10,6 +15,24 @@ logger = logging.getLogger(__name__) +def _state_dir() -> Path: + base = os.environ.get('JOYCONTROL_STATE_DIR') \ + or os.environ.get('XDG_STATE_HOME') \ + or str(Path.home() / '.local' / 'state') + p = Path(base) / 'joycontrol' + p.mkdir(parents=True, exist_ok=True) + return p + + +def _make_session(words): + return PromptSession( + message='cmd >> ', + completer=WordCompleter(sorted(set(words)), ignore_case=True), + history=FileHistory(str(_state_dir() / 'cli_history')), + complete_while_typing=False, + ) + + def _print_doc(string): """ Attempts to remove common white space at the start of the lines in a doc string @@ -60,34 +83,47 @@ async def cmd_help(self): print('Commands can be chained using "&&"') print('Type "exit" to close.') - async def run(self): - while True: - user_input = await ainput(prompt='cmd >> ') - if not user_input: - continue - - for command in user_input.split('&&'): - cmd, *args = shlex.split(command) + def _completion_words(self): + words = ['exit', 'help'] + for name, _ in inspect.getmembers(self): + if name.startswith('cmd_'): + words.append(name[len('cmd_'):]) + words.extend(self.commands.keys()) + return words - if cmd == 'exit': + async def run(self): + session = _make_session(self._completion_words()) + with patch_stdout(raw=True): + while True: + try: + user_input = await session.prompt_async() + except (EOFError, KeyboardInterrupt): return - - if hasattr(self, f'cmd_{cmd}'): - try: - result = await getattr(self, f'cmd_{cmd}')(*args) - if result: - print(result) - except Exception as e: - print(e) - elif cmd in self.commands: - try: - result = await self.commands[cmd](*args) - if result: - print(result) - except Exception as e: - print(e) - else: - print('command', cmd, 'not found, call help for help.') + if not user_input: + continue + + for command in user_input.split('&&'): + cmd, *args = shlex.split(command) + + if cmd == 'exit': + return + + if hasattr(self, f'cmd_{cmd}'): + try: + result = await getattr(self, f'cmd_{cmd}')(*args) + if result: + print(result) + except Exception as e: + print(e) + elif cmd in self.commands: + try: + result = await self.commands[cmd](*args) + if result: + print(result) + except Exception as e: + print(e) + else: + print('command', cmd, 'not found, call help for help.') @staticmethod def deprecated(message): @@ -158,46 +194,59 @@ async def cmd_stick(self, side, direction, value=None): else: raise ValueError('Value of side must be "l", "left" or "r", "right"') - async def run(self): - while True: - user_input = await ainput(prompt='cmd >> ') - if not user_input: - continue + def _completion_words(self): + words = super()._completion_words() + words.extend(self.controller_state.button_state.get_available_buttons()) + # stick command operands + words.extend(['stick', 'l', 'r', 'left', 'right', + 'center', 'up', 'down', 'h', 'v', 'horizontal', 'vertical']) + return words - buttons_to_push = [] - - for command in user_input.split('&&'): - cmd, *args = shlex.split(command) - - if cmd == 'exit': - return - - available_buttons = self.controller_state.button_state.get_available_buttons() - - if hasattr(self, f'cmd_{cmd}'): - try: - result = await getattr(self, f'cmd_{cmd}')(*args) - if result: - print(result) - except Exception as e: - print(e) - elif cmd in self.commands: - try: - result = await self.commands[cmd](*args) - if result: - print(result) - except Exception as e: - print(e) - elif cmd in available_buttons: - buttons_to_push.append(cmd) - else: - print('command', cmd, 'not found, call help for help.') - - if buttons_to_push: - await button_push(self.controller_state, *buttons_to_push) - else: + async def run(self): + session = _make_session(self._completion_words()) + with patch_stdout(raw=True): + while True: try: - await self.controller_state.send() - except NotConnectedError: - logger.info('Connection was lost.') + user_input = await session.prompt_async() + except (EOFError, KeyboardInterrupt): return + if not user_input: + continue + + buttons_to_push = [] + + for command in user_input.split('&&'): + cmd, *args = shlex.split(command) + + if cmd == 'exit': + return + + available_buttons = self.controller_state.button_state.get_available_buttons() + + if hasattr(self, f'cmd_{cmd}'): + try: + result = await getattr(self, f'cmd_{cmd}')(*args) + if result: + print(result) + except Exception as e: + print(e) + elif cmd in self.commands: + try: + result = await self.commands[cmd](*args) + if result: + print(result) + except Exception as e: + print(e) + elif cmd in available_buttons: + buttons_to_push.append(cmd) + else: + print('command', cmd, 'not found, call help for help.') + + if buttons_to_push: + await button_push(self.controller_state, *buttons_to_push) + else: + try: + await self.controller_state.send() + except NotConnectedError: + logger.info('Connection was lost.') + return diff --git a/joycontrol/logging_default.py b/joycontrol/logging_default.py index 0e80561a..898116e5 100644 --- a/joycontrol/logging_default.py +++ b/joycontrol/logging_default.py @@ -1,5 +1,6 @@ import logging import datetime +import sys def configure(console_level=logging.DEBUG, file_level=logging.DEBUG, logfile_name=None): @@ -18,8 +19,9 @@ def configure(console_level=logging.DEBUG, file_level=logging.DEBUG, logfile_nam "%H:%M:%S" ) - # create console logger - console_handler = logging.StreamHandler() + # Write to stdout so prompt_toolkit's patch_stdout can buffer log output + # and render it above the live `cmd >>` prompt instead of breaking it. + console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(formatter) console_handler.setLevel(console_level) diff --git a/setup.py b/setup.py index 687531f9..c1a6b84b 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ 'hid', 'aioconsole', 'crc8', + 'prompt-toolkit', # dbus-python must come from the distro package (python3-dbus) on most # systems because it links against system libraries — pip-installing it # often fails. We list it here for completeness; install with apt if pip fails. From eb3676ae35aaa93b8034ac2282e625d010f1b23d Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 17:39:37 +0800 Subject: [PATCH 11/24] Fix mash/test_buttons stop-on-enter under prompt_toolkit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in run_controller_cli.py's mash/test_buttons commands: 1) They still used aioconsole.ainput, which competed for stdin against prompt_toolkit's terminal-mode handling left over from the parent CLI session. Result: pressing Enter never resolved the input future and the loop ran forever until Ctrl-C. 2) Even if Enter had been received, the loop awaited `asyncio.sleep(interval)` between button pushes, so a stop only took effect after the next full interval elapsed — visibly broken for `mash x 0.5` and worse for longer intervals. Add joycontrol.command_line_interface.wait_for_enter() that uses a fresh PromptSession (single source of truth for stdin in this process) and call it from both spots. Race the inter-button sleep against the stop signal via asyncio.wait_for(shield(user_input), timeout=...) so Enter ends the loop immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/command_line_interface.py | 14 ++++++++++++ run_controller_cli.py | 34 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/joycontrol/command_line_interface.py b/joycontrol/command_line_interface.py index 7814a123..af61287b 100644 --- a/joycontrol/command_line_interface.py +++ b/joycontrol/command_line_interface.py @@ -33,6 +33,20 @@ def _make_session(words): ) +async def wait_for_enter(message: str = '') -> None: + """ + Block until the user presses enter (or sends EOF / Ctrl-C). Replaces + aioconsole.ainput inside the CLI so we don't end up with two competing + stdin readers — that mix caused the `mash` stop-on-enter to hang under + prompt_toolkit's terminal mode. + """ + session = PromptSession(message=message) + try: + await session.prompt_async() + except (EOFError, KeyboardInterrupt): + return + + def _print_doc(string): """ Attempts to remove common white space at the start of the lines in a doc string diff --git a/run_controller_cli.py b/run_controller_cli.py index 339f9b78..705f5239 100755 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -5,11 +5,9 @@ import logging import os -from aioconsole import ainput - import joycontrol.debug as debug from joycontrol import logging_default as log, utils -from joycontrol.command_line_interface import ControllerCLI +from joycontrol.command_line_interface import ControllerCLI, wait_for_enter from joycontrol.controller import Controller from joycontrol.controller_state import ControllerState, button_push, button_press, button_release from joycontrol.memory import FlashMemory @@ -67,7 +65,7 @@ async def test_controller_buttons(controller_state: ControllerState): # waits until controller is fully connected await controller_state.connect() - await ainput(prompt='Make sure the Switch is in the Home menu and press to continue.') + await wait_for_enter('Make sure the Switch is in the Home menu and press to continue.') """ # We assume we are in the "Change Grip/Order" menu of the switch @@ -119,19 +117,22 @@ async def test_controller_buttons(controller_state: ControllerState): button_list.remove('home') user_input = asyncio.ensure_future( - ainput(prompt='Pressing all buttons... Press to stop.') + wait_for_enter('Pressing all buttons... Press to stop.') ) # push all buttons consecutively until user input while not user_input.done(): for button in button_list: await button_push(controller_state, button) - await asyncio.sleep(0.1) - - if user_input.done(): + try: + # race the inter-button delay against the stop signal so + # Enter takes effect within ~0.1s instead of after the loop. + await asyncio.wait_for(asyncio.shield(user_input), timeout=0.1) break + except asyncio.TimeoutError: + pass - # await future to trigger exceptions in case something went wrong + # await future to surface any exceptions await user_input # go back to home @@ -155,14 +156,21 @@ async def mash_button(controller_state, button, interval): ensure_valid_button(controller_state, button) user_input = asyncio.ensure_future( - ainput(prompt=f'Pressing the {button} button every {interval} seconds... Press to stop.') + wait_for_enter(f'Pressing the {button} button every {interval} seconds... Press to stop.') ) - # push a button repeatedly until user input + interval_s = float(interval) + # push a button repeatedly, but race the interval sleep against the + # stop signal so Enter takes effect immediately instead of after the + # next interval elapses. while not user_input.done(): await button_push(controller_state, button) - await asyncio.sleep(float(interval)) + try: + await asyncio.wait_for(asyncio.shield(user_input), timeout=interval_s) + break + except asyncio.TimeoutError: + pass - # await future to trigger exceptions in case something went wrong + # await future to surface any exceptions await user_input def _register_commands_with_controller_state(controller_state, cli): From 63e26a304cf1aa18dd08d2e08cb42725913671e0 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 17:42:41 +0800 Subject: [PATCH 12/24] Default -r to 'auto'; fall back to initial pairing if no Switch paired Most invocations of run_controller_cli.py needed -r auto to reconnect to an already-paired Switch. Make that the default so a bare invocation "just works": - argparse default for --reconnect_bt_addr is now 'auto'. - create_hid_server() normalizes 'auto' to None when no Switch is paired, falling through to the initial-pairing flow instead of fatal-exiting. - '' or 'none' (case-insensitive) are accepted as an explicit opt-out for users who really want to force initial pairing even with bonds present. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/server.py | 12 ++++++++++++ run_controller_cli.py | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index dbe27716..dc3a3a72 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -51,6 +51,18 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= # await hid.set_address("94:58:CB" + bt_addr[8:], interactive=interactive) # bt_addr = hid.get_address() + # Normalize reconnect_bt_addr. The CLI default is 'auto', so the most + # common case is "reconnect if a Switch is paired, otherwise fall through + # to initial-pairing flow". Treat an empty string or 'none' as an explicit + # opt-out from reconnect. + if isinstance(reconnect_bt_addr, str): + normalized = reconnect_bt_addr.strip().lower() + if normalized in ('', 'none'): + reconnect_bt_addr = None + elif normalized == 'auto' and not hid.get_paired_switches(): + logger.info('no paired Switch found; falling back to initial pairing flow') + reconnect_bt_addr = None + if reconnect_bt_addr is None: if interactive: if len(hid.get_UUIDs()) > 3: diff --git a/run_controller_cli.py b/run_controller_cli.py index 705f5239..a701cc91 100755 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -356,8 +356,8 @@ async def _main(args): parser.add_argument('-l', '--log', help="BT-communication logfile output") parser.add_argument('-d', '--device_id', help='not fully working yet, the BT-adapter to use') parser.add_argument('--spi_flash', help="controller SPI-memory dump to use") - parser.add_argument('-r', '--reconnect_bt_addr', type=str, default=None, - help='The Switch console Bluetooth address (or "auto" for automatic detection), for reconnecting as an already paired controller.') + parser.add_argument('-r', '--reconnect_bt_addr', type=str, default='auto', + help='The Switch console Bluetooth address (or "auto", the default, for automatic detection) for reconnecting as an already paired controller. Pass "" or "none" to force the initial-pairing flow.') parser.add_argument('--nfc', type=str, default=None, help="amiibo dump placed on the controller. Äquivalent to the nfc command.") args = parser.parse_args() From aa479dc95ba221d43bdb5ad77addafbc9251bf08 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 17:43:46 +0800 Subject: [PATCH 13/24] Default controller arg to PRO_CONTROLLER Make the positional `controller` arg optional with a PRO_CONTROLLER default. The bare invocation `run_controller_cli.py` now sets up a Pro Controller, which combined with -r defaulting to 'auto' lets you reconnect to your last Switch with zero arguments. Co-Authored-By: Claude Opus 4.7 (1M context) --- run_controller_cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/run_controller_cli.py b/run_controller_cli.py index a701cc91..f90882da 100755 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -352,7 +352,8 @@ async def _main(args): log.configure() parser = argparse.ArgumentParser() - parser.add_argument('controller', help='JOYCON_R, JOYCON_L or PRO_CONTROLLER') + parser.add_argument('controller', nargs='?', default='PRO_CONTROLLER', + help='JOYCON_R, JOYCON_L or PRO_CONTROLLER (default: PRO_CONTROLLER)') parser.add_argument('-l', '--log', help="BT-communication logfile output") parser.add_argument('-d', '--device_id', help='not fully working yet, the BT-adapter to use') parser.add_argument('--spi_flash', help="controller SPI-memory dump to use") From 4a413d4f5c35f1add740f3199fa2d798d9897114 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 17:44:31 +0800 Subject: [PATCH 14/24] Use argparse choices/default instead of hardcoded controller help Replace the manually-typed "JOYCON_R, JOYCON_L or PRO_CONTROLLER (default: PRO_CONTROLLER)" help string with `choices=[c.name for c in Controller]`, which makes argparse: - show the valid options in the auto-generated usage line - reject invalid values with a clear "invalid choice" error before Controller.from_arg gets called - stay in sync if a new Controller variant is ever added argparse appends the default to the help automatically when "%(default)s" is used, but here `choices=` plus the existing default already produce the right rendering, so the help text can just describe purpose. Co-Authored-By: Claude Opus 4.7 (1M context) --- run_controller_cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/run_controller_cli.py b/run_controller_cli.py index f90882da..e2bbb521 100755 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -353,7 +353,8 @@ async def _main(args): parser = argparse.ArgumentParser() parser.add_argument('controller', nargs='?', default='PRO_CONTROLLER', - help='JOYCON_R, JOYCON_L or PRO_CONTROLLER (default: PRO_CONTROLLER)') + choices=[c.name for c in Controller], + help='controller type to emulate') parser.add_argument('-l', '--log', help="BT-communication logfile output") parser.add_argument('-d', '--device_id', help='not fully working yet, the BT-adapter to use') parser.add_argument('--spi_flash', help="controller SPI-memory dump to use") From c5374cb7d15c91314b6975c80365e14dd0d47bdf Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 17:52:12 +0800 Subject: [PATCH 15/24] Sort paired-Switch picker by last-bond mtime (most recent first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort `paths` from get_paired_switches() by the bond file's mtime descending before showing the picker. Effects: - Interactive picker: option 1 is always the most-recently-bonded Switch — what you almost always want when reconnecting. - Non-interactive `-r auto` with multiple paired Switches: previously picked an arbitrary entry (DBus iteration order); now picks the most recent one. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/server.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index dc3a3a72..e2857351 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -149,19 +149,26 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= if not paths: logger.fatal("couldn't find paired switch to reconnect to, terminating...") exit(1) - elif len(paths) > 1: + + # Sort by last bond mtime (most recent first) so option 1 is + # always the most recently used Switch. + def _bond_mtime(p): + addr = HidDevice.get_address_of_paired_path(p) + info_path = f"/var/lib/bluetooth/{bt_addr}/{addr}/info" + try: + return os.path.getmtime(info_path) + except OSError: + return 0.0 + + paths = sorted(paths, key=_bond_mtime, reverse=True) + + if len(paths) > 1: if interactive: print("found the following paired switches, please choose one:") for i, p in enumerate(paths, start=1): - addr = HidDevice.get_address_of_paired_path(p) - info_path = f"/var/lib/bluetooth/{bt_addr}/{addr}/info" - try: - ts = time.strftime( - "%Y-%m-%d %H:%M:%S", - time.localtime(os.path.getmtime(info_path)), - ) - except OSError: - ts = "unknown" + mt = _bond_mtime(p) + ts = (time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mt)) + if mt > 0 else "unknown") print(f" {i}: {p} (last bond: {ts})") print(" 0: abort") choice = input(f"number 1 - {len(paths)}, 0 to abort [1]: ") From 706563103b530650aec7fdcca0a19935bf8d15a1 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 17:59:10 +0800 Subject: [PATCH 16/24] Add 'pair new' option to the -r auto picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the auto-resolution logic into _resolve_auto() and run it as part of reconnect_bt_addr normalization, before the if/else split. This lets the picker fall through to initial pairing by simply returning None — no special-case plumbing needed in the caller. User-visible changes: - The picker now shows even when only one Switch is paired (so you can pair a new one without first unpairing what you have). - New 'n' (or 'new') option pairs a fresh Switch via the existing initial-pairing flow. - Existing '0' / 'q' (abort) and Enter (most-recent default) still work. - Numeric choices outside the valid range now abort cleanly with a message instead of crashing with IndexError. Sample prompt: found the following paired switches, please choose one: 1: /org/bluez/hci0/dev_AA_.. (last bond: 2026-05-01 16:36:48) 2: /org/bluez/hci0/dev_BB_.. (last bond: 2026-05-01 14:31:04) n: pair a new Switch 0: abort number 1 - 2, n to pair new, 0 to abort [1]: Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/server.py | 114 ++++++++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 49 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index e2857351..44953ff7 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -23,6 +23,66 @@ async def _send_empty_input_reports(transport): await transport.write(report) await asyncio.sleep(1) + +def _resolve_auto(hid: HidDevice, adapter_addr: str, interactive: bool): + """ + Resolve `-r auto` to either a concrete BD address (reconnect path) or + None (fall through to initial pairing). When interactive and at least + one Switch is paired, present a menu so the user can pick a paired + Switch, pair a new one, or abort. + + :returns BD address string for reconnect, or None for initial pairing. + """ + paths = hid.get_paired_switches() + if not paths: + logger.info('no paired Switch found; falling back to initial pairing flow') + return None + + def bond_mtime(p): + addr = HidDevice.get_address_of_paired_path(p) + info_path = f"/var/lib/bluetooth/{adapter_addr}/{addr}/info" + try: + return os.path.getmtime(info_path) + except OSError: + return 0.0 + + paths = sorted(paths, key=bond_mtime, reverse=True) + + if not interactive: + if len(paths) > 1: + logger.warning(f"Automatic reconnect address chose {paths[0]} out of {paths}") + else: + logger.info(f"auto detected paired switch {paths[0]}") + return HidDevice.get_address_of_paired_path(paths[0]) + + print("found the following paired switches, please choose one:") + for i, p in enumerate(paths, start=1): + mt = bond_mtime(p) + ts = (time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mt)) + if mt > 0 else "unknown") + print(f" {i}: {p} (last bond: {ts})") + print(" n: pair a new Switch") + print(" 0: abort") + choice = input(f"number 1 - {len(paths)}, n to pair new, 0 to abort [1]: ").strip().lower() + + if choice == '': + return HidDevice.get_address_of_paired_path(paths[0]) + if choice in ('0', 'q'): + print("aborted") + sys.exit(0) + if choice in ('n', 'new'): + logger.info('user chose to pair a new Switch; falling through to initial pairing flow') + return None + try: + idx = int(choice) + except ValueError: + print(f"unrecognized choice {choice!r}, aborting") + sys.exit(1) + if not 1 <= idx <= len(paths): + print(f"choice {idx} out of range, aborting") + sys.exit(1) + return HidDevice.get_address_of_paired_path(paths[idx - 1]) + async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id=None, reconnect_bt_addr=None, capture_file=None, interactive=False): """ @@ -59,9 +119,8 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= normalized = reconnect_bt_addr.strip().lower() if normalized in ('', 'none'): reconnect_bt_addr = None - elif normalized == 'auto' and not hid.get_paired_switches(): - logger.info('no paired Switch found; falling back to initial pairing flow') - reconnect_bt_addr = None + elif normalized == 'auto': + reconnect_bt_addr = _resolve_auto(hid, bt_addr, interactive) if reconnect_bt_addr is None: if interactive: @@ -143,52 +202,9 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= hid.pairable(False) else: - if reconnect_bt_addr.lower() == 'auto': - paths = hid.get_paired_switches() - path = "" - if not paths: - logger.fatal("couldn't find paired switch to reconnect to, terminating...") - exit(1) - - # Sort by last bond mtime (most recent first) so option 1 is - # always the most recently used Switch. - def _bond_mtime(p): - addr = HidDevice.get_address_of_paired_path(p) - info_path = f"/var/lib/bluetooth/{bt_addr}/{addr}/info" - try: - return os.path.getmtime(info_path) - except OSError: - return 0.0 - - paths = sorted(paths, key=_bond_mtime, reverse=True) - - if len(paths) > 1: - if interactive: - print("found the following paired switches, please choose one:") - for i, p in enumerate(paths, start=1): - mt = _bond_mtime(p) - ts = (time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mt)) - if mt > 0 else "unknown") - print(f" {i}: {p} (last bond: {ts})") - print(" 0: abort") - choice = input(f"number 1 - {len(paths)}, 0 to abort [1]: ") - if not choice: - path = paths[0] - elif choice.strip() in ('0', 'q', 'Q'): - print("aborted") - sys.exit(0) - else: - path = paths[int(choice) - 1] - else: - path = paths[0] - logger.warning(f"Automatic reconnect address chose {path} out of {paths}") - else: - path = paths[0] - logger.info(f"auto detected paired switch {path}") - reconnect_bt_addr = hid.get_address_of_paired_path(path) - else: - # Todo: figure out if we're actually paired - pass + # reconnect_bt_addr is already a concrete address — _resolve_auto + # handled the 'auto' case earlier and either returned an address + # or None (which would have hit the if-None branch above). # Reconnection to reconnect_bt_addr client_ctl = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) client_itr = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) From 025dbfe64c1dfa4718fd8308d226fdd2122dbcd1 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 18:02:51 +0800 Subject: [PATCH 17/24] Stop prompting to unpair existing Switches before initial pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Warning: a switch was found paired, do you want to unpair it?' prompt fired on every initial-pairing attempt for every existing bond, forcing the user through a y/n cycle just to pair an additional Switch. Now that the auto picker has explicit 'pair new' / 'reconnect' / 'abort' options, the user has already made the connect-vs-pair choice upstream. The unpair prompt offered no useful new information — it just disturbed unrelated bonds. Drop the prompt and the matching non-interactive 'switches are paired' warning. The SDP-record-count warning is preserved because it's still informational about Switch compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/server.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index 44953ff7..56ec466a 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -123,22 +123,18 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= reconnect_bt_addr = _resolve_auto(hid, bt_addr, interactive) if reconnect_bt_addr is None: + # The user has already made the connect-vs-pair choice (either via + # `-r ` / `-r auto` picker, or by passing `-r none`). Don't + # prompt to unpair existing bonds — pairing a new Switch should not + # disturb other Switches the host is already bonded to. if interactive: if len(hid.get_UUIDs()) > 3: - print("too many SPD-records active, Switch might refuse connection.") - print("try modifieing /lib/systemd/system/bluetooth.service and see") + print("too many SDP records active, Switch might refuse connection.") + print("try modifying /lib/systemd/system/bluetooth.service and see") print("https://github.com/Poohl/joycontrol/issues/4 if it doesn't work") - for sw in hid.get_paired_switches(): - print(f"Warning: a switch ({sw}) was found paired, do you want to unpair it?") - i = input("y/n [y]: ") - if i == '' or i == 'y' or i == 'Y': - hid.unpair_path(sw) else: if len(hid.get_UUIDs()) > 3: - logger.warning("detected too many SDP-records. Switch might refuse connection.") - b = hid.get_paired_switches() - if b: - logger.warning(f"Attempting initial pairing, but switches are paired: {b}") + logger.warning("detected too many SDP records. Switch might refuse connection.") ctl_sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) itr_sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) From c7e02fe4519c3163909f46553ab32ca552b860fb Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 18:09:58 +0800 Subject: [PATCH 18/24] Add 'unpair' option to the -r auto menu and loop until a final choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the picker into a reusable read-eval loop so the user can: - 1..N: reconnect to that paired Switch - Enter: most recent (default) - n / new: fall through to initial pairing - u / unpair: pick a Switch to forget, with y/N confirm; menu re-prints - 0 / q: abort Bad/out-of-range input no longer aborts the script — the menu just reprints with a hint, since the user is now in an interactive loop. The unpair path uses HidDevice.unpair_path (already implemented) which calls org.bluez.Adapter1.RemoveDevice — same effect as `bluetoothctl remove ` — and reflows the menu on success. Co-Authored-By: Claude Opus 4.7 (1M context) --- joycontrol/server.py | 130 +++++++++++++++++++++++++++++-------------- 1 file changed, 89 insertions(+), 41 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index 56ec466a..29b91886 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -24,30 +24,76 @@ async def _send_empty_input_reports(transport): await asyncio.sleep(1) +def _bond_mtime(adapter_addr: str, path: str) -> float: + addr = HidDevice.get_address_of_paired_path(path) + info_path = f"/var/lib/bluetooth/{adapter_addr}/{addr}/info" + try: + return os.path.getmtime(info_path) + except OSError: + return 0.0 + + +def _list_paired(hid: HidDevice, adapter_addr: str): + """Return paired Switch DBus paths sorted by last-bond mtime, newest first.""" + return sorted(hid.get_paired_switches(), + key=lambda p: _bond_mtime(adapter_addr, p), + reverse=True) + + +def _print_menu(paths, adapter_addr: str) -> None: + print("found the following paired switches, please choose one:") + for i, p in enumerate(paths, start=1): + mt = _bond_mtime(adapter_addr, p) + ts = (time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mt)) + if mt > 0 else "unknown") + print(f" {i}: {p} (last bond: {ts})") + print(" n: pair a new Switch") + print(" u: unpair a Switch") + print(" 0: abort") + + +def _prompt_unpair(hid: HidDevice, paths) -> None: + """Ask which entry to unpair, then remove its bond. Tolerant of bad input.""" + if not paths: + return + raw = input(f"unpair which? number 1 - {len(paths)} (Enter to cancel): ").strip() + if not raw: + return + try: + idx = int(raw) + except ValueError: + print(f"unrecognized choice {raw!r}, cancelled") + return + if not 1 <= idx <= len(paths): + print(f"choice {idx} out of range, cancelled") + return + target = paths[idx - 1] + addr = HidDevice.get_address_of_paired_path(target) + confirm = input(f"remove bond for {addr}? y/N: ").strip().lower() + if confirm not in ('y', 'yes'): + print("cancelled") + return + try: + hid.unpair_path(target) + print(f"unpaired {addr}") + except Exception as exc: + print(f"failed to unpair {addr}: {exc}") + + def _resolve_auto(hid: HidDevice, adapter_addr: str, interactive: bool): """ Resolve `-r auto` to either a concrete BD address (reconnect path) or - None (fall through to initial pairing). When interactive and at least - one Switch is paired, present a menu so the user can pick a paired - Switch, pair a new one, or abort. + None (fall through to initial pairing). When interactive, present a + menu so the user can pick a paired Switch, pair a new one, unpair an + existing one, or abort. :returns BD address string for reconnect, or None for initial pairing. """ - paths = hid.get_paired_switches() + paths = _list_paired(hid, adapter_addr) if not paths: logger.info('no paired Switch found; falling back to initial pairing flow') return None - def bond_mtime(p): - addr = HidDevice.get_address_of_paired_path(p) - info_path = f"/var/lib/bluetooth/{adapter_addr}/{addr}/info" - try: - return os.path.getmtime(info_path) - except OSError: - return 0.0 - - paths = sorted(paths, key=bond_mtime, reverse=True) - if not interactive: if len(paths) > 1: logger.warning(f"Automatic reconnect address chose {paths[0]} out of {paths}") @@ -55,33 +101,35 @@ def bond_mtime(p): logger.info(f"auto detected paired switch {paths[0]}") return HidDevice.get_address_of_paired_path(paths[0]) - print("found the following paired switches, please choose one:") - for i, p in enumerate(paths, start=1): - mt = bond_mtime(p) - ts = (time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mt)) - if mt > 0 else "unknown") - print(f" {i}: {p} (last bond: {ts})") - print(" n: pair a new Switch") - print(" 0: abort") - choice = input(f"number 1 - {len(paths)}, n to pair new, 0 to abort [1]: ").strip().lower() - - if choice == '': - return HidDevice.get_address_of_paired_path(paths[0]) - if choice in ('0', 'q'): - print("aborted") - sys.exit(0) - if choice in ('n', 'new'): - logger.info('user chose to pair a new Switch; falling through to initial pairing flow') - return None - try: - idx = int(choice) - except ValueError: - print(f"unrecognized choice {choice!r}, aborting") - sys.exit(1) - if not 1 <= idx <= len(paths): - print(f"choice {idx} out of range, aborting") - sys.exit(1) - return HidDevice.get_address_of_paired_path(paths[idx - 1]) + while True: + if not paths: + logger.info('no paired Switch left; falling back to initial pairing flow') + return None + + _print_menu(paths, adapter_addr) + choice = input(f"number 1 - {len(paths)}, n to pair new, u to unpair, 0 to abort [1]: ").strip().lower() + + if choice == '': + return HidDevice.get_address_of_paired_path(paths[0]) + if choice in ('0', 'q'): + print("aborted") + sys.exit(0) + if choice in ('n', 'new'): + logger.info('user chose to pair a new Switch; falling through to initial pairing flow') + return None + if choice in ('u', 'unpair'): + _prompt_unpair(hid, paths) + paths = _list_paired(hid, adapter_addr) + continue + try: + idx = int(choice) + except ValueError: + print(f"unrecognized choice {choice!r}, try again") + continue + if not 1 <= idx <= len(paths): + print(f"choice {idx} out of range, try again") + continue + return HidDevice.get_address_of_paired_path(paths[idx - 1]) async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id=None, reconnect_bt_addr=None, capture_file=None, interactive=False): From 1ecdda30cb563409fecae06f641fb2e2e9bfca6f Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 18:31:32 +0800 Subject: [PATCH 19/24] Update README install + bluetooth service setup for current behavior Installation: - Add Fedora/RHEL/Oracle Linux dnf line alongside the apt line. - Pivot to `pip install .` (or local venv) since setup.py now lists the full runtime dep set (hid, aioconsole, crc8, prompt-toolkit, dbus-python). - One-line verification command for installed deps. Bluetooth service setup: - Replace the "edit /lib/systemd/system/bluetooth.service" instructions with a systemd drop-in override at /etc/systemd/system/bluetooth. service.d/override.conf, which survives package upgrades. - Document the bluetoothd path difference between Debian-likes (/usr/lib/bluetooth/bluetoothd) and RHEL-likes (/usr/libexec/bluetooth/bluetoothd) and how to check. - Spell out which services break (input/sap/avrcp) so users know what they're trading away. CLI section: - Refresh argparse usage block (controller arg now optional, -r defaults to "auto", `-r ""`/`none` opt-out documented). - Document the new picker (1..N / n / u / 0) with a cheat-sheet table. - Mention scripts/list_paired_switches.sh. - Document tab completion, history, patch_stdout behavior in the prompt. Issues section: refresh the "reconnect spins" entry to point at the new in-picker unpair option instead of dropping out to bluetoothctl. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 210 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 155 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index c0859d8f..32efb013 100644 --- a/README.md +++ b/README.md @@ -14,60 +14,125 @@ Emulation of JOYCON_R, JOYCON_L and PRO_CONTROLLER. Able to send: ## Installation -Tested on Python 3.9+ and BlueZ 5.55+. The legacy `hciconfig`/`hcitool` tools -are deprecated on modern distributions; this project now prefers `btmgmt` -(part of `bluez-tools`) and falls back to the legacy tools only when present. +Tested on Python 3.9+ and BlueZ 5.55+ (verified on Raspbian and Oracle +Linux 10 with Python 3.12 / BlueZ 5.83). The legacy `hciconfig` / +`hcitool` tools are deprecated on modern distributions; this project +prefers `btmgmt` (part of `bluez-tools`) and falls back to the legacy +tools only when present. -- Install dependencies - Raspbian / Debian / Ubuntu: +### System packages + +Debian / Ubuntu / Raspbian: ```bash -sudo apt install python3-dbus python3-hid libhidapi-hidraw0 libbluetooth-dev bluez bluez-tools +sudo apt install python3-dbus libhidapi-hidraw0 libbluetooth-dev bluez bluez-tools ``` - Note: `bluez-tools` provides `btmgmt`. On distributions that no longer ship - `hciconfig`/`hcitool` (e.g. recent Debian/Ubuntu where `bluez` no longer - installs the legacy CLI by default) `btmgmt` is the modern replacement. - Python: +Fedora / RHEL / Oracle Linux: ```bash -sudo pip3 install aioconsole hid crc8 +sudo dnf install python3-dbus hidapi bluez bluez-libs-devel bluez-tools ``` - Or install the project (and its Python deps) directly with: + +`bluez-tools` provides `btmgmt`, which is the modern replacement for +`hciconfig` / `hcitool` and is now the default path used by joycontrol. + +### Python packages + +Easiest — install the project (which pulls in all Python deps) directly: + ```bash sudo pip3 install . ``` - Note that pip here _has_ to be run as root so the packages are available to - the root user (the script must run as root to access raw L2CAP sockets). - If you are unsure if the packages are properly installed, try running - `sudo python3` and import each using `import package_name`. - -- setup bluetooth - - [I shouldn't have to say this, but] make sure you have a working Bluetooth adapter\ - If you are running inside a VM, the PC might but not the VM. Check for a controller using `bluetoothctl show` or `bluetoothctl list`. Also a good indicator it the actual os reporting to not have bluetooth anymore. - - disable SDP [only necessary when pairing]\ - change the `ExecStart` parameter in `/lib/systemd/system/bluetooth.service` to `ExecStart=/usr/lib/bluetooth/bluetoothd -C -P sap,input,avrcp`.\ - This is to remove the additional reported features as the switch only looks for a controller.\ - This also breaks all other Bluetooth gadgets, as this also disabled the needed drivers. - - disable input plugin [experimental alternative to above when not pairing]\ - When not pairing, you can get away with only disabling the `input` plugin, only breaking bluetooth-input devices on your PC. Do so by changing `ExecStart` to `ExecStart=/usr/lib/bluetooth/bluetoothd -C -P input` instead. - - Restart bluetooth-deamon to apply the changes: - ```bash - sudo systemctl daemon-reload - sudo systemctl restart bluetooth.service - ``` - - see [Issue #4](https://github.com/Poohl/joycontrol/issues/4) if despite that the switch doesn't connect or disconnects randomly. + +A project-local venv is fine too: + +```bash +python3 -m venv .venv +sudo .venv/bin/pip install . +``` + +joycontrol must run as root (raw L2CAP sockets), so install where the +root user can find the packages — either system-wide or in a venv that +you'll launch via `sudo .venv/bin/python ...`. + +To verify the install: +```bash +sudo python3 -c "import dbus, hid, aioconsole, crc8, prompt_toolkit" +``` +Should exit silently. + +## Bluetooth service setup + +The Switch refuses to connect if the adapter advertises non-controller +profiles like AVRCP — you need to disable the `input`, `sap`, and +`avrcp` plugins on `bluetoothd`. The maintainable way is a systemd +drop-in (won't be clobbered by package upgrades): + +```bash +sudo mkdir -p /etc/systemd/system/bluetooth.service.d +sudo tee /etc/systemd/system/bluetooth.service.d/override.conf >/dev/null <<'EOF' +[Service] +ExecStart= +ExecStart=/usr/libexec/bluetooth/bluetoothd -C -P sap,input,avrcp +EOF +sudo systemctl daemon-reload +sudo systemctl restart bluetooth.service +``` + +The `bluetoothd` binary path differs by distro: +- `/usr/libexec/bluetooth/bluetoothd` — Fedora / RHEL family +- `/usr/lib/bluetooth/bluetoothd` — Debian / Ubuntu / Raspbian + +Check yours and adjust the override if needed: +```bash +systemctl cat bluetooth.service | grep -m1 ExecStart=/usr +``` + +Verify the right flags are in effect: +```bash +ps -ef | grep bluetoothd +# ... /usr/libexec/bluetooth/bluetoothd -C -P sap,input,avrcp +``` + +### What this breaks (host-wide) +- `input` — disables Bluetooth keyboards / mice / joysticks on this host. +- `sap` — SIM Access Profile (rarely used). +- `avrcp` — media remote control (e.g. play/pause from BT headphones). + +For *reconnecting* to an already-paired Switch you can sometimes get +away with only disabling `input`, but **initial pairing** needs all +three or the Switch sees too many SDP records and refuses (see +[Issue #4](https://github.com/Poohl/joycontrol/issues/4)). + +### Make sure the adapter actually exists + +If running in a VM, the host might have Bluetooth but the VM might +not. Confirm with `bluetoothctl show` — you want to see a `Controller` +line with a real BD address. ## Command line interface example -There is a simple CLI (`sudo python3 run_controller_cli.py`) provided with this app. Startup-options are: + +A simple CLI lives in `run_controller_cli.py`. Bare invocation: + +```bash +sudo python3 run_controller_cli.py +``` + +…defaults to emulating a Pro Controller and reconnecting to your most +recently paired Switch (or falling through to initial pairing if none). + +Startup options: + ``` usage: run_controller_cli.py [-h] [-l LOG] [-d DEVICE_ID] [--spi_flash SPI_FLASH] [-r RECONNECT_BT_ADDR] [--nfc NFC] - controller + [{JOYCON_L,JOYCON_R,PRO_CONTROLLER}] positional arguments: - controller JOYCON_R, JOYCON_L or PRO_CONTROLLER + {JOYCON_L,JOYCON_R,PRO_CONTROLLER} + controller type to emulate (default: PRO_CONTROLLER) -optional arguments: +options: -h, --help show this help message and exit -l LOG, --log LOG BT-communication logfile output -d DEVICE_ID, --device_id DEVICE_ID @@ -75,31 +140,66 @@ optional arguments: --spi_flash SPI_FLASH controller SPI-memory dump to use -r RECONNECT_BT_ADDR, --reconnect_bt_addr RECONNECT_BT_ADDR - The Switch console Bluetooth address (or "auto" for - automatic detection), for reconnecting as an already - paired controller. - --nfc NFC amiibo dump placed on the controller. Equivalent to - the nfc command. + Switch BD address, "auto" (the default) for picker, + or "" / "none" to force initial pairing + --nfc NFC amiibo dump placed on the controller (same as the + in-prompt `nfc` command) +``` + +### Pairing / reconnecting +When at least one Switch is already paired, the script presents an +interactive picker: + +``` +found the following paired switches, please choose one: + 1: /org/bluez/hci0/dev_AA_AA_AA_AA_AA_AA (last bond: 2026-05-01 16:36:48) + 2: /org/bluez/hci0/dev_BB_BB_BB_BB_BB_BB (last bond: 2026-05-01 14:31:04) + n: pair a new Switch + u: unpair a Switch + 0: abort +number 1 - 2, n to pair new, u to unpair, 0 to abort [1]: ``` -To use the script: -- start it (this is a minimal example) +| Input | Result | +|----------------|----------------------------------------------------------------------| +| Enter | reconnect to the most-recently bonded Switch (option 1) | +| `1`–`N` | reconnect to that entry | +| `n` / `new` | initial-pairing flow — open *Change Grip/Order* on the Switch | +| `u` / `unpair` | pick a Switch to forget (with `y/N` confirm), menu reflows | +| `0` / `q` | exit cleanly | + +To bypass the picker entirely: +- `-r 04:03:D6:8F:08:B5` — reconnect to that specific BD address +- `-r ""` or `-r none` — force initial pairing even if other Switches are paired + +If no Switch is paired yet, the picker is skipped and the script goes +straight to the initial-pairing flow — open *Change Grip/Order* on the +Switch. + +### Inspecting paired Switches + +`scripts/list_paired_switches.sh` lists paired devices for the default +adapter, sorted by last-bond timestamp: + ```bash -sudo python3 run_controller_cli.py PRO_CONTROLLER +sudo ./scripts/list_paired_switches.sh +# 04:03:D6:8F:08:B5 Nintendo Switch 2026-05-01 16:36:48 ``` -- The cli does sanity checks on startup, you might get promps telling you they failed. Check the command-line options and your setup in this case. (Note: not the logging messages). You can however still try to proceed, sometimes it works despite the warnings. - -- Afterwards a PRO_CONTROLLER instance waiting for the Switch to connect is created. -- If you didn't pass the `-r` option, Open the "Change Grip/Order" menu of the Switch and wait for it to pair. +### Inside the prompt -- If you already connected the emulated controller once, you can use the reconnect option of the script (`-r `). Don't open the "Change Grip/Order" menu in this case, just make sure the switch is turned on. You can find out a paired mac address using the `bluetoothctl paired-devices` system command or pass `-r auto` as address for automatic detection. +Once connected, a `cmd >>` prompt opens with: -- After connecting, a command line interface is opened. - Note: Press \ if you don't see a prompt. +- **Tab** — completes commands and button names. +- **↑ / ↓** — recalls previous commands (persisted at + `~/.local/state/joycontrol/cli_history`, overridable via + `$JOYCONTROL_STATE_DIR` or `$XDG_STATE_HOME`). +- Logs render *above* the prompt without disturbing your input. +- **Ctrl-D** / **Ctrl-C** / `exit` exit cleanly. - Call "help" to see a list of available commands. +Type `help` for the full command list (button names, `stick`, `mash`, +`hold`/`release`, `nfc`, `pause`/`unpause`, etc.). ## API @@ -127,9 +227,9 @@ await controller_state.send() ``` ## Issues -- Some bluetooth adapters seem to cause disconnects for reasons unknown, try to use an usb adapter or a raspi instead. -- Incompatibility with Bluetooth "input" plugin requires it to be disabled (along with the others), see [Issue #8](https://github.com/mart1nro/joycontrol/issues/8) -- The reconnect doesn't ever connect, `bluetoothctl` shows the connection constantly turning on and off. This means the switch tries initial pairing, you have to unpair the switch and try without the `-r` option again. +- Some Bluetooth adapters cause disconnects for reasons unknown — try a USB adapter or a Raspberry Pi instead. +- Incompatibility with Bluetooth "input" plugin (and `sap` / `avrcp` for initial pairing) requires them to be disabled — see the *Bluetooth service setup* section above and [Issue #8](https://github.com/mart1nro/joycontrol/issues/8). +- Reconnect spins (`bluetoothctl` shows the connection bouncing on/off) usually means the Switch lost its bond key but the host still has one. Use the `u` / unpair option in the picker to forget the host's bond, then pick `n` to pair fresh. - ... ## Thanks From 41c7c48facb9af063486fe1bb257e1cc34a54a22 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 18:32:53 +0800 Subject: [PATCH 20/24] Note CodeReady Builder repo for bluez-libs-devel on RHEL-likes On Oracle Linux / RHEL / AlmaLinux / Rocky, bluez-libs-devel ships in the CodeReady Builder (CRB) repo, which isn't enabled by default. Add the per-distro enable command so first-time installers don't get a "package not found" error from the dnf line. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 32efb013..d28e83f4 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,22 @@ Fedora / RHEL / Oracle Linux: sudo dnf install python3-dbus hidapi bluez bluez-libs-devel bluez-tools ``` +`bluez-libs-devel` lives in the CodeReady Builder repo, which is not +enabled by default. Enable it first if `dnf` can't find the package: + +```bash +# Oracle Linux 10 +sudo dnf config-manager --enable ol10_codeready_builder + +# RHEL 10 (with a Red Hat subscription) +sudo subscription-manager repos --enable codeready-builder-for-rhel-10-x86_64-rpms + +# AlmaLinux / Rocky Linux 10 +sudo dnf config-manager --set-enabled crb +``` + +Adjust the version number (`ol10_…`, `…rhel-10-…`) for your release. + `bluez-tools` provides `btmgmt`, which is the modern replacement for `hciconfig` / `hcitool` and is now the default path used by joycontrol. From 4efd91c2d50a3a4389f7d45a0b078639c254aee9 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 18:35:19 +0800 Subject: [PATCH 21/24] Drop aioconsole dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aioconsole.ainput was the original stdin reader; the prompt_toolkit refactor replaced every call site (CLI prompt, mash, test_buttons, the various 'press to stop' blockers). The package isn't imported anywhere now — the only remaining references are dead install_requires + verification command lines. Keep the historical mention in command_line_interface.wait_for_enter's docstring since it explains why we route stdin through prompt_toolkit sessions exclusively (avoiding the two-readers-on-stdin race that broke mash's stop-on-enter under terminal raw mode). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- setup.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index d28e83f4..e17f5fe4 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ you'll launch via `sudo .venv/bin/python ...`. To verify the install: ```bash -sudo python3 -c "import dbus, hid, aioconsole, crc8, prompt_toolkit" +sudo python3 -c "import dbus, hid, crc8, prompt_toolkit" ``` Should exit silently. diff --git a/setup.py b/setup.py index c1a6b84b..ead5f249 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,6 @@ python_requires='>=3.9', install_requires=[ 'hid', - 'aioconsole', 'crc8', 'prompt-toolkit', # dbus-python must come from the distro package (python3-dbus) on most From bc28eae227f6ad92e5279ce0c8b9bdaf94d847d9 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 19:21:12 +0800 Subject: [PATCH 22/24] Fix install path: drop dbus-python from install_requires, use --system-site-packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real issues hit while dogfooding the README install on a clean Oracle Linux 10 host: 1. `pip install .` failed with `metadata-generation-failed` for dbus-python. Listing it in install_requires triggers a build from source (no compiler in venv → fails). Drop it from install_requires with an in-tree comment explaining why; treat it as a system package only. 2. `bluez-tools` doesn't exist on RHEL family — `btmgmt` ships in the main `bluez` package there. Adjust the dnf line and document the distro split. README now uses `python3 -m venv --system-site-packages .venv` so the distro python3-dbus stays visible inside the project venv. The verify command runs against the venv interpreter. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 33 ++++++++++++++++++++++----------- setup.py | 10 ++++++---- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e17f5fe4..1b4a85b8 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,12 @@ sudo apt install python3-dbus libhidapi-hidraw0 libbluetooth-dev bluez bluez-too Fedora / RHEL / Oracle Linux: ```bash -sudo dnf install python3-dbus hidapi bluez bluez-libs-devel bluez-tools +sudo dnf install python3-dbus hidapi bluez bluez-libs-devel ``` +On RHEL-family distros `btmgmt` ships *inside* the main `bluez` package, +so there's no separate `bluez-tools`. + `bluez-libs-devel` lives in the CodeReady Builder repo, which is not enabled by default. Enable it first if `dnf` can't find the package: @@ -48,31 +51,39 @@ sudo dnf config-manager --set-enabled crb Adjust the version number (`ol10_…`, `…rhel-10-…`) for your release. -`bluez-tools` provides `btmgmt`, which is the modern replacement for -`hciconfig` / `hcitool` and is now the default path used by joycontrol. +On Debian/Ubuntu, `btmgmt` is shipped as the separate `bluez-tools` +package (already in the apt line above). Either way, joycontrol's +modernized adapter code uses `btmgmt` as the default replacement for +the deprecated `hciconfig` / `hcitool`. ### Python packages -Easiest — install the project (which pulls in all Python deps) directly: +joycontrol uses `python3-dbus` from the distro (it links against +system libraries — pip-building it from source is painful and needs a +C toolchain plus dbus/glib headers). The recommended setup is a +project-local venv that inherits the system's dbus binding: ```bash -sudo pip3 install . +python3 -m venv --system-site-packages .venv +sudo .venv/bin/pip install . ``` -A project-local venv is fine too: +`--system-site-packages` is what makes the system `python3-dbus` +visible inside the venv. Without it, `import dbus` will fail. + +If you'd rather install system-wide (no venv): ```bash -python3 -m venv .venv -sudo .venv/bin/pip install . +sudo pip3 install . ``` joycontrol must run as root (raw L2CAP sockets), so install where the -root user can find the packages — either system-wide or in a venv that -you'll launch via `sudo .venv/bin/python ...`. +root user can find the packages — either system-wide or in a venv +that you'll launch via `sudo .venv/bin/python ...`. To verify the install: ```bash -sudo python3 -c "import dbus, hid, crc8, prompt_toolkit" +sudo .venv/bin/python -c "import dbus, hid, crc8, prompt_toolkit" ``` Should exit silently. diff --git a/setup.py b/setup.py index ead5f249..9bc1ada1 100644 --- a/setup.py +++ b/setup.py @@ -14,9 +14,11 @@ 'hid', 'crc8', 'prompt-toolkit', - # dbus-python must come from the distro package (python3-dbus) on most - # systems because it links against system libraries — pip-installing it - # often fails. We list it here for completeness; install with apt if pip fails. - 'dbus-python', + # NOTE: dbus-python is intentionally NOT listed here. It links + # against system libraries and pip-building it from source needs + # a C toolchain plus dbus-devel/glib-devel headers, which is + # painful to set up in a venv. Use the distro package + # (`python3-dbus` on apt, `python3-dbus` on dnf) and create the + # project venv with `--system-site-packages` so it's visible. ] ) From 70674cfe5677ac5cf9b57b8ed567fca6a984c2f2 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 20:11:44 +0800 Subject: [PATCH 23/24] README: rewrite Installation and Bluetooth setup as numbered steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks through the full setup from a clean clone, written for readers who've never set up a Python venv or BlueZ before. Each step explains *why* it's needed, not just what to type. Installation: 1. git clone (start from zero) 2. distro-package install (apt vs dnf, with CRB enable for RHEL) 3. python3 -m venv --system-site-packages .venv (with explanation of why the flag matters — system dbus visibility) 4. sudo .venv/bin/pip install . (with a note on why sudo) 5. import-test verification with the failure-mode hint Bluetooth setup also reformatted as numbered steps, with a small table mapping distro family to bluetoothd binary path so users can fill their own value into the drop-in override correctly. Adds an explicit "how to revert" line and an adapter-existence sanity check. No behavior change — README only. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 202 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 144 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 1b4a85b8..2003c6a9 100644 --- a/README.md +++ b/README.md @@ -15,84 +15,148 @@ Emulation of JOYCON_R, JOYCON_L and PRO_CONTROLLER. Able to send: ## Installation Tested on Python 3.9+ and BlueZ 5.55+ (verified on Raspbian and Oracle -Linux 10 with Python 3.12 / BlueZ 5.83). The legacy `hciconfig` / +Linux 10.1 with Python 3.12 / BlueZ 5.83). The legacy `hciconfig` / `hcitool` tools are deprecated on modern distributions; this project -prefers `btmgmt` (part of `bluez-tools`) and falls back to the legacy -tools only when present. +prefers `btmgmt` (the modern bluez management tool) and falls back to +the legacy tools only when present. -### System packages +These steps assume a fresh setup. Run them in order. Everything below +is reversible — nothing is installed system-wide except a few distro +packages and a systemd drop-in (covered in *Bluetooth service setup* +below). + +### Step 1 — Get the source -Debian / Ubuntu / Raspbian: ```bash -sudo apt install python3-dbus libhidapi-hidraw0 libbluetooth-dev bluez bluez-tools +git clone https://github.com/Poohl/joycontrol.git +cd joycontrol ``` -Fedora / RHEL / Oracle Linux: +(Substitute your own fork URL if you're using one.) + +### Step 2 — Install the system packages + +joycontrol talks to BlueZ over D-Bus and reads HID devices via HIDAPI, +so a few distro packages have to be present. This step is the only one +that needs to touch system state outside the project directory. + +**Debian / Ubuntu / Raspberry Pi OS (Raspbian):** + ```bash -sudo dnf install python3-dbus hidapi bluez bluez-libs-devel +sudo apt update +sudo apt install python3-venv python3-dbus libhidapi-hidraw0 libbluetooth-dev bluez bluez-tools ``` -On RHEL-family distros `btmgmt` ships *inside* the main `bluez` package, -so there's no separate `bluez-tools`. +**Fedora / RHEL / Oracle Linux 10:** -`bluez-libs-devel` lives in the CodeReady Builder repo, which is not -enabled by default. Enable it first if `dnf` can't find the package: +`bluez-libs-devel` lives in the **CodeReady Builder** repo, which is +disabled by default — enable it once with the line that matches your +distro: ```bash # Oracle Linux 10 sudo dnf config-manager --enable ol10_codeready_builder -# RHEL 10 (with a Red Hat subscription) -sudo subscription-manager repos --enable codeready-builder-for-rhel-10-x86_64-rpms - # AlmaLinux / Rocky Linux 10 sudo dnf config-manager --set-enabled crb + +# Red Hat Enterprise Linux 10 (with an active subscription) +sudo subscription-manager repos --enable codeready-builder-for-rhel-10-x86_64-rpms ``` -Adjust the version number (`ol10_…`, `…rhel-10-…`) for your release. +Then install the packages: + +```bash +sudo dnf install python3 python3-dbus hidapi bluez bluez-libs-devel +``` -On Debian/Ubuntu, `btmgmt` is shipped as the separate `bluez-tools` -package (already in the apt line above). Either way, joycontrol's -modernized adapter code uses `btmgmt` as the default replacement for -the deprecated `hciconfig` / `hcitool`. +> **Why no `bluez-tools`?** On Debian-family distros, `btmgmt` ships in +> a separate `bluez-tools` package; on RHEL-family distros it lives +> *inside* the main `bluez` package. Either way, you end up with +> `btmgmt` available — that's what joycontrol uses. -### Python packages +### Step 3 — Create a Python virtualenv -joycontrol uses `python3-dbus` from the distro (it links against -system libraries — pip-building it from source is painful and needs a -C toolchain plus dbus/glib headers). The recommended setup is a -project-local venv that inherits the system's dbus binding: +A virtualenv keeps the project's Python dependencies isolated from +the rest of your system so they don't conflict with anything else. + +From the cloned project directory, run: ```bash python3 -m venv --system-site-packages .venv -sudo .venv/bin/pip install . ``` -`--system-site-packages` is what makes the system `python3-dbus` -visible inside the venv. Without it, `import dbus` will fail. +This creates a `.venv/` folder inside the project. The +`--system-site-packages` flag is **important**: it lets the venv see +the distro-installed `python3-dbus` from step 2. + +> **Why `--system-site-packages`?** `python3-dbus` is a C extension +> that links against your system's D-Bus libraries. Building it from +> source via `pip` requires a C toolchain plus dbus/glib headers, and +> usually fails. Having the venv inherit the distro package +> sidesteps that whole problem. -If you'd rather install system-wide (no venv): +### Step 4 — Install joycontrol + +This installs the joycontrol package and its remaining Python +dependencies (`hid`, `crc8`, `prompt-toolkit`) *into* the venv: ```bash -sudo pip3 install . +sudo .venv/bin/pip install . ``` -joycontrol must run as root (raw L2CAP sockets), so install where the -root user can find the packages — either system-wide or in a venv -that you'll launch via `sudo .venv/bin/python ...`. +`sudo` is needed here only because the next step (running joycontrol) +must be root to access raw Bluetooth sockets, and the venv files +should be readable by root. + +### Step 5 — Verify the install -To verify the install: ```bash sudo .venv/bin/python -c "import dbus, hid, crc8, prompt_toolkit" ``` -Should exit silently. + +Should print nothing and exit cleanly. If you get +`ModuleNotFoundError: No module named 'dbus'`, you forgot +`--system-site-packages` in step 3 — delete `.venv/` and redo step 3. + +--- + +After these five steps the Python side is done. You **also** need to +adjust BlueZ so the Switch will accept connections — that's the next +section. Without that, the script will start but the Switch will +reject the controller during pairing. ## Bluetooth service setup -The Switch refuses to connect if the adapter advertises non-controller -profiles like AVRCP — you need to disable the `input`, `sap`, and -`avrcp` plugins on `bluetoothd`. The maintainable way is a systemd -drop-in (won't be clobbered by package upgrades): +The Switch is picky about what it connects to. If the host advertises +extra Bluetooth profiles (audio remote, SIM access, regular HID +input), the Switch sees too many service records and **refuses to +pair**. We tell BlueZ to drop those plugins so the controller is the +only thing the Switch sees. + +The cleanest way is a **systemd drop-in override** — a small +configuration file that adjusts the existing `bluetooth.service` +without touching anything BlueZ ships, so it survives package updates. + +### Step 1 — Find your bluetoothd binary path + +```bash +systemctl cat bluetooth.service | grep -m1 ExecStart=/usr +``` + +You'll see one of these two paths in the output: + +| Distro family | `bluetoothd` path | +|------------------------------|-----------------------------------------| +| Fedora / RHEL / Oracle Linux | `/usr/libexec/bluetooth/bluetoothd` | +| Debian / Ubuntu / Raspbian | `/usr/lib/bluetooth/bluetoothd` | + +Note which one you have — you'll plug it into the next step. + +### Step 2 — Write the override + +Replace `/usr/libexec/bluetooth/bluetoothd` below with the path you +found in step 1 if yours differs: ```bash sudo mkdir -p /etc/systemd/system/bluetooth.service.d @@ -101,40 +165,62 @@ sudo tee /etc/systemd/system/bluetooth.service.d/override.conf >/dev/null <<'EOF ExecStart= ExecStart=/usr/libexec/bluetooth/bluetoothd -C -P sap,input,avrcp EOF +``` + +The blank `ExecStart=` line is important — it tells systemd to discard +BlueZ's default command before applying ours. + +### Step 3 — Reload and restart bluetoothd + +```bash sudo systemctl daemon-reload sudo systemctl restart bluetooth.service ``` -The `bluetoothd` binary path differs by distro: -- `/usr/libexec/bluetooth/bluetoothd` — Fedora / RHEL family -- `/usr/lib/bluetooth/bluetoothd` — Debian / Ubuntu / Raspbian +### Step 4 — Verify -Check yours and adjust the override if needed: ```bash -systemctl cat bluetooth.service | grep -m1 ExecStart=/usr +ps -ef | grep bluetoothd | grep -v grep ``` -Verify the right flags are in effect: +You should see the daemon running with the `-C -P sap,input,avrcp` +flags appended, e.g.: + +``` +root 7274 ... /usr/libexec/bluetooth/bluetoothd -C -P sap,input,avrcp +``` + +Confirm there's a working adapter too (especially if you're inside a +VM — the host might have Bluetooth, but the VM might not): + ```bash -ps -ef | grep bluetoothd -# ... /usr/libexec/bluetooth/bluetoothd -C -P sap,input,avrcp +bluetoothctl show ``` +You want a `Controller` line with a real BD address (e.g. `5C:F3:70:…`). +If you don't, joycontrol won't be able to do anything until you fix +the adapter situation — typically by passing through a USB Bluetooth +dongle or running on bare metal. + ### What this breaks (host-wide) -- `input` — disables Bluetooth keyboards / mice / joysticks on this host. -- `sap` — SIM Access Profile (rarely used). -- `avrcp` — media remote control (e.g. play/pause from BT headphones). -For *reconnecting* to an already-paired Switch you can sometimes get -away with only disabling `input`, but **initial pairing** needs all -three or the Switch sees too many SDP records and refuses (see -[Issue #4](https://github.com/Poohl/joycontrol/issues/4)). +The override disables three BlueZ plugins for *everything* on this +machine, not just joycontrol. After applying it: + +- `input` — Bluetooth keyboards / mice / game controllers won't work. +- `sap` — SIM Access Profile (used to share a phone's SIM with a + car kit) won't work; almost certainly nobody cares. +- `avrcp` — media remote control over Bluetooth (play/pause from BT + headphones, etc.) won't work. -### Make sure the adapter actually exists +For *reconnecting* to a Switch you've already paired with, you can +sometimes get away with disabling only `input`. But **initial pairing +needs all three** disabled or the Switch refuses the connection. See +[Issue #4](https://github.com/Poohl/joycontrol/issues/4) for the +underlying details. -If running in a VM, the host might have Bluetooth but the VM might -not. Confirm with `bluetoothctl show` — you want to see a `Controller` -line with a real BD address. +To revert: delete `/etc/systemd/system/bluetooth.service.d/override.conf` +and restart bluetooth.service. ## Command line interface example From 4847727488a577d321bfd4788c9d5830d9ea8ea2 Mon Sep 17 00:00:00 2001 From: Chiawei Chen Date: Fri, 1 May 2026 20:25:49 +0800 Subject: [PATCH 24/24] README: use .venv/bin/python and require cd into project dir for CLI `sudo python3 run_controller_cli.py` would run the script under the system Python, which doesn't see the venv's prompt-toolkit / hid / crc8 / etc., producing "ModuleNotFoundError: No module named 'prompt_toolkit'" right at startup. Using `.venv/bin/python` invokes the venv interpreter directly without needing source-activate. Add an upfront `cd /path/to/joycontrol` and explain the .venv/bin/ prefix for first-time readers. Also call out that list_paired_switches.sh is run from the project directory. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2003c6a9..039f9adb 100644 --- a/README.md +++ b/README.md @@ -224,14 +224,24 @@ and restart bluetooth.service. ## Command line interface example -A simple CLI lives in `run_controller_cli.py`. Bare invocation: +A simple CLI lives in `run_controller_cli.py`. All commands below +assume you're in the project directory and have set up the venv as +described in *Installation*: ```bash -sudo python3 run_controller_cli.py +cd /path/to/joycontrol # the directory you cloned into +``` + +Bare invocation: + +```bash +sudo .venv/bin/python run_controller_cli.py ``` …defaults to emulating a Pro Controller and reconnecting to your most recently paired Switch (or falling through to initial pairing if none). +Use `.venv/bin/python` (not `python3`) so the venv's prompt-toolkit / +hid / crc8 deps are picked up. Startup options: @@ -292,8 +302,8 @@ Switch. ### Inspecting paired Switches -`scripts/list_paired_switches.sh` lists paired devices for the default -adapter, sorted by last-bond timestamp: +From the project directory, `scripts/list_paired_switches.sh` lists +paired devices for the default adapter, sorted by last-bond timestamp: ```bash sudo ./scripts/list_paired_switches.sh