diff --git a/README.md b/README.md index bffd3b4..673453f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ # joycontrol Emulate Nintendo Switch Controllers over Bluetooth. -Work in progress. - -Pairing works, emulated controller shows up in the "Change Grip/Order" menu of the Switch. - Tested on Ubuntu 19.10 and with Raspberry Pi 4B Raspbian GNU/Linux 10 (buster) ## Installation @@ -18,17 +14,35 @@ sudo pip3 install . ``` - Disable the bluez "input" plugin, see [#8](https://github.com/mart1nro/joycontrol/issues/8) -## "Test Controller Buttons" example +## Command line interface example - Run the script ```bash -sudo python3 run_test_controller_buttons.py +sudo python3 run_controller_cli.py PRO_CONTROLLER ``` +This will create a PRO_CONTROLLER instance waiting for the Switch to connect. + - Open the "Change Grip/Order" menu of the Switch -- The emulated controller should pair with the Switch and automatically navigate to the "Test Controller Buttons" menu + +The Switch only pairs with new controllers in the "Change Grip/Order" menu. + +Note: If you already connected an emulated controller once, you can use the reconnect option of the script (-r "\"). +This does not require the "Change Grip/Order" menu to be opened. You can find out a paired mac address using the "bluetoothctl" system command. + +- After connecting a command line interface is opened. Note: Press \ if you don't see a prompt. + +Call "help" to see a list of available commands. + +- If you call "test_buttons", the emulated controller automatically navigates to the "Test Controller Buttons" menu. + ## Issues - When using a Raspberry Pi 4B the connection drops after some time. Might be a hardware issue, since it works fine on my laptop. Using a different bluetooth adapter may help, but haven't tested it yet. - Incompatibility with Bluetooth "input" plugin requires a bluetooth restart, see [#8](https://github.com/mart1nro/joycontrol/issues/8) +- It seems like the Switch is slower processing incoming messages while in the "Change Grip/Order" menu. + This causes flooding of packets and makes pairing somewhat inconsistent. + Not sure yet what exactly a real controller does to prevent that. + A workaround is to use the reconnect option after a controller was paired once, so that + opening of the "Change Grip/Order" menu is not required. - ... diff --git a/joycontrol/command_line_interface.py b/joycontrol/command_line_interface.py index 5e13e6c..8c4cad6 100644 --- a/joycontrol/command_line_interface.py +++ b/joycontrol/command_line_interface.py @@ -9,17 +9,51 @@ from joycontrol.transport import NotConnectedError logger = logging.getLogger(__name__) +def _print_doc(string): + """ + Attempts to remove common white space at the start of the lines in a doc string + to unify the output of doc strings with different indention levels. + + Keeps whitespace lines intact. + + :param fun: function to print the doc string of + """ + lines = string.split('\n') + if lines: + prefix_i = 0 + for i, line_0 in enumerate(lines): + # find non empty start lines + if line_0.strip(): + # traverse line and stop if character mismatch with other non empty lines + for prefix_i, c in enumerate(line_0): + if not c.isspace(): + break + if any(lines[j].strip() and (prefix_i >= len(lines[j]) or c != lines[j][prefix_i]) + for j in range(i+1, len(lines))): + break + break + + for line in lines: + print(line[prefix_i:] if line.strip() else line) + + class ControllerCLI: def __init__(self, controller_state: ControllerState): self.controller_state = controller_state self.commands = {} async def cmd_help(self): - print('Buttons can be used as commands: ', ', '.join(self.controller_state.button_state.get_available_buttons())) - + print('Button commands:') + print(', '.join(self.controller_state.button_state.get_available_buttons())) + print() + print('Commands:') for name, fun in inspect.getmembers(self): if name.startswith('cmd_') and fun.__doc__: - print(fun.__doc__) + _print_doc(fun.__doc__) + + for name, fun in self.commands.items(): + if fun.__doc__: + _print_doc(fun.__doc__) print('Commands can be chained using "&&"') print('Type "exit" to close.') @@ -62,7 +96,7 @@ class ControllerCLI: stick - Command to set stick positions. :param side: 'l', 'left' for left control stick; 'r', 'right' for right control stick :param direction: 'center', 'up', 'down', 'left', 'right'; - 'h', 'horizontal' or 'v', 'vertical' to set the value directly to the "value" argument + 'h', 'horizontal' or 'v', 'vertical' to set the value directly to the "value" argument :param value: horizontal or vertical value """ if side in ('l', 'left'): @@ -104,7 +138,7 @@ class ControllerCLI: print(e) elif cmd in self.commands: try: - result = await self.commands[cmd](self, *args) + result = await self.commands[cmd](*args) if result: print(result) except Exception as e: diff --git a/joycontrol/controller_state.py b/joycontrol/controller_state.py index 3764f2a..6902a22 100644 --- a/joycontrol/controller_state.py +++ b/joycontrol/controller_state.py @@ -41,6 +41,9 @@ class ControllerState: self.sig_is_send = asyncio.Event() + def get_controller(self): + return self._controller + def get_flash_memory(self): return self._spi_flash diff --git a/run_controller_cli.py b/run_controller_cli.py index a99a42a..0221eee 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -1,18 +1,137 @@ +#!/usr/bin/env python3 + import argparse import asyncio import logging import os from contextlib import contextmanager +from aioconsole import ainput + from joycontrol import logging_default as log from joycontrol.command_line_interface import ControllerCLI from joycontrol.controller import Controller +from joycontrol.controller_state import ControllerState, button_push from joycontrol.memory import FlashMemory from joycontrol.protocol import controller_protocol_factory from joycontrol.server import create_hid_server logger = logging.getLogger(__name__) +"""Emulates Switch controller. Opens joycontrol.command_line_interface to send button commands and more. + +While running the cli, call "help" for an explanation of available commands. + +Usage: + run_controller_cli.py [--device_id | -d ] + [--spi_flash ] + [--reconnect_bt_addr | -r ] + [--log | -l ] + run_controller_cli.py -h | --help + +Arguments: + controller Choose which controller to emulate. Either "JOYCON_R", "JOYCON_L" or "PRO_CONTROLLER" + +Options: + -d --device_id ID of the bluetooth adapter. Integer matching the digit in the hci* notation + (e.g. hci0, hci1, ...) or Bluetooth mac address of the adapter in string + notation (e.g. "FF:FF:FF:FF:FF:FF"). + Note: Selection of adapters may not work if the bluez "input" plugin is + enabled. + + --spi_flash Memory dump of a real Switch controller. Required for joystick emulation. + Allows displaying of JoyCon colors. + Memory dumbs can be created using the dump_spi_flash.py script. + + -r --reconnect_bt_addr Previously connected Switch console Bluetooth address in string + notation (e.g. "FF:FF:FF:FF:FF:FF") for reconnection. + Does not require the "Change Grip/Order" menu to be opened, + + -l --log Write hid communication (input reports and output reports) to a file. +""" + + +async def test_controller_buttons(controller_state: ControllerState): + """ + Example controller script. + Navigates to the "Test Controller Buttons" menu and presses all buttons. + """ + if controller_state.get_controller() != Controller.PRO_CONTROLLER: + raise ValueError('This script only works with the Pro Controller!') + + # 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.') + + """ + # We assume we are in the "Change Grip/Order" menu of the switch + await button_push(controller_state, 'home') + + # wait for the animation + await asyncio.sleep(1) + """ + + # Goto settings + await button_push(controller_state, 'down', sec=1) + await button_push(controller_state, 'right', sec=2) + await asyncio.sleep(0.3) + await button_push(controller_state, 'left') + await asyncio.sleep(0.3) + await button_push(controller_state, 'a') + await asyncio.sleep(0.3) + + # go all the way down + await button_push(controller_state, 'down', sec=4) + await asyncio.sleep(0.3) + + # goto "Controllers and Sensors" menu + for _ in range(2): + await button_push(controller_state, 'up') + await asyncio.sleep(0.3) + await button_push(controller_state, 'right') + await asyncio.sleep(0.3) + + # go all the way down + await button_push(controller_state, 'down', sec=3) + await asyncio.sleep(0.3) + + # goto "Test Input Devices" menu + await button_push(controller_state, 'up') + await asyncio.sleep(0.3) + await button_push(controller_state, 'a') + await asyncio.sleep(0.3) + + # goto "Test Controller Buttons" menu + await button_push(controller_state, 'a') + await asyncio.sleep(0.3) + + # push all buttons except home and capture + button_list = controller_state.button_state.get_available_buttons() + if 'capture' in button_list: + button_list.remove('capture') + if 'home' in button_list: + button_list.remove('home') + + user_input = asyncio.ensure_future( + ainput(prompt='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(): + break + + # await future to trigger exceptions in case something went wrong + await user_input + + # go back to home + await button_push(controller_state, 'home') + async def _main(controller, reconnect_bt_addr=None, capture_file=None, spi_flash=None, device_id=None): factory = controller_protocol_factory(controller, spi_flash=spi_flash) @@ -22,7 +141,19 @@ async def _main(controller, reconnect_bt_addr=None, capture_file=None, spi_flash controller_state = protocol.get_controller_state() + # Create command line interface and add some extra commands cli = ControllerCLI(controller_state) + + # Wrap the script so we can pass the controller state. The doc string will be printed when calling 'help' + async def _run_test_controller_buttons(): + """ + test_buttons - Navigates to the "Test Controller Buttons" menu and presses all buttons. + """ + await test_controller_buttons(controller_state) + + # add the script from above + cli.add_command('test_buttons', _run_test_controller_buttons) + await cli.run() logger.info('Stopping communication...') diff --git a/run_test_controller_buttons.py b/run_test_controller_buttons.py deleted file mode 100644 index 7bcfad2..0000000 --- a/run_test_controller_buttons.py +++ /dev/null @@ -1,153 +0,0 @@ -import argparse -import asyncio -import logging -import os -from contextlib import contextmanager, suppress - -from joycontrol import logging_default as log -from joycontrol.controller_state import ControllerState, button_push -from joycontrol.protocol import controller_protocol_factory, Controller -from joycontrol.server import create_hid_server -from joycontrol.transport import NotConnectedError - -logger = logging.getLogger(__name__) - - -async def test_controller_buttons(controller_state: ControllerState): - """ - Navigates to the "Test Controller Buttons" menu and presses all buttons. - """ - await controller_state.connect() - - # We assume we are in the "Change Grip/Order" menu of the switch - await button_push(controller_state, 'home') - - # wait for the animation - await asyncio.sleep(1) - - # Goto settings - await button_push(controller_state, 'down') - await asyncio.sleep(0.3) - for _ in range(4): - await button_push(controller_state, 'right') - await asyncio.sleep(0.3) - await button_push(controller_state, 'a') - await asyncio.sleep(0.3) - - # go all the way down - await button_push(controller_state, 'down', sec=4) - await asyncio.sleep(0.3) - - # goto "Controllers and Sensors" menu - for _ in range(2): - await button_push(controller_state, 'up') - await asyncio.sleep(0.3) - await button_push(controller_state, 'right') - await asyncio.sleep(0.3) - - # go all the way down - await button_push(controller_state, 'down', sec=3) - await asyncio.sleep(0.3) - - # goto "Test Input Devices" menu - await button_push(controller_state, 'up') - await asyncio.sleep(0.3) - await button_push(controller_state, 'a') - await asyncio.sleep(0.3) - - # goto "Test Controller Buttons" menu - await button_push(controller_state, 'a') - await asyncio.sleep(0.3) - - # push all buttons except home and capture - button_list = controller_state.button_state.get_available_buttons() - if 'capture' in button_list: - button_list.remove('capture') - if 'home' in button_list: - button_list.remove('home') - - # push all buttons consecutively - while True: - for button in button_list: - await button_push(controller_state, button) - await asyncio.sleep(0.1) - - -async def _main(controller, capture_file=None, spi_flash=None, device_id=None): - factory = controller_protocol_factory(controller, spi_flash=spi_flash) - transport, protocol = await create_hid_server(factory, 17, 19, capture_file=capture_file, device_id=device_id) - - try: - await test_controller_buttons(protocol.get_controller_state()) - except KeyboardInterrupt: - pass - except NotConnectedError: - logger.error('Connection was lost.') - finally: - logger.info('Stopping communication...') - await transport.close() - - -if __name__ == '__main__': - # check if root - if not os.geteuid() == 0: - raise PermissionError('Script must be run as root!') - - # setup logging - log.configure() - - parser = argparse.ArgumentParser() - #parser.add_argument('controller', help='JOYCON_R, JOYCON_L or PRO_CONTROLLER') - parser.add_argument('-d', '--device_id') - parser.add_argument('-l', '--log') - parser.add_argument('--spi_flash') - args = parser.parse_args() - - """ - if args.controller == 'JOYCON_R': - controller = Controller.JOYCON_R - elif args.controller == 'JOYCON_L': - controller = Controller.JOYCON_L - elif args.controller == 'PRO_CONTROLLER': - controller = Controller.PRO_CONTROLLER - else: - raise ValueError(f'Unknown controller "{args.controller}".') - """ - controller = Controller.PRO_CONTROLLER - - spi_flash = None - if args.spi_flash: - with open(args.spi_flash, 'rb') as spi_flash_file: - spi_flash = spi_flash_file.read() - - @contextmanager - def get_output(path=None): - """ - Opens file if path is given - """ - if path is not None: - file = open(path, 'wb') - yield file - file.close() - else: - yield None - - with get_output(args.log) as capture_file: - loop = asyncio.get_event_loop() - - main_function = asyncio.ensure_future( - _main(controller, capture_file=capture_file, spi_flash=spi_flash, device_id=args.device_id) - ) - - # run main function until keyboard interrupt - try: - loop.run_until_complete(main_function) - except KeyboardInterrupt: - pass - finally: - # make sure main function has a chance to clean up - with suppress(asyncio.CancelledError): - main_function.cancel() - loop.run_until_complete( - main_function - )