Dropped "run_test_controller_buttons.py" script in favor of command line interface.

This commit is contained in:
Robert Martin
2020-04-11 15:08:48 +09:00
parent 04c2d6d9ac
commit feb82a1fd0
5 changed files with 194 additions and 165 deletions
+21 -7
View File
@@ -1,10 +1,6 @@
# joycontrol # joycontrol
Emulate Nintendo Switch Controllers over Bluetooth. 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) Tested on Ubuntu 19.10 and with Raspberry Pi 4B Raspbian GNU/Linux 10 (buster)
## Installation ## Installation
@@ -18,17 +14,35 @@ sudo pip3 install .
``` ```
- Disable the bluez "input" plugin, see [#8](https://github.com/mart1nro/joycontrol/issues/8) - 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 - Run the script
```bash ```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 - 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 "\<Switch Bluetooth Mac address>").
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 \<enter> 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 ## 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. - 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) - 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.
- ... - ...
+39 -5
View File
@@ -9,17 +9,51 @@ from joycontrol.transport import NotConnectedError
logger = logging.getLogger(__name__) 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: class ControllerCLI:
def __init__(self, controller_state: ControllerState): def __init__(self, controller_state: ControllerState):
self.controller_state = controller_state self.controller_state = controller_state
self.commands = {} self.commands = {}
async def cmd_help(self): 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): for name, fun in inspect.getmembers(self):
if name.startswith('cmd_') and fun.__doc__: 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('Commands can be chained using "&&"')
print('Type "exit" to close.') print('Type "exit" to close.')
@@ -62,7 +96,7 @@ class ControllerCLI:
stick - Command to set stick positions. stick - Command to set stick positions.
:param side: 'l', 'left' for left control stick; 'r', 'right' for right control stick :param side: 'l', 'left' for left control stick; 'r', 'right' for right control stick
:param direction: 'center', 'up', 'down', 'left', 'right'; :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 :param value: horizontal or vertical value
""" """
if side in ('l', 'left'): if side in ('l', 'left'):
@@ -104,7 +138,7 @@ class ControllerCLI:
print(e) print(e)
elif cmd in self.commands: elif cmd in self.commands:
try: try:
result = await self.commands[cmd](self, *args) result = await self.commands[cmd](*args)
if result: if result:
print(result) print(result)
except Exception as e: except Exception as e:
+3
View File
@@ -41,6 +41,9 @@ class ControllerState:
self.sig_is_send = asyncio.Event() self.sig_is_send = asyncio.Event()
def get_controller(self):
return self._controller
def get_flash_memory(self): def get_flash_memory(self):
return self._spi_flash return self._spi_flash
+131
View File
@@ -1,18 +1,137 @@
#!/usr/bin/env python3
import argparse import argparse
import asyncio import asyncio
import logging import logging
import os import os
from contextlib import contextmanager from contextlib import contextmanager
from aioconsole import ainput
from joycontrol import logging_default as log from joycontrol import logging_default as log
from joycontrol.command_line_interface import ControllerCLI from joycontrol.command_line_interface import ControllerCLI
from joycontrol.controller import Controller from joycontrol.controller import Controller
from joycontrol.controller_state import ControllerState, button_push
from joycontrol.memory import FlashMemory from joycontrol.memory import FlashMemory
from joycontrol.protocol import controller_protocol_factory from joycontrol.protocol import controller_protocol_factory
from joycontrol.server import create_hid_server from joycontrol.server import create_hid_server
logger = logging.getLogger(__name__) 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 <controller> [--device_id | -d <bluetooth_adapter_id>]
[--spi_flash <spi_flash_memory_file>]
[--reconnect_bt_addr | -r <console_bluetooth_address>]
[--log | -l <communication_log_file>]
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 <bluetooth_adapter_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 <spi_flash_memory_file> 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 <console_bluetooth_address> 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 <communication_log_file> 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 <enter> 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 <enter> 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): 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) 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() controller_state = protocol.get_controller_state()
# Create command line interface and add some extra commands
cli = ControllerCLI(controller_state) 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() await cli.run()
logger.info('Stopping communication...') logger.info('Stopping communication...')
-153
View File
@@ -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
)