diff --git a/controller.py b/controller.py new file mode 100644 index 0000000..f0cda9c --- /dev/null +++ b/controller.py @@ -0,0 +1,20 @@ +import enum + + +class Controller(enum.Enum): + JOYCON_L = 0x01 + JOYCON_R = 0x02 + PRO_CONTROLLER = 0x03 + + def device_name(self): + """ + :returns corresponding bluetooth device name + """ + if self == Controller.JOYCON_L: + return 'Joy-Con (L)' + elif self == Controller.JOYCON_R: + return 'Joy-Con (R)' + elif self == Controller.PRO_CONTROLLER: + return 'Pro Controller' + else: + raise NotImplementedError() \ No newline at end of file diff --git a/device.py b/device.py index 2eeabda..6eace31 100644 --- a/device.py +++ b/device.py @@ -12,10 +12,6 @@ class HidDevice: _HID_UUID = '00001124-0000-1000-8000-00805f9b34fb' _HID_PATH = '/bluez/switch/hid' - PRO_CONTROLLER = 'Pro Controller' - JOYCON_R = 'Joy-Con (R)' - JOYCON_L = 'Joy-Con (L)' - def __init__(self): self._uuid = str(uuid.uuid4()) diff --git a/protocol.py b/protocol.py index 590602a..83e761a 100644 --- a/protocol.py +++ b/protocol.py @@ -1,31 +1,14 @@ import asyncio -import enum import logging from asyncio import BaseTransport, BaseProtocol from typing import Optional, Union, Tuple, Text +from controller import Controller +from report import OutputReport, SubCommand, InputReport + logger = logging.getLogger(__name__) -class Controller(enum.Enum): - JOYCON_L = 0x01 - JOYCON_R = 0x02 - PRO_CONTROLLER = 0x03 - - def device_name(self): - """ - :returns corresponding bluetooth device name - """ - if self == Controller.JOYCON_L: - return 'Joy-Con (L)' - elif self == Controller.JOYCON_R: - return 'Joy-Con (R)' - elif self == Controller.PRO_CONTROLLER: - return 'Pro Controller' - else: - raise NotImplementedError() - - def controller_protocol_factory(controller: Controller): def create_controller_protocol(): return ControllerProtocol(controller) @@ -34,6 +17,8 @@ def controller_protocol_factory(controller: Controller): class ControllerProtocol(BaseProtocol): def __init__(self, controller: Controller): + self.controller = controller + self.transport = None self._data_received = asyncio.Event() @@ -49,8 +34,40 @@ class ControllerProtocol(BaseProtocol): def connection_lost(self, exc: Optional[Exception]) -> None: raise NotImplementedError() + def error_received(self, exc: Exception) -> None: + raise NotImplementedError() + async def report_received(self, data: Union[bytes, Text], addr: Tuple[str, int]) -> None: self._data_received.set() - def error_received(self, exc: Exception) -> None: - raise NotImplementedError() + try: + report = OutputReport(list(data)) + except ValueError as v_err: + logger.warning(f'Report parsing error "{v_err}" - IGNORE') + return + + # classify sub command + sub_command = report.get_sub_command() + logging.info(f'received output report - {sub_command}') + if sub_command is None: + logger.error(f'No sub command found') + elif sub_command == SubCommand.REQUEST_DEVICE_INFO: + await self._command_request_device_info(report) + elif sub_command == SubCommand.NOT_IMPLEMENTED: + logger.error(f'Sub command not implemented') + + async def _command_request_device_info(self, output_report): + address = self.transport.get_extra_info('sockname') + assert address is not None + bd_address = list(map(lambda x: int(x, 16), address[0].split(':'))) + + input_report = InputReport() + input_report.set_input_report_id(0x21) + input_report.set_misc() + input_report.set_button_status() + input_report.set_left_analog_stick() + input_report.set_right_analog_stick() + input_report.set_vibrator_input() + input_report.sub_0x2_device_info(bd_address) + + asyncio.ensure_future(self.transport.write(bytes(input_report))) diff --git a/report.py b/report.py index d17ede7..14d146e 100644 --- a/report.py +++ b/report.py @@ -1,5 +1,13 @@ from enum import Enum, auto +#[0xA1 0, 0x21 1, 0x05 2, 0x8E 3, +# 0x84, 0x00, 0x12, button +# 0x01, 0x18, 0x80, left analog +# 0x01, 0x18, 0x80, right analog +# 0x80, vibrator? +# 0x82, 0x02, 0x03, 0x48, 0x01, 0x02, 0xDC, 0xA6, 0x32, 0x71, 0x58, 0xBB, 0x01, 0x01] +from controller import Controller + class InputReport: def __init__(self): @@ -7,21 +15,50 @@ class InputReport: # all input reports are prepended with 0xA1 self.data[0] = 0xA1 - def set(self, input_report_id, timer=0x00): - self.data[1] = input_report_id + def set_input_report_id(self, _id): + """ + :param _id: e.g. 0x21 Standard input reports used for subcommand replies, etc... (TODO) + """ + self.data[1] = _id + + def set_timer(self, timer): + """ + Input report timer, usually set by the transport + """ self.data[2] = timer % 256 + + def set_misc(self): # battery level + connection info self.data[3] = 0x8E - # Todo: Button status, analog stick data, vibrator input - # ACK byte for subcmd reply self.data[14] = 0x82 - # Reply-to subcommand ID - self.data[14] = 0x02 + def set_button_status(self): + """ + TODO + """ + self.data[4:7] = [0x84, 0x00, 0x12] - def sub_0x2_device_info(self, mac, fm_version=(0x03, 0x48), controller=0x01): + def set_left_analog_stick(self): + """ + TODO + """ + self.data[7:10] = [0x01, 0x18, 0x80] + + def set_right_analog_stick(self): + """ + TODO + """ + self.data[10:13] = [0x01, 0x18, 0x80] + + def set_vibrator_input(self): + """ + TODO + """ + self.data[13] = 0x80 + + def sub_0x2_device_info(self, mac, fm_version=(0x03, 0x48), controller=Controller.JOYCON_L): """ Sub command 0x02 request device info response. @@ -35,14 +72,14 @@ class InputReport: raise ValueError('Bluetooth mac address must consist of 6 bytes!') # reply to sub command ID - self.data[14] = 0x02 + self.data[15] = 0x02 # sub command reply data - offset = 15 - self.data[offset: offset + 1] = fm_version - self.data[offset + 2] = controller + offset = 16 + self.data[offset: offset + 2] = fm_version + self.data[offset + 2] = controller.value self.data[offset + 3] = 0x02 - self.data[offset + 4: offset + 9] = mac + self.data[offset + 4: offset + 10] = mac self.data[offset + 10] = 0x01 self.data[offset + 11] = 0x01 @@ -61,7 +98,8 @@ class OutputReport: raise ValueError('Output reports must start with 0xA2') self.data = data - def sub_command(self): + def get_sub_command(self): + print('subcommand:', self.data[11]) if self.data[11] == 0x02: return SubCommand.REQUEST_DEVICE_INFO else: diff --git a/run_and_pair_switch.py b/run_and_pair_switch.py index 6bc3969..8e4444f 100644 --- a/run_and_pair_switch.py +++ b/run_and_pair_switch.py @@ -32,7 +32,7 @@ async def create_hid_server(protocol_factory, ctl_psm, itr_psm): hid = HidDevice() # setting bluetooth adapter name and class to the device we wish to emulate - await hid.set_name(HidDevice.JOYCON_L) + await hid.set_name(Controller.JOYCON_L.device_name()) await hid.set_class() logger.info('Advertising the Bluetooth SDP record...') @@ -40,13 +40,14 @@ async def create_hid_server(protocol_factory, ctl_psm, itr_psm): hid.discoverable() loop = asyncio.get_event_loop() - client_ctl, address = await loop.sock_accept(ctl_sock) - logger.info(f'Accepted connection at psm {ctl_psm} from {address}') - client_itr, address = await loop.sock_accept(itr_sock) - logger.info(f'Accepted connection at psm {itr_psm} from {address}') + 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] protocol = protocol_factory() - transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, address, 50) + transport = L2CAP_Transport(asyncio.get_event_loop(), protocol, client_itr, 50) protocol.connection_made(transport) return transport, protocol @@ -73,6 +74,8 @@ async def main(): except asyncio.CancelledError: pass + await asyncio.sleep(60) + await transport.close() diff --git a/transport.py b/transport.py index e19217d..1c2915a 100644 --- a/transport.py +++ b/transport.py @@ -6,14 +6,20 @@ logger = logging.getLogger(__name__) class L2CAP_Transport(asyncio.Transport): - def __init__(self, loop, protocol, l2cap_socket, client_addr, read_buffer_size) -> None: + def __init__(self, loop, protocol, l2cap_socket, read_buffer_size) -> None: self._loop = loop self._protocol = protocol self._sock = l2cap_socket - self._client_addr = client_addr self._read_buffer_size = read_buffer_size + self._extra_info = { + 'peername': self._sock.getpeername(), + 'sockname': self._sock.getsockname() + } + + print("peer", self._sock.getpeername()) + self._read_thread = asyncio.ensure_future(self._read()) self._is_closing = False @@ -28,7 +34,7 @@ class L2CAP_Transport(asyncio.Transport): data = await self._loop.sock_recv(self._sock, self._read_buffer_size) logger.debug(f'received "{data}') - await self._protocol.report_received(data, self._client_addr) + await self._protocol.report_received(data, self._sock.getpeername()) except asyncio.CancelledError: # reading has been stopped pass @@ -59,7 +65,7 @@ class L2CAP_Transport(asyncio.Transport): super().abort() def get_extra_info(self, name: Any, default: Any = ...) -> Any: - return super().get_extra_info(name, default) + return self._extra_info.get(name, default) def is_closing(self) -> bool: return self._is_closing diff --git a/utils.py b/utils.py index 4c951f9..d304fb3 100644 --- a/utils.py +++ b/utils.py @@ -1,5 +1,6 @@ import asyncio import logging +import re logger = logging.getLogger(__name__) @@ -18,4 +19,16 @@ async def run_system_command(cmd): if stderr: logger.debug(f'[stderr]\n{stderr.decode()}') - return proc.returncode + return proc.returncode, stdout, stderr + +""" +async def get_bt_mac_address(dev=0): + ret, stdout, stderr = await run_system_command(f'hciconfig hci{dev}') + # TODO: Process error handling + + match = re.search(r'BD Address: (?P\w\w:\w\w:\w\w:\w\w:\w\w:\w\w)', stdout.decode('UTF-8')) + if match: + return list(map(lambda x: int(x, 16), match.group('mac').split(':'))) + else: + raise ValueError(f'BD Address not found in "{stdout}"') +"""