Initial commit

This commit is contained in:
Brikwerk 2020-05-22 16:24:54 -07:00
commit d3aa6a3b73
27 changed files with 4702 additions and 0 deletions

143
.gitignore vendored Normal file
View file

@ -0,0 +1,143 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Project Specific Excludes
.vscode
secrets.txt
messages.txt

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Reece Walsh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

15
README.md Normal file
View file

@ -0,0 +1,15 @@
# NXBT - Control your Switch Locally or Remotely
This is meant to serve as an all-in-one solution to controlling a Nintendo Switch from a variety of devices.
Functionality is currently under development.
## Prerequisites
- A Bluetooth adapter with a Bluetooth version greather than or equal to 4.0
- A computer running Linux
- A Nintendo Switch
## Getting Started
TBA

30
demo.py Normal file
View file

@ -0,0 +1,30 @@
import threading
import time
from nxbt import ControllerServer
from nxbt import ControllerTypes
# con = ControllerServer(ControllerTypes.JOYCON_R)
# con.run()
# # con.run(reconnect_address="7C:BB:8A:D9:91:5A")
def thread_func_1():
print("Starting Thread 1")
con = ControllerServer(ControllerTypes.JOYCON_R)
con.run()
def thread_func_2():
print("Starting Thread 2")
time.sleep(10)
con = ControllerServer(ControllerTypes.JOYCON_L)
con.run()
if __name__ == "__main__":
x = threading.Thread(target=thread_func_1)
x.start()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
# Miscellaneous Notes
This is a compilation of quirks, tidbits, and other info that pertain to
this project.
## Requirements for pairing with the Switch
- Controller SDP record. They all share the same one (generally), so a only a single record is needed to emulate all three controllers
- The Bluetooth alias "Joy-Con (L)", "Joy-Con (R)", or "Pro Controller"
- The Bluetooth Gamepad HID Class
### Weird Tibit:
If you wanted to be a little lazy during emulation, the Bluetooth
alias isn't used to define the identity of the controller within
the Switch. You only need to set the alias as one of the three
mentioned above. The Switch only checks for identity in the device
inquiry input report packet.
Eg: You could get away with emulating a Joy-Con (L) while having the
Bluetooth alias set to "Pro Controller".

8
nxbt/__init__.py Normal file
View file

@ -0,0 +1,8 @@
from .controller import ControllerServer
from .controller import ControllerTypes
from .controller import ControllerProtocol
from .controller import SwitchReportParser
from .controller import SwitchResponses
from .controller import Controller
from .bluez import BlueZ

615
nxbt/bluez.py Normal file
View file

@ -0,0 +1,615 @@
import subprocess
import re
import os
import time
import dbus
class BlueZ():
"""Exposes the BlueZ D-Bus API as a Python object.
"""
SERVICE_NAME = "org.bluez"
BLUEZ_OBJECT_PATH = "/org/bluez"
ADAPTER_INTERFACE = SERVICE_NAME + ".Adapter1"
PROFILEMANAGER_INTERFACE = SERVICE_NAME + ".ProfileManager1"
DEVICE_INTERFACE = SERVICE_NAME + ".Device1"
def __init__(self, device_id="hci0"):
self.bus = dbus.SystemBus()
# Try to find the default adapter (hci0) or a user specified adapter
self.device_path = self.find_object_path(
self.SERVICE_NAME,
self.ADAPTER_INTERFACE,
object_name=device_id)
# If we weren't able to find an adapter with the specified ID,
# try to find any usable Bluetooth adapter
if self.device_path is None:
self.device_path = self.find_object_path(
self.SERVICE_NAME,
self.ADAPTER_INTERFACE)
# If we aren't able to find an adapter still
if self.device_path is None:
raise Exception("Unable to find a bluetooth adapter")
# Load the adapter's interface
print(f"Using adapter under object path: {self.device_path}")
self.device = dbus.Interface(
self.bus.get_object(
self.SERVICE_NAME,
self.device_path),
"org.freedesktop.DBus.Properties")
if device_id:
self.device_id = device_id
else:
self.device_id = self.device_path.split("/")[-1]
# Load the ProfileManager interface
self.profile_manager = dbus.Interface(self.bus.get_object(
self.SERVICE_NAME, self.BLUEZ_OBJECT_PATH),
self.PROFILEMANAGER_INTERFACE)
self.adapter = dbus.Interface(
self.bus.get_object(
self.SERVICE_NAME,
self.device_path),
self.ADAPTER_INTERFACE)
def find_object_path(self, service_name, interface_name, object_name=None):
"""Searches for a D-Bus object path that contains a specified interface
under a specified service.
:param service_name: The name of a D-Bus service to search for the
object path under.
:type service_name: string
:param interface_name: The name of a D-Bus interface to search for
within objects under the specified service.
:type interface_name: string
:param object_name: The name or ending of the object path,
defaults to None
:type object_name: string, optional
:return: The D-Bus object path or None, if no matching object
can be found
:rtype: string
"""
manager = dbus.Interface(
self.bus.get_object(service_name, "/"),
"org.freedesktop.DBus.ObjectManager")
# Iterating over objects under the specified service
# and searching for the specified interface
for path, ifaces in manager.GetManagedObjects().items():
managed_interface = ifaces.get(interface_name)
if managed_interface is None:
continue
# If the object name wasn't specified or it matches
# the interface address or the path ending
elif (not object_name or
object_name == managed_interface["Address"] or
path.endswith(object_name)):
obj = self.bus.get_object(service_name, path)
return dbus.Interface(obj, interface_name).object_path
return None
def find_objects(self, service_name, interface_name):
"""Searches for D-Bus objects that contain a specified interface
under a specified service.
:param service_name: The name of a D-Bus service to search for the
object path under.
:type service_name: string
:param interface_name: The name of a D-Bus interface to search for
within objects under the specified service.
:type interface_name: string
:return: The D-Bus object paths matching the arguments
:rtype: array
"""
manager = dbus.Interface(
self.bus.get_object(service_name, "/"),
"org.freedesktop.DBus.ObjectManager")
paths = []
# Iterating over objects under the specified service
# and searching for the specified interface within them
for path, ifaces in manager.GetManagedObjects().items():
managed_interface = ifaces.get(interface_name)
if managed_interface is None:
continue
else:
obj = self.bus.get_object(service_name, path)
path = str(dbus.Interface(obj, interface_name).object_path)
paths.append(path)
return paths
@property
def address(self):
"""Gets the Bluetooth MAC address of the Bluetooth adapter.
:return: The Bluetooth Adapter's MAC address
:rtype: string
"""
return self.device.Get(self.ADAPTER_INTERFACE, "Address").upper()
@property
def name(self):
"""Gets the name of the Bluetooth adapter.
:return: The name of the Bluetooth adapter.
:rtype: string
"""
return self.device.Get(self.ADAPTER_INTERFACE, "Name")
@property
def alias(self):
"""Gets the alias of the Bluetooth adapter. This value is used
as the "friendly" name of the adapter when communicating over
Bluetooth.
:return: The adapter's alias
:rtype: string
"""
return self.device.Get(self.ADAPTER_INTERFACE, "Alias")
def set_alias(self, value):
"""Asynchronously sets the alias of the Bluetooth adapter.
If you wish to check the set value, a time delay is needed
before the alias getter is run.
:param value: The new value to be set as the adapter's alias
:type value: string
"""
self.device.Set(self.ADAPTER_INTERFACE, "Alias", value)
@property
def pairable(self):
"""Gets the pairable status of the Bluetooth adapter.
:return: A boolean value representing if the adapter is set as
pairable or not
:rtype: boolean
"""
return bool(self.device.Get(self.ADAPTER_INTERFACE, "Pairable"))
def set_pairable(self, value):
"""Sets the pariable boolean status of the Bluetooth adapter.
:param value: A boolean value representing if the adapter is
pairable or not.
:type value: boolean
"""
dbus_value = dbus.Boolean(value)
self.device.Set(self.ADAPTER_INTERFACE, "Pairable", dbus_value)
@property
def pairable_timeout(self):
"""Gets the timeout time (in seconds) for how long the adapter
should remain as pairable. Defaults to 0 (no timeout).
:return: The pairable timeout in seconds
:rtype: int
"""
return self.device.Get(self.ADAPTER_INTERFACE, "PairableTimeout")
def set_pairable_timeout(self, value):
"""Sets the timeout time (in seconds) for the pairable property.
:param value: The pairable timeout value in seconds
:type value: int
"""
dbus_value = dbus.UInt32(value)
self.device.Set(self.ADAPTER_INTERFACE, "PairableTimeout", dbus_value)
@property
def discoverable(self):
"""Gets the discoverable status of the Bluetooth adapter
:return: The boolean status of the discoverable status
:rtype: boolean
"""
return bool(self.device.Get(self.ADAPTER_INTERFACE, "Discoverable"))
def set_discoverable(self, value):
"""Sets the discoverable boolean status of the Bluetooth adapter.
:param value: A boolean value representing if the Bluetooth adapter
is discoverable or not.
:type value: boolean
"""
dbus_value = dbus.Boolean(value)
self.device.Set(self.ADAPTER_INTERFACE, "Discoverable", dbus_value)
@property
def discoverable_timeout(self):
"""Gets the timeout time (in seconds) for how long the adapter
should remain as discoverable. Defaults to 180 (3 minutes).
:return: The discoverable timeout in seconds
:rtype: int
"""
return self.device.Get(self.ADAPTER_INTERFACE, "DiscoverableTimeout")
def set_discoverable_timeout(self, value):
"""Sets the discoverable time (in seconds) for the discoverable
property. Setting this property to 0 results in an infinite
discoverable timeout.
:param value: The discoverable timeout value in seconds
:type value: int
"""
dbus_value = dbus.UInt32(value)
self.device.Set(
self.ADAPTER_INTERFACE,
"DiscoverableTimeout",
dbus_value)
@property
def device_class(self):
"""Gets the Bluetooth class of the device. This represents what type
of device this reporting as (Ex: Gamepad, Headphones, etc).
:return: A 32-bit hexadecimal Integer representing the
Bluetooth Code for a given device type.
:rtype: string
"""
# This is another hacky bit. We're using hciconfig here instead
# of the D-Bus API so that results match the setter. See the
# setter for further justification on using hciconfig.
result = subprocess.run(
["hciconfig", self.device_id, "class"],
stdout=subprocess.PIPE)
device_class = result.stdout.decode("utf-8").split("Class: ")[1][0:8]
return device_class
def set_device_class(self, device_class):
"""Sets the Bluetooth class of the device. This represents what type
of device this reporting as (Ex: Gamepad, Headphones, etc).
Note: To work this function *MUST* be run as the super user. An
exception is returned if this function is run without elevation.
:param device_class: A 32-bit Hexadecimal integer
:type device_class: string
:raises PermissionError: If user is not root
:raises ValueError: If the device class is not length 8
:raises Exception: On inability to set class
"""
if os.geteuid() != 0:
raise PermissionError("The device class must be set as root")
if len(device_class) != 8:
raise ValueError("Device class must be length 8")
# This is a bit of a hack. BlueZ allows you to set this value, however,
# a config file needs to filled and the BT daemon restarted. This is a
# good compromise but requires super user privileges. Not ideal.
result = subprocess.run(
["hciconfig", self.device_id, "class", device_class],
stderr=subprocess.PIPE)
# Checking if there was a problem setting the device class
cmd_err = result.stderr.decode("utf-8").replace("\n", "")
if cmd_err != "":
raise Exception(cmd_err)
@property
def powered(self):
"""The powered state of the adapter (on/off) as a boolean value.
:return: A boolean representing the powered state of the adapter.
:rtype: boolean
"""
return bool(self.device.Get(self.ADAPTER_INTERFACE, "Powered"))
def set_powered(self, value):
"""Switches the adapter on or off.
:param value: A boolean value switching the adapter on or off
:type value: boolean
"""
dbus_value = dbus.Boolean(value)
self.device.Set(self.ADAPTER_INTERFACE, "Powered", dbus_value)
def register_profile(self, profile_path, uuid, opts):
"""Registers an SDP record on the BlueZ SDP server.
Options (non-exhaustive, refer to BlueZ docs for
the complete list):
- Name: Human readable name of the profile
- Role: Specifies precise local role. Either "client"
or "servier".
- RequireAuthentication: A boolean value indicating if
pairing is required before connection.
- RequireAuthorization: A boolean value indiciating if
authorization is needed before connection.
- AutoConnect: A boolean value indicating whether a
connection can be forced if a client UUID is present.
- ServiceRecord: An XML SDP record as a string.
:param profile_path: The path for the SDP record
:type profile_path: string
:param uuid: The UUID for the SDP record
:type uuid: string
:param opts: The options for the SDP server
:type opts: dict
"""
self.profile_manager.RegisterProfile(profile_path, uuid, opts)
def reset(self):
"""Restarts the Bluetooth Service
:raises Exception: If the bluetooth service can't be restarted
"""
result = subprocess.run(
["systemctl", "restart", "bluetooth"],
stderr=subprocess.PIPE)
cmd_err = result.stderr.decode("utf-8").replace("\n", "")
if cmd_err != "":
raise Exception(cmd_err)
self.device = dbus.Interface(
self.bus.get_object(
self.SERVICE_NAME,
self.device_path),
"org.freedesktop.DBus.Properties")
self.profile_manager = dbus.Interface(
self.bus.get_object(
self.SERVICE_NAME,
self.BLUEZ_OBJECT_PATH),
self.PROFILEMANAGER_INTERFACE)
def toggle_input_plugin(self, toggle):
"""Enables or disables the BlueZ input plugin. Requires
root user to be run. The units and Bluetooth service will
not be restarted if the input plugin already matches
the toggle.
:param toggle: A boolean element indicating if the plugin
is enabled (True) or disabled (False)
:type toggle: boolean
:raises PermissionError: If the user is not root
:raises Exception: If the units can't be reloaded
"""
if os.geteuid() != 0:
raise PermissionError("The input plugin must be toggled as root")
service_path = "/lib/systemd/system/bluetooth.service"
service = None
with open(service_path, "r") as f:
service = f.read()
# Find the bluetooth service execution line
lines = service.split("\n")
for i in range(0, len(lines)):
line = lines[i]
if line.startswith("ExecStart="):
# If we want to ensure the plugin is enabled
if toggle:
# If input is already enabled
if "--noplugin=input" not in line:
return
lines[i] = re.sub(" --noplugin=input", "", line)
else:
# If input is already disabled
if "--noplugin=input" in line:
return
# If not, add the flag
lines[i] = line + " --noplugin=input"
service = "\n".join(lines)
with open(service_path, "w") as f:
f.write(service)
# Reload units
result = subprocess.run(
["systemctl", "daemon-reload"],
stderr=subprocess.PIPE)
cmd_err = result.stderr.decode("utf-8").replace("\n", "")
if cmd_err != "":
raise Exception(cmd_err)
# Reload the bluetooth service with input disabled
self.reset()
def get_discovered_devices(self):
"""Gets a dict of all discovered (or previously discovered
and connected) devices. The key is the device's dbus object
path and the values are the device's properties.
The following is a non-exhaustive list of the properties a
device dictionary can contain:
- "Address": The Bluetooth address
- "Alias": The friendly name of the device
- "Paired": Whether the device is paired
- "Connected": Whether the device is presently connected
- "UUIDs": The services a device provides
:return: A dictionary of all discovered devices
:rtype: dictionary
"""
bluez_objects = dbus.Interface(
self.bus.get_object(self.SERVICE_NAME, "/"),
"org.freedesktop.DBus.ObjectManager")
devices = {}
objects = bluez_objects.GetManagedObjects()
for path, interfaces in list(objects.items()):
if self.DEVICE_INTERFACE in interfaces:
devices[str(path)] = interfaces[self.DEVICE_INTERFACE]
return devices
def discover_devices(self, alias=None, timeout=10, callback=None):
"""Runs a device discovery of the timeout length (in seconds)
on the adapter. If specified, a callback is run, every second,
and passed an updated list of discovered devices. An alias
can be specified to filter discovered devices.
The following is a non-exhaustive list of the properties a
device dictionary can contain:
- "Address": The Bluetooth address
- "Alias": The friendly name of the device
- "Paired": Whether the device is paired
- "Connected": Whether the device is presently connected
- "UUIDs": The services a device provides
:param alias: The alias of a bluetooth device, defaults to None
:type alias: string, optional
:param timeout: The discovery timeout in seconds, defaults to 10
:type timeout: int, optional
:param callback: A callback function, defaults to None
:type callback: function, optional
:return: A dictionary of discovered devices with the object path
as the key and the device properties as the dictionary properties
:rtype: dictionary
"""
# TODO: Device discovery still needs work. Currently, devices
# are added as DBus objects while device discovery runs, however,
# added devices linger after discovery stops. This means a device
# can become unpairable, still show up on a new discovery session,
# and throw an error when an attempt is made to pair it. Using DBus
# signals ("interface added"/"property changed") does not solve
# this issue.
# Get all devices that have been previously discovered
devices = self.get_discovered_devices()
# Start discovering new devices and loop
self.adapter.StartDiscovery()
try:
for i in range(0, timeout):
time.sleep(1)
new_devices = self.get_discovered_devices()
# Shallowly merging dictionaries. Latter dictionary
# overrides the former. Requires Python 3.5
devices = {**devices, **new_devices}
if callback:
callback(devices)
finally:
self.adapter.StopDiscovery()
# Filter out paired devices or devices that don't
# match a specified alias.
filtered_devices = {}
for key in devices.keys():
# Filter for devices matching alias, if specified
if "Alias" not in devices[key].keys():
continue
if alias and not alias == devices[key]["Alias"]:
continue
# Filter for paired devices
if "Paired" not in devices[key].keys():
continue
if devices[key]["Paired"]:
continue
filtered_devices[key] = devices[key]
return filtered_devices
def pair_device(self, device_path):
"""Pairs a discovered device at a given DBus object path.
:param device_path: The D-Bus object path to the device
:type device_path: string
"""
device = dbus.Interface(
self.bus.get_object(
self.SERVICE_NAME,
device_path),
self.DEVICE_INTERFACE)
device.Pair()
def connect_device(self, device_path):
device = dbus.Interface(
self.bus.get_object(
self.SERVICE_NAME,
device_path),
self.DEVICE_INTERFACE)
try:
device.Connect()
except dbus.exceptions.DBusException:
print("Here1")
def remove_device(self, path):
"""Removes a device that's been either discovered, paired,
connected, etc.
:param path: The D-Bus path to the object
:type path: string
"""
self.adapter.RemoveDevice(
self.bus.get_object(self.SERVICE_NAME, path))
def find_device_by_address(self, address):
"""Finds the D-Bus path to a device that contains the
specified address.
:param address: The Bluetooth MAC address
:type address: string
:return: The path to the D-Bus object or None
:rtype: string or None
"""
# Find all connected/paired/discovered devices
devices = self.find_objects(
self.SERVICE_NAME,
self.DEVICE_INTERFACE)
for path in devices:
# Get the device's address and paired status
device_props = dbus.Interface(
self.bus.get_object(self.SERVICE_NAME, path),
"org.freedesktop.DBus.Properties")
device_addr = device_props.Get(
self.DEVICE_INTERFACE,
"Address").upper()
# Check for an address match
if device_addr != address.upper():
continue
return path
return None

2
nxbt/cli.py Normal file
View file

@ -0,0 +1,2 @@
def main():
raise NotImplementedError()

View file

@ -0,0 +1,6 @@
from .server import ControllerServer
from .controller import ControllerTypes
from .controller import Controller
from .protocol import ControllerProtocol
from .protocol import SwitchReportParser
from .protocol import SwitchResponses

View file

@ -0,0 +1,69 @@
from enum import Enum
import os
import dbus
class ControllerTypes(Enum):
"""Controller type enumerations for initializing the controller server.
"""
JOYCON_L = 1
JOYCON_R = 2
PRO_CONTROLLER = 3
class Controller():
GAMEPAD_CLASS = "0x002508"
SDP_UUID = "00001000-0000-1000-8000-00805f9b34fb"
SDP_RECORD_PATH = "/nxbt/controller"
ALIASES = {
ControllerTypes.JOYCON_L: "Joy-Con (L)",
ControllerTypes.JOYCON_R: "Joy-Con (R)",
ControllerTypes.PRO_CONTROLLER: "Pro Controller"
}
def __init__(self, bluetooth, controller_type):
self.bt = bluetooth
if controller_type not in self.ALIASES.keys():
raise ValueError("Unknown controller type specified")
self.alias = self.ALIASES[controller_type]
def setup(self):
"""Configures the specified Bluetooth device as the
specified controller.
"""
# Setting up Bluetooth adapter options
self.bt.set_powered(True)
self.bt.set_pairable(True)
self.bt.set_pairable_timeout(0)
self.bt.set_discoverable_timeout(180)
self.bt.set_alias(self.alias)
# Adding the SDP record
sdp_record_path = os.path.join(
os.path.dirname(__file__), "sdp", "switch-controller.xml")
sdp_record = None
with open(sdp_record_path, "r") as f:
sdp_record = f.read()
opts = {
"ServiceRecord": sdp_record,
"Role": "server",
"RequireAuthentication": False,
"RequireAuthorization": False,
"AutoConnect": True
}
# If the profile has already been registered,
# catch the error and continue
try:
self.bt.register_profile(self.SDP_RECORD_PATH, self.SDP_UUID, opts)
except dbus.exceptions.DBusException:
pass
self.bt.set_device_class(self.GAMEPAD_CLASS)

4
nxbt/controller/input.py Normal file
View file

@ -0,0 +1,4 @@
class InputParser():
def __init__():
print("")

575
nxbt/controller/protocol.py Normal file
View file

@ -0,0 +1,575 @@
from enum import Enum
import random
from time import perf_counter
from .controller import ControllerTypes
from .utils import replace_subarray
class SwitchResponses(Enum):
NO_DATA = -1
MALFORMED = -2
TOO_SHORT = -3
UNKNOWN_SUBCOMMAND = -4
REQUEST_DEVICE_INFO = 2
SET_SHIPMENT = 0x08
SPI_READ = 0x10
SET_MODE = 0x03
TRIGGER_BUTTONS = 0x04
TOGGLE_IMU = 0x40
ENABLE_VIBRATION = 0x48
SET_PLAYER = 0x30
SET_NFC_IR_STATE = 0x22
SET_NFC_IR_CONFIG = 0x21
class ControllerProtocol():
CONTROLLER_INFO = {
ControllerTypes.JOYCON_L: {
"id": 0x01,
"connection_info": 0x0E
},
ControllerTypes.JOYCON_R: {
"id": 0x02,
"connection_info": 0x0E
},
ControllerTypes.PRO_CONTROLLER: {
"id": 0x03,
"connection_info": 0x00
}
}
VIBRATOR_BYTES = [0xA0, 0xB0, 0xC0, 0x90]
def __init__(self, controller_type, bt_address, report_size=50):
"""Initializes the protocol for the controller.
:param controller_type: The type of controller (Joy-Con (L),
Pro Controller, etc)
:type controller_type: ControllerTypes
:param bt_address: A colon-separated Bluetooth MAC address
:type bt_address: string
:param report_size: The size of the protocol report, defaults to 50
:type report_size: int, optional
:raises ValueError: On unknown controller type
"""
self.bt_address = bt_address
if controller_type in self.CONTROLLER_INFO.keys():
self.controller_type = controller_type
else:
raise ValueError("Unknown controller type specified")
self.report = None
self.report_size = report_size
self.set_empty_report()
# Input report mode
self.mode = None
# Player number
self.player_number = None
# Setting if the controller has been asked for device info
# Enables buttons/stick output for the standard full report
self.device_info_queried = False
# Standard Input Report Properties
# Timestamp to generate timer byte ticks
self.timer = 0
self.timestamp = None
# High/Low Nibble
self.battery_level = 0x90
self.connection_info = (
self.CONTROLLER_INFO[self.controller_type]["connection_info"])
self.button_status = [0x00] * 3
# Disable left stick if we have a right Joy-Con
if self.controller_type == ControllerTypes.JOYCON_R:
self.left_stick_status = [0x00] * 3
else:
self.left_stick_status = [0x74, 0x58, 0x75]
# Disable right stick if we have a left Joy-Con
if self.controller_type == ControllerTypes.JOYCON_L:
self.right_stick_status = [0x00] * 3
else:
self.right_stick_status = [0x4B, 0x68, 0x7C]
self.vibrator_report = random.choice(self.VIBRATOR_BYTES)
# IMU (Six Axis Sensor) State
self.imu_enabled = False
# Controller colours
# Body Colour
self.colour_body = [0x82] * 3
self.colour_buttons = [0x0F] * 3
def get_report(self):
report = bytes(self.report)
# Clear report
self.set_empty_report()
return report
def process_commands(self, data):
# Parsing the Switch's message
message = SwitchReportParser(data)
# print(message.response)
# Responding to the parsed message
if message.response == SwitchResponses.REQUEST_DEVICE_INFO:
self.device_info_queried = True
self.set_subcommand_reply()
self.set_device_info()
elif message.response == SwitchResponses.SET_SHIPMENT:
self.set_subcommand_reply()
self.set_shipment()
elif message.response == SwitchResponses.SPI_READ:
self.set_subcommand_reply()
self.spi_read(message)
elif message.response == SwitchResponses.SET_MODE:
self.set_subcommand_reply()
self.set_mode(message)
elif message.response == SwitchResponses.TRIGGER_BUTTONS:
self.set_subcommand_reply()
self.set_trigger_buttons()
elif message.response == SwitchResponses.TOGGLE_IMU:
self.set_subcommand_reply()
self.toggle_imu(message)
elif message.response == SwitchResponses.ENABLE_VIBRATION:
self.set_subcommand_reply()
self.enable_vibration()
elif message.response == SwitchResponses.SET_PLAYER:
self.set_subcommand_reply()
self.set_player_lights(message)
elif message.response == SwitchResponses.SET_NFC_IR_STATE:
self.set_subcommand_reply()
self.set_nfc_ir_state()
elif message.response == SwitchResponses.SET_NFC_IR_CONFIG:
self.set_subcommand_reply()
self.set_nfc_ir_config()
# Bad Packet handling statements
elif message.response == SwitchResponses.UNKNOWN_SUBCOMMAND:
self.set_full_input_report()
# self.set_subcommand_reply()
# self.set_unknown_subcommand(message.subcommand_id)
elif message.response == SwitchResponses.NO_DATA:
self.set_full_input_report()
elif message.response == SwitchResponses.TOO_SHORT:
self.set_full_input_report()
elif message.response == SwitchResponses.MALFORMED:
self.set_full_input_report()
def set_empty_report(self):
empty_report = [0] * self.report_size
empty_report[0] = 0xA1
self.report = empty_report
def set_subcommand_reply(self):
# Input Report ID
self.report[1] = 0x21
# TODO: Find out what the vibrator byte is doing.
# This is a hack in an attempt to semi-emulate
# actions of the vibrator byte as it seems to change
# when a subcommand reply is sent.
self.vibrator_report = random.choice(self.VIBRATOR_BYTES)
self.set_standard_input_report()
def set_unknown_subcommand(self, subcommand_id):
# Set NACK
self.report[14]
# Set unknown subcommand ID
self.report[15] = subcommand_id
def set_timer(self):
# If the timer hasn't been set before
if not self.timestamp:
self.timestamp = perf_counter()
self.report[2] = 0x00
return
# Get the time that has passed since the last timestamp
# in milliseconds
now = perf_counter()
delta_t = (now - self.timestamp) * 1000
# Get how many ticks have passed in hex with overflow at 255
# Joy-Con uses 4.96ms as the timer tick rate
elapsed_ticks = int(delta_t // 4.96)
self.timer = (self.timer + elapsed_ticks) & 0xFF
self.report[2] = self.timer
self.timestamp = now
def set_full_input_report(self):
# Setting Report ID to full standard input report ID
self.report[1] = 0x30
self.set_standard_input_report()
self.set_imu_data()
def set_standard_input_report(self):
self.set_timer()
if self.device_info_queried:
self.report[3] = self.battery_level + self.connection_info
self.report[4] = self.button_status[0]
self.report[5] = self.button_status[1]
self.report[6] = self.button_status[2]
self.report[7] = self.left_stick_status[0]
self.report[8] = self.left_stick_status[1]
self.report[9] = self.left_stick_status[2]
self.report[10] = self.right_stick_status[0]
self.report[11] = self.right_stick_status[1]
self.report[12] = self.right_stick_status[2]
self.report[13] = self.vibrator_report
def set_device_info(self):
# ACK Reply
self.report[14] = 0x82
# Subcommand Reply
self.report[15] = 0x02
# Firmware version
self.report[16] = 0x03
self.report[17] = 0x8B
# Controller ID
self.report[18] = self.CONTROLLER_INFO[self.controller_type]["id"]
# Unknown Byte, always 2
self.report[19] = 0x02
# Controller Bluetooth Address
address = self.bt_address.strip().split(":") # Getting from adapter
address_location = 20
for address_byte_str in address:
# Converting string address bytes to hex
# and assigning to report
address_byte = int(address_byte_str, 16)
self.report[address_location] = address_byte
address_location += 1
# Unknown byte, always 1
self.report[26] = 0x01
# Controller colours location (read from SPI)
self.report[27] = 0x01
def set_shipment(self):
# ACK Reply
self.report[14] = 0x80
# Subcommand reply
self.report[15] = 0x08
def toggle_imu(self, message):
if message.subcommand[1] == 0x01:
self.imu_enabled = True
else:
self.imu_enabled = False
# ACK Reply
self.report[14] = 0x80
# Subcommand reply
self.report[15] = 0x40
def set_imu_data(self):
if not self.imu_enabled:
return
imu_data = [0x75, 0xFD, 0xFD, 0xFF, 0x09, 0x10, 0x21, 0x00, 0xD5, 0xFF,
0xE0, 0xFF, 0x72, 0xFD, 0xF9, 0xFF, 0x0A, 0x10, 0x22, 0x00,
0xD5, 0xFF, 0xE0, 0xFF, 0x76, 0xFD, 0xFC, 0xFF, 0x09, 0x10,
0x23, 0x00, 0xD5, 0xFF, 0xE0, 0xFF]
replace_subarray(self.report, 14, 49, replace_arr=imu_data)
def spi_read(self, message):
addr_top = message.subcommand[2]
addr_bottom = message.subcommand[1]
read_length = message.subcommand[5]
# ACK byte
self.report[14] = 0x90
# Subcommand reply
self.report[15] = 0x10
# Read address
self.report[16] = addr_bottom
self.report[17] = addr_top
# Read length
self.report[20] = read_length
# Stick Parameters
# Params are generally the same for all sticks
# Notable difference is the deadzone (10% Joy-Con vs 15% Pro Con)
params = [0x0F, 0x30, 0x61, # Unused
0x96, 0x30, 0xF3, # Dead Zone/Range Ratio
0xD4, 0x14, 0x54, # X/Y ?
0x41, 0x15, 0x54, # X/Y ?
0xC7, 0x79, 0x9C, # X/Y ?
0x33, 0x36, 0x63] # X/Y ?
# Adjusting deadzone for Joy-Cons
if not self.controller_type == ControllerTypes.PRO_CONTROLLER:
params[3] = 0xAE
# Serial Number read
if addr_top == 0x60 and addr_bottom == 0x00:
# Switch will take this as no serial number
replace_subarray(self.report, 21, 16, 0xFF)
# Colours
elif addr_top == 0x60 and addr_bottom == 0x50:
# Body colour
replace_subarray(
self.report, 21, 3,
replace_arr=self.colour_body)
# Buttons colour
replace_subarray(
self.report, 24, 3,
replace_arr=self.colour_buttons)
# Left/right grip colours (Pro controller)
replace_subarray(self.report, 27, 7, 0xFF)
# Factory sensor/stick device parameters
elif addr_top == 0x60 and addr_bottom == 0x80:
# Six-Axis factory parameters
if self.controller_type == ControllerTypes.PRO_CONTROLLER:
self.report[21] = 0x50
self.report[22] = 0xFD
self.report[23] = 0x00
self.report[24] = 0x00
self.report[25] = 0xC6
self.report[26] = 0x0F
else:
self.report[21] = 0x5E
self.report[22] = 0x01
self.report[23] = 0x00
self.report[24] = 0x00
if self.controller_type == ControllerTypes.JOYCON_L:
self.report[25] = 0xF1
self.report[26] = 0x0F
else:
self.report[25] = 0x0F
self.report[26] = 0xF0
replace_subarray(self.report, 27, 18, replace_arr=params)
# Stick device parameters 2
elif addr_top == 0x60 and addr_bottom == 0x98:
# Setting same params since controllers always
# have duplicates of stick params 1 for stick params 2
replace_subarray(self.report, 21, 18, replace_arr=params)
# User analog stick calibration
elif addr_top == 0x80 and addr_bottom == 0x10:
# Fill report with null user calibration info
replace_subarray(self.report, 21, 24, 0xFF)
# Factory analog stick calibration
elif addr_top == 0x60 and addr_bottom == 0x3D:
# Left/right stick calibration
l_calibration = [0xBA, 0xF5, 0x62,
0x6F, 0xC8, 0x77,
0xED, 0x95, 0x5B]
r_calibration = [0x16, 0xD8, 0x7D,
0xF2, 0xB5, 0x5F,
0x86, 0x65, 0x5E]
# Left stick calibration
# If null, fill with 0xFF
if not self.controller_type == ControllerTypes.JOYCON_R:
replace_subarray(self.report, 21, 9, replace_arr=l_calibration)
else:
replace_subarray(self.report, 21, 9, value=0xFF)
# Right stick calibration
# If null, fill with 0xFF
if not self.controller_type == ControllerTypes.JOYCON_L:
replace_subarray(self.report, 30, 9, replace_arr=r_calibration)
else:
replace_subarray(self.report, 30, 9, value=0xFF)
# Body colour
replace_subarray(
self.report, 39, 3,
replace_arr=self.colour_body)
# Buttons colour
replace_subarray(
self.report, 42, 3,
replace_arr=self.colour_buttons)
# Six-Axis motion sensor factor calibration
elif addr_top == 0x60 and addr_bottom == 0x20:
# 1: Acceleration origin position
# 2: Acceleration sensitivity coefficient
# 3: Gyro origin when still
# 4: Gyro sensitivity coefficient
sa_calibration = [0xD3, 0xFF, 0xD5, 0xFF, 0x55, 0x01, # 1
0x00, 0x40, 0x00, 0x40, 0x00, 0x40, # 2
0x19, 0x00, 0xDD, 0xFF, 0xDC, 0xFF, # 3
0x3B, 0x34, 0x3B, 0x34, 0x3B, 0x34] # 4
replace_subarray(self.report, 21, 24, replace_arr=sa_calibration)
def set_mode(self, message):
# ACK byte
self.report[14] = 0x80
# Subcommand reply
self.report[15] = 0x03
if message.subcommand[1] == 0x30:
self.mode = "standard"
elif message.subcommand[1] == 0x31:
self.mode = "nfc/ir"
elif message.subcommand[1] == 0x3F:
self.mode = "simpleHID"
def set_trigger_buttons(self):
# ACK byte
self.report[14] = 0x83
# Subcommand reply
self.report[15] = 0x04
def enable_vibration(self):
# ACK Reply
self.report[14] = 0x80
# Subcommand reply
self.report[15] = 0x48
def set_player_lights(self, message):
# ACK byte
self.report[14] = 0x80
# Subcommand reply
self.report[15] = 0x30
bitfield = message.subcommand[1]
if bitfield == 0x01 or bitfield == 0x1F:
self.player_number = 1
elif bitfield == 0x03 or bitfield == 0x3F:
self.player_number = 2
elif bitfield == 0x07 or bitfield == 0x7F:
self.player_number = 3
elif bitfield == 0x0F or bitfield == 0xFF:
self.player_number = 4
def set_nfc_ir_state(self):
# ACK byte
self.report[14] = 0x80
# Subcommand reply
self.report[15] = 0x22
def set_nfc_ir_config(self):
# ACK byte
self.report[14] = 0xA0
# Subcommand reply
self.report[15] = 0x21
# NFC/IR state data
params = [0x01, 0x00, 0xFF, 0x00, 0x08, 0x00, 0x1B, 0x01]
replace_subarray(self.report, 16, 8, replace_arr=params)
self.report[49] = 0xC8
class SwitchReportParser():
SUBCOMMANDS = {
0x02: SwitchResponses.REQUEST_DEVICE_INFO,
0x08: SwitchResponses.SET_SHIPMENT,
0x10: SwitchResponses.SPI_READ,
0x03: SwitchResponses.SET_MODE,
0x04: SwitchResponses.TRIGGER_BUTTONS,
0x40: SwitchResponses.TOGGLE_IMU,
0x48: SwitchResponses.ENABLE_VIBRATION,
0x30: SwitchResponses.SET_PLAYER,
0x22: SwitchResponses.SET_NFC_IR_STATE,
0x21: SwitchResponses.SET_NFC_IR_CONFIG,
}
def __init__(self, data, data_length=50):
# Non-data check
if not data:
self.response = SwitchResponses.NO_DATA
return
# Report length check
if len(data) < data_length:
self.response = SwitchResponses.TOO_SHORT
return
# First byte check
if data[0] != 0xA2:
self.response = SwitchResponses.MALFORMED
return
# Splitting data
self.payload = data[:11]
self.subcommand = data[11:]
self.subcommand_id = self.subcommand[0]
# Parsing the subcommand
if self.subcommand[0] in self.SUBCOMMANDS.keys():
self.response = self.SUBCOMMANDS[self.subcommand[0]]
else:
self.response = SwitchResponses.UNKNOWN_SUBCOMMAND

View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8" ?>
<record>
<attribute id="0x0001">
<sequence>
<uuid value="0x1124" />
</sequence>
</attribute>
<attribute id="0x0004">
<sequence>
<sequence>
<uuid value="0x0100" />
<uint16 value="0x0011" />
</sequence>
<sequence>
<uuid value="0x0011" />
</sequence>
</sequence>
</attribute>
<attribute id="0x0005">
<sequence>
<uuid value="0x1002" />
</sequence>
</attribute>
<attribute id="0x0006">
<sequence>
<uint16 value="0x656e" />
<uint16 value="0x006a" />
<uint16 value="0x0100" />
</sequence>
</attribute>
<attribute id="0x0009">
<sequence>
<sequence>
<uuid value="0x1124" />
<uint16 value="0x0101" />
</sequence>
</sequence>
</attribute>
<attribute id="0x000d">
<sequence>
<sequence>
<sequence>
<uuid value="0x0100" />
<uint16 value="0x0013" />
</sequence>
<sequence>
<uuid value="0x0011" />
</sequence>
</sequence>
</sequence>
</attribute>
<attribute id="0x0100">
<text value="Wireless Gamepad" />
</attribute>
<attribute id="0x0101">
<text value="Gamepad" />
</attribute>
<attribute id="0x0102">
<text value="Nintendo" />
</attribute>
<attribute id="0x0201">
<uint16 value="0x0111" />
</attribute>
<attribute id="0x0202">
<uint8 value="0x08" />
</attribute>
<attribute id="0x0203">
<uint8 value="0x21" />
</attribute>
<attribute id="0x0204">
<boolean value="true" />
</attribute>
<attribute id="0x0205">
<boolean value="true" />
</attribute>
<attribute id="0x0206">
<sequence>
<sequence>
<uint8 value="0x22" />
<text encoding="hex" value="05010905a1010601ff8521092175089530810285300930750895308102853109317508966901810285320932750896690181028533093375089669018102853f05091901291015002501750195108102050109391500250775049501814205097504950181010501093009310933093416000027ffff00007510950481020601ff85010901750895309102851009107508953091028511091175089530910285120912750895309102c0" />
</sequence>
</sequence>
</attribute>
<attribute id="0x0207">
<sequence>
<sequence>
<uint16 value="0x0409" />
<uint16 value="0x0100" />
</sequence>
</sequence>
</attribute>
<attribute id="0x0209">
<boolean value="true" />
</attribute>
<attribute id="0x020a">
<boolean value="true" />
</attribute>
<attribute id="0x020c">
<uint16 value="0x0c80" />
</attribute>
<attribute id="0x020d">
<boolean value="false" />
</attribute>
<attribute id="0x020e">
<boolean value="false" />
</attribute>
</record>

175
nxbt/controller/server.py Normal file
View file

@ -0,0 +1,175 @@
import socket
import fcntl
import os
import time
from .controller import Controller, ControllerTypes
from ..bluez import BlueZ
from .protocol import ControllerProtocol
from .utils import format_msg_controller, format_msg_switch
class ControllerServer():
def __init__(self, controller_type, bt_device_id="hci0"):
self.controller_type = controller_type
# Intializing Bluetooth
self.bt = BlueZ(device_id=bt_device_id)
self.controller = Controller(self.bt, self.controller_type)
self.protocol = ControllerProtocol(
self.controller_type,
self.bt.address)
def run(self, reconnect_address=None):
"""Runs the mainloop of the controller server.
:param reconnect_address: The Bluetooth MAC address of a
previously connected to Nintendo Switch, defaults to None
:type reconnect_address: string, optional
"""
self.controller.setup()
if reconnect_address:
itr, s_itr, ctrl, s_ctrl = self.reconnect(reconnect_address)
else:
itr, s_itr, ctrl, s_ctrl = self.connect()
# Mainloop
while True:
# Attempt to get output from Switch
try:
reply = itr.recv(50)
if len(reply) > 40:
print(format_msg_switch(reply))
except BlockingIOError:
reply = None
self.protocol.process_commands(reply)
msg = self.protocol.get_report()
if reply:
print(format_msg_controller(msg))
try:
itr.sendall(msg)
except BlockingIOError:
continue
# Respond at 120Hz for Pro Controller
# or 60Hz for Joy-Cons
if self.controller_type == ControllerTypes.PRO_CONTROLLER:
time.sleep(1/120)
else:
time.sleep(1/60)
def connect(self):
"""Configures as a specified controller, pairs with a Nintendo Switch,
and creates/accepts sockets for communication with the Switch.
"""
# Creating control and interrupt sockets
s_ctrl = socket.socket(
family=socket.AF_BLUETOOTH,
type=socket.SOCK_SEQPACKET,
proto=socket.BTPROTO_L2CAP)
s_itr = socket.socket(
family=socket.AF_BLUETOOTH,
type=socket.SOCK_SEQPACKET,
proto=socket.BTPROTO_L2CAP)
# Setting up HID interrupt/control sockets
try:
s_ctrl.bind((self.bt.address, 17))
s_itr.bind((self.bt.address, 19))
except OSError:
s_ctrl.bind((socket.BDADDR_ANY, 17))
s_itr.bind((socket.BDADDR_ANY, 19))
s_itr.listen(1)
s_ctrl.listen(1)
self.bt.set_discoverable(True)
ctrl, ctrl_address = s_ctrl.accept()
itr, itr_address = s_itr.accept()
# Send an empty input report to the Switch to prompt a reply
self.protocol.process_commands(None)
msg = self.protocol.get_report()
itr.sendall(msg)
# Setting interrupt connection as non-blocking
# In this case, non-blocking means it throws a "BlockingIOError"
# for sending and receiving, instead of blocking
fcntl.fcntl(itr, fcntl.F_SETFL, os.O_NONBLOCK)
# Mainloop
while True:
# Attempt to get output from Switch
try:
reply = itr.recv(50)
if len(reply) > 40:
print(format_msg_switch(reply))
except BlockingIOError:
reply = None
self.protocol.process_commands(reply)
msg = self.protocol.get_report()
#if reply:
# print(format_msg_controller(msg))
try:
itr.sendall(msg)
except BlockingIOError:
continue
# Exit pairing loop on set player lights
if reply and len(reply) > 45 and reply[11] == 0x30:
break
# Switch responds to packets slower during pairing
# Pairing cycle responds optimally on a 15Hz loop
time.sleep(1/15)
return itr, s_itr, ctrl, s_ctrl
def reconnect(self, reconnect_address):
"""Attempts to reconnect with a Switch at the given address.
:param reconnect_address: The Bluetooth MAC address of the Switch
:type reconnect_address: string
"""
device_path = self.bt.find_device_by_address(reconnect_address)
if not device_path:
raise ValueError(
"No device Switch found with MAC address " + reconnect_address)
# Creating control and interrupt sockets
s_ctrl = socket.socket(
family=socket.AF_BLUETOOTH,
type=socket.SOCK_SEQPACKET,
proto=socket.BTPROTO_L2CAP)
s_itr = socket.socket(
family=socket.AF_BLUETOOTH,
type=socket.SOCK_SEQPACKET,
proto=socket.BTPROTO_L2CAP)
# Setting up HID interrupt/control sockets
s_ctrl.bind((self.bt.address, 17))
s_itr.bind((self.bt.address, 19))
s_itr.listen(1)
s_ctrl.listen(1)
self.bt.connect_device(device_path)
ctrl, ctrl_address = s_ctrl.accept()
itr, itr_address = s_itr.accept()
print("Here")

59
nxbt/controller/utils.py Normal file
View file

@ -0,0 +1,59 @@
def replace_subarray(arr, start, num_elms, value=0, replace_arr=None):
if replace_arr:
arr[start:start + num_elms] = replace_arr
else:
arr[start:start + num_elms] = [value] * num_elms
def format_message(data, split, name):
"""Formats a given byte message in hex format split
into payload and subcommand sections.
:param data: A series of bytes
:type data: bytes
:param split: The location of the payload/subcommand split
:type split: integer
:param name: The name featured in the start/end messages
:type name: string
:return: The formatted data
:rtype: string
"""
payload = ""
subcommand = ""
for i in range(0, len(data)):
data_byte = str(hex(data[i]))[2:].upper()
if len(data_byte) < 2:
data_byte = "0" + data_byte
if i <= split:
payload += "0x" + data_byte + " "
else:
subcommand += "0x" + data_byte + " "
formatted = (
f"--- {name} Msg ---\n" +
f"Payload: {payload}\n" +
f"Subcommand: {subcommand}")
return formatted
def format_msg_controller(data):
"""Prints a formatted message from a controller
:param data: The bytes from the controller message
:type data: bytes
"""
return format_message(data, 13, "Controller")
def format_msg_switch(data):
"""Prints a formatted message from a Switch
:param data: The bytes from the Switch message
:type data: bytes
"""
return format_message(data, 10, "Switch")

12
nxbt/manager.py Normal file
View file

@ -0,0 +1,12 @@
from multiprocessing import Process
from multiprocessing import Queue
from .controller import ControllerServer
from .controller import ControllerTypes
class Nxbt():
def __init__(self):
self.task_queue = Queue()

53
nxbt/web/app.py Normal file
View file

@ -0,0 +1,53 @@
import time
import os
from flask import Flask, render_template
from flask_socketio import SocketIO, emit
import eventlet
app = Flask(__name__,
static_url_path='',
static_folder='static',)
# Configuring/retrieving secret key
secrets_path = os.path.join(
os.path.dirname(__file__), "secrets.txt"
)
if not os.path.isfile(secrets_path):
secret_key = os.urandom(24).hex()
with open(secrets_path, "w") as f:
f.write(secret_key)
else:
secret_key = None
with open(secrets_path, "r") as f:
secret_key = f.read()
app.config['SECRET_KEY'] = secret_key
# Starting socket server with Flask app
sio = SocketIO(app)
@app.route('/')
def index():
return render_template('index.html')
@sio.on('connect')
def on_connect():
print("Connected")
emit('my response', {'data': 'Connected'})
@sio.on('disconnect')
def on_disconnect():
print("Disconnected")
@sio.on('message')
def handle_message(message):
print("Elapsed Time", (time.time()*1000) - message["timestamp"])
if __name__ == "__main__":
eventlet.wsgi.server(eventlet.listen(('', 8000)), app)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title>NXBT: Control Your Switch Locally or Remotely</title>
</head>
<body>
<script src="js/socket.io.js"></script>
<script type="text/javascript" charset="utf-8">
var socket = io();
socket.on('connect', function() {
let date = new Date();
socket.emit('message', {"timestamp": date.getTime()});
});
</script>
</body>
</html>

8
scripts/dep_check.sh Normal file
View file

@ -0,0 +1,8 @@
# Constants
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
printf "\n${RED}Installing System Requirements and Bluetooth Requirements...${NC}\n\n"
# Bluetooth + Sys Reqs
sudo apt install libbluetooth-dev bluez bluez-tools bluez-firmware libgirepository1.0-dev gcc libcairo2-dev pkg-config python3-dev gir1.2-gtk-3.0 -y

285
scripts/jc_proxy.py Normal file
View file

@ -0,0 +1,285 @@
"""
This is a quick and dirty script for recording input from a controller
and dumping it into a "messages.txt" file. You'll need to input the
devices Bluetooth MAC address manually and specify the type of
controller before this script works.
"""
import socket
import sys
import os
import time
import fcntl
from time import perf_counter
from nxbt import BlueZ
from nxbt import Controller
from nxbt import ControllerTypes
JCL_REPLY02 = b'\xA2\x21\x05\x8E\x84\x00\x12\x01\x18\x80\x01\x18\x80\x80\x82\x02\x03\x48\x01\x02\xDC\xA6\x32\x16\x4A\x7C\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
PRO_REPLY02 = b'\xA2\x21\x1A\x40\x00\x00\x00\x02\x20\x00\x01\x00\x00\x00\x82\x02\x03\x48\x03\x02\xDC\xA6\x32\x16\x4A\x7C\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
JCR_REPLY02 = b'\xA2\x21\x05\x8E\x84\x00\x12\x01\x18\x80\x01\x18\x80\x80\x82\x02\x03\x48\x02\x02\xDC\xA6\x32\x16\x4A\x7C\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
def format_message(data, split, name):
"""Formats a given byte message in hex format split
into payload and subcommand sections.
:param data: A series of bytes
:type data: bytes
:param split: The location of the payload/subcommand split
:type split: integer
:param name: The name featured in the start/end messages
:type name: string
:return: The formatted data
:rtype: string
"""
payload = ""
subcommand = ""
for i in range(0, len(data)):
data_byte = str(hex(data[i]))[2:].upper()
if len(data_byte) < 2:
data_byte = "0" + data_byte
if i <= split:
payload += "0x" + data_byte + " "
else:
subcommand += "0x" + data_byte + " "
formatted = (
f"--- {name} Msg ---\n" +
f"Payload: {payload}\n" +
f"Subcommand: {subcommand}")
return formatted
def print_msg_controller(data):
"""Prints a formatted message from a controller
:param data: The bytes from the controller message
:type data: bytes
"""
print(format_message(data, 13, "Controller"))
def print_msg_switch(data):
"""Prints a formatted message from a Switch
:param data: The bytes from the Switch message
:type data: bytes
"""
print(format_message(data, 10, "Switch"))
def write_to_buffer(buffer, message, message_type):
formatted_message = None
if message_type == "switch":
formatted_message = format_message(message, 10, "Switch")
elif message_type == "controller":
formatted_message = format_message(message, 13, "Controller")
elif message_type == "comment":
formatted_message = "### " + message + " ###"
else:
raise ValueError("Unspecified or wrong message type")
buffer.append(formatted_message)
if __name__ == "__main__":
# Switch Controller Bluetooth MAC Address goes here
jc_MAC = "XX:XX:XX:XX:XX:XX"
# Specify the type of controller here
controller_type = ControllerTypes.JOYCON_L
port_ctrl = 17
port_itr = 19
message_buffer = []
bt = BlueZ()
bt.toggle_input_plugin(False)
controller = Controller(bt, controller_type)
# Joy-Con Sockets
jc_ctrl = socket.socket(family=socket.AF_BLUETOOTH,
type=socket.SOCK_SEQPACKET,
proto=socket.BTPROTO_L2CAP)
jc_itr = socket.socket(family=socket.AF_BLUETOOTH,
type=socket.SOCK_SEQPACKET,
proto=socket.BTPROTO_L2CAP)
# Switch sockets
switch_itr = socket.socket(family=socket.AF_BLUETOOTH,
type=socket.SOCK_SEQPACKET,
proto=socket.BTPROTO_L2CAP)
switch_ctrl = socket.socket(family=socket.AF_BLUETOOTH,
type=socket.SOCK_SEQPACKET,
proto=socket.BTPROTO_L2CAP)
try:
# Remove the device before we try to re-pair
device_path = bt.find_device_by_address(jc_MAC)
if not device_path:
print("Device not paired. Pairing...")
# Ensure we are paired/connected to the JC
print("Attempting to re-pair with device")
devices = bt.discover_devices(alias="Joy-Con (L)", timeout=8)
jc_device_path = None
for key in devices.keys():
print(devices[key]["Address"])
if devices[key]["Address"] == jc_MAC:
jc_device_path = key
break
if not jc_device_path:
print("The specified Joy-Con could not be found")
else:
bt.pair_device(jc_device_path)
print("Paired Joy-Con")
bt.set_alias("Nintendo Switch")
print("Connecting to Joy-Con: ", jc_MAC)
jc_ctrl.connect((jc_MAC, port_ctrl))
jc_itr.connect((jc_MAC, port_itr))
print("Got connection.")
switch_ctrl.bind((bt.address, port_ctrl))
switch_itr.bind((bt.address, port_itr))
# bt.set_alias("Joy-Con (L)")
bt.set_alias("Pro Controller")
bt.set_discoverable(True)
print("Waiting for Switch to connect...")
switch_itr.listen(1)
switch_ctrl.listen(1)
client_control, control_address = switch_ctrl.accept()
print("Got Switch Control Client Connection")
client_interrupt, interrupt_address = switch_itr.accept()
print("Got Switch Interrupt Client Connection")
# Creating a non-blocking client interrupt connection
fcntl.fcntl(client_interrupt, fcntl.F_SETFL, os.O_NONBLOCK)
# Initial Input report from Joy-Con
jc_data = jc_itr.recv(350)
print("Got initial Joy-Con Empty Report")
# print_msg_controller(jc_data)
write_to_buffer(
message_buffer,
"Joy-Con Empty Report",
"comment")
write_to_buffer(message_buffer, jc_data, "controller")
print(message_buffer)
# Send the input report to the Switch a couple times
for i in range(3):
print("Sending input report", i)
client_interrupt.sendall(jc_data)
time.sleep(1)
# Get the Switch's reply and send it to the Joy-Con
reply = client_interrupt.recv(350)
# print_msg_switch(reply)
write_to_buffer(
message_buffer,
"Switch Input Report Reply",
"comment")
write_to_buffer(message_buffer, reply, "switch")
jc_itr.sendall(reply)
# Sending Switch the proxy's device info
if controller_type == ControllerTypes.JOYCON_R:
client_interrupt.sendall(JCR_REPLY02)
elif controller_type == ControllerTypes.JOYCON_L:
client_interrupt.sendall(JCL_REPLY02)
elif controller_type == ControllerTypes.PRO_CONTROLLER:
client_interrupt.sendall(PRO_REPLY02)
# Waste some cycles here until we get the controllers info.
# We don't want to proxy the device's info to the Switch
# since it includes a MAC address.
print("Waiting on Joy-Con Device Info")
while True:
jc_data = jc_itr.recv(350)
if jc_data[1] == 0x21:
print("Got Device Info")
# print_msg_controller(jc_data)
print("Joy-Con Device Info Reply Length", len(jc_data))
write_to_buffer(
message_buffer,
"Joy-Con Device Info",
"comment")
write_to_buffer(message_buffer, jc_data, "controller")
break
# Main loop
print("Entering main proxy loop")
write_to_buffer(
message_buffer,
"Entering Main Loop",
"comment")
time_old = perf_counter()
timer_old = 0
timer_counter = 0
while True:
try:
reply = client_interrupt.recv(350)
# print_msg_switch(reply)
write_to_buffer(message_buffer, reply, "switch")
except BlockingIOError:
reply = None
if reply:
jc_itr.sendall(reply)
jc_data = jc_itr.recv(350)
timer_new = int(jc_data[2])
if timer_new < timer_old:
timer_counter += timer_new - (timer_old - 255)
else:
timer_counter += timer_new - timer_old
timer_old = timer_new
# print_msg_controller(jc_data)
write_to_buffer(message_buffer, jc_data, "controller")
client_interrupt.sendall(jc_data)
except KeyboardInterrupt:
print("Closing sockets")
time_new = perf_counter()
print(f"Total Delta: {(time_new - time_old) * 1000}")
print(f"Timer Counter: {timer_counter}")
jc_ctrl.close()
jc_itr.close()
switch_itr.close()
switch_ctrl.close()
# Write the buffer
with open("messages.txt", "w") as f:
f.write("\n".join(message_buffer))
try:
sys.exit(1)
except SystemExit:
os._exit(1)
except OSError as e:
print("Closing sockets")
jc_ctrl.close()
jc_itr.close()
switch_itr.close()
switch_ctrl.close()
raise e

2
scripts/reset_adapter.sh Normal file
View file

@ -0,0 +1,2 @@
sudo hciconfig hci0 reset
sudo invoke-rc.d bluetooth restart

47
setup.cfg Normal file
View file

@ -0,0 +1,47 @@
[metadata]
name = nxbt
version = 0.1
author = Reece Walsh
author-email = reece@brikwerk.com
project_urls =
Code = https://github.com/Brikwerk/nxbt
Issue tracker = https://github.com/Brikwerk/nxbt/issues
license = MIT
license-file = LICENSE
description = Control a Nintendo Switch Locally or Remotely
long-description = file: README.md
platform = any
url = https://pypi.python.org/pypi/nxbt
classifiers =
Development Status :: 3 - Alpha
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Topic :: Software Development :: Libraries :: Python Modules
[options]
packages = nxbt
include_package_data = true
python_requires = >= 3.6
zip_safe = False
[options.entry_points]
console_scripts =
nxbt = nxbt.cli:main
[aliases]
# Alias `setup.py test` to `setup.py pytest`
test = pytest
[tool:pytest]
testpaths = tests
filterwarnings =
error
[flake8]
max-line-length = 80
exclude = .git, .eggs, __pycache__, tests/, docs/, build/, dist/

16
setup.py Normal file
View file

@ -0,0 +1,16 @@
import setuptools
setuptools.setup(
name="nxbt",
install_requires=[
"dbus-python>=1.2.16",
"Flask>=1.1.2",
"Flask-SocketIO>=4.3.0",
"eventlet>=0.25.2",
],
extra_require={
"dev": [
"pytest"
]
}
)

23
test.py Normal file
View file

@ -0,0 +1,23 @@
import time
from nxbt import ControllerTypes
from nxbt import ControllerProtocol
INPUT_REPORT = b'\xa2\x01\x0E\x00\x00\x00\x00\x00\x00\x00\x00\x02\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
protocol = ControllerProtocol(
ControllerTypes.JOYCON_L,
"AA:AA:AA:AA:AA:AA")
protocol.process_commands(None)
print(hex(protocol.get_report()[2]))
time.sleep(1)
protocol.process_commands(None)
print(hex(protocol.get_report()[2]))
protocol.process_commands(INPUT_REPORT)
print(hex(protocol.get_report()[2]))
time.sleep(1)
protocol.process_commands(None)
print(hex(protocol.get_report()[2]))
protocol.process_commands(None)
print(hex(protocol.get_report()[2]))