From d98e18c4fe59ec5eeac84eebeff27fe949b68645 Mon Sep 17 00:00:00 2001 From: Weihao Jiang Date: Sun, 2 Oct 2022 23:24:21 +0800 Subject: [PATCH] Version 0.2.0 Split dependencies to extras, now can detect available backend. Support multi-button press on some backends. This is no longer compatible with version 0.1.x. --- README.md | 8 ++ setup.py | 40 +++++---- src/libnxctrl/backend.py | 47 ++++++++++ src/libnxctrl/bluetooth.py | 19 ---- src/libnxctrl/splatplost_USB.py | 153 ++++++++++++++++++++++++++++++++ src/libnxctrl/wrapper.py | 53 ++++++----- 6 files changed, 262 insertions(+), 58 deletions(-) create mode 100644 src/libnxctrl/backend.py delete mode 100644 src/libnxctrl/bluetooth.py create mode 100644 src/libnxctrl/splatplost_USB.py diff --git a/README.md b/README.md index a52f097..361182a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ # libnxctrl Python Library Emulating Nintendo Switch Controllers + +## Installation +There are multiple backends: nxbt and splatplost USB. The nxbt backend can only be used on Linux, while the splatplost USB backend can be used on Linux and Windows. + +```bash +pip install libnxctrl[nxbt] +pip install libnxctrl[usb] +``` \ No newline at end of file diff --git a/setup.py b/setup.py index 1aeef75..bfdfdc5 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='libnxctrl', - version='0.1.7', + version='0.2.0', url='https://github.com/Victrid/libnxctrl', license='GPLv3', author='Weihao Jiang', @@ -21,22 +21,32 @@ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Natural Language :: English", "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", ], - install_requires=[ - # NXBT requirements - "dbus-python~=1.2.16", - "Flask>=1.1.2,<2.3.0", - "Flask-SocketIO>=5.0.1,<5.4.0", - "eventlet>=0.31,<0.34", - "blessed>=1.17.10,<1.20.0", - "pynput~=1.7.1", - "psutil~=5.6.6", - "cryptography>=3.3.2,<37.1.0", - # JoyControl requirements - 'hid~=1.0.5', - 'aioconsole~=0.5.1' - ], + python_requires='>=3.9', + install_requires=[], + extras_require={ + "nxbt": [ + "dbus-python~=1.2.16", + "Flask>=1.1.2,<2.3.0", + "Flask-SocketIO>=5.0.1,<5.4.0", + "eventlet>=0.31,<0.34", + "blessed>=1.17.10,<1.20.0", + "pynput~=1.7.1", + "psutil~=5.6.6", + "cryptography>=3.3.2,<37.1.0", + ], + "joycontrol": [ + 'hid~=1.0.5', + 'aioconsole~=0.5.1' + ], + "usb": [ + "pyserial~=3.5", + ] + }, packages=[ "libnxctrl", "libnxctrl.nxbt.nxbt", diff --git a/src/libnxctrl/backend.py b/src/libnxctrl/backend.py new file mode 100644 index 0000000..02d16fa --- /dev/null +++ b/src/libnxctrl/backend.py @@ -0,0 +1,47 @@ +from typing import Type + +from .wrapper import NXWrapper + + +def get_available_backend() -> list[str]: + available_backend = [] + try: + # NXBT + import dbus + import flask + import flask_socketio + import eventlet + import blessed + import pynput + import psutil + import cryptography + available_backend.append("nxbt") + except ImportError: + pass + try: + # Splatplost USB + import serial + available_backend.append("Splatplost USB") + except ImportError: + pass + + return available_backend + + +def get_backend(backend_name: str) -> Type[NXWrapper]: + if backend_name not in get_available_backend(): + raise ValueError(f"Backend {backend_name} is not available.") + if backend_name == "nxbt": + from .nxbt_wrapper import NXBTControl + return NXBTControl + elif backend_name == "mart1no": + raise NotImplementedError("Joycontrol support is not implemented.") + # return Mart1noJoyControl + elif backend_name == "poohl": + raise NotImplementedError("Joycontrol support is not implemented.") + # return PoohlJoyControl + elif backend_name == "Splatplost USB": + from .splatplost_USB import SplatplostUSBControl + return SplatplostUSBControl + else: + raise ValueError(f"Backend {backend_name} is not available.") diff --git a/src/libnxctrl/bluetooth.py b/src/libnxctrl/bluetooth.py deleted file mode 100644 index 26de54e..0000000 --- a/src/libnxctrl/bluetooth.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Type - -from .wrapper import NXWrapper - - -def get_backend(backend_name: str) -> Type[NXWrapper]: - if backend_name == "nxbt": - from .nxbt_wrapper import NXBTControl - return NXBTControl - elif backend_name == "mart1no": - from .joycontrol_wrapper import Mart1noJoyControl - raise NotImplementedError("Joycontrol support is not implemented.") - # return Mart1noJoyControl - elif backend_name == "poohl": - from .joycontrol_wrapper import PoohlJoyControl - raise NotImplementedError("Joycontrol support is not implemented.") - # return PoohlJoyControl - else: - raise ValueError("Unknown backend: {}".format(backend_name)) diff --git a/src/libnxctrl/splatplost_USB.py b/src/libnxctrl/splatplost_USB.py new file mode 100644 index 0000000..f6b370b --- /dev/null +++ b/src/libnxctrl/splatplost_USB.py @@ -0,0 +1,153 @@ +import ctypes +import re +from enum import IntEnum, IntFlag +from time import sleep +from typing import Optional + +import serial + +from .wrapper import Button, NXWrapper + + +class USBInput(ctypes.Structure): + _fields_ = [("index", ctypes.c_uint32), ("ctrl", ctypes.c_uint8), ("buttons", ctypes.c_uint16), + ("dpad", ctypes.c_uint8), ("lx", ctypes.c_uint8), ("ly", ctypes.c_uint8), ("rx", ctypes.c_uint8), + ("ry", ctypes.c_uint8), ("pressTick", ctypes.c_uint32), ] + + +class SplatplostUSBControl(NXWrapper): + support_combo = True + + class SPUButton(IntFlag): + """ + This value is fixed, do not change it. + """ + NONE = 0x00, + SWITCH_Y = 0x01, + SWITCH_B = 0x02, + SWITCH_A = 0x04, + SWITCH_X = 0x08, + SWITCH_L = 0x10, + SWITCH_R = 0x20, + SWITCH_ZL = 0x40, + SWITCH_ZR = 0x80, + SWITCH_MINUS = 0x100, + SWITCH_PLUS = 0x200, + SWITCH_LCLICK = 0x400, + SWITCH_RCLICK = 0x800, + SWITCH_HOME = 0x1000, + SWITCH_CAPTURE = 0x2000, + + class SPU_DPAD(IntEnum): + """ + This value is fixed, do not change it. + """ + CENTER = 0x08, + UP = 0x00, + RIGHT = 0x02, + DOWN = 0x04, + LEFT = 0x06, + RIGHT_UP = 0x01, + RIGHT_DOWN = 0x03, + LEFT_UP = 0x07, + LEFT_DOWN = 0x05, + + STICK_MIN = 0 + STICK_CENTER = 128 + STICK_MAX = 255 + + def __init__(self, serial_port: str, press_duration_ms: int = 30): + super().__init__(press_duration_ms) + + self.serial_port = serial_port + self.serial: Optional[serial.Serial] = None + self.poll_rate = 10 + self.report_idx = 0 + + self.wait_time = 20 + + def send_report(self, report: USBInput): + report.index = self.report_idx + self.serial.write(report) + acknowledge_info = self.serial.readline().decode("ASCII").strip() + ack_format = r"###ACK ([0-9]+)###" + match = re.match(ack_format, acknowledge_info) + if match: + if self.report_idx != int(match.group(1)): + raise Exception("Report index mismatch") + else: + raise Exception("Invalid acknowledge info") + self.report_idx += 1 + + def connect(self): + # TODO: Implement connection check + self.serial = serial.Serial(self.serial_port, 115200) + for _ in range(50): + self.button_press(Button.SHOULDER_L | Button.SHOULDER_R) + + def button_name_to_SPUButton(self, button_name: Button) -> SPUButton: + button_map = { + Button.A: self.SPUButton.SWITCH_A, + Button.B: self.SPUButton.SWITCH_B, + Button.X: self.SPUButton.SWITCH_X, + Button.Y: self.SPUButton.SWITCH_Y, + Button.SHOULDER_L: self.SPUButton.SWITCH_L, + Button.SHOULDER_R: self.SPUButton.SWITCH_R, + Button.SHOULDER_ZL: self.SPUButton.SWITCH_ZL, + Button.SHOULDER_ZR: self.SPUButton.SWITCH_ZR, + Button.L_STICK_PRESS: self.SPUButton.SWITCH_LCLICK, + Button.R_STICK_PRESS: self.SPUButton.SWITCH_RCLICK, + Button.HOME: self.SPUButton.SWITCH_HOME, + Button.CAPTURE: self.SPUButton.SWITCH_CAPTURE, + Button.MINUS: self.SPUButton.SWITCH_MINUS, + Button.PLUS: self.SPUButton.SWITCH_PLUS, + } + + new_button = self.SPUButton.NONE + for button, internal_button in button_map.items(): + if button_name & button: + new_button = internal_button | new_button + + return new_button + + def button_name_to_DPAD(self, button_name: Button) -> SPU_DPAD: + dpad_map = { + Button.DPAD_UP: self.SPU_DPAD.UP, + Button.DPAD_DOWN: self.SPU_DPAD.DOWN, + Button.DPAD_LEFT: self.SPU_DPAD.LEFT, + Button.DPAD_RIGHT: self.SPU_DPAD.RIGHT, + } + + new_button = self.SPU_DPAD.CENTER + for button, dpad_button in dpad_map.items(): + # Only allow one dpad button to be pressed at a time + if button_name & button: + new_button = dpad_button + + return new_button + + def button_hold(self, button_name: Button, duration_ms: int): + report = USBInput(buttons=self.button_name_to_SPUButton(button_name).value, + dpad=self.button_name_to_DPAD(button_name).value, + lx=self.STICK_CENTER, + ly=self.STICK_CENTER, + rx=self.STICK_CENTER, + ry=self.STICK_CENTER, + pressTick=1 if duration_ms // self.poll_rate == 0 else duration_ms // self.poll_rate, + ) + self.send_report(report) + report = USBInput(buttons=0, + dpad=self.SPU_DPAD.CENTER.value, + lx=self.STICK_CENTER, + ly=self.STICK_CENTER, + rx=self.STICK_CENTER, + ry=self.STICK_CENTER, + pressTick=1 if duration_ms // self.poll_rate == 0 else duration_ms // self.poll_rate, + ) + self.send_report(report) + + def button_press(self, button_name: Button): + self.button_hold(button_name, self.press_duration_ms) + + def disconnect(self): + self.serial.close() diff --git a/src/libnxctrl/wrapper.py b/src/libnxctrl/wrapper.py index 2eec827..00d5240 100644 --- a/src/libnxctrl/wrapper.py +++ b/src/libnxctrl/wrapper.py @@ -1,34 +1,39 @@ from abc import abstractmethod -from enum import Enum +from enum import Flag, auto from typing import Union -class Button(Enum): - A = 0 - B = 1 - X = 2 - Y = 3 - DPAD_UP = 4 - DPAD_DOWN = 5 - DPAD_LEFT = 6 - DPAD_RIGHT = 7 - L_STICK_PRESS = 8 - R_STICK_PRESS = 9 - SHOULDER_L = 10 - SHOULDER_R = 11 - SHOULDER_ZL = 12 - SHOULDER_ZR = 13 - HOME = 14 - CAPTURE = 15 - MINUS = 16 - PLUS = 17 - JCL_SR = 18 - JCL_SL = 19 - JCR_SR = 20 - JCR_SL = 21 +class Button(Flag): + A = auto() + B = auto() + X = auto() + Y = auto() + DPAD_UP = auto() + DPAD_DOWN = auto() + DPAD_LEFT = auto() + DPAD_RIGHT = auto() + L_STICK_PRESS = auto() + R_STICK_PRESS = auto() + SHOULDER_L = auto() + SHOULDER_R = auto() + SHOULDER_ZL = auto() + SHOULDER_ZR = auto() + HOME = auto() + CAPTURE = auto() + MINUS = auto() + PLUS = auto() + JCL_SR = auto() + JCL_SL = auto() + JCR_SR = auto() + JCR_SL = auto() class NXWrapper: + support_combo = False + + def combo_supported(self): + return self.support_combo + def __init__(self, press_duration_ms: int = 50, delay_ms: int = 120): self.press_duration_ms = press_duration_ms self.delay_ms = delay_ms