From 32f5edc04b58853cac82eb67b9c3d22a3d0bcd85 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 21 Mar 2020 18:15:19 +0900 Subject: [PATCH 01/43] added comments --- joycontrol/profile/sdp_record_hid.xml | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/joycontrol/profile/sdp_record_hid.xml b/joycontrol/profile/sdp_record_hid.xml index 36e5c93..0b6acb6 100644 --- a/joycontrol/profile/sdp_record_hid.xml +++ b/joycontrol/profile/sdp_record_hid.xml @@ -1,50 +1,50 @@ - + - + - + - - + + - + - + - + - + - - + + - + - + - + From b699b8802f84cef084b98ba08b4c805f9c02d130 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 21 Mar 2020 18:15:50 +0900 Subject: [PATCH 02/43] input plugin issue --- joycontrol/device.py | 8 +++++--- joycontrol/server.py | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/joycontrol/device.py b/joycontrol/device.py index 1a42281..69b0c36 100644 --- a/joycontrol/device.py +++ b/joycontrol/device.py @@ -21,11 +21,13 @@ class HidDevice: self.adapter = dbus.Interface(obj, 'org.bluez.Adapter1') self.properties = dbus.Interface(self.adapter, 'org.freedesktop.DBus.Properties') + def powered(self, boolean=True): + self.properties.Set(self.adapter.dbus_interface, 'Powered', boolean) + def discoverable(self, boolean=True): - #self.properties.Set(self.adapter.dbus_interface, 'Powered', True) self.properties.Set(self.adapter.dbus_interface, 'Discoverable', boolean) - async def set_class(self, cls=0x002508): + async def set_class(self, cls='0x002508'): """ :param cls: default 0x002508 (Gamepad/joystick device class) """ @@ -47,4 +49,4 @@ class HidDevice: } bus = dbus.SystemBus() manager = dbus.Interface(bus.get_object("org.bluez", "/org/bluez"), "org.bluez.ProfileManager1") - manager.RegisterProfile(self._HID_PATH, self._uuid, opts) \ No newline at end of file + manager.RegisterProfile(self._HID_PATH, self._uuid, opts) diff --git a/joycontrol/server.py b/joycontrol/server.py index 1b27b04..c3d4ad6 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -25,7 +25,9 @@ async def create_hid_server(protocol_factory, ctl_psm, itr_psm, capture_file=Non 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) - # for some reason we need to restart bluetooth here, the Switch does not connect to the sockets if we don't... + # HACK: To avoid incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. + # The Switch does not connect to the sockets if we don't. + # For more info see: https://github.com/mart1nro/joycontrol/issues/8 logger.info('Restarting bluetooth service...') await utils.run_system_command('systemctl restart bluetooth.service') await asyncio.sleep(1) @@ -42,6 +44,7 @@ async def create_hid_server(protocol_factory, ctl_psm, itr_psm, capture_file=Non protocol = protocol_factory() hid = HidDevice() + hid.powered(True) # setting bluetooth adapter name and class to the device we wish to emulate await hid.set_name(protocol.controller.device_name()) await hid.set_class() From ae4ff8881ab97391753fbe4409d65903dc058539 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 21 Mar 2020 18:20:29 +0900 Subject: [PATCH 03/43] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1b7a7b7..4823d47 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ sudo python3 run_test_controller_buttons.py ## 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, see [#8](https://github.com/mart1nro/joycontrol/issues/8) - ... From 26715f1be0dd17aecdc0a950e486a221c727c1f3 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sun, 22 Mar 2020 02:12:32 +0900 Subject: [PATCH 04/43] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4823d47..71d033d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ sudo python3 run_test_controller_buttons.py ## 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, 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) - ... From 44c684a22428f4bc71628158d627de390cde64a9 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Tue, 31 Mar 2020 00:18:38 +0900 Subject: [PATCH 05/43] added support for multiple Bluetooth adapters --- dump_spi_flash.py | 3 +- joycontrol/device.py | 62 +++++++++++++++++++++++++--------- joycontrol/server.py | 59 ++++++++++++++++++++++++-------- run_controller_cli.py | 12 +++---- run_test_controller_buttons.py | 12 ++++--- 5 files changed, 106 insertions(+), 42 deletions(-) diff --git a/dump_spi_flash.py b/dump_spi_flash.py index aced347..7f54e7c 100644 --- a/dump_spi_flash.py +++ b/dump_spi_flash.py @@ -145,7 +145,8 @@ async def dumb_spi_flash(hid_device, output_file=None): async def _main(args, loop): - logger.info('Waiting for HID devices... Please connect JoyCon over bluetooth.') + logger.info('Waiting for HID devices... Please connect JoyCon over Bluetooth. ' + 'Note: The bluez "input" plugin needs to be enabled (default)"') controller = None while controller is None: diff --git a/joycontrol/device.py b/joycontrol/device.py index 69b0c36..739df5c 100644 --- a/joycontrol/device.py +++ b/joycontrol/device.py @@ -1,6 +1,5 @@ import logging import uuid - import dbus from joycontrol import utils @@ -8,45 +7,76 @@ from joycontrol import utils logger = logging.getLogger(__name__) +HID_UUID = '00001124-0000-1000-8000-00805f9b34fb' +HID_PATH = '/bluez/switch/hid' + + class HidDevice: - _HID_UUID = '00001124-0000-1000-8000-00805f9b34fb' - _HID_PATH = '/bluez/switch/hid' - - def __init__(self): - self._uuid = str(uuid.uuid4()) - - # Setting up dbus to advertise the service record + def __init__(self, device_id=None): bus = dbus.SystemBus() - obj = bus.get_object('org.bluez', '/org/bluez/hci0') - self.adapter = dbus.Interface(obj, 'org.bluez.Adapter1') - self.properties = dbus.Interface(self.adapter, 'org.freedesktop.DBus.Properties') + + # Get Bluetooth adapter from dbus interface + manager = dbus.Interface(bus.get_object('org.bluez', '/'), 'org.freedesktop.DBus.ObjectManager') + for path, ifaces in manager.GetManagedObjects().items(): + adapter_info = ifaces.get('org.bluez.Adapter1') + if adapter_info is None: + continue + elif device_id is None or device_id == adapter_info['Address'] or path.endswith(str(device_id)): + obj = bus.get_object('org.bluez', path) + self.adapter = dbus.Interface(obj, 'org.bluez.Adapter1') + self.address = adapter_info['Address'] + self._adapter_name = path.split('/')[-1] + + self.properties = dbus.Interface(self.adapter, 'org.freedesktop.DBus.Properties') + break + else: + raise ValueError(f'Adapter {device_id} not found.') + + def get_address(self) -> str: + """ + :returns adapter Bluetooth address + """ + return self.address def powered(self, boolean=True): self.properties.Set(self.adapter.dbus_interface, 'Powered', boolean) def discoverable(self, boolean=True): + """ + Make adapter discoverable, starts advertising. + """ self.properties.Set(self.adapter.dbus_interface, 'Discoverable', boolean) async def set_class(self, cls='0x002508'): """ + Sets Bluetooth device class. Requires hciconfig system command. :param cls: default 0x002508 (Gamepad/joystick device class) """ logger.info(f'setting device class to {cls}...') - await utils.run_system_command(f'hciconfig hci0 class {cls}') + await utils.run_system_command(f'hciconfig {self._adapter_name} class {cls}') async def set_name(self, name: str): + """ + Set Bluetooth device name. + :param name: to set. + """ logger.info(f'setting device name to {name}...') - await utils.run_system_command(f'hciconfig hci0 name "{name}"') + self.properties.Set(self.adapter.dbus_interface, 'Alias', name) + + @staticmethod + def register_sdp_record(record_path): + _uuid = str(uuid.uuid4()) - def register_sdp_record(self, record_path): with open(record_path) as record: opts = { 'ServiceRecord': record.read(), 'Role': 'server', - 'Service': self._HID_UUID, + 'Service': HID_UUID, 'RequireAuthentication': False, 'RequireAuthorization': False } bus = dbus.SystemBus() manager = dbus.Interface(bus.get_object("org.bluez", "/org/bluez"), "org.bluez.ProfileManager1") - manager.RegisterProfile(self._HID_PATH, self._uuid, opts) + manager.RegisterProfile(HID_PATH, _uuid, opts) + + return _uuid diff --git a/joycontrol/server.py b/joycontrol/server.py index c3d4ad6..65351a3 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -2,6 +2,7 @@ import asyncio import logging import socket +import dbus import pkg_resources from joycontrol import utils @@ -21,38 +22,68 @@ async def _send_empty_input_reports(transport): await asyncio.sleep(1) -async def create_hid_server(protocol_factory, ctl_psm, itr_psm, capture_file=None): +async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id=None, capture_file=None): + """ + :param protocol_factory: Factory function returning a ControllerProtocol instance + :param ctl_psm: hid control channel port + :param itr_psm: hid interrupt channel port + :param device_id: ID of the bluetooth adapter. + Integer matching the digit in the hci* notation (e.g. hci0, hci1, ...) or + Bluetooth mac address in string notation of the adapter (e.g. "FF:FF:FF:FF:FF:FF"). + If None, choose any device. + Note: Selection of adapters may currently not work if the bluez "input" plugin is enabled. + :param capture_file: opened file to log incoming and outgoing messages + :returns transport for input reports and protocol which handles incoming output reports + """ 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) - - # HACK: To avoid incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. - # The Switch does not connect to the sockets if we don't. - # For more info see: https://github.com/mart1nro/joycontrol/issues/8 - logger.info('Restarting bluetooth service...') - await utils.run_system_command('systemctl restart bluetooth.service') - await asyncio.sleep(1) - ctl_sock.setblocking(False) itr_sock.setblocking(False) - ctl_sock.bind((socket.BDADDR_ANY, ctl_psm)) - itr_sock.bind((socket.BDADDR_ANY, itr_psm)) + try: + hid = HidDevice(device_id=device_id) + + ctl_sock.bind((hid.address, ctl_psm)) + itr_sock.bind((hid.address, itr_psm)) + except OSError as err: + logger.warning(err) + # If the ports are already taken, this probably means that the bluez "input" plugin is enabled. + logger.warning('Fallback: Restarting bluetooth due to incompatibilities with the bluez "input" plugin. ' + 'Disable the plugin to avoid issues. See https://github.com/mart1nro/joycontrol/issues/8.') + # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. + # The Switch does not connect to the sockets if we don't. + # For more info see: https://github.com/mart1nro/joycontrol/issues/8 + logger.info('Restarting bluetooth service...') + await utils.run_system_command('systemctl restart bluetooth.service') + await asyncio.sleep(1) + + hid = HidDevice(device_id=device_id) + + ctl_sock.bind((socket.BDADDR_ANY, ctl_psm)) + itr_sock.bind((socket.BDADDR_ANY, itr_psm)) ctl_sock.listen(1) itr_sock.listen(1) protocol = protocol_factory() - hid = HidDevice() hid.powered(True) # setting bluetooth adapter name and class to the device we wish to emulate await hid.set_name(protocol.controller.device_name()) await hid.set_class() logger.info('Advertising the Bluetooth SDP record...') - hid.register_sdp_record(PROFILE_PATH) + try: + HidDevice.register_sdp_record(PROFILE_PATH) + except dbus.exceptions.DBusException as dbus_err: + # Already registered (If multiple controllers are being emulated and this method is called consecutive times) + logger.debug(dbus_err) + + # start advertising hid.discoverable() + logger.info('Waiting for Switch to connect... Please open the "Change Grip/Order" menu.') + loop = asyncio.get_event_loop() client_ctl, ctl_address = await loop.sock_accept(ctl_sock) logger.info(f'Accepted connection at psm {ctl_psm} from {ctl_address}') @@ -66,7 +97,7 @@ async def create_hid_server(protocol_factory, ctl_psm, itr_psm, capture_file=Non transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, 50, capture_file=capture_file) protocol.connection_made(transport) - # send some empty input reports until the switch decides to reply + # send some empty input reports until the Switch decides to reply future = asyncio.ensure_future(_send_empty_input_reports(transport)) await protocol.wait_for_output_report() future.cancel() diff --git a/run_controller_cli.py b/run_controller_cli.py index acc2adf..8baab92 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -14,9 +14,9 @@ from joycontrol.server import create_hid_server logger = logging.getLogger(__name__) -async def _main(controller, capture_file=None, spi_flash=None): +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) + transport, protocol = await create_hid_server(factory, 17, 19, capture_file=capture_file, device_id=device_id) controller_state = protocol.get_controller_state() @@ -39,6 +39,7 @@ if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('controller', help='JOYCON_R, JOYCON_L or PRO_CONTROLLER') parser.add_argument('-l', '--log') + parser.add_argument('-d', '--device_id') parser.add_argument('--spi_flash') args = parser.parse_args() @@ -71,7 +72,6 @@ if __name__ == '__main__': with get_output(args.log) as capture_file: loop = asyncio.get_event_loop() - loop.run_until_complete(_main(controller, capture_file=capture_file, spi_flash=spi_flash)) - - - + loop.run_until_complete( + _main(controller, capture_file=capture_file, spi_flash=spi_flash, device_id=args.device_id) + ) diff --git a/run_test_controller_buttons.py b/run_test_controller_buttons.py index 1341681..d9f0a8b 100644 --- a/run_test_controller_buttons.py +++ b/run_test_controller_buttons.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) async def test_controller_buttons(controller_state: ControllerState): """ - Navigates to the "Test Controller Buttons" menu and presses all buttons + Navigates to the "Test Controller Buttons" menu and presses all buttons. """ await controller_state.connect() @@ -75,9 +75,9 @@ async def test_controller_buttons(controller_state: ControllerState): pass -async def _main(controller, capture_file=None, spi_flash=None): +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) + transport, protocol = await create_hid_server(factory, 17, 19, capture_file=capture_file, device_id=device_id) await test_controller_buttons(protocol.get_controller_state()) @@ -95,6 +95,7 @@ if __name__ == '__main__': 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() @@ -116,7 +117,6 @@ if __name__ == '__main__': with open(args.spi_flash, 'rb') as spi_flash_file: spi_flash = spi_flash_file.read() - # creates file if arg is given @contextmanager def get_output(path=None): """ @@ -131,4 +131,6 @@ if __name__ == '__main__': with get_output(args.log) as capture_file: loop = asyncio.get_event_loop() - loop.run_until_complete(_main(controller, capture_file=capture_file, spi_flash=spi_flash)) + loop.run_until_complete( + _main(controller, capture_file=capture_file, spi_flash=spi_flash, device_id=args.device_id) + ) From 770f0ab7278596f15a7d0f16701021b5c2be0033 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Tue, 31 Mar 2020 00:28:59 +0900 Subject: [PATCH 06/43] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 71d033d..bffd3b4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ sudo apt install python3-dbus ```bash sudo pip3 install . ``` +- Disable the bluez "input" plugin, see [#8](https://github.com/mart1nro/joycontrol/issues/8) ## "Test Controller Buttons" example - Run the script From d3fbd441d86df50e57a28a1fe40fcfbdb7ff9a95 Mon Sep 17 00:00:00 2001 From: Cambridge Yang Date: Thu, 2 Apr 2020 09:20:25 -0400 Subject: [PATCH 07/43] trying to reconnect --- joycontrol/server.py | 12 +++++++++--- run_controller_cli.py | 25 +++++++++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index 65351a3..a680f02 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -39,7 +39,9 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= itr_sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) ctl_sock.setblocking(False) itr_sock.setblocking(False) - + ctl_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + itr_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: hid = HidDevice(device_id=device_id) @@ -94,6 +96,12 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= # stop advertising hid.discoverable(False) + await run_protocol_on_connection(protocol, client_itr, capture_file=capture_file) + + return protocol.transport, protocol + + +async def run_protocol_on_connection(protocol, client_itr, capture_file=None): transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, 50, capture_file=capture_file) protocol.connection_made(transport) @@ -105,5 +113,3 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= await future except asyncio.CancelledError: pass - - return transport, protocol diff --git a/run_controller_cli.py b/run_controller_cli.py index 8baab92..2550924 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -2,6 +2,7 @@ import argparse import asyncio import logging import os +import socket from contextlib import contextmanager from joycontrol import logging_default as log @@ -9,14 +10,24 @@ from joycontrol.command_line_interface import ControllerCLI from joycontrol.controller import Controller from joycontrol.memory import FlashMemory from joycontrol.protocol import controller_protocol_factory -from joycontrol.server import create_hid_server +from joycontrol.server import create_hid_server, run_protocol_on_connection logger = logging.getLogger(__name__) -async def _main(controller, capture_file=None, spi_flash=None, device_id=None): +async def _main(controller, console_bt_addr=None, 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) + ctl_psm, itr_psm = 17, 19 + if console_bt_addr is None: + transport, protocol = await create_hid_server(factory, + ctl_psm=ctl_psm, itr_psm=itr_psm, capture_file=capture_file, device_id=device_id) + else: + protocol = factory() + client_ctl = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) + # client_ctl.setblocking(False) + client_ctl.connect((console_bt_addr, ctl_psm)) + await run_protocol_on_connection(protocol, client_ctl) + transport = protocol.transport controller_state = protocol.get_controller_state() @@ -41,6 +52,7 @@ if __name__ == '__main__': parser.add_argument('-l', '--log') parser.add_argument('-d', '--device_id') parser.add_argument('--spi_flash') + parser.add_argument('--console-bt-addr', type=str, default=None) args = parser.parse_args() if args.controller == 'JOYCON_R': @@ -73,5 +85,10 @@ if __name__ == '__main__': with get_output(args.log) as capture_file: loop = asyncio.get_event_loop() loop.run_until_complete( - _main(controller, capture_file=capture_file, spi_flash=spi_flash, device_id=args.device_id) + _main(controller, + console_bt_addr=args.console_bt_addr, + capture_file=capture_file, + spi_flash=spi_flash, + device_id=args.device_id + ) ) From 0105a21c3a4a032205e377ed7fdd54e3878be4d4 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Thu, 2 Apr 2020 23:14:03 +0900 Subject: [PATCH 08/43] pause reading in transport now also affects input report modes --- joycontrol/protocol.py | 14 +++++++- joycontrol/transport.py | 71 ++++++++++++++++++++++++++++++----------- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index e41d155..935cc1e 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -78,9 +78,11 @@ class ControllerProtocol(BaseProtocol): self.transport = transport def connection_lost(self, exc: Optional[Exception]) -> None: + # TODO raise NotImplementedError() def error_received(self, exc: Exception) -> None: + # TODO raise NotImplementedError() async def input_report_mode_0x30(self): @@ -288,16 +290,26 @@ class ControllerProtocol(BaseProtocol): # start sending 0x30 input reports if self._0x30_input_report_sender is None: self.transport.pause_reading() - self._0x30_input_report_sender = asyncio.ensure_future(self.input_report_mode_0x30()) # create callback to check for exceptions def callback(future): try: future.result() + except asyncio.CancelledError: + # Future may be cancelled at anytime + pass except Exception as err: logger.exception(err) + self._0x30_input_report_sender = asyncio.ensure_future(self.input_report_mode_0x30()) self._0x30_input_report_sender.add_done_callback(callback) + + # We have to swap the reader in the future because this function was probably called by it + async def set_reader(): + await self.transport.set_reader(self._0x30_input_report_sender) + self.transport.resume_reading() + + asyncio.ensure_future(set_reader()).add_done_callback(callback) else: logger.error(f'input report mode {sub_command_data[0]} not implemented - ignoring request') diff --git a/joycontrol/transport.py b/joycontrol/transport.py index d2e9c27..e8beddc 100644 --- a/joycontrol/transport.py +++ b/joycontrol/transport.py @@ -4,8 +4,6 @@ import struct import time from typing import Any -from joycontrol.report import InputReport - logger = logging.getLogger(__name__) @@ -23,35 +21,72 @@ class L2CAP_Transport(asyncio.Transport): 'socket': self._sock } + self._is_closing = False + self._is_reading = asyncio.Event() + + self._capture_file = capture_file + + # start underlying reader + self._read_thread = None + self._is_reading.set() + self.start_reader() + + async def _reader(self): + while True: + data = await self.read() + + #logger.debug(f'received "{list(data)}"') + await self._protocol.report_received(data, self._sock.getpeername()) + + def start_reader(self): + """ + Starts the transport reader which calls the protocols report_received function for every incoming message + """ + if self._read_thread is not None: + raise ValueError('Reader is already running.') + self._read_thread = asyncio.ensure_future(self._reader()) # create callback to check for exceptions def callback(future): try: future.result() + except asyncio.CancelledError: + # Future may be cancelled at anytime + pass except Exception as err: logger.exception(err) self._read_thread.add_done_callback(callback) - self._is_closing = False - self._is_reading = asyncio.Event() - self._is_reading.set() + async def set_reader(self, reader: asyncio.Future): + """ + Cancel the currently running reader and register the new one. + A reader is a coroutine that calls this transports 'read' function. + The 'read' function calls can be paused by calling pause_reading of this transport. + :param reader: future reader + """ + if self._read_thread is not None: + # cancel currently running reader + self._read_thread.cancel() + try: + await self._read_thread + except asyncio.CancelledError: + pass - self._input_report_timer = 0x00 + self._read_thread = reader - self._capture_file = capture_file - - async def _reader(self): - while True: - await self._is_reading.wait() - - data = await self.read() - - #logger.debug(f'received "{list(data)}"') - await self._protocol.report_received(data, self._sock.getpeername()) + def get_reader(self): + return self._read_thread async def read(self): + """ + Read data from the unterlying socket. This function "blocks", + if reading is paused using the pause_reading function. + + :returns bytes + """ + await self._is_reading.wait() data = await self._loop.sock_recv(self._sock, self._read_buffer_size) if self._capture_file is not None: @@ -66,7 +101,7 @@ class L2CAP_Transport(asyncio.Transport): """ :returns True if the reader is running """ - return self._is_reading.is_set() + return self._reader is not None and self._is_reading.is_set() def pause_reading(self) -> None: """ @@ -109,7 +144,7 @@ class L2CAP_Transport(asyncio.Transport): async def close(self): """ - Stops socket reader and closes socket + Stops reader and closes underlying socket """ self._is_closing = True self._read_thread.cancel() From 3897d97696de6224f304f3b1c68f283b2a20795d Mon Sep 17 00:00:00 2001 From: Cambridge Yang Date: Fri, 3 Apr 2020 10:48:52 -0400 Subject: [PATCH 09/43] reconnect paired device --- joycontrol/server.py | 45 ++++++++++++++++++++++++++++++------------- run_controller_cli.py | 12 +++++------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index a680f02..8000177 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -16,12 +16,25 @@ logger = logging.getLogger(__name__) async def _send_empty_input_reports(transport): report = InputReport() - while True: await transport.write(report) await asyncio.sleep(1) +async def _run_protocol_on_connection(protocol, client_itr, capture_file=None): + transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, 50, capture_file=capture_file) + protocol.connection_made(transport) + + # send some empty input reports until the Switch decides to reply + future = asyncio.ensure_future(_send_empty_input_reports(transport)) + await protocol.wait_for_output_report() + future.cancel() + try: + await future + except asyncio.CancelledError: + pass + + async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id=None, capture_file=None): """ :param protocol_factory: Factory function returning a ControllerProtocol instance @@ -96,20 +109,26 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= # stop advertising hid.discoverable(False) - await run_protocol_on_connection(protocol, client_itr, capture_file=capture_file) + await _run_protocol_on_connection(protocol, client_itr, capture_file=capture_file) return protocol.transport, protocol -async def run_protocol_on_connection(protocol, client_itr, capture_file=None): - transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, 50, capture_file=capture_file) - protocol.connection_made(transport) +async def create_reconnection(protocol_factory, console_bt_addr, ctl_psm=17, itr_psm=19, capture_file=None): + """Setup a running protocal by reconnecting to a pairsed console. + + :param console_bt_addr: a bluetooth address for the Switch console. + :param *args, **kwargs: see `create_hid_server`, except that `create_reconnection` does not require device_id. + :returns: see `create_hid_server` + """ + protocol = protocol_factory() + 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) + client_ctl.connect((console_bt_addr, ctl_psm)) + client_itr.connect((console_bt_addr, itr_psm)) + client_ctl.setblocking(False) + client_itr.setblocking(False) - # send some empty input reports until the Switch decides to reply - future = asyncio.ensure_future(_send_empty_input_reports(transport)) - await protocol.wait_for_output_report() - future.cancel() - try: - await future - except asyncio.CancelledError: - pass + await _run_protocol_on_connection(protocol, client_itr) + transport = protocol.transport + return transport, protocol diff --git a/run_controller_cli.py b/run_controller_cli.py index 2550924..70ed769 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -10,7 +10,8 @@ from joycontrol.command_line_interface import ControllerCLI from joycontrol.controller import Controller from joycontrol.memory import FlashMemory from joycontrol.protocol import controller_protocol_factory -from joycontrol.server import create_hid_server, run_protocol_on_connection +from joycontrol.server import create_hid_server, create_reconnection +from joycontrol.report import InputReport logger = logging.getLogger(__name__) @@ -22,12 +23,9 @@ async def _main(controller, console_bt_addr=None, capture_file=None, spi_flash=N transport, protocol = await create_hid_server(factory, ctl_psm=ctl_psm, itr_psm=itr_psm, capture_file=capture_file, device_id=device_id) else: - protocol = factory() - client_ctl = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) - # client_ctl.setblocking(False) - client_ctl.connect((console_bt_addr, ctl_psm)) - await run_protocol_on_connection(protocol, client_ctl) - transport = protocol.transport + transport, protocol = await create_reconnection(factory, + console_bt_addr, + ctl_psm=ctl_psm, itr_psm=itr_psm, capture_file=capture_file) controller_state = protocol.get_controller_state() From f2a0e866775cc20c11f32c4c184dc69027d1c150 Mon Sep 17 00:00:00 2001 From: Cambridge Yang Date: Fri, 3 Apr 2020 12:16:03 -0400 Subject: [PATCH 10/43] cleanup --- joycontrol/server.py | 143 ++++++++++++++++++++---------------------- run_controller_cli.py | 19 +++--- 2 files changed, 77 insertions(+), 85 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index 8000177..cd31c8d 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -35,7 +35,8 @@ async def _run_protocol_on_connection(protocol, client_itr, capture_file=None): pass -async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id=None, capture_file=None): +async def create_hid_server(protocol_factory, + ctl_psm=17, itr_psm=19, device_id=None, reconnect_bt_addr=None, capture_file=None): """ :param protocol_factory: Factory function returning a ControllerProtocol instance :param ctl_psm: hid control channel port @@ -45,90 +46,84 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= Bluetooth mac address in string notation of the adapter (e.g. "FF:FF:FF:FF:FF:FF"). If None, choose any device. Note: Selection of adapters may currently not work if the bluez "input" plugin is enabled. + :param reconnect_bt_addr: the Bluetooth address of the console that was previously connected. Defaults to None. + If None, a new hid server will be started for the initial paring. + Otherwise, the function assumes an initial pairing with the console was already done and reconnects + to the provided Bluetooth address. :param capture_file: opened file to log incoming and outgoing messages :returns transport for input reports and protocol which handles incoming output reports """ - 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) - ctl_sock.setblocking(False) - itr_sock.setblocking(False) - ctl_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - itr_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - try: - hid = HidDevice(device_id=device_id) - - ctl_sock.bind((hid.address, ctl_psm)) - itr_sock.bind((hid.address, itr_psm)) - except OSError as err: - logger.warning(err) - # If the ports are already taken, this probably means that the bluez "input" plugin is enabled. - logger.warning('Fallback: Restarting bluetooth due to incompatibilities with the bluez "input" plugin. ' - 'Disable the plugin to avoid issues. See https://github.com/mart1nro/joycontrol/issues/8.') - # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. - # The Switch does not connect to the sockets if we don't. - # For more info see: https://github.com/mart1nro/joycontrol/issues/8 - logger.info('Restarting bluetooth service...') - await utils.run_system_command('systemctl restart bluetooth.service') - await asyncio.sleep(1) - - hid = HidDevice(device_id=device_id) - - ctl_sock.bind((socket.BDADDR_ANY, ctl_psm)) - itr_sock.bind((socket.BDADDR_ANY, itr_psm)) - - ctl_sock.listen(1) - itr_sock.listen(1) - protocol = protocol_factory() - hid.powered(True) - # setting bluetooth adapter name and class to the device we wish to emulate - await hid.set_name(protocol.controller.device_name()) - await hid.set_class() + if reconnect_bt_addr is None: + 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) + ctl_sock.setblocking(False) + itr_sock.setblocking(False) + ctl_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + itr_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + hid = HidDevice(device_id=device_id) - logger.info('Advertising the Bluetooth SDP record...') - try: - HidDevice.register_sdp_record(PROFILE_PATH) - except dbus.exceptions.DBusException as dbus_err: - # Already registered (If multiple controllers are being emulated and this method is called consecutive times) - logger.debug(dbus_err) + ctl_sock.bind((hid.address, ctl_psm)) + itr_sock.bind((hid.address, itr_psm)) + except OSError as err: + logger.warning(err) + # If the ports are already taken, this probably means that the bluez "input" plugin is enabled. + logger.warning('Fallback: Restarting bluetooth due to incompatibilities with the bluez "input" plugin. ' + 'Disable the plugin to avoid issues. See https://github.com/mart1nro/joycontrol/issues/8.') + # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. + # The Switch does not connect to the sockets if we don't. + # For more info see: https://github.com/mart1nro/joycontrol/issues/8 + logger.info('Restarting bluetooth service...') + await utils.run_system_command('systemctl restart bluetooth.service') + await asyncio.sleep(1) - # start advertising - hid.discoverable() + hid = HidDevice(device_id=device_id) - logger.info('Waiting for Switch to connect... Please open the "Change Grip/Order" menu.') + ctl_sock.bind((socket.BDADDR_ANY, ctl_psm)) + itr_sock.bind((socket.BDADDR_ANY, itr_psm)) - loop = asyncio.get_event_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) - logger.info(f'Accepted connection at psm {itr_psm} from {itr_address}') - assert ctl_address[0] == itr_address[0] + ctl_sock.listen(1) + itr_sock.listen(1) - # stop advertising - hid.discoverable(False) + hid.powered(True) + # setting bluetooth adapter name and class to the device we wish to emulate + await hid.set_name(protocol.controller.device_name()) + await hid.set_class() + + logger.info('Advertising the Bluetooth SDP record...') + try: + HidDevice.register_sdp_record(PROFILE_PATH) + except dbus.exceptions.DBusException as dbus_err: + # Already registered (If multiple controllers are being emulated and this method is called consecutive times) + logger.debug(dbus_err) + + # start advertising + hid.discoverable() + + logger.info('Waiting for Switch to connect... Please open the "Change Grip/Order" menu.') + + loop = asyncio.get_event_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) + logger.info(f'Accepted connection at psm {itr_psm} from {itr_address}') + assert ctl_address[0] == itr_address[0] + + # stop advertising + hid.discoverable(False) + + else: + # 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) + client_ctl.connect((reconnect_bt_addr, ctl_psm)) + client_itr.connect((reconnect_bt_addr, itr_psm)) + client_ctl.setblocking(False) + client_itr.setblocking(False) await _run_protocol_on_connection(protocol, client_itr, capture_file=capture_file) return protocol.transport, protocol - - -async def create_reconnection(protocol_factory, console_bt_addr, ctl_psm=17, itr_psm=19, capture_file=None): - """Setup a running protocal by reconnecting to a pairsed console. - - :param console_bt_addr: a bluetooth address for the Switch console. - :param *args, **kwargs: see `create_hid_server`, except that `create_reconnection` does not require device_id. - :returns: see `create_hid_server` - """ - protocol = protocol_factory() - 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) - client_ctl.connect((console_bt_addr, ctl_psm)) - client_itr.connect((console_bt_addr, itr_psm)) - client_ctl.setblocking(False) - client_itr.setblocking(False) - - await _run_protocol_on_connection(protocol, client_itr) - transport = protocol.transport - return transport, protocol diff --git a/run_controller_cli.py b/run_controller_cli.py index 70ed769..cf1935b 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -10,22 +10,18 @@ from joycontrol.command_line_interface import ControllerCLI from joycontrol.controller import Controller from joycontrol.memory import FlashMemory from joycontrol.protocol import controller_protocol_factory -from joycontrol.server import create_hid_server, create_reconnection +from joycontrol.server import create_hid_server from joycontrol.report import InputReport logger = logging.getLogger(__name__) -async def _main(controller, console_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) ctl_psm, itr_psm = 17, 19 - if console_bt_addr is None: - transport, protocol = await create_hid_server(factory, - ctl_psm=ctl_psm, itr_psm=itr_psm, capture_file=capture_file, device_id=device_id) - else: - transport, protocol = await create_reconnection(factory, - console_bt_addr, - ctl_psm=ctl_psm, itr_psm=itr_psm, capture_file=capture_file) + transport, protocol = await create_hid_server(factory, + reconnect_bt_addr=reconnect_bt_addr, + ctl_psm=ctl_psm, itr_psm=itr_psm, capture_file=capture_file, device_id=device_id) controller_state = protocol.get_controller_state() @@ -50,7 +46,8 @@ if __name__ == '__main__': parser.add_argument('-l', '--log') parser.add_argument('-d', '--device_id') parser.add_argument('--spi_flash') - parser.add_argument('--console-bt-addr', type=str, default=None) + parser.add_argument('-r', '--reconnect_bt_addr', type=str, default=None, + help='The Switch console bluetooth address, for reconnecting as an already paired controller') args = parser.parse_args() if args.controller == 'JOYCON_R': @@ -84,7 +81,7 @@ if __name__ == '__main__': loop = asyncio.get_event_loop() loop.run_until_complete( _main(controller, - console_bt_addr=args.console_bt_addr, + reconnect_bt_addr=args.reconnect_bt_addr, capture_file=capture_file, spi_flash=spi_flash, device_id=args.device_id From 1a26b30d82984c44e0c5a5cb62fc24ce076a8590 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 4 Apr 2020 01:35:14 +0900 Subject: [PATCH 11/43] removed unused imports, cleanup --- joycontrol/server.py | 37 +++++++++++++++++-------------------- run_controller_cli.py | 23 ++++++++++------------- 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index cd31c8d..f3e10ae 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -21,22 +21,8 @@ async def _send_empty_input_reports(transport): await asyncio.sleep(1) -async def _run_protocol_on_connection(protocol, client_itr, capture_file=None): - transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, 50, capture_file=capture_file) - protocol.connection_made(transport) - - # send some empty input reports until the Switch decides to reply - future = asyncio.ensure_future(_send_empty_input_reports(transport)) - await protocol.wait_for_output_report() - future.cancel() - try: - await future - except asyncio.CancelledError: - pass - - -async def create_hid_server(protocol_factory, - ctl_psm=17, itr_psm=19, device_id=None, reconnect_bt_addr=None, capture_file=None): +async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id=None, reconnect_bt_addr=None, + capture_file=None): """ :param protocol_factory: Factory function returning a ControllerProtocol instance :param ctl_psm: hid control channel port @@ -46,10 +32,10 @@ async def create_hid_server(protocol_factory, Bluetooth mac address in string notation of the adapter (e.g. "FF:FF:FF:FF:FF:FF"). If None, choose any device. Note: Selection of adapters may currently not work if the bluez "input" plugin is enabled. - :param reconnect_bt_addr: the Bluetooth address of the console that was previously connected. Defaults to None. + :param reconnect_bt_addr: The Bluetooth address of the console that was previously connected. Defaults to None. If None, a new hid server will be started for the initial paring. - Otherwise, the function assumes an initial pairing with the console was already done and reconnects - to the provided Bluetooth address. + Otherwise, the function assumes an initial pairing with the console was already done + and reconnects to the provided Bluetooth address. :param capture_file: opened file to log incoming and outgoing messages :returns transport for input reports and protocol which handles incoming output reports """ @@ -124,6 +110,17 @@ async def create_hid_server(protocol_factory, client_ctl.setblocking(False) client_itr.setblocking(False) - await _run_protocol_on_connection(protocol, client_itr, capture_file=capture_file) + # create transport for the established connection and activate the HID protocol + transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, 50, capture_file=capture_file) + protocol.connection_made(transport) + + # send some empty input reports until the Switch decides to reply + future = asyncio.ensure_future(_send_empty_input_reports(transport)) + await protocol.wait_for_output_report() + future.cancel() + try: + await future + except asyncio.CancelledError: + pass return protocol.transport, protocol diff --git a/run_controller_cli.py b/run_controller_cli.py index cf1935b..a99a42a 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -2,7 +2,6 @@ import argparse import asyncio import logging import os -import socket from contextlib import contextmanager from joycontrol import logging_default as log @@ -11,7 +10,6 @@ from joycontrol.controller import Controller from joycontrol.memory import FlashMemory from joycontrol.protocol import controller_protocol_factory from joycontrol.server import create_hid_server -from joycontrol.report import InputReport logger = logging.getLogger(__name__) @@ -19,9 +17,8 @@ logger = logging.getLogger(__name__) 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) ctl_psm, itr_psm = 17, 19 - transport, protocol = await create_hid_server(factory, - reconnect_bt_addr=reconnect_bt_addr, - ctl_psm=ctl_psm, itr_psm=itr_psm, capture_file=capture_file, device_id=device_id) + transport, protocol = await create_hid_server(factory, reconnect_bt_addr=reconnect_bt_addr, ctl_psm=ctl_psm, + itr_psm=itr_psm, capture_file=capture_file, device_id=device_id) controller_state = protocol.get_controller_state() @@ -46,8 +43,8 @@ if __name__ == '__main__': parser.add_argument('-l', '--log') parser.add_argument('-d', '--device_id') parser.add_argument('--spi_flash') - parser.add_argument('-r', '--reconnect_bt_addr', type=str, default=None, - help='The Switch console bluetooth address, for reconnecting as an already paired controller') + parser.add_argument('-r', '--reconnect_bt_addr', type=str, default=None, + help='The Switch console Bluetooth address, for reconnecting as an already paired controller') args = parser.parse_args() if args.controller == 'JOYCON_R': @@ -80,10 +77,10 @@ if __name__ == '__main__': with get_output(args.log) as capture_file: loop = asyncio.get_event_loop() loop.run_until_complete( - _main(controller, - reconnect_bt_addr=args.reconnect_bt_addr, - capture_file=capture_file, - spi_flash=spi_flash, - device_id=args.device_id - ) + _main(controller, + reconnect_bt_addr=args.reconnect_bt_addr, + capture_file=capture_file, + spi_flash=spi_flash, + device_id=args.device_id + ) ) From af6f9152ddede8f1be7f455eed27b898c103f23f Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sun, 5 Apr 2020 00:02:14 +0900 Subject: [PATCH 12/43] disconnect error handling --- joycontrol/command_line_interface.py | 7 +- joycontrol/controller_state.py | 7 +- joycontrol/protocol.py | 169 ++++++++++++++++----------- joycontrol/server.py | 2 +- joycontrol/transport.py | 102 ++++++++++------ joycontrol/utils.py | 19 +++ run_test_controller_buttons.py | 45 ++++--- 7 files changed, 229 insertions(+), 122 deletions(-) diff --git a/joycontrol/command_line_interface.py b/joycontrol/command_line_interface.py index b7aead9..5e13e6c 100644 --- a/joycontrol/command_line_interface.py +++ b/joycontrol/command_line_interface.py @@ -4,6 +4,7 @@ import logging from aioconsole import ainput from joycontrol.controller_state import button_push, ControllerState +from joycontrol.transport import NotConnectedError logger = logging.getLogger(__name__) @@ -116,4 +117,8 @@ class ControllerCLI: if buttons_to_push: await button_push(self.controller_state, *buttons_to_push) else: - await self.controller_state.send() + try: + await self.controller_state.send() + except NotConnectedError: + logger.info('Connection was lost.') + return diff --git a/joycontrol/controller_state.py b/joycontrol/controller_state.py index 6e2adf7..3764f2a 100644 --- a/joycontrol/controller_state.py +++ b/joycontrol/controller_state.py @@ -45,8 +45,11 @@ class ControllerState: return self._spi_flash async def send(self): - self.sig_is_send.clear() - await self.sig_is_send.wait() + """ + Invokes protocol.send_controller_state(). Returns after the controller state was send. + Raises NotConnected exception if the connection was lost. + """ + await self._protocol.send_controller_state() async def connect(self): """ diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index 935cc1e..9606211 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -1,12 +1,15 @@ import asyncio import logging from asyncio import BaseTransport, BaseProtocol +from contextlib import suppress from typing import Optional, Union, Tuple, Text +from joycontrol import utils from joycontrol.controller import Controller from joycontrol.controller_state import ControllerState from joycontrol.memory import FlashMemory from joycontrol.report import OutputReport, SubCommand, InputReport, OutputReportID +from joycontrol.transport import NotConnectedError logger = logging.getLogger(__name__) @@ -17,6 +20,7 @@ def controller_protocol_factory(controller: Controller, spi_flash=None): def create_controller_protocol(): return ControllerProtocol(controller, spi_flash=spi_flash) + return create_controller_protocol @@ -27,23 +31,48 @@ class ControllerProtocol(BaseProtocol): self.transport = None - # Increases for each input report send, overflows at 0x100 + # Increases for each input report send, should overflow at 0x100 self._input_report_timer = 0x00 self._data_received = asyncio.Event() self._controller_state = ControllerState(self, controller, spi_flash=spi_flash) + self._controller_state_sender = None - self._0x30_input_report_sender = None + # None = Just answer to sub commands + self._input_report_mode = None # This event gets triggered once the Switch assigns a player number to the controller and accepts user inputs self.sig_set_player_lights = asyncio.Event() + async def send_controller_state(self): + """ + Waits for the controller state to be send. + + Raises NotConnected exception if the transport is not connected or the connection was lost. + """ + # TODO: Call write directly if not in 0x30 input report mode + + if self.transport is None: + raise NotConnectedError('Transport not registered.') + + 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 + async def write(self, input_report: InputReport): """ Sets timer byte and current button state in the input report and sends it. - Fires sig_is_send event afterwards. + Fires sig_is_send event in the controller state afterwards. + + Raises NotConnected exception if the transport is not connected or the connection was lost. """ + if self.transport is None: + raise NotConnectedError('Transport not registered.') + # set button and stick data of input report input_report.set_button_status(self._controller_state.button_state) if self._controller_state.l_stick_state is None: @@ -61,6 +90,7 @@ class ControllerProtocol(BaseProtocol): self._input_report_timer = (self._input_report_timer + 1) % 0x100 await self.transport.write(input_report) + self._controller_state.sig_is_send.set() def get_controller_state(self) -> ControllerState: @@ -68,7 +98,7 @@ class ControllerProtocol(BaseProtocol): async def wait_for_output_report(self): """ - Blocks until an output report from the Switch is received. + Waits until an output report from the Switch is received. """ self._data_received.clear() await self._data_received.wait() @@ -77,12 +107,17 @@ class ControllerProtocol(BaseProtocol): logger.debug('Connection established.') self.transport = transport - def connection_lost(self, exc: Optional[Exception]) -> None: - # TODO - raise NotImplementedError() + def connection_lost(self, exc: Optional[Exception] = None) -> None: + if self.transport is not None: + logger.error('Connection lost.') + asyncio.ensure_future(self.transport.close()) + self.transport = None + + if self._controller_state_sender is not None: + self._controller_state_sender.set_exception(NotConnectedError) def error_received(self, exc: Exception) -> None: - # TODO + # TODO? raise NotImplementedError() async def input_report_mode_0x30(self): @@ -99,52 +134,58 @@ class ControllerProtocol(BaseProtocol): reader = asyncio.ensure_future(self.transport.read()) - while True: - if self.controller == Controller.PRO_CONTROLLER: - # send state at 120Hz - await asyncio.sleep(1 / 120) - else: - # send state at 60Hz - await asyncio.sleep(1 / 60) + try: + while True: + # TODO: improve timing + if self.controller == Controller.PRO_CONTROLLER: + # send state at 120Hz + await asyncio.sleep(1 / 120) + else: + # send state at 60Hz + await asyncio.sleep(1 / 60) - reply_send = False - if reader.done(): - data = await reader - if not data: - # disconnect happened - logger.error('No data received (most likely due to a disconnect).') - break + reply_send = False + if reader.done(): + data = await reader - reader = asyncio.ensure_future(self.transport.read()) + reader = asyncio.ensure_future(self.transport.read()) - try: - report = OutputReport(list(data)) - output_report_id = report.get_output_report_id() + try: + report = OutputReport(list(data)) + output_report_id = report.get_output_report_id() - if output_report_id == OutputReportID.RUMBLE_ONLY: - # TODO - pass - elif output_report_id == OutputReportID.SUB_COMMAND: - reply_send = await self._reply_to_sub_command(report) - except ValueError as v_err: - logger.warning(f'Report parsing error "{v_err}" - IGNORE') - except NotImplementedError as err: - logger.warning(err) + if output_report_id == OutputReportID.RUMBLE_ONLY: + # TODO + pass + elif output_report_id == OutputReportID.SUB_COMMAND: + reply_send = await self._reply_to_sub_command(report) + except ValueError as v_err: + logger.warning(f'Report parsing error "{v_err}" - IGNORE') + except NotImplementedError as err: + logger.warning(err) - if reply_send: - # Hack: Adding a delay here to avoid flooding during pairing - await asyncio.sleep(0.3) - else: - # write 0x30 input report. TODO: set some sensor data - input_report.set_6axis_data() - await self.write(input_report) + if reply_send: + # Hack: Adding a delay here to avoid flooding during pairing + await asyncio.sleep(0.3) + else: + # write 0x30 input report. + # TODO: set some sensor data + input_report.set_6axis_data() + + await self.write(input_report) + + except NotConnectedError as err: + # Stop 0x30 input report mode if disconnected. + logger.error(err) + finally: + # cleanup + self._input_report_mode = None + # cancel the reader + with suppress(asyncio.CancelledError, NotConnectedError): + if reader.cancel(): + await reader async def report_received(self, data: Union[bytes, Text], addr: Tuple[str, int]) -> None: - if not data: - # disconnect happened - logger.error('No data received (most likely due to a disconnect).') - return - self._data_received.set() try: @@ -161,7 +202,7 @@ class ControllerProtocol(BaseProtocol): if output_report_id == OutputReportID.SUB_COMMAND: await self._reply_to_sub_command(report) - #elif output_report_id == OutputReportID.RUMBLE_ONLY: + # elif output_report_id == OutputReportID.RUMBLE_ONLY: # pass else: logger.warning(f'Output report {output_report_id} not implemented - ignoring') @@ -216,7 +257,7 @@ class ControllerProtocol(BaseProtocol): else: logger.warning(f'Sub command 0x{sub_command.value:02x} not implemented - ignoring') return False - except Exception as err: + except NotImplementedError as err: logger.error(f'Failed to answer {sub_command} - {err}') return False return True @@ -266,7 +307,7 @@ class ControllerProtocol(BaseProtocol): size = sub_command_data[4] if self.spi_flash is not None: - spi_flash_data = self.spi_flash[offset: offset+size] + spi_flash_data = self.spi_flash[offset: offset + size] input_report.sub_0x10_spi_flash_read(offset, size, spi_flash_data) else: spi_flash_data = size * [0x00] @@ -288,28 +329,20 @@ class ControllerProtocol(BaseProtocol): await self.write(input_report) # start sending 0x30 input reports - if self._0x30_input_report_sender is None: + if self._input_report_mode != 0x30: + self._input_report_mode = 0x30 + self.transport.pause_reading() + new_reader = asyncio.ensure_future(self.input_report_mode_0x30()) - # create callback to check for exceptions - def callback(future): - try: - future.result() - except asyncio.CancelledError: - # Future may be cancelled at anytime - pass - except Exception as err: - logger.exception(err) - - self._0x30_input_report_sender = asyncio.ensure_future(self.input_report_mode_0x30()) - self._0x30_input_report_sender.add_done_callback(callback) - - # We have to swap the reader in the future because this function was probably called by it + # We need to swap the reader in the future because this function was probably called by it async def set_reader(): - await self.transport.set_reader(self._0x30_input_report_sender) + await self.transport.set_reader(new_reader) self.transport.resume_reading() - asyncio.ensure_future(set_reader()).add_done_callback(callback) + asyncio.ensure_future(set_reader()).add_done_callback( + utils.create_error_check_callback() + ) else: logger.error(f'input report mode {sub_command_data[0]} not implemented - ignoring request') @@ -362,7 +395,7 @@ class ControllerProtocol(BaseProtocol): # TODO data = [1, 0, 255, 0, 8, 0, 27, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 200] for i in range(len(data)): - input_report.data[16+i] = data[i] + input_report.data[16 + i] = data[i] await self.write(input_report) diff --git a/joycontrol/server.py b/joycontrol/server.py index f3e10ae..e4d9fae 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -111,7 +111,7 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= client_itr.setblocking(False) # create transport for the established connection and activate the HID protocol - transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, 50, capture_file=capture_file) + transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, client_ctl, 50, capture_file=capture_file) protocol.connection_made(transport) # send some empty input reports until the Switch decides to reply diff --git a/joycontrol/transport.py b/joycontrol/transport.py index e8beddc..e271637 100644 --- a/joycontrol/transport.py +++ b/joycontrol/transport.py @@ -4,21 +4,31 @@ import struct import time from typing import Any +from joycontrol import utils + logger = logging.getLogger(__name__) +class NotConnectedError(ConnectionResetError): + pass + + class L2CAP_Transport(asyncio.Transport): - def __init__(self, loop, protocol, l2cap_socket, read_buffer_size, capture_file=None) -> None: + def __init__(self, loop, protocol, itr_sock, ctr_sock, read_buffer_size, capture_file=None) -> None: + super(L2CAP_Transport, self).__init__() + self._loop = loop self._protocol = protocol - self._sock = l2cap_socket + self._itr_sock = itr_sock + self._ctr_sock = ctr_sock + self._read_buffer_size = read_buffer_size self._extra_info = { - 'peername': self._sock.getpeername(), - 'sockname': self._sock.getsockname(), - 'socket': self._sock + 'peername': self._itr_sock.getpeername(), + 'sockname': self._itr_sock.getsockname(), + 'socket': self._itr_sock } self._is_closing = False @@ -33,10 +43,14 @@ class L2CAP_Transport(asyncio.Transport): async def _reader(self): while True: - data = await self.read() + try: + data = await self.read() + except NotConnectedError: + self._read_thread = None + break #logger.debug(f'received "{list(data)}"') - await self._protocol.report_received(data, self._sock.getpeername()) + await self._protocol.report_received(data, self._itr_sock.getpeername()) def start_reader(self): """ @@ -47,16 +61,8 @@ class L2CAP_Transport(asyncio.Transport): self._read_thread = asyncio.ensure_future(self._reader()) - # create callback to check for exceptions - def callback(future): - try: - future.result() - except asyncio.CancelledError: - # Future may be cancelled at anytime - pass - except Exception as err: - logger.exception(err) - + # Create callback in case the reader is failing + callback = utils.create_error_check_callback(ignore=asyncio.CancelledError) self._read_thread.add_done_callback(callback) async def set_reader(self, reader: asyncio.Future): @@ -68,11 +74,15 @@ class L2CAP_Transport(asyncio.Transport): """ if self._read_thread is not None: # cancel currently running reader - self._read_thread.cancel() - try: - await self._read_thread - except asyncio.CancelledError: - pass + if self._read_thread.cancel(): + try: + await self._read_thread + except asyncio.CancelledError: + pass + + # Create callback for debugging in case the reader is failing + err_callback = utils.create_error_check_callback(ignore=asyncio.CancelledError) + reader.add_done_callback(err_callback) self._read_thread = reader @@ -81,13 +91,19 @@ class L2CAP_Transport(asyncio.Transport): async def read(self): """ - Read data from the unterlying socket. This function "blocks", + Read data from the underlying socket. This function waits, if reading is paused using the pause_reading function. :returns bytes """ await self._is_reading.wait() - data = await self._loop.sock_recv(self._sock, self._read_buffer_size) + data = await self._loop.sock_recv(self._itr_sock, self._read_buffer_size) + + if not data: + # disconnect happened + logger.error('No data received.') + self._protocol.connection_lost() + raise NotConnectedError('No data received.') if self._capture_file is not None: # write data to log file @@ -105,13 +121,13 @@ class L2CAP_Transport(asyncio.Transport): def pause_reading(self) -> None: """ - Pauses the reader + Pauses any 'read' function calls. """ self._is_reading.clear() def resume_reading(self) -> None: """ - Resumes the reader + Resumes all 'read' function calls. """ self._is_reading.set() @@ -131,10 +147,19 @@ class L2CAP_Transport(asyncio.Transport): self._capture_file.write(_time + size + _bytes) #logger.debug(f'sending "{_bytes}"') - await self._loop.sock_sendall(self._sock, _bytes) + try: + await self._loop.sock_sendall(self._itr_sock, _bytes) + except OSError as err: + logger.error(err) + self._protocol.connection_lost() + raise NotConnectedError(err) + except ConnectionResetError as err: + logger.error(err) + self._protocol.connection_lost() + raise err def abort(self) -> None: - super().abort() + raise NotImplementedError def get_extra_info(self, name: Any, default=None) -> Any: return self._extra_info.get(name, default) @@ -146,14 +171,19 @@ class L2CAP_Transport(asyncio.Transport): """ Stops reader and closes underlying socket """ - self._is_closing = True - self._read_thread.cancel() - # wait for reader to cancel - try: - await self._read_thread - except asyncio.CancelledError: - pass - self._sock.close() + if not self._is_closing: + # was not already closed + self._is_closing = True + if self._read_thread.cancel(): + # wait for reader to cancel + try: + await self._read_thread + except asyncio.CancelledError: + pass + + # interrupt connection should be closed first + self._itr_sock.close() + self._ctr_sock.close() def set_protocol(self, protocol: asyncio.BaseProtocol) -> None: self._protocol = protocol diff --git a/joycontrol/utils.py b/joycontrol/utils.py index 4aa33c8..77c0498 100644 --- a/joycontrol/utils.py +++ b/joycontrol/utils.py @@ -12,6 +12,25 @@ def flip_bit(value, n): return value ^ (1 << n) +def create_error_check_callback(ignore=None): + """ + Creates callback causing errors of a finished future to be raised. + Useful for debugging futures that are never awaited. + :param ignore: Any number of errors to ignore. + :returns callback which can be added to a future with future.add_done_callback(...) + """ + def callback(future): + if ignore: + try: + future.result() + except ignore: + # ignore suppressed errors + pass + else: + future.result() + return callback + + async def run_system_command(cmd): proc = await asyncio.create_subprocess_shell( cmd, diff --git a/run_test_controller_buttons.py b/run_test_controller_buttons.py index d9f0a8b..7bcfad2 100644 --- a/run_test_controller_buttons.py +++ b/run_test_controller_buttons.py @@ -2,12 +2,13 @@ import argparse import asyncio import logging import os -from contextlib import contextmanager +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__) @@ -65,24 +66,26 @@ async def test_controller_buttons(controller_state: ControllerState): if 'home' in button_list: button_list.remove('home') - # push all buttons consecutively until KeyboardInterrupt - try: - while True: - for button in button_list: - await button_push(controller_state, button) - await asyncio.sleep(0.1) - except KeyboardInterrupt: - pass + # 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) - await test_controller_buttons(protocol.get_controller_state()) - - logger.info('Stopping communication...') - await transport.close() + 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__': @@ -131,6 +134,20 @@ if __name__ == '__main__': with get_output(args.log) as capture_file: loop = asyncio.get_event_loop() - loop.run_until_complete( + + 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 + ) From feb82a1fd06b2d92459657a8c373263dd610fb29 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 11 Apr 2020 15:08:48 +0900 Subject: [PATCH 13/43] Dropped "run_test_controller_buttons.py" script in favor of command line interface. --- README.md | 28 +++-- joycontrol/command_line_interface.py | 44 +++++++- joycontrol/controller_state.py | 3 + run_controller_cli.py | 131 +++++++++++++++++++++++ run_test_controller_buttons.py | 153 --------------------------- 5 files changed, 194 insertions(+), 165 deletions(-) delete mode 100644 run_test_controller_buttons.py 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 - ) From 7851eced4f361c80359c17f97921a9d6d033a213 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 11 Apr 2020 15:10:00 +0900 Subject: [PATCH 14/43] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 673453f..dcdc7ea 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ 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. +- 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. From 18a423aa5b92d1db085322d0d522ec29a9a0dc93 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 11 Apr 2020 16:34:16 +0900 Subject: [PATCH 15/43] added scripts --- joycontrol/utils.py | 33 +++++++++ run_controller_cli.py | 17 +---- .../dump_spi_flash.py | 2 +- scripts/parse_capture.py | 68 +++++++++++++++++++ scripts/relay_joycon.py | 1 + 5 files changed, 105 insertions(+), 16 deletions(-) rename dump_spi_flash.py => scripts/dump_spi_flash.py (99%) create mode 100644 scripts/parse_capture.py create mode 100644 scripts/relay_joycon.py diff --git a/joycontrol/utils.py b/joycontrol/utils.py index 77c0498..4eadc31 100644 --- a/joycontrol/utils.py +++ b/joycontrol/utils.py @@ -1,9 +1,42 @@ import asyncio import logging +from contextlib import contextmanager + +import hid logger = logging.getLogger(__name__) +class AsyncHID(hid.Device): + def __init__(self, *args, loop=asyncio.get_event_loop(), **kwargs): + super().__init__(*args, **kwargs) + self._loop = loop + + self._write_lock = asyncio.Lock() + self._read_lock = asyncio.Lock() + + async def read(self, size, timeout=None): + async with self._read_lock: + return await self._loop.run_in_executor(None, hid.Device.read, self, size, timeout) + + async def write(self, data): + async with self._write_lock: + return await self._loop.run_in_executor(None, hid.Device.write, self, data) + + +@contextmanager +def get_output(path=None, open_flags='wb', default=None): + """ + Context manager that open the file a path was given, otherwise returns default value. + """ + if path is not None: + file = open(path, open_flags) + yield file + file.close() + else: + yield default + + def get_bit(value, n): return (value >> n & 1) != 0 diff --git a/run_controller_cli.py b/run_controller_cli.py index 0221eee..6f517b2 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -8,7 +8,7 @@ from contextlib import contextmanager from aioconsole import ainput -from joycontrol import logging_default as log +from joycontrol import logging_default as log, utils from joycontrol.command_line_interface import ControllerCLI from joycontrol.controller import Controller from joycontrol.controller_state import ControllerState, button_push @@ -192,20 +192,7 @@ if __name__ == '__main__': with open(args.spi_flash, 'rb') as spi_flash_file: spi_flash = FlashMemory(spi_flash_file.read()) - # creates file if arg is given - @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: + with utils.get_output(path=args.log, default=None) as capture_file: loop = asyncio.get_event_loop() loop.run_until_complete( _main(controller, diff --git a/dump_spi_flash.py b/scripts/dump_spi_flash.py similarity index 99% rename from dump_spi_flash.py rename to scripts/dump_spi_flash.py index 7f54e7c..05c82b4 100644 --- a/dump_spi_flash.py +++ b/scripts/dump_spi_flash.py @@ -11,7 +11,7 @@ from joycontrol.report import OutputReport, InputReport, SubCommand logger = logging.getLogger(__name__) - +# TODO: Add Pro Controller VENDOR_ID = 1406 PRODUCT_ID_JL = 8198 PRODUCT_ID_JR = 8199 diff --git a/scripts/parse_capture.py b/scripts/parse_capture.py new file mode 100644 index 0000000..2b47613 --- /dev/null +++ b/scripts/parse_capture.py @@ -0,0 +1,68 @@ +import argparse +import struct + +from joycontrol.report import InputReport, OutputReport, SubCommand + +""" joycontrol capture parsing example. + +Usage: + parse_capture.py + parse_capture.py -h | --help +""" + + +def _eof_read(file, size): + """ + Raises EOFError if end of file is reached. + """ + data = file.read(size) + if not data: + raise EOFError() + return data + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('capture_file') + args = parser.parse_args() + + # list of time, report tuples + input_reports = [] + output_reports = [] + + with open(args.capture_file, 'rb') as capture: + try: + start_time = None + while True: + # parse capture time + time = struct.unpack('d', _eof_read(capture, 8))[0] + if start_time is None: + start_time = time + + # parse data size + size = struct.unpack('i', _eof_read(capture, 4))[0] + # parse data + data = list(_eof_read(capture, size)) + + if data[0] == 0xA1: + report = InputReport(data) + # normalise time + input_reports.append((time - start_time, report)) + elif data[0] == 0xA2: + report = OutputReport(data) + # normalise time + output_reports.append((time - start_time, report)) + else: + raise ValueError(f'Unexpected data.') + + # only interested in pairing + if isinstance(report, OutputReport) and report.get_sub_command() == SubCommand.SET_PLAYER_LIGHTS: + break + except EOFError: + pass + + print('Finished parsing reports.') + print('Input reports:', len(input_reports)) + print('Output reports:', len(output_reports)) + + # Do some investigation... diff --git a/scripts/relay_joycon.py b/scripts/relay_joycon.py new file mode 100644 index 0000000..30f5b45 --- /dev/null +++ b/scripts/relay_joycon.py @@ -0,0 +1 @@ +import argparse import asyncio import logging import os import socket import struct import time import hid from joycontrol import logging_default as log, utils from joycontrol.device import HidDevice from joycontrol.server import PROFILE_PATH from joycontrol.utils import AsyncHID logger = logging.getLogger(__name__) # TODO: Add Pro Controller VENDOR_ID = 1406 PRODUCT_ID_JL = 8198 PRODUCT_ID_JR = 8199 class Relay: 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() while True: data = await hid_device.read(100) # add adding byte for input report data = b'\xa1' + data if self._capture_file is not None: # write data to log file current_time = struct.pack('d', time.time()) size = struct.pack('i', len(data)) self._capture_file.write(current_time + size + data) await loop.sock_sendall(client_itr, data) await asyncio.sleep(0) async def relay_output(self, hid_device, client_itr): loop = asyncio.get_event_loop() while True: data = await loop.sock_recv(client_itr, 50) if self._capture_file is not None: # write data to log file current_time = struct.pack('d', time.time()) size = struct.pack('i', len(data)) self._capture_file.write(current_time + size + data) # remove padding byte for output report (not required when using the hid driver) data = data[1:] await hid_device.write(data) await asyncio.sleep(0) async def _main(capture_file=None): # Creating l2cap sockets 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) # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. # The Switch does not connect to the sockets if we don't. # For more info see: https://github.com/mart1nro/joycontrol/issues/8 logger.info('Restarting bluetooth service...') await utils.run_system_command('systemctl restart bluetooth.service') await asyncio.sleep(1) logger.info('Waiting for HID devices... Please connect JoyCon over bluetooth. ' 'Note: The bluez "input" plugin needs to be enabled (default)"') controller = None while controller is None: for device in hid.enumerate(0, 0): # looking for devices matching Nintendo's vendor id and JoyCon product id if device['vendor_id'] == VENDOR_ID and device['product_id'] in (PRODUCT_ID_JL, PRODUCT_ID_JR): controller = device break else: await asyncio.sleep(2) logger.info(f'Found controller "{controller}".') logger.info('Connecting with the Switch... Please open the "Change Grip/Order" menu.') ctl_sock.setblocking(False) itr_sock.setblocking(False) ctl_sock.bind((socket.BDADDR_ANY, 17)) itr_sock.bind((socket.BDADDR_ANY, 19)) ctl_sock.listen(1) itr_sock.listen(1) emulated_hid = HidDevice() # setting bluetooth adapter name and class to the device we wish to emulate await emulated_hid.set_name(controller['product_string']) await emulated_hid.set_class() logger.info('Advertising the Bluetooth SDP record...') emulated_hid.register_sdp_record(PROFILE_PATH) emulated_hid.discoverable() loop = asyncio.get_event_loop() client_ctl, ctl_address = await loop.sock_accept(ctl_sock) logger.info(f'Accepted connection at psm 17 from {ctl_address}') client_itr, itr_address = await loop.sock_accept(itr_sock) logger.info(f'Accepted connection at psm 19 from {itr_address}') assert ctl_address[0] == itr_address[0] # stop advertising emulated_hid.discoverable(False) relay = Relay(capture_file) try: with AsyncHID(path=controller['path'], loop=loop) as hid_controller: await asyncio.gather( asyncio.ensure_future(relay.relay_input(hid_controller, client_itr)), asyncio.ensure_future(relay.relay_output(hid_controller, client_itr)), ) finally: logger.info('Stopping communication...') client_itr.close() client_ctl.close() if __name__ == '__main__': # check if root if not os.geteuid() == 0: raise PermissionError('Script must be run as root!') parser = argparse.ArgumentParser() parser.add_argument('-l', '--log', help='log file path for capturing communication') args = parser.parse_args() # setup logging log.configure() with utils.get_output(args.log, default=None) as capture_file: loop = asyncio.get_event_loop() loop.run_until_complete( _main(capture_file=capture_file) ) \ No newline at end of file From cee19451db71501911641ccdd8a36c5eb8c15db3 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 11 Apr 2020 16:35:26 +0900 Subject: [PATCH 16/43] increased version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 142bee1..1e0e4c9 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup(name='joycontrol', - version='0.12', + version='0.13', author='Robert Martin', author_email='martinro@informatik.hu-berlin.de', description='Emulate Nintendo Switch Controllers over Bluetooth', From c5cd075ab7e84f7222a754cf1bee014d87646f73 Mon Sep 17 00:00:00 2001 From: Hasan Ibraheem Date: Sat, 11 Apr 2020 15:43:14 -0400 Subject: [PATCH 17/43] Make device pairable before trying to pair to switch --- joycontrol/device.py | 7 +++++++ joycontrol/server.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/joycontrol/device.py b/joycontrol/device.py index 739df5c..f2366bf 100644 --- a/joycontrol/device.py +++ b/joycontrol/device.py @@ -47,6 +47,13 @@ class HidDevice: """ self.properties.Set(self.adapter.dbus_interface, 'Discoverable', boolean) + def pairable(self, boolean=True): + """ + Make adapter pairable + """ + self.properties.Set(self.adapter.dbus_interface, 'Pairable', boolean) + + async def set_class(self, cls='0x002508'): """ Sets Bluetooth device class. Requires hciconfig system command. diff --git a/joycontrol/server.py b/joycontrol/server.py index e4d9fae..8d54140 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -75,6 +75,7 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= itr_sock.listen(1) hid.powered(True) + hid.pairable(True) # setting bluetooth adapter name and class to the device we wish to emulate await hid.set_name(protocol.controller.device_name()) await hid.set_class() @@ -100,6 +101,7 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= # stop advertising hid.discoverable(False) + hid.pairable(False) else: # Reconnection to reconnect_bt_addr From 2d56e38b2bde9f32c73a6f61bc460b3fb4aaace4 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sun, 12 Apr 2020 05:08:11 +0900 Subject: [PATCH 18/43] cleanup --- joycontrol/device.py | 1 - joycontrol/server.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/joycontrol/device.py b/joycontrol/device.py index f2366bf..f92c0d6 100644 --- a/joycontrol/device.py +++ b/joycontrol/device.py @@ -53,7 +53,6 @@ class HidDevice: """ self.properties.Set(self.adapter.dbus_interface, 'Pairable', boolean) - async def set_class(self, cls='0x002508'): """ Sets Bluetooth device class. Requires hciconfig system command. diff --git a/joycontrol/server.py b/joycontrol/server.py index 8d54140..b6a98af 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -76,6 +76,7 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= hid.powered(True) hid.pairable(True) + # setting bluetooth adapter name and class to the device we wish to emulate await hid.set_name(protocol.controller.device_name()) await hid.set_class() From 4456e8456a9b495ad81afe3a0ea17d02fae2ef5b Mon Sep 17 00:00:00 2001 From: Aaron Golliver Date: Sat, 11 Apr 2020 17:23:33 -0700 Subject: [PATCH 19/43] add pro controller product id --- scripts/dump_spi_flash.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/dump_spi_flash.py b/scripts/dump_spi_flash.py index 05c82b4..6dea09b 100644 --- a/scripts/dump_spi_flash.py +++ b/scripts/dump_spi_flash.py @@ -11,10 +11,10 @@ from joycontrol.report import OutputReport, InputReport, SubCommand logger = logging.getLogger(__name__) -# TODO: Add Pro Controller VENDOR_ID = 1406 PRODUCT_ID_JL = 8198 PRODUCT_ID_JR = 8199 +PRODUCT_ID_PC = 8201 class AsyncHID(hid.Device): @@ -145,14 +145,14 @@ async def dumb_spi_flash(hid_device, output_file=None): async def _main(args, loop): - logger.info('Waiting for HID devices... Please connect JoyCon over Bluetooth. ' + logger.info('Waiting for HID devices... Please connect one JoyCon (left OR right), or a Pro Controller over Bluetooth. ' 'Note: The bluez "input" plugin needs to be enabled (default)"') controller = None while controller is None: for device in hid.enumerate(0, 0): # looking for devices matching Nintendo's vendor id and JoyCon product id - if device['vendor_id'] == VENDOR_ID and device['product_id'] in (PRODUCT_ID_JL, PRODUCT_ID_JR): + if device['vendor_id'] == VENDOR_ID and device['product_id'] in (PRODUCT_ID_JL, PRODUCT_ID_JR, PRODUCT_ID_PC): controller = device break else: From fd373aa48fc038cd49b2224622624ed8b3d79d22 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sun, 12 Apr 2020 15:11:12 +0900 Subject: [PATCH 20/43] Added Pro Controller product id to relay_joycon.py script. --- scripts/dump_spi_flash.py | 2 +- scripts/relay_joycon.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/dump_spi_flash.py b/scripts/dump_spi_flash.py index 6dea09b..c34bfa2 100644 --- a/scripts/dump_spi_flash.py +++ b/scripts/dump_spi_flash.py @@ -146,7 +146,7 @@ async def dumb_spi_flash(hid_device, output_file=None): async def _main(args, loop): logger.info('Waiting for HID devices... Please connect one JoyCon (left OR right), or a Pro Controller over Bluetooth. ' - 'Note: The bluez "input" plugin needs to be enabled (default)"') + 'Note: The bluez "input" plugin needs to be enabled (default)') controller = None while controller is None: diff --git a/scripts/relay_joycon.py b/scripts/relay_joycon.py index 30f5b45..cbd0522 100644 --- a/scripts/relay_joycon.py +++ b/scripts/relay_joycon.py @@ -1 +1 @@ -import argparse import asyncio import logging import os import socket import struct import time import hid from joycontrol import logging_default as log, utils from joycontrol.device import HidDevice from joycontrol.server import PROFILE_PATH from joycontrol.utils import AsyncHID logger = logging.getLogger(__name__) # TODO: Add Pro Controller VENDOR_ID = 1406 PRODUCT_ID_JL = 8198 PRODUCT_ID_JR = 8199 class Relay: 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() while True: data = await hid_device.read(100) # add adding byte for input report data = b'\xa1' + data if self._capture_file is not None: # write data to log file current_time = struct.pack('d', time.time()) size = struct.pack('i', len(data)) self._capture_file.write(current_time + size + data) await loop.sock_sendall(client_itr, data) await asyncio.sleep(0) async def relay_output(self, hid_device, client_itr): loop = asyncio.get_event_loop() while True: data = await loop.sock_recv(client_itr, 50) if self._capture_file is not None: # write data to log file current_time = struct.pack('d', time.time()) size = struct.pack('i', len(data)) self._capture_file.write(current_time + size + data) # remove padding byte for output report (not required when using the hid driver) data = data[1:] await hid_device.write(data) await asyncio.sleep(0) async def _main(capture_file=None): # Creating l2cap sockets 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) # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. # The Switch does not connect to the sockets if we don't. # For more info see: https://github.com/mart1nro/joycontrol/issues/8 logger.info('Restarting bluetooth service...') await utils.run_system_command('systemctl restart bluetooth.service') await asyncio.sleep(1) logger.info('Waiting for HID devices... Please connect JoyCon over bluetooth. ' 'Note: The bluez "input" plugin needs to be enabled (default)"') controller = None while controller is None: for device in hid.enumerate(0, 0): # looking for devices matching Nintendo's vendor id and JoyCon product id if device['vendor_id'] == VENDOR_ID and device['product_id'] in (PRODUCT_ID_JL, PRODUCT_ID_JR): controller = device break else: await asyncio.sleep(2) logger.info(f'Found controller "{controller}".') logger.info('Connecting with the Switch... Please open the "Change Grip/Order" menu.') ctl_sock.setblocking(False) itr_sock.setblocking(False) ctl_sock.bind((socket.BDADDR_ANY, 17)) itr_sock.bind((socket.BDADDR_ANY, 19)) ctl_sock.listen(1) itr_sock.listen(1) emulated_hid = HidDevice() # setting bluetooth adapter name and class to the device we wish to emulate await emulated_hid.set_name(controller['product_string']) await emulated_hid.set_class() logger.info('Advertising the Bluetooth SDP record...') emulated_hid.register_sdp_record(PROFILE_PATH) emulated_hid.discoverable() loop = asyncio.get_event_loop() client_ctl, ctl_address = await loop.sock_accept(ctl_sock) logger.info(f'Accepted connection at psm 17 from {ctl_address}') client_itr, itr_address = await loop.sock_accept(itr_sock) logger.info(f'Accepted connection at psm 19 from {itr_address}') assert ctl_address[0] == itr_address[0] # stop advertising emulated_hid.discoverable(False) relay = Relay(capture_file) try: with AsyncHID(path=controller['path'], loop=loop) as hid_controller: await asyncio.gather( asyncio.ensure_future(relay.relay_input(hid_controller, client_itr)), asyncio.ensure_future(relay.relay_output(hid_controller, client_itr)), ) finally: logger.info('Stopping communication...') client_itr.close() client_ctl.close() if __name__ == '__main__': # check if root if not os.geteuid() == 0: raise PermissionError('Script must be run as root!') parser = argparse.ArgumentParser() parser.add_argument('-l', '--log', help='log file path for capturing communication') args = parser.parse_args() # setup logging log.configure() with utils.get_output(args.log, default=None) as capture_file: loop = asyncio.get_event_loop() loop.run_until_complete( _main(capture_file=capture_file) ) \ No newline at end of file +import argparse import asyncio import logging import os import socket import struct import time import hid from joycontrol import logging_default as log, utils from joycontrol.device import HidDevice from joycontrol.server import PROFILE_PATH from joycontrol.utils import AsyncHID logger = logging.getLogger(__name__) VENDOR_ID = 1406 PRODUCT_ID_JL = 8198 PRODUCT_ID_JR = 8199 PRODUCT_ID_PC = 8201 class Relay: 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() while True: data = await hid_device.read(100) # add adding byte for input report data = b'\xa1' + data if self._capture_file is not None: # write data to log file current_time = struct.pack('d', time.time()) size = struct.pack('i', len(data)) self._capture_file.write(current_time + size + data) await loop.sock_sendall(client_itr, data) await asyncio.sleep(0) async def relay_output(self, hid_device, client_itr): loop = asyncio.get_event_loop() while True: data = await loop.sock_recv(client_itr, 50) if self._capture_file is not None: # write data to log file current_time = struct.pack('d', time.time()) size = struct.pack('i', len(data)) self._capture_file.write(current_time + size + data) # remove padding byte for output report (not required when using the hid driver) data = data[1:] await hid_device.write(data) await asyncio.sleep(0) async def _main(capture_file=None): # Creating l2cap sockets 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) # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. # The Switch does not connect to the sockets if we don't. # For more info see: https://github.com/mart1nro/joycontrol/issues/8 logger.info('Restarting bluetooth service...') await utils.run_system_command('systemctl restart bluetooth.service') await asyncio.sleep(1) logger.info('Waiting for HID devices... Please connect one JoyCon (left OR right), ' 'or a Pro Controller over Bluetooth. Note: The bluez "input" plugin needs to be enabled (default)') controller = None while controller is None: for device in hid.enumerate(0, 0): # looking for devices matching Nintendo's vendor id and JoyCon product id if device['vendor_id'] == VENDOR_ID and device['product_id'] in (PRODUCT_ID_JL, PRODUCT_ID_JR, PRODUCT_ID_PC): controller = device break else: await asyncio.sleep(2) logger.info(f'Found controller "{controller}".') logger.info('Connecting with the Switch... Please open the "Change Grip/Order" menu.') ctl_sock.setblocking(False) itr_sock.setblocking(False) ctl_sock.bind((socket.BDADDR_ANY, 17)) itr_sock.bind((socket.BDADDR_ANY, 19)) ctl_sock.listen(1) itr_sock.listen(1) emulated_hid = HidDevice() # setting bluetooth adapter name and class to the device we wish to emulate await emulated_hid.set_name(controller['product_string']) await emulated_hid.set_class() logger.info('Advertising the Bluetooth SDP record...') emulated_hid.register_sdp_record(PROFILE_PATH) emulated_hid.discoverable() loop = asyncio.get_event_loop() client_ctl, ctl_address = await loop.sock_accept(ctl_sock) logger.info(f'Accepted connection at psm 17 from {ctl_address}') client_itr, itr_address = await loop.sock_accept(itr_sock) logger.info(f'Accepted connection at psm 19 from {itr_address}') assert ctl_address[0] == itr_address[0] # stop advertising emulated_hid.discoverable(False) relay = Relay(capture_file) try: with AsyncHID(path=controller['path'], loop=loop) as hid_controller: await asyncio.gather( asyncio.ensure_future(relay.relay_input(hid_controller, client_itr)), asyncio.ensure_future(relay.relay_output(hid_controller, client_itr)), ) finally: logger.info('Stopping communication...') client_itr.close() client_ctl.close() if __name__ == '__main__': # check if root if not os.geteuid() == 0: raise PermissionError('Script must be run as root!') parser = argparse.ArgumentParser() parser.add_argument('-l', '--log', help='log file path for capturing communication') args = parser.parse_args() # setup logging log.configure() with utils.get_output(args.log, default=None) as capture_file: loop = asyncio.get_event_loop() loop.run_until_complete( _main(capture_file=capture_file) ) \ No newline at end of file From db3c2b3d191a88ec4d23a40b4436fc7627608eac Mon Sep 17 00:00:00 2001 From: Hasan Ibraheem Date: Sat, 11 Apr 2020 23:06:56 -0400 Subject: [PATCH 21/43] Fix newline formatting --- scripts/relay_joycon.py | 152 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/scripts/relay_joycon.py b/scripts/relay_joycon.py index cbd0522..37025a9 100644 --- a/scripts/relay_joycon.py +++ b/scripts/relay_joycon.py @@ -1 +1,151 @@ -import argparse import asyncio import logging import os import socket import struct import time import hid from joycontrol import logging_default as log, utils from joycontrol.device import HidDevice from joycontrol.server import PROFILE_PATH from joycontrol.utils import AsyncHID logger = logging.getLogger(__name__) VENDOR_ID = 1406 PRODUCT_ID_JL = 8198 PRODUCT_ID_JR = 8199 PRODUCT_ID_PC = 8201 class Relay: 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() while True: data = await hid_device.read(100) # add adding byte for input report data = b'\xa1' + data if self._capture_file is not None: # write data to log file current_time = struct.pack('d', time.time()) size = struct.pack('i', len(data)) self._capture_file.write(current_time + size + data) await loop.sock_sendall(client_itr, data) await asyncio.sleep(0) async def relay_output(self, hid_device, client_itr): loop = asyncio.get_event_loop() while True: data = await loop.sock_recv(client_itr, 50) if self._capture_file is not None: # write data to log file current_time = struct.pack('d', time.time()) size = struct.pack('i', len(data)) self._capture_file.write(current_time + size + data) # remove padding byte for output report (not required when using the hid driver) data = data[1:] await hid_device.write(data) await asyncio.sleep(0) async def _main(capture_file=None): # Creating l2cap sockets 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) # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. # The Switch does not connect to the sockets if we don't. # For more info see: https://github.com/mart1nro/joycontrol/issues/8 logger.info('Restarting bluetooth service...') await utils.run_system_command('systemctl restart bluetooth.service') await asyncio.sleep(1) logger.info('Waiting for HID devices... Please connect one JoyCon (left OR right), ' 'or a Pro Controller over Bluetooth. Note: The bluez "input" plugin needs to be enabled (default)') controller = None while controller is None: for device in hid.enumerate(0, 0): # looking for devices matching Nintendo's vendor id and JoyCon product id if device['vendor_id'] == VENDOR_ID and device['product_id'] in (PRODUCT_ID_JL, PRODUCT_ID_JR, PRODUCT_ID_PC): controller = device break else: await asyncio.sleep(2) logger.info(f'Found controller "{controller}".') logger.info('Connecting with the Switch... Please open the "Change Grip/Order" menu.') ctl_sock.setblocking(False) itr_sock.setblocking(False) ctl_sock.bind((socket.BDADDR_ANY, 17)) itr_sock.bind((socket.BDADDR_ANY, 19)) ctl_sock.listen(1) itr_sock.listen(1) emulated_hid = HidDevice() # setting bluetooth adapter name and class to the device we wish to emulate await emulated_hid.set_name(controller['product_string']) await emulated_hid.set_class() logger.info('Advertising the Bluetooth SDP record...') emulated_hid.register_sdp_record(PROFILE_PATH) emulated_hid.discoverable() loop = asyncio.get_event_loop() client_ctl, ctl_address = await loop.sock_accept(ctl_sock) logger.info(f'Accepted connection at psm 17 from {ctl_address}') client_itr, itr_address = await loop.sock_accept(itr_sock) logger.info(f'Accepted connection at psm 19 from {itr_address}') assert ctl_address[0] == itr_address[0] # stop advertising emulated_hid.discoverable(False) relay = Relay(capture_file) try: with AsyncHID(path=controller['path'], loop=loop) as hid_controller: await asyncio.gather( asyncio.ensure_future(relay.relay_input(hid_controller, client_itr)), asyncio.ensure_future(relay.relay_output(hid_controller, client_itr)), ) finally: logger.info('Stopping communication...') client_itr.close() client_ctl.close() if __name__ == '__main__': # check if root if not os.geteuid() == 0: raise PermissionError('Script must be run as root!') parser = argparse.ArgumentParser() parser.add_argument('-l', '--log', help='log file path for capturing communication') args = parser.parse_args() # setup logging log.configure() with utils.get_output(args.log, default=None) as capture_file: loop = asyncio.get_event_loop() loop.run_until_complete( _main(capture_file=capture_file) ) \ No newline at end of file +import argparse +import asyncio +import logging +import os +import socket +import struct +import time + +import hid + +from joycontrol import logging_default as log, utils +from joycontrol.device import HidDevice +from joycontrol.server import PROFILE_PATH +from joycontrol.utils import AsyncHID + +logger = logging.getLogger(__name__) + +VENDOR_ID = 1406 +PRODUCT_ID_JL = 8198 +PRODUCT_ID_JR = 8199 +PRODUCT_ID_PC = 8201 + +class Relay: + 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() + + while True: + data = await hid_device.read(100) + # add adding byte for input report + data = b'\xa1' + data + + if self._capture_file is not None: + # write data to log file + current_time = struct.pack('d', time.time()) + size = struct.pack('i', len(data)) + self._capture_file.write(current_time + size + data) + + await loop.sock_sendall(client_itr, data) + await asyncio.sleep(0) + + async def relay_output(self, hid_device, client_itr): + loop = asyncio.get_event_loop() + + while True: + data = await loop.sock_recv(client_itr, 50) + + if self._capture_file is not None: + # write data to log file + current_time = struct.pack('d', time.time()) + size = struct.pack('i', len(data)) + self._capture_file.write(current_time + size + data) + + # remove padding byte for output report (not required when using the hid driver) + data = data[1:] + + await hid_device.write(data) + await asyncio.sleep(0) + + +async def _main(capture_file=None): + # Creating l2cap sockets + 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) + + # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. + # The Switch does not connect to the sockets if we don't. + # For more info see: https://github.com/mart1nro/joycontrol/issues/8 + logger.info('Restarting bluetooth service...') + await utils.run_system_command('systemctl restart bluetooth.service') + await asyncio.sleep(1) + + logger.info('Waiting for HID devices... Please connect one JoyCon (left OR right), ' + 'or a Pro Controller over Bluetooth. Note: The bluez "input" plugin needs to be enabled (default)') + + controller = None + while controller is None: + for device in hid.enumerate(0, 0): + # looking for devices matching Nintendo's vendor id and JoyCon product id + if device['vendor_id'] == VENDOR_ID and device['product_id'] in (PRODUCT_ID_JL, PRODUCT_ID_JR, PRODUCT_ID_PC): + controller = device + break + else: + await asyncio.sleep(2) + + logger.info(f'Found controller "{controller}".') + + logger.info('Connecting with the Switch... Please open the "Change Grip/Order" menu.') + + ctl_sock.setblocking(False) + itr_sock.setblocking(False) + + ctl_sock.bind((socket.BDADDR_ANY, 17)) + itr_sock.bind((socket.BDADDR_ANY, 19)) + + ctl_sock.listen(1) + itr_sock.listen(1) + + emulated_hid = HidDevice() + # setting bluetooth adapter name and class to the device we wish to emulate + await emulated_hid.set_name(controller['product_string']) + await emulated_hid.set_class() + + logger.info('Advertising the Bluetooth SDP record...') + emulated_hid.register_sdp_record(PROFILE_PATH) + emulated_hid.discoverable() + + loop = asyncio.get_event_loop() + client_ctl, ctl_address = await loop.sock_accept(ctl_sock) + logger.info(f'Accepted connection at psm 17 from {ctl_address}') + client_itr, itr_address = await loop.sock_accept(itr_sock) + logger.info(f'Accepted connection at psm 19 from {itr_address}') + assert ctl_address[0] == itr_address[0] + + # stop advertising + emulated_hid.discoverable(False) + + relay = Relay(capture_file) + + try: + with AsyncHID(path=controller['path'], loop=loop) as hid_controller: + await asyncio.gather( + asyncio.ensure_future(relay.relay_input(hid_controller, client_itr)), + asyncio.ensure_future(relay.relay_output(hid_controller, client_itr)), + ) + finally: + logger.info('Stopping communication...') + client_itr.close() + client_ctl.close() + + +if __name__ == '__main__': + # check if root + if not os.geteuid() == 0: + raise PermissionError('Script must be run as root!') + + parser = argparse.ArgumentParser() + parser.add_argument('-l', '--log', help='log file path for capturing communication') + args = parser.parse_args() + + # setup logging + log.configure() + + with utils.get_output(args.log, default=None) as capture_file: + loop = asyncio.get_event_loop() + loop.run_until_complete( + _main(capture_file=capture_file) + ) + From b4daaca22aba7ca32c44b3f40530522ab12da3be Mon Sep 17 00:00:00 2001 From: Hasan Ibraheem Date: Sun, 12 Apr 2020 22:31:25 -0400 Subject: [PATCH 22/43] Add reconnect option to relay script --- scripts/relay_joycon.py | 109 +++++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 40 deletions(-) diff --git a/scripts/relay_joycon.py b/scripts/relay_joycon.py index 37025a9..14d6b4c 100644 --- a/scripts/relay_joycon.py +++ b/scripts/relay_joycon.py @@ -60,22 +60,12 @@ class Relay: await asyncio.sleep(0) -async def _main(capture_file=None): - # Creating l2cap sockets - 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) - - # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. - # The Switch does not connect to the sockets if we don't. - # For more info see: https://github.com/mart1nro/joycontrol/issues/8 - logger.info('Restarting bluetooth service...') - await utils.run_system_command('systemctl restart bluetooth.service') - await asyncio.sleep(1) - - logger.info('Waiting for HID devices... Please connect one JoyCon (left OR right), ' - 'or a Pro Controller over Bluetooth. Note: The bluez "input" plugin needs to be enabled (default)') +async def get_hid_controller(): + logger.info('Waiting for HID devices... Please connect JoyCon over bluetooth. ' + 'Note: The bluez "input" plugin needs to be enabled (default)"') controller = None + while controller is None: for device in hid.enumerate(0, 0): # looking for devices matching Nintendo's vendor id and JoyCon product id @@ -87,38 +77,75 @@ async def _main(capture_file=None): logger.info(f'Found controller "{controller}".') - logger.info('Connecting with the Switch... Please open the "Change Grip/Order" menu.') + return controller - ctl_sock.setblocking(False) - itr_sock.setblocking(False) - - ctl_sock.bind((socket.BDADDR_ANY, 17)) - itr_sock.bind((socket.BDADDR_ANY, 19)) - - ctl_sock.listen(1) - itr_sock.listen(1) - - emulated_hid = HidDevice() - # setting bluetooth adapter name and class to the device we wish to emulate - await emulated_hid.set_name(controller['product_string']) - await emulated_hid.set_class() - - logger.info('Advertising the Bluetooth SDP record...') - emulated_hid.register_sdp_record(PROFILE_PATH) - emulated_hid.discoverable() +async def _main(capture_file=None, reconnect_bt_addr=None): loop = asyncio.get_event_loop() - client_ctl, ctl_address = await loop.sock_accept(ctl_sock) - logger.info(f'Accepted connection at psm 17 from {ctl_address}') - client_itr, itr_address = await loop.sock_accept(itr_sock) - logger.info(f'Accepted connection at psm 19 from {itr_address}') - assert ctl_address[0] == itr_address[0] - # stop advertising - emulated_hid.discoverable(False) + if reconnect_bt_addr == None: + # Creating l2cap sockets, we have to do this before restarting bluetooth + 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) + + # HACK: To circumvent incompatibilities with the bluetooth "input" plugin, we need to restart Bluetooth here. + # The Switch does not connect to the sockets if we don't. + # For more info see: https://github.com/mart1nro/joycontrol/issues/8 + logger.info('Restarting bluetooth service...') + await utils.run_system_command('systemctl restart bluetooth.service') + await asyncio.sleep(1) + + controller = await get_hid_controller() + + logger.info('Connecting with the Switch... Please open the "Change Grip/Order" menu.') + + ctl_sock.setblocking(False) + itr_sock.setblocking(False) + + ctl_sock.bind((socket.BDADDR_ANY, 17)) + itr_sock.bind((socket.BDADDR_ANY, 19)) + + ctl_sock.listen(1) + itr_sock.listen(1) + + emulated_hid = HidDevice() + # setting bluetooth adapter name and class to the device we wish to emulate + await emulated_hid.set_name(controller['product_string']) + await emulated_hid.set_class() + + logger.info('Advertising the Bluetooth SDP record...') + + emulated_hid.register_sdp_record(PROFILE_PATH) + #emulated_hid.powered(True) + emulated_hid.discoverable(True) + #emulated_hid.pairable(True) + + client_ctl, ctl_address = await loop.sock_accept(ctl_sock) + logger.info(f'Accepted connection at psm 17 from {ctl_address}') + client_itr, itr_address = await loop.sock_accept(itr_sock) + logger.info(f'Accepted connection at psm 19 from {itr_address}') + assert ctl_address[0] == itr_address[0] + + # stop advertising + emulated_hid.discoverable(False) + else: + controller = await get_hid_controller() + + 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) + + client_ctl.connect((reconnect_bt_addr, 17)) + logger.info(f'Reconnected at psm 17 to switch {reconnect_bt_addr}') + client_itr.connect((reconnect_bt_addr, 19)) + logger.info(f'Reconnected at psm 19 to switch {reconnect_bt_addr}') + + client_ctl.setblocking(False) + client_itr.setblocking(False) relay = Relay(capture_file) + logger.info('Relaying starting...') + try: with AsyncHID(path=controller['path'], loop=loop) as hid_controller: await asyncio.gather( @@ -138,6 +165,8 @@ if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('-l', '--log', help='log file path for capturing communication') + parser.add_argument('-r', '--reconnect_bt_addr', type=str, default=None, + help='The Switch console Bluetooth address, for reconnecting as an already paired controller') args = parser.parse_args() # setup logging @@ -146,6 +175,6 @@ if __name__ == '__main__': with utils.get_output(args.log, default=None) as capture_file: loop = asyncio.get_event_loop() loop.run_until_complete( - _main(capture_file=capture_file) + _main(capture_file=capture_file, reconnect_bt_addr=args.reconnect_bt_addr) ) From ac28b4e39e2667a3aa61ff8bd55b7b7e69fe4b26 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Wed, 15 Apr 2020 19:27:37 +0900 Subject: [PATCH 23/43] removed break --- scripts/parse_capture.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/parse_capture.py b/scripts/parse_capture.py index 2b47613..1f5d337 100644 --- a/scripts/parse_capture.py +++ b/scripts/parse_capture.py @@ -54,10 +54,6 @@ if __name__ == '__main__': output_reports.append((time - start_time, report)) else: raise ValueError(f'Unexpected data.') - - # only interested in pairing - if isinstance(report, OutputReport) and report.get_sub_command() == SubCommand.SET_PLAYER_LIGHTS: - break except EOFError: pass From e90499393be4d829015785f7949566d6dc1561ba Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Wed, 15 Apr 2020 20:31:17 +0900 Subject: [PATCH 24/43] Hotfix: Make controller emulation work on new Switch version --- joycontrol/server.py | 6 ++++-- joycontrol/transport.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/joycontrol/server.py b/joycontrol/server.py index b6a98af..7d7a212 100644 --- a/joycontrol/server.py +++ b/joycontrol/server.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) async def _send_empty_input_reports(transport): report = InputReport() - while True: + for i in range(10): await transport.write(report) await asyncio.sleep(1) @@ -117,13 +117,15 @@ async def create_hid_server(protocol_factory, ctl_psm=17, itr_psm=19, device_id= transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, client_ctl, 50, capture_file=capture_file) protocol.connection_made(transport) - # send some empty input reports until the Switch decides to reply + # HACK: send some empty input reports until the Switch decides to reply future = asyncio.ensure_future(_send_empty_input_reports(transport)) await protocol.wait_for_output_report() + """ future.cancel() try: await future except asyncio.CancelledError: pass + """ return protocol.transport, protocol diff --git a/joycontrol/transport.py b/joycontrol/transport.py index e271637..f2ff62d 100644 --- a/joycontrol/transport.py +++ b/joycontrol/transport.py @@ -49,7 +49,6 @@ class L2CAP_Transport(asyncio.Transport): self._read_thread = None break - #logger.debug(f'received "{list(data)}"') await self._protocol.report_received(data, self._itr_sock.getpeername()) def start_reader(self): @@ -99,6 +98,8 @@ class L2CAP_Transport(asyncio.Transport): await self._is_reading.wait() data = await self._loop.sock_recv(self._itr_sock, self._read_buffer_size) + # logger.debug(f'received "{list(data)}"') + if not data: # disconnect happened logger.error('No data received.') @@ -146,7 +147,8 @@ class L2CAP_Transport(asyncio.Transport): size = struct.pack('i', len(_bytes)) self._capture_file.write(_time + size + _bytes) - #logger.debug(f'sending "{_bytes}"') + # logger.debug(f'sending "{_bytes}"') + try: await self._loop.sock_sendall(self._itr_sock, _bytes) except OSError as err: From be8dce71a0bcddd00b7b599bed3b8f495f7b87e8 Mon Sep 17 00:00:00 2001 From: spacemeowx2 Date: Thu, 23 Apr 2020 23:07:21 +0800 Subject: [PATCH 25/43] feat: send amiibo report --- joycontrol/controller_state.py | 5 ++ joycontrol/mcu.py | 154 +++++++++++++++++++++++++++++++++ joycontrol/protocol.py | 145 ++++++++++++++++++++++++------- joycontrol/report.py | 14 ++- run_amiibo_cli.py | 104 ++++++++++++++++++++++ setup.py | 2 +- 6 files changed, 387 insertions(+), 37 deletions(-) create mode 100644 joycontrol/mcu.py create mode 100644 run_amiibo_cli.py diff --git a/joycontrol/controller_state.py b/joycontrol/controller_state.py index 6902a22..aee833e 100644 --- a/joycontrol/controller_state.py +++ b/joycontrol/controller_state.py @@ -9,6 +9,7 @@ class ControllerState: def __init__(self, protocol, controller: Controller, spi_flash: FlashMemory = None): self._protocol = protocol self._controller = controller + self._nfc_content = None self._spi_flash = spi_flash @@ -198,6 +199,10 @@ async def button_push(controller_state, *buttons, sec=0.1): await controller_state.send() +async def set_nfc(controller_state, nfc_content): + controller_state._nfc_content = nfc_content + + class _StickCalibration: def __init__(self, h_center, v_center, h_max_above_center, v_max_above_center, h_max_below_center, v_max_below_center): self.h_center = h_center diff --git a/joycontrol/mcu.py b/joycontrol/mcu.py new file mode 100644 index 0000000..0f26e93 --- /dev/null +++ b/joycontrol/mcu.py @@ -0,0 +1,154 @@ +from enum import Enum +from crc8 import crc8 + + +class Action(Enum): + NON = 0 + REQUEST_STATUS = 1 + START_TAG_POLLING = 2 + START_TAG_DISCOVERY = 3 + READ_TAG = 4 + READ_TAG_2 = 5 + READ_FINISHED = 6 + + +class McuState(Enum): + NOT_INITIALIZED = 0 + IRC = 1 + NFC = 2 + STAND_BY = 3 + BUSY = 4 + + +def copyarray(dest, offset, src): + for i in range(len(src)): + dest[offset + i] = src[i] + +class Mcu: + def __init__(self): + self._fw_major = [0, 3] + self._fw_minor = [0, 5] + + self._bytes = [0] * 313 + + self._action = Action.NON + self._state = McuState.NOT_INITIALIZED + + self._nfc_content = None + + def get_fw_major(self): + return self._fw_major + + def get_fw_minor(self): + return self._fw_minor + + def set_action(self, v): + self._action = v + + def get_action(self): + return self._action + + def set_state(self, v): + self._state = v + + def get_state(self): + return self._state + + def _get_state_byte(self): + if self.get_state() == McuState.NFC: + return 4 + elif self.get_state() == McuState.BUSY: + return 6 + elif self.get_state() == McuState.NOT_INITIALIZED: + return 1 + elif self.get_state() == McuState.STAND_BY: + return 1 + else: + return 0 + + def update_status(self): + self._bytes[0] = 1 + self._bytes[1] = 0 + self._bytes[2] = 0 + self._bytes[3] = self._fw_major[0] + self._bytes[4] = self._fw_major[1] + self._bytes[5] = self._fw_minor[0] + self._bytes[6] = self._fw_minor[1] + self._bytes[7] = self._get_state_byte() + + def update_nfc_report(self): + self._bytes = [0] * 313 + if self.get_action() == Action.REQUEST_STATUS: + self._bytes[0] = 1 + self._bytes[1] = 0 + self._bytes[2] = 0 + self._bytes[3] = self._fw_major[0] + self._bytes[4] = self._fw_major[1] + self._bytes[5] = self._fw_minor[0] + self._bytes[6] = self._fw_minor[1] + self._bytes[7] = self._get_state_byte() + elif self.get_action() == Action.NON: + self._bytes[0] = 0xff + elif self.get_action() == Action.START_TAG_DISCOVERY: + self._bytes[0] = 0x2a + self._bytes[1] = 0 + self._bytes[2] = 5 + self._bytes[3] = 0 + self._bytes[4] = 0 + self._bytes[5] = 9 + self._bytes[6] = 0x31 + self._bytes[7] = 0 + elif self.get_action() == Action.START_TAG_POLLING: + self._bytes[0] = 0x2a + self._bytes[1] = 0 + self._bytes[2] = 5 + self._bytes[3] = 0 + self._bytes[4] = 0 + if not self._nfc_content is None: + data = [0x09, 0x31, 0x09, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x00, 0x07] + copyarray(self._bytes, 5, data) + copyarray(self._bytes, 5 + len(data), self._nfc_content[0:3]) + copyarray(self._bytes, 5 + len(data) + 3, self._nfc_content[4:8]) + else: + print('nfc content is none') + self._bytes[5] = 9 + self._bytes[6] = 0x31 + self._bytes[7] = 0 + elif self.get_action() == Action.READ_TAG or self.get_action() == Action.READ_TAG_2: + self._bytes[0] = 0x3a + self._bytes[1] = 0 + self._bytes[2] = 7 + if self.get_action() == Action.READ_TAG: + data1 = bytes.fromhex('010001310200000001020007') + copyarray(self._bytes, 3, data1) + copyarray(self._bytes, 3 + len(data1), self._nfc_content[0:3]) + copyarray(self._bytes, 3 + len(data1) + 3, self._nfc_content[4:8]) + data2 = bytes.fromhex('000000007DFDF0793651ABD7466E39C191BABEB856CEEDF1CE44CC75EAFB27094D087AE803003B3C7778860000') + copyarray(self._bytes, 3 + len(data1) + 3 + 4, data2) + copyarray(self._bytes, 3 + len(data1) + 3 + 4 + len(data2), self._nfc_content[0:245]) + self.set_action(Action.READ_TAG_2) + else: + data = bytes.fromhex('02000927') + copyarray(self._bytes, 3, data) + copyarray(self._bytes, 3 + len(data), self._nfc_content[245:]) + self.set_action(Action.READ_FINISHED) + elif self.get_action() == Action.READ_FINISHED: + self._bytes[0] = 0x2a + self._bytes[1] = 0 + self._bytes[2] = 5 + self._bytes[3] = 0 + self._bytes[4] = 0 + data = bytes.fromhex('0931040000000101020007') + copyarray(self._bytes, 5, data) + copyarray(self._bytes, 5 + len(data), self._nfc_content[0:3]) + copyarray(self._bytes, 5 + len(data) + 3, self._nfc_content[4:8]) + + crc = crc8() + crc.update(bytes(self._bytes[:-1])) + self._bytes[-1] = ord(crc.digest()) + + def set_nfc(self, nfc_content): + self._nfc_content = nfc_content + + def __bytes__(self): + return bytes(self._bytes) diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index 9606211..97b2537 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -10,6 +10,8 @@ from joycontrol.controller_state import ControllerState from joycontrol.memory import FlashMemory from joycontrol.report import OutputReport, SubCommand, InputReport, OutputReportID from joycontrol.transport import NotConnectedError +from joycontrol.mcu import Mcu, McuState, Action +from crc8 import crc8 logger = logging.getLogger(__name__) @@ -39,6 +41,8 @@ class ControllerProtocol(BaseProtocol): self._controller_state = ControllerState(self, controller, spi_flash=spi_flash) self._controller_state_sender = None + self._mcu = Mcu() + # None = Just answer to sub commands self._input_report_mode = None @@ -89,6 +93,11 @@ class ControllerProtocol(BaseProtocol): input_report.set_timer(self._input_report_timer) self._input_report_timer = (self._input_report_timer + 1) % 0x100 + if input_report.get_input_report_id() == 0x31: + self._mcu.set_nfc(self._controller_state._nfc_content) + self._mcu.update_nfc_report() + input_report.set_mcu(self._mcu) + await self.transport.write(input_report) self._controller_state.sig_is_send.set() @@ -120,15 +129,14 @@ class ControllerProtocol(BaseProtocol): # TODO? raise NotImplementedError() - async def input_report_mode_0x30(self): + async def input_report_mode_full(self): """ - Continuously sends 0x30 input reports containing the controller state. + Continuously sends full input reports containing the controller state. """ if self.transport.is_reading(): - raise ValueError('Transport must be paused in 0x30 input report mode') + raise ValueError('Transport must be paused in full input report mode') input_report = InputReport() - input_report.set_input_report_id(0x30) input_report.set_vibrator_input() input_report.set_misc() @@ -136,6 +144,7 @@ class ControllerProtocol(BaseProtocol): try: while True: + input_report.set_input_report_id(self._input_report_mode) # TODO: improve timing if self.controller == Controller.PRO_CONTROLLER: # send state at 120Hz @@ -159,6 +168,10 @@ class ControllerProtocol(BaseProtocol): pass elif output_report_id == OutputReportID.SUB_COMMAND: reply_send = await self._reply_to_sub_command(report) + elif output_report_id == OutputReportID.REQUEST_MCU: + reply_send = await self._reply_to_mcu(report) + else: + logger.warning(f'Report unknown output report "{output_report_id}" - IGNORE') except ValueError as v_err: logger.warning(f'Report parsing error "{v_err}" - IGNORE') except NotImplementedError as err: @@ -207,6 +220,47 @@ class ControllerProtocol(BaseProtocol): else: logger.warning(f'Output report {output_report_id} not implemented - ignoring') + async def _reply_to_mcu(self, report): + sub_command = report.data[11] + sub_command_data = report.data[12:] + + # logging.info(f'received output report - Request MCU sub command {sub_command}') + + if self._mcu.get_action() == Action.READ_TAG or self._mcu.get_action() == Action.READ_TAG_2 or self._mcu.get_action() == Action.READ_FINISHED: + return + + # Request mcu state + if sub_command == 0x01: + # input_report = InputReport() + # input_report.set_input_report_id(0x21) + # input_report.set_misc() + + # input_report.set_ack(0xA0) + # input_report.reply_to_subcommand_id(0x21) + + self._mcu.set_action(Action.REQUEST_STATUS) + # input_report.set_mcu(self._mcu) + + # await self.write(input_report) + # Send Start tag discovery + elif sub_command == 0x02: + # 0: Cancel all, 4: StartWaitingReceive + if sub_command_data[0] == 0x04: + self._mcu.set_action(Action.START_TAG_DISCOVERY) + # 1: Start polling + elif sub_command_data[0] == 0x01: + self._mcu.set_action(Action.START_TAG_POLLING) + # 2: stop polling + elif sub_command_data[0] == 0x02: + self._mcu.set_action(Action.NON) + elif sub_command_data[0] == 0x06: + self._mcu.set_action(Action.READ_TAG) + else: + logging.info(f'Unknown sub_command_data arg {sub_command_data}') + else: + logging.info(f'Unknown MCU sub command {sub_command}') + + async def _reply_to_sub_command(self, report): # classify sub command try: @@ -317,34 +371,40 @@ class ControllerProtocol(BaseProtocol): async def _command_set_input_report_mode(self, sub_command_data): if sub_command_data[0] == 0x30: - logger.info('Setting input report mode to 0x30...') - - input_report = InputReport() - input_report.set_input_report_id(0x21) - input_report.set_misc() - - input_report.set_ack(0x80) - input_report.reply_to_subcommand_id(0x03) - - await self.write(input_report) - - # start sending 0x30 input reports - if self._input_report_mode != 0x30: - self._input_report_mode = 0x30 - - self.transport.pause_reading() - new_reader = asyncio.ensure_future(self.input_report_mode_0x30()) - - # We need to swap the reader in the future because this function was probably called by it - async def set_reader(): - await self.transport.set_reader(new_reader) - self.transport.resume_reading() - - asyncio.ensure_future(set_reader()).add_done_callback( - utils.create_error_check_callback() - ) + pass + elif sub_command_data[0] == 0x31: + pass else: logger.error(f'input report mode {sub_command_data[0]} not implemented - ignoring request') + return + + logger.info(f'Setting input report mode to {hex(sub_command_data[0])}...') + + input_report = InputReport() + input_report.set_input_report_id(0x21) + input_report.set_misc() + + input_report.set_ack(0x80) + input_report.reply_to_subcommand_id(0x03) + + await self.write(input_report) + + # start sending input reports + if self._input_report_mode is None: + + self.transport.pause_reading() + new_reader = asyncio.ensure_future(self.input_report_mode_full()) + + # We need to swap the reader in the future because this function was probably called by it + async def set_reader(): + await self.transport.set_reader(new_reader) + self.transport.resume_reading() + + asyncio.ensure_future(set_reader()).add_done_callback( + utils.create_error_check_callback() + ) + + self._input_report_mode = sub_command_data[0] async def _command_trigger_buttons_elapsed_time(self, sub_command_data): input_report = InputReport() @@ -392,10 +452,26 @@ class ControllerProtocol(BaseProtocol): input_report.set_ack(0xA0) input_report.reply_to_subcommand_id(SubCommand.SET_NFC_IR_MCU_CONFIG.value) - # TODO - data = [1, 0, 255, 0, 8, 0, 27, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 200] + self._mcu.update_status() + data = list(bytes(self._mcu)[0:34]) + crc = crc8() + crc.update(bytes(data[:-1])) + checksum = crc.digest() + data[-1] = ord(checksum) + for i in range(len(data)): - input_report.data[16 + i] = data[i] + input_report.data[16+i] = data[i] + + # Set MCU mode cmd + if sub_command_data[1] == 0: + if sub_command_data[2] == 0: + self._mcu.set_state(McuState.STAND_BY) + elif sub_command_data[2] == 4: + self._mcu.set_state(McuState.NFC) + else: + logger.info(f"unknown mcu state {sub_command_data[2]}") + else: + logger.info(f"unknown mcu config command {sub_command_data}") await self.write(input_report) @@ -408,10 +484,13 @@ class ControllerProtocol(BaseProtocol): # 0x01 = Resume input_report.set_ack(0x80) input_report.reply_to_subcommand_id(SubCommand.SET_NFC_IR_MCU_STATE.value) + self._mcu.set_action(Action.NON) + self._mcu.set_state(McuState.STAND_BY) elif sub_command_data[0] == 0x00: # 0x00 = Suspend input_report.set_ack(0x80) input_report.reply_to_subcommand_id(SubCommand.SET_NFC_IR_MCU_STATE.value) + self._mcu.set_state(McuState.STAND_BY) else: raise NotImplementedError(f'Argument {sub_command_data[0]} of {SubCommand.SET_NFC_IR_MCU_STATE} ' f'not implemented.') diff --git a/joycontrol/report.py b/joycontrol/report.py index c6db581..fed830e 100644 --- a/joycontrol/report.py +++ b/joycontrol/report.py @@ -10,8 +10,7 @@ class InputReport: """ def __init__(self, data=None): if not data: - # TODO: not enough space for NFC/IR data input report - self.data = [0x00] * 51 + self.data = [0x00] * 364 # all input reports are prepended with 0xA1 self.data[0] = 0xA1 else: @@ -113,6 +112,12 @@ class InputReport: for i in range(14, 50): self.data[i] = 0x00 + def set_mcu(self, data): + # write to data + data = bytes(data) + for i in range(len(data)): + self.data[50 + i] = data[i] + def reply_to_subcommand_id(self, _id): if isinstance(_id, SubCommand): self.data[15] = _id.value @@ -195,8 +200,10 @@ class InputReport: return bytes(self.data[:51]) elif _id == 0x30: return bytes(self.data[:14]) + elif _id == 0x31: + return bytes(self.data[:363]) else: - return bytes(self.data) + return bytes(self.data[:51]) class SubCommand(Enum): @@ -215,6 +222,7 @@ class SubCommand(Enum): class OutputReportID(Enum): SUB_COMMAND = 0x01 RUMBLE_ONLY = 0x10 + REQUEST_MCU = 0x11 class OutputReport: diff --git a/run_amiibo_cli.py b/run_amiibo_cli.py new file mode 100644 index 0000000..57a00e0 --- /dev/null +++ b/run_amiibo_cli.py @@ -0,0 +1,104 @@ +import argparse +import asyncio +import logging +import os +from contextlib import contextmanager + +from joycontrol import logging_default as log +from joycontrol.command_line_interface import ControllerCLI +from joycontrol.controller_state import ControllerState, button_push, set_nfc +from joycontrol.protocol import controller_protocol_factory, Controller +from joycontrol.server import create_hid_server + +logger = logging.getLogger(__name__) + + +async def _main(controller, reconnect_bt_addr=None, capture_file=None, spi_flash=None, device_id=None, amiibo=None): + factory = controller_protocol_factory(controller, spi_flash=spi_flash) + ctl_psm, itr_psm = 17, 19 + transport, protocol = await create_hid_server(factory, + reconnect_bt_addr=reconnect_bt_addr, + ctl_psm=ctl_psm, itr_psm=itr_psm, capture_file=capture_file, device_id=device_id) + + controller_state = protocol.get_controller_state() + if amiibo: + await set_nfc(controller_state, amiibo.read()) + + await controller_state.connect() + + async def amiibo(filename): + with open(filename, "rb") as amiibo_file: + content = amiibo_file.read() + await set_nfc(controller_state, content) + + async def remove_amiibo(): + await controller_state.set_nfc(None) + + cli = ControllerCLI(controller_state) + cli.add_command('amiibo', amiibo) + cli.add_command('remove_amiibo', remove_amiibo) + await cli.run() + + 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('-l', '--log') + parser.add_argument('-d', '--device_id') + parser.add_argument('--spi_flash') + parser.add_argument('-r', '--reconnect_bt_addr', type=str, default=None, + help='The Switch console bluetooth address, for reconnecting as an already paired controller') + parser.add_argument('-a', '--amiibo', type=argparse.FileType('rb'), default=None, + help='The amiibo dump file') + 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() + + # creates file if arg is given + @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() + loop.run_until_complete(_main( + controller, + reconnect_bt_addr=args.reconnect_bt_addr, + capture_file=capture_file, + spi_flash=spi_flash, + device_id=args.device_id, + amiibo=args.amiibo + )) diff --git a/setup.py b/setup.py index 1e0e4c9..bc1abf1 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup(name='joycontrol', package_data={'joycontrol': ['profile/sdp_record_hid.xml']}, zip_safe=False, install_requires=[ - 'hid', 'aioconsole', 'dbus-python' + 'hid', 'aioconsole', 'dbus-python', 'crc8' ] ) From c17ab2114074b86a3fc9505c17391016a99007c9 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Thu, 30 Apr 2020 20:44:07 +0200 Subject: [PATCH 26/43] merged run_amiibo_cli with run_controller_cli; some cleanups --- joycontrol/command_line_interface.py | 3 +- joycontrol/controller.py | 11 +++ joycontrol/controller_state.py | 7 +- joycontrol/protocol.py | 1 - run_amiibo_cli.py | 104 ------------------------ run_controller_cli.py | 115 +++++++++++++++++---------- 6 files changed, 87 insertions(+), 154 deletions(-) delete mode 100644 run_amiibo_cli.py diff --git a/joycontrol/command_line_interface.py b/joycontrol/command_line_interface.py index 8c4cad6..526726d 100644 --- a/joycontrol/command_line_interface.py +++ b/joycontrol/command_line_interface.py @@ -1,5 +1,6 @@ import inspect import logging +import shlex from aioconsole import ainput @@ -122,7 +123,7 @@ class ControllerCLI: buttons_to_push = [] for command in user_input.split('&&'): - cmd, *args = command.split() + cmd, *args = shlex.split(command) if cmd == 'exit': return diff --git a/joycontrol/controller.py b/joycontrol/controller.py index 33da3ae..3faecb4 100644 --- a/joycontrol/controller.py +++ b/joycontrol/controller.py @@ -18,3 +18,14 @@ class Controller(enum.Enum): return 'Pro Controller' else: raise NotImplementedError() + + @staticmethod + def from_arg(arg): + if arg == 'JOYCON_R': + return Controller.JOYCON_R + elif arg == 'JOYCON_L': + return Controller.JOYCON_L + elif arg == 'PRO_CONTROLLER': + return Controller.PRO_CONTROLLER + else: + raise ValueError(f'Unknown controller "{arg}".') diff --git a/joycontrol/controller_state.py b/joycontrol/controller_state.py index aee833e..997ed6b 100644 --- a/joycontrol/controller_state.py +++ b/joycontrol/controller_state.py @@ -48,6 +48,9 @@ class ControllerState: def get_flash_memory(self): return self._spi_flash + def set_nfc(self, nfc_content): + self._nfc_content = nfc_content + async def send(self): """ Invokes protocol.send_controller_state(). Returns after the controller state was send. @@ -199,10 +202,6 @@ async def button_push(controller_state, *buttons, sec=0.1): await controller_state.send() -async def set_nfc(controller_state, nfc_content): - controller_state._nfc_content = nfc_content - - class _StickCalibration: def __init__(self, h_center, v_center, h_max_above_center, v_max_above_center, h_max_below_center, v_max_below_center): self.h_center = h_center diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index 97b2537..cecf0b3 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -260,7 +260,6 @@ class ControllerProtocol(BaseProtocol): else: logging.info(f'Unknown MCU sub command {sub_command}') - async def _reply_to_sub_command(self, report): # classify sub command try: diff --git a/run_amiibo_cli.py b/run_amiibo_cli.py deleted file mode 100644 index 57a00e0..0000000 --- a/run_amiibo_cli.py +++ /dev/null @@ -1,104 +0,0 @@ -import argparse -import asyncio -import logging -import os -from contextlib import contextmanager - -from joycontrol import logging_default as log -from joycontrol.command_line_interface import ControllerCLI -from joycontrol.controller_state import ControllerState, button_push, set_nfc -from joycontrol.protocol import controller_protocol_factory, Controller -from joycontrol.server import create_hid_server - -logger = logging.getLogger(__name__) - - -async def _main(controller, reconnect_bt_addr=None, capture_file=None, spi_flash=None, device_id=None, amiibo=None): - factory = controller_protocol_factory(controller, spi_flash=spi_flash) - ctl_psm, itr_psm = 17, 19 - transport, protocol = await create_hid_server(factory, - reconnect_bt_addr=reconnect_bt_addr, - ctl_psm=ctl_psm, itr_psm=itr_psm, capture_file=capture_file, device_id=device_id) - - controller_state = protocol.get_controller_state() - if amiibo: - await set_nfc(controller_state, amiibo.read()) - - await controller_state.connect() - - async def amiibo(filename): - with open(filename, "rb") as amiibo_file: - content = amiibo_file.read() - await set_nfc(controller_state, content) - - async def remove_amiibo(): - await controller_state.set_nfc(None) - - cli = ControllerCLI(controller_state) - cli.add_command('amiibo', amiibo) - cli.add_command('remove_amiibo', remove_amiibo) - await cli.run() - - 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('-l', '--log') - parser.add_argument('-d', '--device_id') - parser.add_argument('--spi_flash') - parser.add_argument('-r', '--reconnect_bt_addr', type=str, default=None, - help='The Switch console bluetooth address, for reconnecting as an already paired controller') - parser.add_argument('-a', '--amiibo', type=argparse.FileType('rb'), default=None, - help='The amiibo dump file') - 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() - - # creates file if arg is given - @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() - loop.run_until_complete(_main( - controller, - reconnect_bt_addr=args.reconnect_bt_addr, - capture_file=capture_file, - spi_flash=spi_flash, - device_id=args.device_id, - amiibo=args.amiibo - )) diff --git a/run_controller_cli.py b/run_controller_cli.py index 6f517b2..9204b6e 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -4,7 +4,6 @@ import argparse import asyncio import logging import os -from contextlib import contextmanager from aioconsole import ainput @@ -133,31 +132,79 @@ async def test_controller_buttons(controller_state: ControllerState): 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) - ctl_psm, itr_psm = 17, 19 - transport, protocol = await create_hid_server(factory, reconnect_bt_addr=reconnect_bt_addr, ctl_psm=ctl_psm, - itr_psm=itr_psm, capture_file=capture_file, device_id=device_id) +async def set_amiibo(controller_state, file_path): + """ + Sets nfc content of the controller state to contents of the given file. + :param controller_state: Emulated controller state + :param file_path: Path to amiibo dump file + """ + loop = asyncio.get_event_loop() - controller_state = protocol.get_controller_state() + with open(file_path, 'rb') as amiibo_file: + content = await loop.run_in_executor(None, amiibo_file.read) + controller_state.set_nfc(content) - # 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) +async def _main(args): + # parse the spi flash + spi_flash = None + if args.spi_flash: + with open(args.spi_flash, 'rb') as spi_flash_file: + spi_flash = FlashMemory(spi_flash_file.read()) - # add the script from above - cli.add_command('test_buttons', _run_test_controller_buttons) + # Get controller name to emulate from arguments + controller = Controller.from_arg(args.controller) - await cli.run() + with utils.get_output(path=args.log, default=None) as capture_file: + factory = controller_protocol_factory(controller, spi_flash=spi_flash) + ctl_psm, itr_psm = 17, 19 + transport, protocol = await create_hid_server(factory, reconnect_bt_addr=args.reconnect_bt_addr, + ctl_psm=ctl_psm, + itr_psm=itr_psm, capture_file=capture_file, + device_id=args.device_id) - logger.info('Stopping communication...') - await transport.close() + 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) + + # Create amiibo command + async def amiibo(*args): + """ + amiibo - Sets amiibo content + + Usage: + amiibo Set controller state NFC content to file + amiibo remove Remove NFC content from controller state + """ + if controller_state.get_controller() == Controller.JOYCON_L: + raise ValueError('NFC content cannot be set for JOYCON_L') + elif not args: + raise ValueError('"amiibo" command requires amiibo dump file path as argument!') + elif args[0] == 'remove': + controller_state.set_nfc(None) + print('Removed nfc content.') + else: + await set_amiibo(controller_state, args[0]) + + # add the script from above + cli.add_command('amiibo', amiibo) + + try: + await cli.run() + finally: + logger.info('Stopping communication...') + await transport.close() if __name__ == '__main__': @@ -178,27 +225,7 @@ if __name__ == '__main__': help='The Switch console Bluetooth address, for reconnecting as an already paired controller') 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}".') - - spi_flash = None - if args.spi_flash: - with open(args.spi_flash, 'rb') as spi_flash_file: - spi_flash = FlashMemory(spi_flash_file.read()) - - with utils.get_output(path=args.log, default=None) as capture_file: - loop = asyncio.get_event_loop() - loop.run_until_complete( - _main(controller, - reconnect_bt_addr=args.reconnect_bt_addr, - capture_file=capture_file, - spi_flash=spi_flash, - device_id=args.device_id - ) - ) + loop = asyncio.get_event_loop() + loop.run_until_complete( + _main(args) + ) From 3822568738efa9f927575ae24faf656791379d96 Mon Sep 17 00:00:00 2001 From: Tyler Carberry Date: Fri, 1 May 2020 20:40:23 -0400 Subject: [PATCH 27/43] Include the libhidapi-hidraw0 library in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dcdc7ea..d1639e6 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Emulate Nintendo Switch Controllers over Bluetooth. Tested on Ubuntu 19.10 and with Raspberry Pi 4B Raspbian GNU/Linux 10 (buster) ## Installation -- Install dbus-python package +- Install the dbus-python and libhidapi-hidraw0 packages ```bash -sudo apt install python3-dbus +sudo apt install python3-dbus libhidapi-hidraw0 ``` - Clone the repository and install the joycontrol package to get missing dependencies (Note: Controller script needs super user rights, so python packages must be installed as root). In the joycontrol folder run: ```bash From 3adf0b2878b2a9677644a88eda351e122f432095 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Mon, 4 May 2020 16:11:47 +0200 Subject: [PATCH 28/43] improved input report timing; some nfc cleanup --- joycontrol/command_line_interface.py | 59 +++++++++++--- joycontrol/controller_state.py | 3 + joycontrol/{mcu.py => ir_nfc_mcu.py} | 23 +++--- joycontrol/protocol.py | 111 ++++++++++++++++----------- joycontrol/report.py | 26 ++++++- scripts/relay_joycon.py | 1 + 6 files changed, 154 insertions(+), 69 deletions(-) rename joycontrol/{mcu.py => ir_nfc_mcu.py} (91%) diff --git a/joycontrol/command_line_interface.py b/joycontrol/command_line_interface.py index 526726d..fb5a870 100644 --- a/joycontrol/command_line_interface.py +++ b/joycontrol/command_line_interface.py @@ -38,15 +38,16 @@ def _print_doc(string): print(line[prefix_i:] if line.strip() else line) -class ControllerCLI: - def __init__(self, controller_state: ControllerState): - self.controller_state = controller_state +class CLI: + def __init__(self): self.commands = {} + def add_command(self, name, command): + if name in self.commands: + raise ValueError(f'Command {name} already registered.') + self.commands[name] = command + async def cmd_help(self): - 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__: @@ -59,6 +60,47 @@ class ControllerCLI: 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) + + 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.') + + +class ControllerCLI(CLI): + def __init__(self, controller_state: ControllerState): + super().__init__() + self.controller_state = controller_state + + async def cmd_help(self): + print('Button commands:') + print(', '.join(self.controller_state.button_state.get_available_buttons())) + print() + await super().cmd_help() + @staticmethod def _set_stick(stick, direction, value): if direction == 'center': @@ -109,11 +151,6 @@ class ControllerCLI: else: raise ValueError('Value of side must be "l", "left" or "r", "right"') - def add_command(self, name, command): - if name in self.commands: - raise ValueError(f'Command {name} already registered.') - self.commands[name] = command - async def run(self): while True: user_input = await ainput(prompt='cmd >> ') diff --git a/joycontrol/controller_state.py b/joycontrol/controller_state.py index 997ed6b..dcc0460 100644 --- a/joycontrol/controller_state.py +++ b/joycontrol/controller_state.py @@ -51,6 +51,9 @@ class ControllerState: def set_nfc(self, nfc_content): self._nfc_content = nfc_content + def get_nfc(self): + return self._nfc_content + async def send(self): """ Invokes protocol.send_controller_state(). Returns after the controller state was send. diff --git a/joycontrol/mcu.py b/joycontrol/ir_nfc_mcu.py similarity index 91% rename from joycontrol/mcu.py rename to joycontrol/ir_nfc_mcu.py index 0f26e93..e618edd 100644 --- a/joycontrol/mcu.py +++ b/joycontrol/ir_nfc_mcu.py @@ -1,6 +1,9 @@ +import logging from enum import Enum from crc8 import crc8 +logger = logging.getLogger(__name__) + class Action(Enum): NON = 0 @@ -24,7 +27,12 @@ def copyarray(dest, offset, src): for i in range(len(src)): dest[offset + i] = src[i] -class Mcu: + +class IrNfcMcu: + """ + TODO: cleanup + """ + def __init__(self): self._fw_major = [0, 3] self._fw_minor = [0, 5] @@ -79,14 +87,7 @@ class Mcu: def update_nfc_report(self): self._bytes = [0] * 313 if self.get_action() == Action.REQUEST_STATUS: - self._bytes[0] = 1 - self._bytes[1] = 0 - self._bytes[2] = 0 - self._bytes[3] = self._fw_major[0] - self._bytes[4] = self._fw_major[1] - self._bytes[5] = self._fw_minor[0] - self._bytes[6] = self._fw_minor[1] - self._bytes[7] = self._get_state_byte() + self.update_status() elif self.get_action() == Action.NON: self._bytes[0] = 0xff elif self.get_action() == Action.START_TAG_DISCOVERY: @@ -104,13 +105,13 @@ class Mcu: self._bytes[2] = 5 self._bytes[3] = 0 self._bytes[4] = 0 - if not self._nfc_content is None: + if self._nfc_content is not None: data = [0x09, 0x31, 0x09, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x00, 0x07] copyarray(self._bytes, 5, data) copyarray(self._bytes, 5 + len(data), self._nfc_content[0:3]) copyarray(self._bytes, 5 + len(data) + 3, self._nfc_content[4:8]) else: - print('nfc content is none') + logger.info('nfc content is none') self._bytes[5] = 9 self._bytes[6] = 0x31 self._bytes[7] = 0 diff --git a/joycontrol/protocol.py b/joycontrol/protocol.py index cecf0b3..7134f0d 100644 --- a/joycontrol/protocol.py +++ b/joycontrol/protocol.py @@ -1,5 +1,6 @@ import asyncio import logging +import time from asyncio import BaseTransport, BaseProtocol from contextlib import suppress from typing import Optional, Union, Tuple, Text @@ -10,7 +11,7 @@ from joycontrol.controller_state import ControllerState from joycontrol.memory import FlashMemory from joycontrol.report import OutputReport, SubCommand, InputReport, OutputReportID from joycontrol.transport import NotConnectedError -from joycontrol.mcu import Mcu, McuState, Action +from joycontrol.ir_nfc_mcu import IrNfcMcu, McuState, Action from crc8 import crc8 logger = logging.getLogger(__name__) @@ -41,7 +42,7 @@ class ControllerProtocol(BaseProtocol): self._controller_state = ControllerState(self, controller, spi_flash=spi_flash) self._controller_state_sender = None - self._mcu = Mcu() + self._mcu = IrNfcMcu() # None = Just answer to sub commands self._input_report_mode = None @@ -55,7 +56,7 @@ class ControllerProtocol(BaseProtocol): Raises NotConnected exception if the transport is not connected or the connection was lost. """ - # TODO: Call write directly if not in 0x30 input report mode + # TODO: Call write directly if in continuously sending input report mode if self.transport is None: raise NotConnectedError('Transport not registered.') @@ -93,11 +94,6 @@ class ControllerProtocol(BaseProtocol): input_report.set_timer(self._input_report_timer) self._input_report_timer = (self._input_report_timer + 1) % 0x100 - if input_report.get_input_report_id() == 0x31: - self._mcu.set_nfc(self._controller_state._nfc_content) - self._mcu.update_nfc_report() - input_report.set_mcu(self._mcu) - await self.transport.write(input_report) self._controller_state.sig_is_send.set() @@ -131,28 +127,29 @@ class ControllerProtocol(BaseProtocol): async def input_report_mode_full(self): """ - Continuously sends full input reports containing the controller state. + Continuously sends: + 0x30 input reports containing the controller state OR + 0x31 input reports containing the controller state and nfc data """ if self.transport.is_reading(): raise ValueError('Transport must be paused in full input report mode') + # send state at 66Hz + send_delay = 0.015 + await asyncio.sleep(send_delay) + last_send_time = time.time() + input_report = InputReport() input_report.set_vibrator_input() input_report.set_misc() + if self._input_report_mode is None: + raise ValueError('Input report mode is not set.') + input_report.set_input_report_id(self._input_report_mode) reader = asyncio.ensure_future(self.transport.read()) try: while True: - input_report.set_input_report_id(self._input_report_mode) - # TODO: improve timing - if self.controller == Controller.PRO_CONTROLLER: - # send state at 120Hz - await asyncio.sleep(1 / 120) - else: - # send state at 60Hz - await asyncio.sleep(1 / 60) - reply_send = False if reader.done(): data = await reader @@ -168,8 +165,10 @@ class ControllerProtocol(BaseProtocol): pass elif output_report_id == OutputReportID.SUB_COMMAND: reply_send = await self._reply_to_sub_command(report) - elif output_report_id == OutputReportID.REQUEST_MCU: - reply_send = await self._reply_to_mcu(report) + elif output_report_id == OutputReportID.REQUEST_IR_NFC_MCU: + # TODO: This does not reply anything + # reply_send = await self._reply_to_ir_nfc_mcu(report) + await self._reply_to_ir_nfc_mcu(report) else: logger.warning(f'Report unknown output report "{output_report_id}" - IGNORE') except ValueError as v_err: @@ -185,8 +184,26 @@ class ControllerProtocol(BaseProtocol): # TODO: set some sensor data input_report.set_6axis_data() + # set nfc data + if input_report.get_input_report_id() == 0x31: + self._mcu.set_nfc(self._controller_state.get_nfc()) + self._mcu.update_nfc_report() + input_report.set_ir_nfc_data(bytes(self._mcu)) + await self.write(input_report) + # calculate delay + current_time = time.time() + time_delta = time.time() - last_send_time + sleep_time = send_delay - time_delta + last_send_time = current_time + + if sleep_time < 0: + # logger.warning(f'Code is running {abs(sleep_time)} s too slow!') + sleep_time = 0 + + await asyncio.sleep(sleep_time) + except NotConnectedError as err: # Stop 0x30 input report mode if disconnected. logger.error(err) @@ -220,13 +237,17 @@ class ControllerProtocol(BaseProtocol): else: logger.warning(f'Output report {output_report_id} not implemented - ignoring') - async def _reply_to_mcu(self, report): + async def _reply_to_ir_nfc_mcu(self, report): + """ + TODO: Cleanup + We aren't replying to anything here, do we need to? + """ sub_command = report.data[11] sub_command_data = report.data[12:] # logging.info(f'received output report - Request MCU sub command {sub_command}') - if self._mcu.get_action() == Action.READ_TAG or self._mcu.get_action() == Action.READ_TAG_2 or self._mcu.get_action() == Action.READ_FINISHED: + if self._mcu.get_action() in (Action.READ_TAG, Action.READ_TAG_2, Action.READ_FINISHED): return # Request mcu state @@ -369,16 +390,35 @@ class ControllerProtocol(BaseProtocol): await self.write(input_report) async def _command_set_input_report_mode(self, sub_command_data): - if sub_command_data[0] == 0x30: - pass - elif sub_command_data[0] == 0x31: - pass + if self._input_report_mode == sub_command_data[0]: + logger.warning(f'Already in input report mode {sub_command_data[0]} - ignoring request') + + # Start input report reader + if sub_command_data[0] in (0x30, 0x31): + new_reader = asyncio.ensure_future(self.input_report_mode_full()) else: logger.error(f'input report mode {sub_command_data[0]} not implemented - ignoring request') return - logger.info(f'Setting input report mode to {hex(sub_command_data[0])}...') + # Replace the currently running reader with the input report mode sender, + # which will also handle incoming requests in the future + self.transport.pause_reading() + + # We need to replace the reader in the future because this function was probably called by it + async def set_reader(): + await self.transport.set_reader(new_reader) + + logger.info(f'Setting input report mode to {hex(sub_command_data[0])}...') + self._input_report_mode = sub_command_data[0] + + self.transport.resume_reading() + + asyncio.ensure_future(set_reader()).add_done_callback( + utils.create_error_check_callback() + ) + + # Send acknowledgement input_report = InputReport() input_report.set_input_report_id(0x21) input_report.set_misc() @@ -388,23 +428,6 @@ class ControllerProtocol(BaseProtocol): await self.write(input_report) - # start sending input reports - if self._input_report_mode is None: - - self.transport.pause_reading() - new_reader = asyncio.ensure_future(self.input_report_mode_full()) - - # We need to swap the reader in the future because this function was probably called by it - async def set_reader(): - await self.transport.set_reader(new_reader) - self.transport.resume_reading() - - asyncio.ensure_future(set_reader()).add_done_callback( - utils.create_error_check_callback() - ) - - self._input_report_mode = sub_command_data[0] - async def _command_trigger_buttons_elapsed_time(self, sub_command_data): input_report = InputReport() input_report.set_input_report_id(0x21) diff --git a/joycontrol/report.py b/joycontrol/report.py index fed830e..3218245 100644 --- a/joycontrol/report.py +++ b/joycontrol/report.py @@ -112,9 +112,11 @@ class InputReport: for i in range(14, 50): self.data[i] = 0x00 - def set_mcu(self, data): + def set_ir_nfc_data(self, data): + if 50 + len(data) > len(self.data): + raise ValueError('Too much data.') + # write to data - data = bytes(data) for i in range(len(data)): self.data[50 + i] = data[i] @@ -205,6 +207,15 @@ class InputReport: else: return bytes(self.data[:51]) + def __str__(self): + _id = f'Input {self.get_input_report_id():x}' + _info = '' + if self.get_input_report_id() == 0x21: + _info = self.get_reply_to_subcommand_id() + _bytes = ' '.join(f'{byte:x}' for byte in bytes(self)) + + return f'{_id} {_info}\n{_bytes}' + class SubCommand(Enum): REQUEST_DEVICE_INFO = 0x02 @@ -222,7 +233,7 @@ class SubCommand(Enum): class OutputReportID(Enum): SUB_COMMAND = 0x01 RUMBLE_ONLY = 0x10 - REQUEST_MCU = 0x11 + REQUEST_IR_NFC_MCU = 0x11 class OutputReport: @@ -306,3 +317,12 @@ class OutputReport: def __bytes__(self): return bytes(self.data) + + def __str__(self): + _id = f'Output {self.get_output_report_id()}' + _info = '' + if self.get_output_report_id() == OutputReportID.SUB_COMMAND: + _info = self.get_sub_command() + _bytes = ' '.join(f'{byte:x}' for byte in bytes(self)) + + return f'{_id} {_info}\n{_bytes}' diff --git a/scripts/relay_joycon.py b/scripts/relay_joycon.py index 14d6b4c..41b37e3 100644 --- a/scripts/relay_joycon.py +++ b/scripts/relay_joycon.py @@ -20,6 +20,7 @@ PRODUCT_ID_JL = 8198 PRODUCT_ID_JR = 8199 PRODUCT_ID_PC = 8201 + class Relay: def __init__(self, capture_file=None): self._capture_file = capture_file From 9e03d0c7820656f1c718ecc7e3dceff709140caa Mon Sep 17 00:00:00 2001 From: Wenyu Zhang Date: Tue, 5 May 2020 21:56:10 -0700 Subject: [PATCH 29/43] Add default factory stick calibration data for FlashMemory. --- joycontrol/memory.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/joycontrol/memory.py b/joycontrol/memory.py index 272cb50..c87bdf9 100644 --- a/joycontrol/memory.py +++ b/joycontrol/memory.py @@ -2,7 +2,11 @@ class FlashMemory: def __init__(self, spi_flash_memory_data=None, size=0x80000): if spi_flash_memory_data is None: - self.data = size * [0x00] + self.data = [0xFF] * size # Blank data is all 0xFF + # L-stick factory calibration + self.data[0x603D:0x6046] = [0x00, 0x07, 0x70, 0x00, 0x08, 0x80, 0x00, 0x07, 0x70] + # R-stick factory calibration + self.data[0x6046:0x604F] = [0x00, 0x08, 0x80, 0x00, 0x07, 0x70, 0x00, 0x07, 0x70] else: if len(spi_flash_memory_data) != size: raise ValueError(f'Given data size {len(spi_flash_memory_data)} does not match size {size}.') From c33c4ff851fcdf4bc9720db174a357edd4a27a41 Mon Sep 17 00:00:00 2001 From: "Lu.Mi" <37594893+Tenkoni@users.noreply.github.com> Date: Fri, 8 May 2020 14:05:14 -0500 Subject: [PATCH 30/43] Adjusting size of source amiibo file When scanning amiibo binaries over 540 bytes like Twilight Princess Link an out of range error is thrown. Just writing up to the byte 540 solves this error. This is similar to TagMo implementation for bigger amiibos. --- joycontrol/ir_nfc_mcu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/joycontrol/ir_nfc_mcu.py b/joycontrol/ir_nfc_mcu.py index e618edd..b76510f 100644 --- a/joycontrol/ir_nfc_mcu.py +++ b/joycontrol/ir_nfc_mcu.py @@ -131,7 +131,7 @@ class IrNfcMcu: else: data = bytes.fromhex('02000927') copyarray(self._bytes, 3, data) - copyarray(self._bytes, 3 + len(data), self._nfc_content[245:]) + copyarray(self._bytes, 3 + len(data), self._nfc_content[245:540]) self.set_action(Action.READ_FINISHED) elif self.get_action() == Action.READ_FINISHED: self._bytes[0] = 0x2a From ed839f1c0dc04827da23033216af8a9b25d1810d Mon Sep 17 00:00:00 2001 From: Ted Zhu Date: Fri, 8 May 2020 23:04:53 -0400 Subject: [PATCH 31/43] Fix typo Fix typo dumb -> dump --- run_controller_cli.py | 2 +- scripts/dump_spi_flash.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/run_controller_cli.py b/run_controller_cli.py index 9204b6e..0f9365c 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -40,7 +40,7 @@ Options: --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. + Memory dumps 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. diff --git a/scripts/dump_spi_flash.py b/scripts/dump_spi_flash.py index c34bfa2..b92fd62 100644 --- a/scripts/dump_spi_flash.py +++ b/scripts/dump_spi_flash.py @@ -122,7 +122,7 @@ class DataReader: output_file.write(bytes(spi_data)) -async def dumb_spi_flash(hid_device, output_file=None): +async def dump_spi_flash(hid_device, output_file=None): SPI_FLASH_SIZE = 0x80000 spi_flash_reader = DataReader() @@ -163,10 +163,10 @@ async def _main(args, loop): if args.output: with open(args.output, 'wb') as output: with AsyncHID(path=controller['path'], loop=loop) as hid_controller: - await dumb_spi_flash(hid_controller, output_file=output) + await dump_spi_flash(hid_controller, output_file=output) else: with AsyncHID(path=controller['path'], loop=loop) as hid_controller: - await dumb_spi_flash(hid_controller) + await dump_spi_flash(hid_controller) if __name__ == '__main__': From 11d9bbbeb17636fd84cec0a54c9636263918bcfb Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 9 May 2020 11:30:39 +0200 Subject: [PATCH 32/43] cleanup --- scripts/dump_spi_flash.py | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/scripts/dump_spi_flash.py b/scripts/dump_spi_flash.py index b92fd62..4eb24dc 100644 --- a/scripts/dump_spi_flash.py +++ b/scripts/dump_spi_flash.py @@ -6,8 +6,9 @@ from contextlib import suppress import hid -from joycontrol import logging_default as log +from joycontrol import logging_default as log, utils from joycontrol.report import OutputReport, InputReport, SubCommand +from joycontrol.utils import AsyncHID logger = logging.getLogger(__name__) @@ -17,23 +18,6 @@ PRODUCT_ID_JR = 8199 PRODUCT_ID_PC = 8201 -class AsyncHID(hid.Device): - def __init__(self, *args, loop=asyncio.get_event_loop(), **kwargs): - super().__init__(*args, **kwargs) - self._loop = loop - - self._write_lock = asyncio.Lock() - self._read_lock = asyncio.Lock() - - async def read(self, size, timeout=None): - async with self._read_lock: - return await self._loop.run_in_executor(None, hid.Device.read, self, size, timeout) - - async def write(self, data): - async with self._write_lock: - return await self._loop.run_in_executor(None, hid.Device.write, self, data) - - class DataReader: def __init__(self): self.pending_request = None @@ -160,13 +144,9 @@ async def _main(args, loop): logger.info(f'Found controller "{controller}".') - if args.output: - with open(args.output, 'wb') as output: - with AsyncHID(path=controller['path'], loop=loop) as hid_controller: - await dump_spi_flash(hid_controller, output_file=output) - else: + with utils.get_output(path=args.output, open_flags='wb', default=None) as output: with AsyncHID(path=controller['path'], loop=loop) as hid_controller: - await dump_spi_flash(hid_controller) + await dump_spi_flash(hid_controller, output_file=output) if __name__ == '__main__': @@ -193,5 +173,3 @@ if __name__ == '__main__': finally: loop.stop() loop.close() - - From 62a7fdec560a731d700e78e96535a12820848a59 Mon Sep 17 00:00:00 2001 From: Robert Martin Date: Sat, 9 May 2020 12:19:38 +0200 Subject: [PATCH 33/43] set default memory in run_controller_cli; set center if stick calibration available --- joycontrol/controller_state.py | 4 ++++ joycontrol/memory.py | 30 ++++++++++++++++++++---------- run_controller_cli.py | 4 +++- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/joycontrol/controller_state.py b/joycontrol/controller_state.py index dcc0460..7eef5b7 100644 --- a/joycontrol/controller_state.py +++ b/joycontrol/controller_state.py @@ -27,6 +27,8 @@ class ControllerState: calibration = LeftStickCalibration.from_bytes(calibration_data) self.l_stick_state = StickState(calibration=calibration) + if calibration is not None: + self.l_stick_state.set_center() # create right stick state if controller in (Controller.PRO_CONTROLLER, Controller.JOYCON_R): @@ -39,6 +41,8 @@ class ControllerState: calibration = RightStickCalibration.from_bytes(calibration_data) self.r_stick_state = StickState(calibration=calibration) + if calibration is not None: + self.r_stick_state.set_center() self.sig_is_send = asyncio.Event() diff --git a/joycontrol/memory.py b/joycontrol/memory.py index c87bdf9..50b7788 100644 --- a/joycontrol/memory.py +++ b/joycontrol/memory.py @@ -1,18 +1,28 @@ class FlashMemory: - def __init__(self, spi_flash_memory_data=None, size=0x80000): + def __init__(self, spi_flash_memory_data=None, default_stick_cal=False, size=0x80000): + """ + :param spi_flash_memory_data: data from a memory dump (can be created using dump_spi_flash.py). + :param default_stick_cal: If True, override stick calibration bytes with factory default + :param size of the memory dump, should be constant + """ if spi_flash_memory_data is None: - self.data = [0xFF] * size # Blank data is all 0xFF + spi_flash_memory_data = [0xFF] * size # Blank data is all 0xFF + default_stick_cal = True + + if len(spi_flash_memory_data) != size: + raise ValueError(f'Given data size {len(spi_flash_memory_data)} does not match size {size}.') + if isinstance(spi_flash_memory_data, bytes): + spi_flash_memory_data = list(spi_flash_memory_data) + + # set default controller stick calibration + if default_stick_cal: # L-stick factory calibration - self.data[0x603D:0x6046] = [0x00, 0x07, 0x70, 0x00, 0x08, 0x80, 0x00, 0x07, 0x70] + spi_flash_memory_data[0x603D:0x6046] = [0x00, 0x07, 0x70, 0x00, 0x08, 0x80, 0x00, 0x07, 0x70] # R-stick factory calibration - self.data[0x6046:0x604F] = [0x00, 0x08, 0x80, 0x00, 0x07, 0x70, 0x00, 0x07, 0x70] - else: - if len(spi_flash_memory_data) != size: - raise ValueError(f'Given data size {len(spi_flash_memory_data)} does not match size {size}.') - if isinstance(spi_flash_memory_data, bytes): - spi_flash_memory_data = list(spi_flash_memory_data) - self.data = spi_flash_memory_data + spi_flash_memory_data[0x6046:0x604F] = [0x00, 0x08, 0x80, 0x00, 0x07, 0x70, 0x00, 0x07, 0x70] + + self.data = spi_flash_memory_data def __getitem__(self, item): return self.data[item] diff --git a/run_controller_cli.py b/run_controller_cli.py index 0f9365c..c129e1a 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -147,10 +147,12 @@ async def set_amiibo(controller_state, file_path): async def _main(args): # parse the spi flash - spi_flash = None if args.spi_flash: with open(args.spi_flash, 'rb') as spi_flash_file: spi_flash = FlashMemory(spi_flash_file.read()) + else: + # Create memory containing default controller stick calibration + spi_flash = FlashMemory() # Get controller name to emulate from arguments controller = Controller.from_arg(args.controller) From faf9a09229d0615e58f9b2a89b7e5ac11a837fcc Mon Sep 17 00:00:00 2001 From: Jesse Millar Date: Mon, 11 May 2020 12:20:29 -0500 Subject: [PATCH 34/43] Adding a mash_button function Signed-off-by: Jesse Millar --- run_controller_cli.py | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/run_controller_cli.py b/run_controller_cli.py index c129e1a..8c2f567 100644 --- a/run_controller_cli.py +++ b/run_controller_cli.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) While running the cli, call "help" for an explanation of available commands. Usage: - run_controller_cli.py [--device_id | -d ] + run_controller_cli.py [--device_id | -d ] [--spi_flash ] [--reconnect_bt_addr | -r ] [--log | -l ] @@ -33,7 +33,7 @@ Arguments: 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 + (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. @@ -41,11 +41,11 @@ Options: --spi_flash Memory dump of a real Switch controller. Required for joystick emulation. Allows displaying of JoyCon colors. Memory dumps can be created using the dump_spi_flash.py script. - - -r --reconnect_bt_addr Previously connected Switch console Bluetooth address in string + + -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. """ @@ -180,6 +180,39 @@ async def _main(args): # add the script from above cli.add_command('test_buttons', _run_test_controller_buttons) + # Mash a button command + async def mash_button(*args): + """ + mash_button - Mash a specified button at a set interval + + Usage: + mash_button