diff --git a/README.md b/README.md index cac5c9ad..039f9adb 100644 --- a/README.md +++ b/README.md @@ -13,46 +13,249 @@ 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+ (verified on Raspbian and Oracle +Linux 10.1 with Python 3.12 / BlueZ 5.83). The legacy `hciconfig` / +`hcitool` tools are deprecated on modern distributions; this project +prefers `btmgmt` (the modern bluez management tool) and falls back to +the legacy tools only when present. + +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 + +```bash +git clone https://github.com/Poohl/joycontrol.git +cd joycontrol +``` + +(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 apt update +sudo apt install python3-venv python3-dbus libhidapi-hidraw0 libbluetooth-dev bluez bluez-tools +``` + +**Fedora / RHEL / Oracle Linux 10:** + +`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 + +# 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 +``` + +Then install the packages: + +```bash +sudo dnf install python3 python3-dbus hidapi bluez bluez-libs-devel +``` + +> **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. + +### Step 3 — Create a Python virtualenv + +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 +``` + +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. + +### Step 4 — Install joycontrol + +This installs the joycontrol package and its remaining Python +dependencies (`hid`, `crc8`, `prompt-toolkit`) *into* the venv: + +```bash +sudo .venv/bin/pip install . +``` + +`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 + +```bash +sudo .venv/bin/python -c "import dbus, hid, crc8, prompt_toolkit" +``` + +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 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 -sudo apt install python3-dbus libhidapi-hidraw0 libbluetooth-dev bluez +systemctl cat bluetooth.service | grep -m1 ExecStart=/usr ``` - 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. + +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 +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 +``` + +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 +``` + +### Step 4 — Verify + ```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`. - -- 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. +ps -ef | grep bluetoothd | grep -v grep +``` + +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 +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) + +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. + +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. + +To revert: delete `/etc/systemd/system/bluetooth.service.d/override.conf` +and restart bluetooth.service. ## 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`. All commands below +assume you're in the project directory and have set up the venv as +described in *Installation*: + +```bash +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: + ``` 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 @@ -60,31 +263,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 + +From the project directory, `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 @@ -112,9 +350,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 diff --git a/joycontrol/command_line_interface.py b/joycontrol/command_line_interface.py index d3b5f908..af61287b 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,38 @@ 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, + ) + + +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 @@ -60,34 +97,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 +208,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 - - 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() + 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 - 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/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/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/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 diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index 19758c03..b1d46c46 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -171,14 +171,26 @@ 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 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: @@ -259,12 +271,22 @@ 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 - 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 +337,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): """ @@ -452,6 +497,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 diff --git a/joycontrol/server.py b/joycontrol/server.py index 8ba5e323..29b91886 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -1,16 +1,19 @@ import asyncio import logging +import os import socket +import sys +import time +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__) @@ -20,6 +23,114 @@ async def _send_empty_input_reports(transport): await transport.write(report) 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, 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 = _list_paired(hid, adapter_addr) + if not paths: + logger.info('no paired Switch found; falling back to initial pairing flow') + return None + + 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]) + + 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): """ @@ -48,23 +159,30 @@ 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': + 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) @@ -116,7 +234,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) @@ -128,32 +246,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) - elif len(paths) > 1: - if interactive: - print("found the following paired switches, please choose one:") - for i, p in paths.items(): - print(f" {i}: {p}") - choice = input(f"number 1 - {len(paths)} [1]:") - if not choice: - path = paths[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) @@ -168,7 +263,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/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: 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(' 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): @@ -344,16 +352,15 @@ 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', + 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") - 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() - loop = asyncio.get_event_loop() - loop.run_until_complete( - _main(args) - ) + asyncio.run(_main(args)) diff --git a/scripts/change_btaddr.sh b/scripts/change_btaddr.sh index 38c8e958..4f81c58c 100755 --- a/scripts/change_btaddr.sh +++ b/scripts/change_btaddr.sh @@ -4,12 +4,13 @@ # and 3B+ (untestd) to 94:58:CB for Nintendo Co. Ltd. # For some reason after a reboot you have to run -# sudo hcitool cmd 0x3f 0x001 0x66 0x55 0x44 0x33 0x22 0x11 -# where 11:22:33:44:55:66 is your mac address. -# (yes the ordering is on purpose, pass in reverse to hcitool) +# sudo btmgmt --index 0 public-addr 11:22:33:44:55:66 +# (or, on systems still shipping bluez-tools' hcitool: +# sudo hcitool cmd 0x3f 0x001 0x66 0x55 0x44 0x33 0x22 0x11 — note reversed byte order) -if [ -z "$1" ] -then +set -e + +if [ -z "$1" ]; then bdaddr_dev=$(bluetoothctl show | grep -Eo '(:[0-9a-fA-F]{2}){3}\s') target_addr="94:58:CB${bdaddr_dev}" echo "detected dev id: ${bdaddr_dev}" @@ -18,8 +19,25 @@ else fi echo "changing address to ${target_addr}" -bdaddr -i hci0 "${target_addr}" -hciconfig hci0 reset + +if command -v bdaddr >/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/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 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..9bc1ada1 100644 --- a/setup.py +++ b/setup.py @@ -2,15 +2,23 @@ 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', + 'crc8', + 'prompt-toolkit', + # 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. ] ) -