Initial commit
This commit is contained in:
commit
d3aa6a3b73
27 changed files with 4702 additions and 0 deletions
143
.gitignore
vendored
Normal file
143
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
15
README.md
Normal 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
30
demo.py
Normal 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()
|
||||
2379
docs/Example Pairing Session.md
Normal file
2379
docs/Example Pairing Session.md
Normal file
File diff suppressed because it is too large
Load diff
21
docs/Miscellaneous Notes.md
Normal file
21
docs/Miscellaneous Notes.md
Normal 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
8
nxbt/__init__.py
Normal 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
615
nxbt/bluez.py
Normal 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
2
nxbt/cli.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
def main():
|
||||
raise NotImplementedError()
|
||||
6
nxbt/controller/__init__.py
Normal file
6
nxbt/controller/__init__.py
Normal 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
|
||||
69
nxbt/controller/controller.py
Normal file
69
nxbt/controller/controller.py
Normal 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
4
nxbt/controller/input.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
class InputParser():
|
||||
|
||||
def __init__():
|
||||
print("")
|
||||
575
nxbt/controller/protocol.py
Normal file
575
nxbt/controller/protocol.py
Normal 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
|
||||
108
nxbt/controller/sdp/switch-controller.xml
Normal file
108
nxbt/controller/sdp/switch-controller.xml
Normal 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
175
nxbt/controller/server.py
Normal 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
59
nxbt/controller/utils.py
Normal 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
12
nxbt/manager.py
Normal 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
53
nxbt/web/app.py
Normal 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)
|
||||
9
nxbt/web/static/js/socket.io.js
Normal file
9
nxbt/web/static/js/socket.io.js
Normal file
File diff suppressed because one or more lines are too long
1
nxbt/web/static/js/socket.io.js.map
Normal file
1
nxbt/web/static/js/socket.io.js.map
Normal file
File diff suppressed because one or more lines are too long
16
nxbt/web/templates/index.html
Normal file
16
nxbt/web/templates/index.html
Normal 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
8
scripts/dep_check.sh
Normal 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
285
scripts/jc_proxy.py
Normal 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
2
scripts/reset_adapter.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
sudo hciconfig hci0 reset
|
||||
sudo invoke-rc.d bluetooth restart
|
||||
47
setup.cfg
Normal file
47
setup.cfg
Normal 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
16
setup.py
Normal 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
23
test.py
Normal 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]))
|
||||
Loading…
Add table
Add a link
Reference in a new issue