├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── README.md ├── images ├── app.ico ├── help1.png └── help2.png ├── pyproject.toml └── src ├── config.ini ├── hidapi.dll ├── hidapi.lib ├── hidapi.pdb ├── main.py └── pyjoycon ├── __init__.py ├── constants.py ├── device.py ├── event.py ├── gyro.py ├── joycon.py └── wrappers.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | /aioconsole-main -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "justMyCode": true 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OSC-based JoyCon (Haptics) for ChilloutVR/VRChat 2 | 3 | Link Nintendo Switch Joy-Cons to [Chillout VR](https://store.steampowered.com/app/661130/ChilloutVR/)/VRChat! Uses [OSC Mod](https://github.com/kafeijao/Kafe_CVR_Mods/tree/master/OSC) in ChilloutVR and the [OSC system](https://docs.vrchat.com/docs/osc-overview) in VRChat. 4 | 5 | For now, only simple on/off rumble haptics are supported! 6 | 7 | # Requirements 8 | 9 | - Nintendo Switch Joy-Con controller(s). **Only tested with knockoffs from Aliexpress.** 10 | - A customisable avatar or a compatible avatar 11 | - Bluetooth Dongle or similar. [You can use another Windows device as relay](#relay) 12 | - Something to attach controller to body/headset straps, for example ["Exercise Patch Self Adhesive Tape Sports Gym Fitness"](https://www.google.com/search?q=Exercise+Patch+Self+Adhesive+Tape+Sports+Gym+Fitness) 13 | - Unity editor 14 | - `vrcjoycon.exe` from this repository's [Releases](https://github.com/Python1320/vrcjoycon/releases/tag/vrcjoycon) 15 | - **ChilloutVR** 16 | - [OSC Mod](https://github.com/kafeijao/Kafe_CVR_Mods/tree/master/OSC) (Recommended to install via [CVR Assistant](https://github.com/knah/CVRMelonAssistant)) 17 | - Other avatars need [CVR Pointer]( https://documentation.abinteractive.net/cck/components/pointer/?h=pointer#cvr-pointer) on at least one index finger. 18 | - Your avatar needs [CVR Advanced Avatar Trigger](https://documentation.abinteractive.net/cck/components/aas-trigger/) boxes and parameters 19 | 20 | # HELP WANTED 21 | Only tested with knockoff joycons. Does it work with real joycons? Apparently not. We need to fix this! 22 | 23 | # Linux 24 | 25 | 26 | ### **1. Installation** 27 | 28 | Only tested on Debian 12 (bookworm) and python 3.11 29 | 30 | Steps 31 | ```bash 32 | 33 | # Our dependencies 34 | sudo apt install libhidapi-dev 35 | 36 | # https://innovativeinnovation.github.io/ubuntu-setup/python/pyenv.html 37 | # Pyenv dependencies 38 | sudo apt install make build-essential libssl-dev zlib1g-dev libbz2-dev \ 39 | libreadline-dev libsqlite3-dev wget curl llvm libncursesw5-dev \ 40 | xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev 41 | 42 | # Get pyenv so we can get python 3.11 exactly 43 | curl https://pyenv.run | bash 44 | 45 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 46 | # ^^^ Follow comments above to install pyenv lines in .bashrc ^^^ 47 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 48 | 49 | pyenv install 3.11 # build 3.11 python 50 | pyenv virtualenv 3.11 joycon # create joycon virtualenv 51 | 52 | . .bashrc # reload .bashrc (or restart shell here) 53 | 54 | git clone "https://github.com/Python1320/vrcjoycon.git" && \ 55 | cd vrcjoycon && \ 56 | pyenv local joycon 57 | 58 | # Make sure you see (joycon) prefix on your shell now 59 | 60 | # install poetry dependency manager 61 | pip install poetry # OR: curl -sSL https://install.python-poetry.org | python3 62 | 63 | poetry install # install python dependencies with poetry 64 | 65 | ####### Do steps 2 and 3 below and (*) ########## 66 | 67 | cd src 68 | ./main.py 69 | 70 | # Start controllers for discovery 71 | 72 | # Modify config 73 | 74 | ${EDITOR:-nano} config.ini 75 | 76 | 77 | ``` 78 | (*) you may need to try `poetry add hid` and `poetry add hidapi` to find a working one (remove the other one). 79 | 80 | ### **2. hid_nintendo kernel module must not be loaded** 81 | ```bash 82 | echo "blacklist hid_nintendo" > /etc/modprobe.d/blacklist_hid_nintendo.conf 83 | rmmod hid_nintendo 84 | ``` 85 | 86 | ### **3. Install udev rules** 87 | 88 | See: https://www.reddit.com/r/Stadia/comments/egcvpq/comment/fc5s7qm/ 89 | 90 | ```bash 91 | cat << 'EOF' > /etc/udev/rules.d/50-nintendo-switch.rules 92 | # Switch Joy-con (L) (Bluetooth only) 93 | KERNEL=="hidraw*", SUBSYSTEM=="hidraw", KERNELS=="0005:057E:2006.*", MODE="0666" 94 | 95 | # Switch Joy-con (R) (Bluetooth only) 96 | KERNEL=="hidraw*", SUBSYSTEM=="hidraw", KERNELS=="0005:057E:2007.*", MODE="0666" 97 | 98 | # Switch Pro controller (USB and Bluetooth) 99 | KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="057e", ATTRS{idProduct}=="2009", MODE="0666" 100 | KERNEL=="hidraw*", SUBSYSTEM=="hidraw", KERNELS=="0005:057E:2009.*", MODE="0666" 101 | 102 | EOF 103 | 104 | ``` 105 | 106 | Run `udevadm control --reload-rules` 107 | 108 | # TODO 109 | - Example haptics avatar + world (please submit in PR!) 110 | - Button input possibility 111 | - Gyroscope? 112 | - Switch joy-con to poll only to reduce wireless interference (partially done) 113 | 114 | # Haptics: Setting Up / Usage 115 | ### **Unity** (For Chillout VR) 116 | 117 | Rather simple. Just add a few components with right parameter name and it should just work. 118 | 119 | 1. Add joyconrumble1 to avatar parameters 120 | 121 | ![Add joyconrumble1 to avatar parameters](https://user-images.githubusercontent.com/207340/188220202-71f9448d-4c50-4405-a0eb-7d022e5a590a.png) 122 | 2. Add [CVR Advanced Avatar Trigger](https://documentation.abinteractive.net/cck/components/aas-trigger/) to your head 123 | 124 | ![Add CVR Advanced Avatar Trigger](https://user-images.githubusercontent.com/207340/188219981-bdeecdf4-a738-4fd1-9dfd-59fb5beacae5.png) 125 | 3. Add [CVR Pointer]( https://documentation.abinteractive.net/cck/components/pointer/?h=pointer#cvr-pointer) on at least one finger 126 | 127 | ![Add CVR Pointer](https://user-images.githubusercontent.com/207340/188220053-e38ae570-91c1-41c0-a9de-9ac943f99a3f.png) 128 | 129 | ### **Unity** (for VRCHat) 130 | 1. Position one or multiple [Contact Receivers](https://docs.vrchat.com/docs/contacts#vrccontactreceiver) components to your chosen avatar bone 131 | 1. Choose at least some collision tags or you will receive no contacts 132 | 2. Haptics can be set to `local only`. `Allow Self` is recommended for testing. 133 | 3. Select `Proximity` from `Receiver Type`. 134 | 4. Set target parameter to `joyconrumble1`. For right controller choose `joyconrumble2`. 135 | 136 | ![componentdetails](images/help2.png) 137 | 5. Add the above parameters to your [animator parameters](https://docs.vrchat.com/docs/animator-parameters) with default float value of 0.0. This is used by OSC to relay the status to VRCJoyCon. 138 | 139 | ![animator](images/help1.png) 140 | 141 | ### **VRChat** 142 | 1. Put controllers into pairing mode by pressing the pairing button. 143 | 2. Pair controllers manually over Bluetooth with Windows. 144 | 3. Launch [vrcjoycon.exe](https://github.com/Python1320/vrcjoycon/releases/tag/vrcjoycon) 145 | 4. When pairing is successful, the controller should vibrate. You may need to press the pair button in the controllers a few times before Windows notices the controllers. They should say "connected" in the windows settings when this is so. 146 | 5. In case of trouble, test with other joycon software first 147 | 6. Launch **VRChat** if not already launched 148 | 1. From the VRChat's **circular menu**, inside **settings**, inside **OSC**, choose **Enable** OSC. Additional help [here](https://docs.vrchat.com/docs/osc-overview#enabling-it). 149 | (*If the haptics do not work, try reset configuration option in the same menu* **ATTN.** The OSC Debug menu does not help you with debugging haptics, only output) 150 | 151 | # Relay 152 | If you have no bluetooth on your VR PC or are experiencing interference it should also be possible to use `vrcjoycon.exe` on a different Windows Laptop, for example. 153 | 154 | 1. You will need to configure VRChat to relay the OSC output data to your target computer: https://docs.vrchat.com/docs/osc-overview#vrchat-ports 155 | 2. Start `vrcjoycon` with command line arguments `vrcjoycon.exe --listen=any --port=9001` 156 | - You can replace `any` with an IP address. Any is an alias for `0.0.0.0` 157 | - The default port is 9001 158 | - Make sure your firewall allows listening on `UDP` protocol port `9001` for `vrcjoycon.exe` 159 | 160 | # Troubleshooting 161 | 162 | Test with OSC receiver to see if your avatar is transmitting. Try transmitting `1` to `/avatar/parameters/joyconrumble1` with OSC sender to see if `vrcjoycon.exe` works. 163 | 164 | 165 | # Credits / components used 166 | - [joycon-python](https://github.com/tocoteron/joycon-python) library 167 | 168 | # License 169 | 170 | PENDING MISSING LICENSE ON: https://github.com/tocoteron/joycon-python 171 | 172 | -------------------------------------------------------------------------------- /images/app.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python1320/vrcjoycon/c63264e9c9bc42ffbe725547cefcd4442bacc6f4/images/app.ico -------------------------------------------------------------------------------- /images/help1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python1320/vrcjoycon/c63264e9c9bc42ffbe725547cefcd4442bacc6f4/images/help1.png -------------------------------------------------------------------------------- /images/help2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python1320/vrcjoycon/c63264e9c9bc42ffbe725547cefcd4442bacc6f4/images/help2.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "vrcjoycon" 3 | version = "0.1.0" 4 | description = "VR Joy-Con OSC Connector" 5 | authors = ["python1320 "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = ">=3.11,<3.12" 10 | coloredlogs = "^15.0.1" 11 | PyGLM = "^2.7.0" 12 | python-osc = "^1.8.1" 13 | aioconsole = "^0.6.1" 14 | configobj = "^5.0.8" 15 | hid = "^1.0.5" 16 | 17 | [tool.poetry.group.dev.dependencies] 18 | pyinstaller = "^5.9.0" 19 | black-with-tabs = "^22.10.0" 20 | pyright = "^1.1.302" 21 | 22 | [build-system] 23 | requires = ["poetry-core"] 24 | build-backend = "poetry.core.masonry.api" 25 | 26 | [tool.pyright] 27 | include = ["src"] 28 | exclude = ["**/node_modules", 29 | "**/__pycache__", 30 | "src/experimental", 31 | "src/typestubs" 32 | ] -------------------------------------------------------------------------------- /src/config.ini: -------------------------------------------------------------------------------- 1 | [help] 2 | help= 3 | this config has many parts, good luck 4 | 5 | section:osc_output controller events out 6 | 7 | section:listen osc input (haptics) 8 | 9 | section:controllers populated automatically on first start 10 | controllerid = serial of your controller 11 | 12 | section:osc.rumble needs configuration for osc listen rumble operation: controllerid=oscpath 13 | 14 | [main] 15 | verbose = 0 16 | debug = 0 17 | autorestart = 1 18 | notele = 0 19 | 20 | [listen] 21 | port = 9001 22 | ip = 0.0.0.0 23 | 24 | [osc_output] 25 | enabled=0 26 | port = 9007 27 | ip = 10.0.6.130 28 | frequency = 0.1 29 | 30 | [osc_output.1] 31 | analog-sticks.horizontal = float_remap:0:4096:-1:1:/input/Horizontal 32 | analog-sticks.vertical = float_remap:0:4096:-1:1:/input/Vertical 33 | buttons.up = /button/b 34 | buttons.down = /button/a 35 | buttons.l =/button/grab 36 | buttons.zl = /button/trigger 37 | buttons.capture = /button/menu 38 | buttons.sl = toggle:joytoggler:bool:/button/joy 39 | buttons.l-stick = /button/joy 40 | 41 | 42 | [osc_output.2] 43 | analog-sticks.horizontal = float_remap:0:4096:-1:1:/input/Horizontal 44 | analog-sticks.vertical = float_remap:0:4096:-1:1:/input/Vertical 45 | buttons.a = /input/Jump 46 | buttons.b = /input/Jump 47 | 48 | [opengloves] 49 | enable = 1 50 | 51 | [controllers] 52 | 1 = 98:b6:af:53:c9:ca 53 | 2 = 98:b6:af:d7:d6:27 54 | 55 | [osc.rumble] 56 | 1 = /avatar/parameters/joyconrumble1 57 | /avatar/parameters/RightEar_IsGrabbed 58 | /brr1 59 | 2 = /avatar/parameters/joyconrumble2 60 | /avatar/parameters/LeftEar_IsGrabbed 61 | /brr2 62 | 3 = supports more than two controllers in theory 63 | 64 | [relay] 65 | port = -1 66 | ip = 127.0.0.1 67 | 68 | [relay.headhaptics] 69 | port = 1234 70 | ip = 10.200.0.2 71 | /brr = /brr -------------------------------------------------------------------------------- /src/hidapi.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python1320/vrcjoycon/c63264e9c9bc42ffbe725547cefcd4442bacc6f4/src/hidapi.dll -------------------------------------------------------------------------------- /src/hidapi.lib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python1320/vrcjoycon/c63264e9c9bc42ffbe725547cefcd4442bacc6f4/src/hidapi.lib -------------------------------------------------------------------------------- /src/hidapi.pdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python1320/vrcjoycon/c63264e9c9bc42ffbe725547cefcd4442bacc6f4/src/hidapi.pdb -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from configparser import ConfigParser 4 | import coloredlogs, logging, os, sys, ctypes 5 | from pathlib import Path 6 | 7 | log = logging.getLogger("VRCJOYCON") 8 | configpath = Path("config.ini") 9 | if not configpath.exists(): 10 | configpath = Path("src/config.ini") 11 | if not configpath.exists(): 12 | configpath = Path("../config.ini") 13 | if not configpath.exists(): 14 | log.critical("Could not find config.ini") 15 | 16 | 17 | def setup_config(): 18 | global config, debug, verbose 19 | 20 | config = ConfigParser(default_section="DONOTUSE") 21 | config.read(configpath) 22 | 23 | debug = config.getboolean("main", "debug") 24 | verbose = config.getboolean("main", "verbose") 25 | coloredlogs.install(level=logging.DEBUG if debug else logging.INFO) 26 | if debug: 27 | log.debug("DEBUG ON") 28 | 29 | 30 | setup_config() 31 | 32 | if sys.platform == "win32": 33 | os.system("title ChilloutVR/VRChat Joy-Con OSC Connector") 34 | try: 35 | ctypes.cdll.LoadLibrary(r"hidapi.dll") 36 | except (FileNotFoundError,ImportError): 37 | try: 38 | ctypes.cdll.LoadLibrary(r"./hidapi.dll") 39 | except (FileNotFoundError,ImportError): 40 | pass 41 | else: 42 | print("\33]0;ChilloutVR/VRChat Joy-Con OSC Connector\a") 43 | 44 | 45 | from pyjoycon import JoyCon, get_device_ids, joycon 46 | from pythonosc import dispatcher, osc_server 47 | from pythonosc.udp_client import SimpleUDPClient 48 | 49 | from functools import reduce 50 | import operator 51 | 52 | # from pprint import pprint 53 | import asyncio, argparse, sys, typing, functools 54 | 55 | # from contextlib import suppress 56 | # from asynccmd import Cmd 57 | from aioconsole import AsynchronousCli 58 | 59 | 60 | 61 | autorestart = config.getboolean("main", "autorestart") 62 | notele = config.getboolean("main", "notele") 63 | log.debug("Debug: %s", debug) 64 | log.debug("verbose: %s", verbose) 65 | log.debug("autorestart: %s", autorestart) 66 | log.debug("notele: %s", notele) 67 | listen_ip = config["listen"]["ip"] 68 | listen_port = int(config["listen"]["port"]) 69 | 70 | osc_output = SimpleUDPClient(config["osc_output"]["ip"],int(config["osc_output"]["port"])) if config["osc_output"]["enabled"] else None 71 | 72 | relay = config["relay"] 73 | relay_port = int(relay["port"]) 74 | controllers = config["controllers"] 75 | if verbose: 76 | for key in controllers: 77 | print("Finding controller ", key) 78 | 79 | 80 | def config_save(): 81 | with configpath.open("w") as configfile: 82 | config.write(configfile) 83 | 84 | 85 | shutdown_everything = False 86 | 87 | 88 | parser = argparse.ArgumentParser(description="VRChat Joy-Con OSC Connector") 89 | args = parser.parse_args() 90 | 91 | def map_range(x, in_min, in_max, out_min, out_max): 92 | return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min 93 | 94 | def map_float_remap(path): 95 | data=path.split(":",maxsplit=5) 96 | myid=data[0] 97 | if myid != "float_remap": 98 | return None 99 | (in_min, in_max, out_min, out_max)=map(float,data[1:5]) 100 | osc_path=data[5] 101 | 102 | def remapper(input): 103 | return map_range(input,in_min, in_max, out_min, out_max) 104 | return (remapper,osc_path) 105 | 106 | def map_int_remap(path): 107 | data=path.split(":",maxsplit=5) 108 | myid=data[0] 109 | if myid != "int_remap": 110 | return None 111 | (in_min, in_max, out_min, out_max)=map(float,data[1:5]) 112 | osc_path=data[5] 113 | 114 | def remapper(input): 115 | return int(map_range(input,in_min, in_max, out_min, out_max)) 116 | return (remapper,osc_path) 117 | 118 | def map_float(path): 119 | data=path.split(":",maxsplit=1) 120 | myid=data[0] 121 | if myid != "float": 122 | return None 123 | osc_path=data[1] 124 | 125 | def remapper(input): 126 | return float(input) 127 | 128 | return (remapper,osc_path) 129 | 130 | def map_int(path): 131 | data=path.split(":",maxsplit=1) 132 | myid=data[0] 133 | if myid != "int": 134 | return None 135 | osc_path=data[1] 136 | 137 | def remapper(input): 138 | return int(input) 139 | 140 | return (remapper,osc_path) 141 | 142 | uniqueifiers={} 143 | def map_toggle(path): 144 | data=path.split(":",maxsplit=3) 145 | myid=data[0] 146 | if myid != "toggle": 147 | return None 148 | (myid,uniqueifier,output_type,osc_path)=data 149 | uniqueifiers[uniqueifier]=[False,True] 150 | def remapper(input): 151 | input=bool(input) 152 | 153 | toggledata=uniqueifiers[uniqueifier] 154 | (toggle,released_key)=toggledata 155 | 156 | if input: 157 | if released_key: 158 | toggle=not toggle 159 | toggledata[0]=toggle 160 | toggledata[1]=False 161 | elif not released_key: 162 | toggledata[1]=True 163 | 164 | if output_type=='bool': 165 | return bool(toggle) 166 | elif output_type=='int': 167 | return int(toggle) 168 | elif output_type=='float': 169 | return float(toggle) 170 | else: 171 | log.error(f"INVALID OUTPUT TYPE (from config.ini): {output_type}") 172 | return bool(toggle) 173 | 174 | return (remapper,osc_path) 175 | 176 | def map_bool(path): 177 | data=path.split(":",maxsplit=1) 178 | myid=data[0] 179 | if myid != "bool": 180 | return None 181 | osc_path=data[1] 182 | 183 | def remapper(input): 184 | return bool(input) 185 | 186 | return (remapper,osc_path) 187 | 188 | REMAPPERS = [map_toggle,map_float_remap,map_int_remap,map_float,map_int,map_bool] 189 | 190 | class JoyConX(JoyCon): 191 | 192 | current_rumble = False 193 | rumble_event = None 194 | status_getters = False 195 | 196 | def __init__(self, osc_outputs, *identifiers): 197 | super().__init__(*identifiers) 198 | self.last_gyro_x = 0 199 | self.last_a = 0 200 | self.rumble_event = asyncio.Event() 201 | self.osc_output = osc_output 202 | self._build_osc(osc_outputs) 203 | if osc_outputs: 204 | self.register_update_hook(lambda _: self.on_update_thread()) 205 | 206 | def _build_osc(self,osc_outputs): 207 | self.osc_outputs = [] 208 | status_getters = self.get_status_getters() 209 | for controller_path,osc_definition in osc_outputs.items(): 210 | mapper=None 211 | osc_path = osc_definition 212 | 213 | for remapper in REMAPPERS: 214 | ret = remapper(osc_definition) 215 | if ret: 216 | log.info("remapping as requested: %s",osc_definition) 217 | assert not mapper,"two mappers taking ownership" 218 | (mapper,osc_path) = ret 219 | 220 | controller_path = controller_path.split(".") 221 | try: 222 | getter = reduce(operator.getitem, controller_path, status_getters) 223 | if not callable(getter): 224 | log.error("Omitting %s for %s: not a function",controller_path,id) 225 | continue 226 | except KeyError as e: 227 | log.error("Omitting %s for %s",controller_path,id) 228 | log.error(e) 229 | self.osc_outputs.append([None,osc_path,getter,mapper]) 230 | # TODO: not needed? asyncio.run_coroutine_threadsafe() 231 | 232 | def set_rumble(self, f: float): 233 | self.rumble_event.set() 234 | self.current_rumble = f 235 | 236 | def on_update_thread(self): 237 | # print(self.serial,self.get_gyro_x()) 238 | for data in self.osc_outputs: 239 | (prev,osc_path,getter,mapper) = data 240 | if mapper: 241 | val = mapper(getter()) 242 | else: 243 | val = getter() 244 | if prev != val: 245 | data[0] = val 246 | log.debug(f"send {osc_path} <- {val}") 247 | self.osc_output.send_message(osc_path,val) 248 | 249 | def get_status_getters(self) -> dict: 250 | if self.status_getters: 251 | return self.status_getters 252 | add_buttons = { 253 | "y": self.get_button_y, 254 | "x": self.get_button_x, 255 | "b": self.get_button_b, 256 | "a": self.get_button_a, 257 | "sr": self.get_button_right_sr, 258 | "sl": self.get_button_right_sl, 259 | "r": self.get_button_r, 260 | "zr": self.get_button_zr, 261 | } if self.is_right() else { 262 | "down": self.get_button_down, 263 | "up": self.get_button_up, 264 | "right": self.get_button_right, 265 | "left": self.get_button_left, 266 | "sr": self.get_button_left_sr, 267 | "sl": self.get_button_left_sl, 268 | "l": self.get_button_l, 269 | "zl": self.get_button_zl, 270 | } 271 | buttons = { 272 | "minus": self.get_button_minus, 273 | "plus": self.get_button_plus, 274 | "r-stick": self.get_button_r_stick, 275 | "l-stick": self.get_button_l_stick, 276 | "home": self.get_button_home, 277 | "capture": self.get_button_capture, 278 | "charging-grip": self.get_button_charging_grip, 279 | } 280 | buttons.update(add_buttons) 281 | self.status_getters = { 282 | "battery": { 283 | "charging": self.get_battery_charging, 284 | "level": self.get_battery_level, 285 | }, 286 | "buttons": buttons, 287 | "analog-sticks": { 288 | "horizontal": self.get_stick_left_horizontal, 289 | "vertical": self.get_stick_left_vertical, 290 | } if self.is_left() else { 291 | "horizontal": self.get_stick_right_horizontal, 292 | "vertical": self.get_stick_right_vertical, 293 | }, 294 | "accel": { 295 | "x": self.get_accel_x, 296 | "y": self.get_accel_y, 297 | "z": self.get_accel_z, 298 | }, 299 | "gyro": { 300 | "x": self.get_gyro_x, 301 | "y": self.get_gyro_y, 302 | "z": self.get_gyro_z, 303 | }, 304 | } 305 | return self.status_getters 306 | 307 | 308 | joycons: typing.Dict[str, JoyConX | None] = { 309 | id: None for id, serial in controllers.items() 310 | } 311 | # lock = threading.Lock() 312 | 313 | 314 | async def joycon_worker(joycon_serial, id): 315 | for i in range(13): 316 | if shutdown_everything: 317 | break 318 | joycon_id = False 319 | 320 | while not joycon_id: 321 | # TODO: move to thread spawning thread 322 | 323 | for vendor_id, product_id, serial in get_device_ids(): 324 | if serial == joycon_serial: 325 | joycon_id = (vendor_id, product_id, serial) 326 | break 327 | 328 | if joycon_id: 329 | assert joycon_id[0] 330 | break 331 | if verbose: 332 | log.debug(f"Finding {id}") 333 | await asyncio.sleep(0.4421 if not verbose else 2.3) 334 | 335 | name = "Controller-" + str(id) 336 | log.info("Found JoyCon %s (%s)\n", name, joycon_id) 337 | 338 | osc_output_id = "osc_output."+str(id) 339 | osc_outputs = config[osc_output_id] if osc_output_id in config else None 340 | joycon = JoyConX(osc_outputs,*joycon_id) 341 | joycons[id] = joycon 342 | 343 | 344 | joycon.set_player_lamp_on( 345 | (int(id) + 1) % 3 + 1 346 | ) # required to keep fake controller running 347 | 348 | log.debug("Testing vibration") 349 | await asyncio.sleep(1) 350 | 351 | joycon.rumble_simple() 352 | await asyncio.sleep(1.5) 353 | 354 | joycon.rumble_simple() 355 | await asyncio.sleep(0.5) 356 | 357 | joycon.rumble_stop() 358 | log.debug("Vibrated") 359 | if notele: 360 | await asyncio.sleep(0.02) 361 | 362 | await asyncio.sleep(0.02) 363 | joycon._write_output_report(b"\x01", b"\x03", b"\x00") 364 | 365 | while not shutdown_everything and joycon.connected(): 366 | await joycon.rumble_event.wait() 367 | 368 | status = joycon.current_rumble 369 | if status: 370 | 371 | joycon.rumble_simple() 372 | if verbose: 373 | # too spammy even for debug 374 | log.debug("Vibrating %s @ %s", name, str(status)) 375 | 376 | await asyncio.sleep(0.35) 377 | 378 | elif status is not False: 379 | joycon.current_rumble = False 380 | log.debug("STOP %s", name) 381 | joycon.rumble_event.clear() 382 | joycon.rumble_stop() 383 | 384 | log.error("LOST JOYCON %s", name) 385 | 386 | del joycon 387 | joycons[id] = False 388 | log.critical(f"TOO MANY FAILURES, CLOSING {id}") 389 | 390 | 391 | controller_tasks = [] 392 | server_osc: osc_server.AsyncIOOSCUDPServer = None 393 | osc_relayer: SimpleUDPClient = None 394 | 395 | #TODO: untested 396 | def gen_relay(relayinfo,dispatcher): 397 | relay = SimpleUDPClient(relayinfo["ip"],int(relayinfo["port"])) 398 | remappings={k:v for k,v in relayinfo.items() if k!="ip" and k!="port"} 399 | for map_from,map_to in remappings.items(): 400 | log.info(" - Relaying '%s' as '%s'",map_from,map_to) 401 | dispatcher.map(map_from, (lambda map_to=map_to: lambda _,*args: relay.send_message(map_to,args))() ) 402 | 403 | async def startOSC(loop): 404 | global server_osc, osc_relayer 405 | 406 | def default_handler(key, *vals): 407 | if osc_relayer: 408 | osc_relayer.send_message(key, vals) 409 | if verbose: 410 | logging.error("osc received unhandled: %s %s", key, vals) 411 | 412 | def osc_rumble(id, address, *args): 413 | log.debug( 414 | "osc_rumble(id=%s,address=%s,*args=%s)", str(id), str(address), str(args) 415 | ) 416 | j: JoyConX | None = joycons.get(id, None) 417 | if not j: 418 | log.debug("CANNOT RUMBLE CONTROLLER %s (does not exist)", id) 419 | return 420 | 421 | val = 0 422 | if len(args) > 0: 423 | val = args[0] 424 | if isinstance(val, list): 425 | if len(val) == 0: 426 | log.error(f"Unhandled {id} {address} {val}") 427 | return 428 | val = val[0] 429 | 430 | rumble_strength = 0 431 | if type(val) == int or type(val) == float: 432 | rumble_strength = val 433 | elif type(val) == bool: 434 | rumble_strength = 1 if val else 0 435 | else: 436 | log.error("Unknown type received") 437 | 438 | log.info(f"JoyCon {id}: set_rumble(rumble_strength={rumble_strength})") 439 | j.set_rumble(rumble_strength) 440 | 441 | d = dispatcher.Dispatcher() 442 | 443 | for id, osc_paths in config["osc.rumble"].items(): 444 | for osc_path in osc_paths.splitlines(): 445 | # def handler(address,*args): 446 | # osc_rumble(id,address,*args) 447 | 448 | # Captures id 449 | handler = ( 450 | lambda id: lambda address, *args: osc_rumble(id, address, *args) 451 | )(id) 452 | d.map(osc_path, handler) # We could use the extra param here also 453 | 454 | log.debug("Map %s <- %s", id, osc_path) 455 | if relay_port > 0: 456 | d.set_default_handler(default_handler) 457 | 458 | server_osc = osc_server.AsyncIOOSCUDPServer((listen_ip, listen_port), d, loop) 459 | log.info(f"[osc_server] listen_ip={listen_ip} listen_port={listen_port}") 460 | ( 461 | transport, 462 | protocol, 463 | ) = ( 464 | await server_osc.create_serve_endpoint() 465 | ) # Create datagram endpoint and start serving 466 | 467 | if relay_port > 0: 468 | osc_relayer = SimpleUDPClient(relay["ip"], relay_port) 469 | log.info( 470 | "Relaying other messages to {} on port {}".format(relay["ip"], relay_port) 471 | ) 472 | 473 | for relayinfo in [config[sect] for sect in config.sections() if sect.startswith("relay.")]: 474 | if int(relayinfo["port"])>0: 475 | log.info("Creating relay to %s port %s",relayinfo["ip"],relayinfo["port"]) 476 | gen_relay(relayinfo,d) 477 | 478 | 479 | 480 | def watchdog(): 481 | while True: 482 | anyAlive = False 483 | for t in controller_tasks: 484 | t.join(0.2) 485 | if t.is_alive(): 486 | anyAlive = True 487 | if not anyAlive: 488 | global shutdown_everything 489 | shutdown_everything = True 490 | log.critical("Lost all JoyCon threads") 491 | # print("\nFATAL: Lost all JoyCon threads\n") 492 | return 493 | """ 494 | 495 | hadAny=False 496 | anyAlive=False 497 | for id,c in enumerate(joycons): 498 | if c==False: 499 | continue 500 | hadAny=True 501 | 502 | # TODO: Race condition here? 503 | c._update_input_report_thread.join(0.2) 504 | if c._update_input_report_thread.is_alive(): 505 | anyAlive=True 506 | 507 | if hadAny and not anyAlive: 508 | server.shutdown() 509 | print("\nFATAL: Lost all JoyCons\n") 510 | return 511 | 512 | """ 513 | 514 | 515 | async def startJoyCons(): 516 | log.info( 517 | "Attempting to locate joycons. Known controllers: %s", 518 | {k: v for k, v in controllers.items()}, 519 | ) 520 | if len(controllers) == 0 or False: 521 | print("ATTENTION: Start all joycons (to discover serial IDs") 522 | print("THEN: modify config.ini to set up proper ids for your osc...") 523 | found = [] 524 | while not shutdown_everything: 525 | 526 | for vendor_id, product_id, serial in get_device_ids(): 527 | if serial not in found: 528 | found.append(serial) 529 | id = len(found) 530 | controllers[str(id)] = serial 531 | print( 532 | "Saving found: ", 533 | f"vendor_id={vendor_id}, product_id={product_id}, serial={serial} id={id}", 534 | "LEFT" if product_id == 0x2006 else "RIGHT", 535 | ) 536 | print("Type exit and press enter to save! Then restart vrcjoycon.") 537 | config_save() 538 | 539 | sys.stdout.write(".") 540 | sys.stdout.flush() 541 | await asyncio.sleep(2.123) 542 | print("Stopping joycon discovery") 543 | return 544 | 545 | for id, serial in controllers.items(): 546 | # x = threading.Thread(target=joycon_worker, 547 | # args=(serial, id), daemon=True,name="joyconController"+str(id)) 548 | # x.start() 549 | x = asyncio.create_task(joycon_worker(serial, id),name="JoyConController-"+str(id)) 550 | 551 | controller_tasks.append(x) 552 | 553 | # Watchdog 554 | # x = threading.Thread(target=watchdog, 555 | # args=(), daemon=True,name="watchdog") 556 | # x.start() 557 | 558 | 559 | do_status_args = argparse.ArgumentParser(description="Random status info") 560 | 561 | import pprint 562 | async def do_status(reader, writer): 563 | for id,con in joycons.items(): 564 | print("JOYCON: ",id,"=",con) 565 | if con: 566 | print(" Connected=",con.connected(),"status=") 567 | pprint.pprint(con.get_status()) 568 | 569 | 570 | 571 | do_exit_args = argparse.ArgumentParser(description="Exits") 572 | 573 | 574 | async def do_exit(reader, writer): 575 | log.warning("Setting shutdown_everything=True ...") 576 | global shutdown_everything 577 | shutdown_everything = True 578 | 579 | 580 | 581 | do_tasks_args = argparse.ArgumentParser(description="List tasks") 582 | 583 | 584 | async def do_tasks(reader, writer): 585 | for task in asyncio.all_tasks(): 586 | print(task) 587 | 588 | do_vibrate_args = argparse.ArgumentParser(description="Vibrates a controller") 589 | do_vibrate_args.add_argument("id", nargs='?',default="1", help="controller id") 590 | 591 | 592 | async def do_vibrate(reader, writer, id=1): 593 | log.warning(f"vibrating {id}") 594 | joycon = joycons.get(id) 595 | if joycon: 596 | joycon.set_rumble(1) 597 | await asyncio.sleep(3) 598 | joycon.set_rumble(0) 599 | else: 600 | writer.write("no such joycon\n") 601 | 602 | 603 | CLI_CMDS = {"vibrate": (do_vibrate, do_vibrate_args), "status": (do_status, do_status_args), "exit": (do_exit, do_exit_args), "tasks": (do_tasks, do_tasks_args)} 604 | 605 | async def startCLI(loop): 606 | 607 | cli = AsynchronousCli(CLI_CMDS, prog="cvrcjoycon", loop=loop) 608 | await cli.interact() 609 | 610 | async def amain(): 611 | 612 | loop = asyncio.get_event_loop() 613 | 614 | await startOSC(loop) 615 | 616 | 617 | jcstarter=asyncio.create_task(startJoyCons(),name="startJoyCons") 618 | clitask=asyncio.create_task(startCLI(loop),name="CLI") 619 | 620 | while not shutdown_everything: 621 | for task in controller_tasks: 622 | try: 623 | task.result() 624 | except asyncio.InvalidStateError: 625 | pass 626 | try: 627 | jcstarter.result() 628 | except asyncio.InvalidStateError: 629 | pass 630 | 631 | await asyncio.wait([clitask],timeout=1.234) 632 | 633 | log.info("shutdown_everything=1") 634 | for task in asyncio.all_tasks(): 635 | task.cancel() 636 | 637 | # cmd = Commander(intro="===== VR JoyCon Command Prompt =====", prompt="vrcjoycon> ") 638 | # cmd.start(loop) 639 | 640 | 641 | if __name__ == "__main__": 642 | try: 643 | asyncio.run(amain(),debug=debug) 644 | except SystemExit as e: 645 | pass 646 | except Exception as e: 647 | if verbose or debug: 648 | 649 | try: 650 | #TODO: BUG: aioconsole likely closes stdin/stderr/etc... 651 | input("SHUTTING DOWN. PRESS ENTER TO CLOSE.") 652 | except Exception: 653 | pass 654 | 655 | raise 656 | 657 | # server.serve_forever() 658 | -------------------------------------------------------------------------------- /src/pyjoycon/__init__.py: -------------------------------------------------------------------------------- 1 | from .joycon import JoyCon 2 | from .wrappers import PythonicJoyCon # as JoyCon 3 | from .gyro import GyroTrackingJoyCon 4 | from .event import ButtonEventJoyCon 5 | from .device import get_device_ids, get_ids_of_type 6 | from .device import is_id_L 7 | from .device import get_R_ids, get_L_ids 8 | from .device import get_R_id, get_L_id 9 | 10 | 11 | __version__ = "0.2.4" 12 | 13 | __all__ = [ 14 | "ButtonEventJoyCon", 15 | "GyroTrackingJoyCon", 16 | "JoyCon", 17 | "PythonicJoyCon", 18 | "get_L_id", 19 | "get_L_ids", 20 | "get_R_id", 21 | "get_R_ids", 22 | "get_device_ids", 23 | "get_ids_of_type", 24 | "is_id_L", 25 | ] 26 | -------------------------------------------------------------------------------- /src/pyjoycon/constants.py: -------------------------------------------------------------------------------- 1 | JOYCON_VENDOR_ID = 0x057E 2 | JOYCON_L_PRODUCT_ID = 0x2006 3 | JOYCON_R_PRODUCT_ID = 0x2007 4 | JOYCON_PRODUCT_IDS = (JOYCON_L_PRODUCT_ID, JOYCON_R_PRODUCT_ID) 5 | -------------------------------------------------------------------------------- /src/pyjoycon/device.py: -------------------------------------------------------------------------------- 1 | import hid 2 | from .constants import JOYCON_VENDOR_ID, JOYCON_PRODUCT_IDS 3 | from .constants import JOYCON_L_PRODUCT_ID, JOYCON_R_PRODUCT_ID 4 | 5 | 6 | def get_device_ids(debug=False): 7 | """ 8 | returns a list of tuples like `(vendor_id, product_id, serial_number)` 9 | """ 10 | devices = hid.enumerate(0, 0) 11 | 12 | out = [] 13 | for device in devices: 14 | vendor_id = device["vendor_id"] 15 | product_id = device["product_id"] 16 | product_string = device["product_string"] 17 | serial = device.get('serial') or device.get("serial_number") 18 | 19 | if vendor_id != JOYCON_VENDOR_ID: 20 | continue 21 | if product_id not in JOYCON_PRODUCT_IDS: 22 | continue 23 | if not product_string: 24 | continue 25 | 26 | out.append((vendor_id, product_id, serial)) 27 | 28 | if debug: 29 | print(product_string) 30 | print(f"\tvendor_id is {vendor_id!r}") 31 | print(f"\tproduct_id is {product_id!r}") 32 | print(f"\tserial is {serial!r}") 33 | 34 | return out 35 | 36 | 37 | def is_id_L(id): 38 | return id[1] == JOYCON_L_PRODUCT_ID 39 | 40 | 41 | def get_ids_of_type(lr, **kw): 42 | """ 43 | returns a list of tuples like `(vendor_id, product_id, serial_number)` 44 | 45 | arg: lr : str : put `R` or `L` 46 | """ 47 | if lr.lower() == "l": 48 | product_id = JOYCON_L_PRODUCT_ID 49 | else: 50 | product_id = JOYCON_R_PRODUCT_ID 51 | return [i for i in get_device_ids(**kw) if i[1] == product_id] 52 | 53 | 54 | def get_R_ids(**kw): 55 | """returns a list of tuple like `(vendor_id, product_id, serial_number)`""" 56 | return get_ids_of_type("R", **kw) 57 | 58 | 59 | def get_L_ids(**kw): 60 | """returns a list of tuple like `(vendor_id, product_id, serial_number)`""" 61 | return get_ids_of_type("L", **kw) 62 | 63 | 64 | def get_R_id(**kw): 65 | """returns a tuple like `(vendor_id, product_id, serial_number)`""" 66 | ids = get_R_ids(**kw) 67 | if not ids: 68 | return (None, None, None) 69 | return ids[0] 70 | 71 | 72 | def get_L_id(**kw): 73 | """returns a tuple like `(vendor_id, product_id, serial_number)`""" 74 | ids = get_L_ids(**kw) 75 | if not ids: 76 | return (None, None, None) 77 | return ids[0] 78 | -------------------------------------------------------------------------------- /src/pyjoycon/event.py: -------------------------------------------------------------------------------- 1 | from .wrappers import PythonicJoyCon 2 | 3 | 4 | class ButtonEventJoyCon(PythonicJoyCon): 5 | def __init__(self, *args, track_sticks=False, **kwargs): 6 | super().__init__(*args, **kwargs) 7 | 8 | self._events_buffer = [] # TODO: perhaps use a deque instead? 9 | 10 | self._event_handlers = {} 11 | self._event_track_sticks = track_sticks 12 | 13 | self._previous_stick_l_btn = 0 14 | self._previous_stick_r_btn = 0 15 | self._previous_stick_r = self._previous_stick_l = (0, 0) 16 | self._previous_r = self._previous_l = 0 17 | self._previous_zr = self._previous_zl = 0 18 | self._previous_plus = self._previous_minus = 0 19 | self._previous_a = self._previous_right = 0 20 | self._previous_b = self._previous_down = 0 21 | self._previous_x = self._previous_up = 0 22 | self._previous_y = self._previous_left = 0 23 | self._previous_home = self._previous_capture = 0 24 | self._previous_right_sr = self._previous_left_sr = 0 25 | self._previous_right_sl = self._previous_left_sl = 0 26 | 27 | if self.is_left(): 28 | self.register_update_hook(self._event_tracking_update_hook_left) 29 | else: 30 | self.register_update_hook(self._event_tracking_update_hook_right) 31 | 32 | def joycon_button_event(self, button, state): # overridable 33 | self._events_buffer.append((button, state)) 34 | 35 | def events(self): 36 | while self._events_buffer: 37 | yield self._events_buffer.pop(0) 38 | 39 | @staticmethod 40 | def _event_tracking_update_hook_right(self): 41 | if self._event_track_sticks: 42 | pressed = self.stick_r_btn 43 | if self._previous_stick_r_btn != pressed: 44 | self._previous_stick_r_btn = pressed 45 | self.joycon_button_event("stick_r_btn", pressed) 46 | pressed = self.r 47 | if self._previous_r != pressed: 48 | self._previous_r = pressed 49 | self.joycon_button_event("r", pressed) 50 | pressed = self.zr 51 | if self._previous_zr != pressed: 52 | self._previous_zr = pressed 53 | self.joycon_button_event("zr", pressed) 54 | pressed = self.plus 55 | if self._previous_plus != pressed: 56 | self._previous_plus = pressed 57 | self.joycon_button_event("plus", pressed) 58 | pressed = self.a 59 | if self._previous_a != pressed: 60 | self._previous_a = pressed 61 | self.joycon_button_event("a", pressed) 62 | pressed = self.b 63 | if self._previous_b != pressed: 64 | self._previous_b = pressed 65 | self.joycon_button_event("b", pressed) 66 | pressed = self.x 67 | if self._previous_x != pressed: 68 | self._previous_x = pressed 69 | self.joycon_button_event("x", pressed) 70 | pressed = self.y 71 | if self._previous_y != pressed: 72 | self._previous_y = pressed 73 | self.joycon_button_event("y", pressed) 74 | pressed = self.home 75 | if self._previous_home != pressed: 76 | self._previous_home = pressed 77 | self.joycon_button_event("home", pressed) 78 | pressed = self.right_sr 79 | if self._previous_right_sr != pressed: 80 | self._previous_right_sr = pressed 81 | self.joycon_button_event("right_sr", pressed) 82 | pressed = self.right_sl 83 | if self._previous_right_sl != pressed: 84 | self._previous_right_sl = pressed 85 | self.joycon_button_event("right_sl", pressed) 86 | 87 | @staticmethod 88 | def _event_tracking_update_hook_left(self): 89 | if self._event_track_sticks: 90 | pressed = self.stick_l_btn 91 | if self._previous_stick_l_btn != pressed: 92 | self._previous_stick_l_btn = pressed 93 | self.joycon_button_event("stick_l_btn", pressed) 94 | pressed = self.l 95 | if self._previous_l != pressed: 96 | self._previous_l = pressed 97 | self.joycon_button_event("l", pressed) 98 | pressed = self.zl 99 | if self._previous_zl != pressed: 100 | self._previous_zl = pressed 101 | self.joycon_button_event("zl", pressed) 102 | pressed = self.minus 103 | if self._previous_minus != pressed: 104 | self._previous_minus = pressed 105 | self.joycon_button_event("minus", pressed) 106 | pressed = self.up 107 | if self._previous_up != pressed: 108 | self._previous_up = pressed 109 | self.joycon_button_event("up", pressed) 110 | pressed = self.down 111 | if self._previous_down != pressed: 112 | self._previous_down = pressed 113 | self.joycon_button_event("down", pressed) 114 | pressed = self.left 115 | if self._previous_left != pressed: 116 | self._previous_left = pressed 117 | self.joycon_button_event("left", pressed) 118 | pressed = self.right 119 | if self._previous_right != pressed: 120 | self._previous_right = pressed 121 | self.joycon_button_event("right", pressed) 122 | pressed = self.capture 123 | if self._previous_capture != pressed: 124 | self._previous_capture = pressed 125 | self.joycon_button_event("capture", pressed) 126 | pressed = self.left_sr 127 | if self._previous_left_sr != pressed: 128 | self._previous_left_sr = pressed 129 | self.joycon_button_event("left_sr", pressed) 130 | pressed = self.left_sl 131 | if self._previous_left_sl != pressed: 132 | self._previous_left_sl = pressed 133 | self.joycon_button_event("left_sl", pressed) 134 | -------------------------------------------------------------------------------- /src/pyjoycon/gyro.py: -------------------------------------------------------------------------------- 1 | from .wrappers import PythonicJoyCon 2 | from glm import vec2, vec3, quat, angleAxis, eulerAngles 3 | from typing import Optional 4 | import time 5 | 6 | 7 | class GyroTrackingJoyCon(PythonicJoyCon): 8 | """ 9 | A specialized class based on PythonicJoyCon which tracks the gyroscope data 10 | and deduces the current rotation of the JoyCon. Can be used to create a 11 | pointer rotate an object or pointin a direction. Comes with the need to be 12 | calibrated. 13 | """ 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, simple_mode=False, **kwargs) 16 | 17 | # set internal state: 18 | self.reset_orientation() 19 | 20 | # register the update callback 21 | self.register_update_hook(self._gyro_update_hook) 22 | 23 | @property 24 | def pointer(self) -> Optional[vec2]: 25 | d = self.direction 26 | if d.x <= 0: 27 | return None 28 | return vec2(d.y, -d.z) / d.x 29 | 30 | @property 31 | def direction(self) -> vec3: 32 | return self.direction_X 33 | 34 | @property 35 | def rotation(self) -> vec3: 36 | return -eulerAngles(self.direction_Q) 37 | 38 | is_calibrating = False 39 | 40 | def calibrate(self, seconds=2): 41 | self.calibration_acumulator = vec3(0) 42 | self.calibration_acumulations = 0 43 | self.is_calibrating = time.time() + seconds 44 | 45 | def _set_calibration(self, gyro_offset=None): 46 | if not gyro_offset: 47 | c = vec3(1, self._ime_yz_coeff, self._ime_yz_coeff) 48 | gyro_offset = self.calibration_acumulator * c 49 | gyro_offset /= self.calibration_acumulations 50 | gyro_offset += vec3( 51 | self._GYRO_OFFSET_X, 52 | self._GYRO_OFFSET_Y, 53 | self._GYRO_OFFSET_Z, 54 | ) 55 | self.is_calibrating = False 56 | self.set_gyro_calibration(gyro_offset) 57 | 58 | def reset_orientation(self): 59 | self.direction_X = vec3(1, 0, 0) 60 | self.direction_Y = vec3(0, 1, 0) 61 | self.direction_Z = vec3(0, 0, 1) 62 | self.direction_Q = quat() 63 | 64 | @staticmethod 65 | def _gyro_update_hook(self): 66 | if self.is_calibrating: 67 | if self.is_calibrating < time.time(): 68 | self._set_calibration() 69 | else: 70 | for xyz in self.gyro: 71 | self.calibration_acumulator += xyz 72 | self.calibration_acumulations += 3 73 | 74 | for gx, gy, gz in self.gyro_in_rad: 75 | # TODO: find out why 1/86 works, and not 1/60 or 1/(60*30) 76 | rotation \ 77 | = angleAxis(gx * (-1/86), self.direction_X) \ 78 | * angleAxis(gy * (-1/86), self.direction_Y) \ 79 | * angleAxis(gz * (-1/86), self.direction_Z) 80 | 81 | self.direction_X *= rotation 82 | self.direction_Y *= rotation 83 | self.direction_Z *= rotation 84 | self.direction_Q *= rotation 85 | -------------------------------------------------------------------------------- /src/pyjoycon/joycon.py: -------------------------------------------------------------------------------- 1 | from .constants import JOYCON_VENDOR_ID, JOYCON_PRODUCT_IDS 2 | from .constants import JOYCON_L_PRODUCT_ID, JOYCON_R_PRODUCT_ID 3 | import hid 4 | import time 5 | import threading 6 | from typing import Optional 7 | 8 | # TODO: disconnect, power off sequence 9 | 10 | 11 | class JoyCon: 12 | _INPUT_REPORT_SIZE = 49 13 | _INPUT_REPORT_PERIOD = 0.015 14 | _RUMBLE_DATA = b'\x00\x01\x40\x40\x00\x01\x40\x40' 15 | 16 | vendor_id : int 17 | product_id : int 18 | serial : Optional[str] 19 | simple_mode: bool 20 | color_body : (int, int, int) 21 | color_btn : (int, int, int) 22 | 23 | def __init__(self, vendor_id: int, product_id: int, serial: str = None, simple_mode=False): 24 | if vendor_id != JOYCON_VENDOR_ID: 25 | raise ValueError(f'vendor_id is invalid: {vendor_id!r}') 26 | 27 | if product_id not in JOYCON_PRODUCT_IDS: 28 | raise ValueError(f'product_id is invalid: {product_id!r}') 29 | 30 | self.vendor_id = vendor_id 31 | self.product_id = product_id 32 | self.serial = serial 33 | self.simple_mode = simple_mode # TODO: It's for reporting mode 0x3f 34 | 35 | # setup internal state 36 | self._input_hooks = [] 37 | self._input_report = bytes(self._INPUT_REPORT_SIZE) 38 | self._packet_number = 0 39 | self.set_accel_calibration((0, 0, 0), (1, 1, 1)) 40 | self.set_gyro_calibration((0, 0, 0), (1, 1, 1)) 41 | 42 | # connect to joycon 43 | self._joycon_device = self._open(vendor_id, product_id, serial=serial) 44 | self._read_joycon_data() 45 | self._setup_sensors() 46 | 47 | # start talking with the joycon in a daemon thread 48 | self._update_input_report_thread \ 49 | = threading.Thread(target=self._update_input_report) 50 | self._update_input_report_thread.setDaemon(True) 51 | self._update_input_report_thread.start() 52 | 53 | def _open(self, vendor_id, product_id, serial): 54 | try: 55 | if hasattr(hid, "device"): # hidapi 56 | _joycon_device = hid.device() 57 | _joycon_device.open(vendor_id, product_id, serial) 58 | elif hasattr(hid, "Device"): # hid 59 | _joycon_device = hid.Device(vendor_id, product_id, serial) 60 | 61 | else: 62 | raise Exception("Implementation of hid is not recognized!") 63 | except IOError as e: 64 | raise IOError('joycon connect failed') from e 65 | return _joycon_device 66 | 67 | def _close(self): 68 | if hasattr(self, "_joycon_device"): 69 | self._joycon_device.close() 70 | del self._joycon_device 71 | 72 | def _read_input_report(self) -> bytes: 73 | data = self._joycon_device.read(self._INPUT_REPORT_SIZE)# notele mode will fail: timeout=1000*5 74 | if not data: 75 | raise TimeoutError("Controller read timed out") 76 | return bytes(data) 77 | 78 | def _write_output_report(self, command, subcommand, argument): 79 | # TODO: add documentation 80 | self._joycon_device.write(b''.join([ 81 | command, 82 | self._packet_number.to_bytes(1, byteorder='little'), 83 | self._RUMBLE_DATA, 84 | subcommand, 85 | argument, 86 | ])) 87 | self._packet_number = (self._packet_number + 1) & 0xF 88 | 89 | def _send_subcmd_get_response(self, subcommand, argument) -> (bool, bytes): 90 | # TODO: handle subcmd when daemon is running 91 | self._write_output_report(b'\x01', subcommand, argument) 92 | 93 | report = self._read_input_report() 94 | while report[0] != 0x21: # TODO, avoid this, await daemon instead 95 | report = self._read_input_report() 96 | 97 | # TODO, remove, see the todo above 98 | assert report[1:2] != subcommand, "THREAD carefully" 99 | 100 | # TODO: determine if the cut bytes are worth anything 101 | 102 | return report[13] & 0x80, report[13:] # (ack, data) 103 | 104 | def _spi_flash_read(self, address, size) -> bytes: 105 | assert size <= 0x1d 106 | argument = address.to_bytes(4, "little") + size.to_bytes(1, "little") 107 | ack, report = self._send_subcmd_get_response(b'\x10', argument) 108 | if not ack: 109 | raise IOError("After SPI read @ {address:#06x}: got NACK") 110 | 111 | if report[:2] != b'\x90\x10': 112 | raise IOError("Something else than the expected ACK was recieved!") 113 | assert report[2:7] == argument, (report[2:5], argument) 114 | 115 | return report[7:size+7] 116 | 117 | def _update_input_report(self): # daemon thread 118 | while True: 119 | report = self._read_input_report() 120 | # TODO, handle input reports of type 0x21 and 0x3f 121 | while report[0] != 0x30: 122 | report = self._read_input_report() 123 | 124 | self._input_report = report 125 | 126 | for callback in self._input_hooks: 127 | callback(self) 128 | 129 | def _read_joycon_data(self): 130 | color_data = self._spi_flash_read(0x6050, 6) 131 | 132 | # TODO: use this 133 | # stick_cal_addr = 0x8012 if self.is_left else 0x801D 134 | # stick_cal = self._spi_flash_read(stick_cal_addr, 8) 135 | 136 | # user IME data 137 | if self._spi_flash_read(0x8026, 2) == b"\xB2\xA1": 138 | # print(f"Calibrate {self.serial} IME with user data") 139 | imu_cal = self._spi_flash_read(0x8028, 24) 140 | 141 | # factory IME data 142 | else: 143 | # print(f"Calibrate {self.serial} IME with factory data") 144 | imu_cal = self._spi_flash_read(0x6020, 24) 145 | 146 | self.color_body = tuple(color_data[:3]) 147 | self.color_btn = tuple(color_data[3:]) 148 | 149 | self.set_accel_calibration(( 150 | self._to_int16le_from_2bytes(imu_cal[ 0], imu_cal[ 1]), 151 | self._to_int16le_from_2bytes(imu_cal[ 2], imu_cal[ 3]), 152 | self._to_int16le_from_2bytes(imu_cal[ 4], imu_cal[ 5]), 153 | ), ( 154 | self._to_int16le_from_2bytes(imu_cal[ 6], imu_cal[ 7]), 155 | self._to_int16le_from_2bytes(imu_cal[ 8], imu_cal[ 9]), 156 | self._to_int16le_from_2bytes(imu_cal[10], imu_cal[11]), 157 | ) 158 | ) 159 | self.set_gyro_calibration(( 160 | self._to_int16le_from_2bytes(imu_cal[12], imu_cal[13]), 161 | self._to_int16le_from_2bytes(imu_cal[14], imu_cal[15]), 162 | self._to_int16le_from_2bytes(imu_cal[16], imu_cal[17]), 163 | ), ( 164 | self._to_int16le_from_2bytes(imu_cal[18], imu_cal[19]), 165 | self._to_int16le_from_2bytes(imu_cal[20], imu_cal[21]), 166 | self._to_int16le_from_2bytes(imu_cal[22], imu_cal[23]), 167 | ) 168 | ) 169 | 170 | def _setup_sensors(self): 171 | # Enable 6 axis sensors 172 | self._write_output_report(b'\x01', b'\x40', b'\x01') 173 | # It needs delta time to update the setting 174 | time.sleep(0.02) 175 | # Change format of input report 176 | self._write_output_report(b'\x01', b'\x03', b'\x30') 177 | 178 | @staticmethod 179 | def _to_int16le_from_2bytes(hbytebe, lbytebe): 180 | uint16le = (lbytebe << 8) | hbytebe 181 | int16le = uint16le if uint16le < 32768 else uint16le - 65536 182 | return int16le 183 | 184 | def _get_nbit_from_input_report(self, offset_byte, offset_bit, nbit): 185 | byte = self._input_report[offset_byte] 186 | return (byte >> offset_bit) & ((1 << nbit) - 1) 187 | 188 | def __del__(self): 189 | self._close() 190 | 191 | def set_gyro_calibration(self, offset_xyz=None, coeff_xyz=None): 192 | if offset_xyz: 193 | self._GYRO_OFFSET_X, \ 194 | self._GYRO_OFFSET_Y, \ 195 | self._GYRO_OFFSET_Z = offset_xyz 196 | if coeff_xyz: 197 | cx, cy, cz = coeff_xyz 198 | self._GYRO_COEFF_X = 0x343b / cx if cx != 0x343b else 1 199 | self._GYRO_COEFF_Y = 0x343b / cy if cy != 0x343b else 1 200 | self._GYRO_COEFF_Z = 0x343b / cz if cz != 0x343b else 1 201 | 202 | def set_accel_calibration(self, offset_xyz=None, coeff_xyz=None): 203 | if offset_xyz: 204 | self._ACCEL_OFFSET_X, \ 205 | self._ACCEL_OFFSET_Y, \ 206 | self._ACCEL_OFFSET_Z = offset_xyz 207 | if coeff_xyz: 208 | cx, cy, cz = coeff_xyz 209 | self._ACCEL_COEFF_X = 0x4000 / cx if cx != 0x4000 else 1 210 | self._ACCEL_COEFF_Y = 0x4000 / cy if cy != 0x4000 else 1 211 | self._ACCEL_COEFF_Z = 0x4000 / cz if cz != 0x4000 else 1 212 | 213 | def register_update_hook(self, callback): 214 | self._input_hooks.append(callback) 215 | return callback # this makes it so you could use it as a decorator 216 | 217 | def is_left(self): 218 | return self.product_id == JOYCON_L_PRODUCT_ID 219 | 220 | def is_right(self): 221 | return self.product_id == JOYCON_R_PRODUCT_ID 222 | 223 | def get_battery_charging(self): 224 | return self._get_nbit_from_input_report(2, 4, 1) 225 | 226 | def get_battery_level(self): 227 | return self._get_nbit_from_input_report(2, 5, 3) 228 | 229 | def get_button_y(self): 230 | return self._get_nbit_from_input_report(3, 0, 1) 231 | 232 | def get_button_x(self): 233 | return self._get_nbit_from_input_report(3, 1, 1) 234 | 235 | def get_button_b(self): 236 | return self._get_nbit_from_input_report(3, 2, 1) 237 | 238 | def get_button_a(self): 239 | return self._get_nbit_from_input_report(3, 3, 1) 240 | 241 | def get_button_right_sr(self): 242 | return self._get_nbit_from_input_report(3, 4, 1) 243 | 244 | def get_button_right_sl(self): 245 | return self._get_nbit_from_input_report(3, 5, 1) 246 | 247 | def get_button_r(self): 248 | return self._get_nbit_from_input_report(3, 6, 1) 249 | 250 | def get_button_zr(self): 251 | return self._get_nbit_from_input_report(3, 7, 1) 252 | 253 | def get_button_minus(self): 254 | return self._get_nbit_from_input_report(4, 0, 1) 255 | 256 | def get_button_plus(self): 257 | return self._get_nbit_from_input_report(4, 1, 1) 258 | 259 | def get_button_r_stick(self): 260 | return self._get_nbit_from_input_report(4, 2, 1) 261 | 262 | def get_button_l_stick(self): 263 | return self._get_nbit_from_input_report(4, 3, 1) 264 | 265 | def get_button_home(self): 266 | return self._get_nbit_from_input_report(4, 4, 1) 267 | 268 | def get_button_capture(self): 269 | return self._get_nbit_from_input_report(4, 5, 1) 270 | 271 | def get_button_charging_grip(self): 272 | return self._get_nbit_from_input_report(4, 7, 1) 273 | 274 | def get_button_down(self): 275 | return self._get_nbit_from_input_report(5, 0, 1) 276 | 277 | def get_button_up(self): 278 | return self._get_nbit_from_input_report(5, 1, 1) 279 | 280 | def get_button_right(self): 281 | return self._get_nbit_from_input_report(5, 2, 1) 282 | 283 | def get_button_left(self): 284 | return self._get_nbit_from_input_report(5, 3, 1) 285 | 286 | def get_button_left_sr(self): 287 | return self._get_nbit_from_input_report(5, 4, 1) 288 | 289 | def get_button_left_sl(self): 290 | return self._get_nbit_from_input_report(5, 5, 1) 291 | 292 | def get_button_l(self): 293 | return self._get_nbit_from_input_report(5, 6, 1) 294 | 295 | def get_button_zl(self): 296 | return self._get_nbit_from_input_report(5, 7, 1) 297 | 298 | def get_stick_left_horizontal(self): 299 | return self._get_nbit_from_input_report(6, 0, 8) \ 300 | | (self._get_nbit_from_input_report(7, 0, 4) << 8) 301 | 302 | def get_stick_left_vertical(self): 303 | return self._get_nbit_from_input_report(7, 4, 4) \ 304 | | (self._get_nbit_from_input_report(8, 0, 8) << 4) 305 | 306 | def get_stick_right_horizontal(self): 307 | return self._get_nbit_from_input_report(9, 0, 8) \ 308 | | (self._get_nbit_from_input_report(10, 0, 4) << 8) 309 | 310 | def get_stick_right_vertical(self): 311 | return self._get_nbit_from_input_report(10, 4, 4) \ 312 | | (self._get_nbit_from_input_report(11, 0, 8) << 4) 313 | 314 | def get_accel_x(self, sample_idx=0): 315 | if sample_idx not in (0, 1, 2): 316 | raise IndexError('sample_idx should be between 0 and 2') 317 | data = self._to_int16le_from_2bytes( 318 | self._input_report[13 + sample_idx * 12], 319 | self._input_report[14 + sample_idx * 12]) 320 | return (data - self._ACCEL_OFFSET_X) * self._ACCEL_COEFF_X 321 | 322 | def get_accel_y(self, sample_idx=0): 323 | if sample_idx not in (0, 1, 2): 324 | raise IndexError('sample_idx should be between 0 and 2') 325 | data = self._to_int16le_from_2bytes( 326 | self._input_report[15 + sample_idx * 12], 327 | self._input_report[16 + sample_idx * 12]) 328 | return (data - self._ACCEL_OFFSET_Y) * self._ACCEL_COEFF_Y 329 | 330 | def get_accel_z(self, sample_idx=0): 331 | if sample_idx not in (0, 1, 2): 332 | raise IndexError('sample_idx should be between 0 and 2') 333 | data = self._to_int16le_from_2bytes( 334 | self._input_report[17 + sample_idx * 12], 335 | self._input_report[18 + sample_idx * 12]) 336 | return (data - self._ACCEL_OFFSET_Z) * self._ACCEL_COEFF_Z 337 | 338 | def get_gyro_x(self, sample_idx=0): 339 | if sample_idx not in (0, 1, 2): 340 | raise IndexError('sample_idx should be between 0 and 2') 341 | data = self._to_int16le_from_2bytes( 342 | self._input_report[19 + sample_idx * 12], 343 | self._input_report[20 + sample_idx * 12]) 344 | return (data - self._GYRO_OFFSET_X) * self._GYRO_COEFF_X 345 | 346 | def get_gyro_y(self, sample_idx=0): 347 | if sample_idx not in (0, 1, 2): 348 | raise IndexError('sample_idx should be between 0 and 2') 349 | data = self._to_int16le_from_2bytes( 350 | self._input_report[21 + sample_idx * 12], 351 | self._input_report[22 + sample_idx * 12]) 352 | return (data - self._GYRO_OFFSET_Y) * self._GYRO_COEFF_Y 353 | 354 | def get_gyro_z(self, sample_idx=0): 355 | if sample_idx not in (0, 1, 2): 356 | raise IndexError('sample_idx should be between 0 and 2') 357 | data = self._to_int16le_from_2bytes( 358 | self._input_report[23 + sample_idx * 12], 359 | self._input_report[24 + sample_idx * 12]) 360 | return (data - self._GYRO_OFFSET_Z) * self._GYRO_COEFF_Z 361 | 362 | def get_status(self) -> dict: 363 | return { 364 | "battery": { 365 | "charging": self.get_battery_charging(), 366 | "level": self.get_battery_level(), 367 | }, 368 | "buttons": { 369 | "right": { 370 | "y": self.get_button_y(), 371 | "x": self.get_button_x(), 372 | "b": self.get_button_b(), 373 | "a": self.get_button_a(), 374 | "sr": self.get_button_right_sr(), 375 | "sl": self.get_button_right_sl(), 376 | "r": self.get_button_r(), 377 | "zr": self.get_button_zr(), 378 | }, 379 | "shared": { 380 | "minus": self.get_button_minus(), 381 | "plus": self.get_button_plus(), 382 | "r-stick": self.get_button_r_stick(), 383 | "l-stick": self.get_button_l_stick(), 384 | "home": self.get_button_home(), 385 | "capture": self.get_button_capture(), 386 | "charging-grip": self.get_button_charging_grip(), 387 | }, 388 | "left": { 389 | "down": self.get_button_down(), 390 | "up": self.get_button_up(), 391 | "right": self.get_button_right(), 392 | "left": self.get_button_left(), 393 | "sr": self.get_button_left_sr(), 394 | "sl": self.get_button_left_sl(), 395 | "l": self.get_button_l(), 396 | "zl": self.get_button_zl(), 397 | } 398 | }, 399 | "analog-sticks": { 400 | "left": { 401 | "horizontal": self.get_stick_left_horizontal(), 402 | "vertical": self.get_stick_left_vertical(), 403 | }, 404 | "right": { 405 | "horizontal": self.get_stick_right_horizontal(), 406 | "vertical": self.get_stick_right_vertical(), 407 | }, 408 | }, 409 | "accel": { 410 | "x": self.get_accel_x(), 411 | "y": self.get_accel_y(), 412 | "z": self.get_accel_z(), 413 | }, 414 | "gyro": { 415 | "x": self.get_gyro_x(), 416 | "y": self.get_gyro_y(), 417 | "z": self.get_gyro_z(), 418 | }, 419 | } 420 | 421 | def set_player_lamp_on(self, on_pattern: int): 422 | self._write_output_report( 423 | b'\x01', b'\x30', 424 | (on_pattern & 0xF).to_bytes(1, byteorder='little')) 425 | 426 | def set_player_lamp_flashing(self, flashing_pattern: int): 427 | self._write_output_report( 428 | b'\x01', b'\x30', 429 | ((flashing_pattern & 0xF) << 4).to_bytes(1, byteorder='little')) 430 | 431 | def set_player_lamp(self, pattern: int): 432 | self._write_output_report( 433 | b'\x01', b'\x30', 434 | pattern.to_bytes(1, byteorder='little')) 435 | 436 | def disconnect_device(self): 437 | self._write_output_report(b'\x01', b'\x06', b'\x04') 438 | 439 | def _send_rumble(self,data=b'\x00\x00\x00\x00\x00\x00\x00\x00'): 440 | self._RUMBLE_DATA = data 441 | self._write_output_report(b'\x10', b'', b'') 442 | 443 | def enable_vibration(self,enable=True): 444 | """Sends enable or disable command for vibration. Seems to do nothing.""" 445 | self._write_output_report(b'\x01', b'\x48', b'\x01' if enable else b'\x00') 446 | 447 | def rumble_simple(self): 448 | """Rumble for approximately 1.5 seconds (why?). Repeat sending to keep rumbling.""" 449 | self._send_rumble(b'\x98\x1e\xc6\x47\x98\x1e\xc6\x47') 450 | 451 | def rumble_stop(self): 452 | """Instantly stops the rumble""" 453 | self._send_rumble() 454 | 455 | def connected(self): 456 | """Are we still connected to the joycon?""" 457 | return self._update_input_report_thread.is_alive() 458 | 459 | 460 | if __name__ == '__main__': 461 | import pyjoycon.device as d 462 | ids = d.get_L_id() if None not in d.get_L_id() else d.get_R_id() 463 | 464 | if None not in ids: 465 | joycon = JoyCon(*ids) 466 | lamp_pattern = 0 467 | while True: 468 | print(joycon.get_status()) 469 | joycon.set_player_lamp_on(lamp_pattern) 470 | lamp_pattern = (lamp_pattern + 1) & 0xf 471 | time.sleep(0.2) 472 | -------------------------------------------------------------------------------- /src/pyjoycon/wrappers.py: -------------------------------------------------------------------------------- 1 | from .joycon import JoyCon 2 | 3 | 4 | # Preferably, this class gets merged into the 5 | # parent class if approved by the original author 6 | class PythonicJoyCon(JoyCon): 7 | """ 8 | A wrapper class for the JoyCon parent class. 9 | This creates a more pythonic interface by 10 | * using properties instead of requiring java-style getters and setters, 11 | * bundles related xy/xyz data in tuples 12 | * bundles the multiple measurements of the 13 | gyroscope and accelerometer into a list 14 | * Adds the option to invert the y and z axis of the left joycon 15 | to make it match the right joycon. This is enabled by default 16 | """ 17 | 18 | def __init__(self, *a, invert_left_ime_yz=True, **kw): 19 | super().__init__(*a, **kw) 20 | self._ime_yz_coeff = -1 if invert_left_ime_yz and self.is_left() else 1 21 | 22 | is_charging = property(JoyCon.get_battery_charging) 23 | battery_level = property(JoyCon.get_battery_level) 24 | 25 | r = property(JoyCon.get_button_r) 26 | zr = property(JoyCon.get_button_zr) 27 | plus = property(JoyCon.get_button_plus) 28 | a = property(JoyCon.get_button_a) 29 | b = property(JoyCon.get_button_b) 30 | x = property(JoyCon.get_button_x) 31 | y = property(JoyCon.get_button_y) 32 | stick_r_btn = property(JoyCon.get_button_r_stick) 33 | home = property(JoyCon.get_button_home) 34 | right_sr = property(JoyCon.get_button_right_sr) 35 | right_sl = property(JoyCon.get_button_right_sl) 36 | 37 | l = property(JoyCon.get_button_l) # noqa: E741 38 | zl = property(JoyCon.get_button_zl) 39 | minus = property(JoyCon.get_button_minus) 40 | stick_l_btn = property(JoyCon.get_button_l_stick) 41 | up = property(JoyCon.get_button_up) 42 | down = property(JoyCon.get_button_down) 43 | left = property(JoyCon.get_button_left) 44 | right = property(JoyCon.get_button_right) 45 | capture = property(JoyCon.get_button_capture) 46 | left_sr = property(JoyCon.get_button_left_sr) 47 | left_sl = property(JoyCon.get_button_left_sl) 48 | 49 | set_led_on = JoyCon.set_player_lamp_on 50 | set_led_flashing = JoyCon.set_player_lamp_flashing 51 | set_led = JoyCon.set_player_lamp 52 | disconnect = JoyCon.disconnect_device 53 | 54 | @property 55 | def stick_l(self): 56 | return ( 57 | self.get_stick_left_horizontal(), 58 | self.get_stick_left_vertical(), 59 | ) 60 | 61 | @property 62 | def stick_r(self): 63 | return ( 64 | self.get_stick_right_horizontal(), 65 | self.get_stick_right_vertical(), 66 | ) 67 | 68 | @property 69 | def accel(self): 70 | c = self._ime_yz_coeff 71 | return [ 72 | ( 73 | self.get_accel_x(i), 74 | self.get_accel_y(i) * c, 75 | self.get_accel_z(i) * c, 76 | ) 77 | for i in range(3) 78 | ] 79 | 80 | @property 81 | def accel_in_g(self): 82 | c = 4.0 / 0x4000 83 | c2 = c * self._ime_yz_coeff 84 | return [ 85 | ( 86 | self.get_accel_x(i) * c, 87 | self.get_accel_y(i) * c2, 88 | self.get_accel_z(i) * c2, 89 | ) 90 | for i in range(3) 91 | ] 92 | 93 | @property 94 | def gyro(self): 95 | c = self._ime_yz_coeff 96 | return [ 97 | ( 98 | self.get_gyro_x(i), 99 | self.get_gyro_y(i) * c, 100 | self.get_gyro_z(i) * c, 101 | ) 102 | for i in range(3) 103 | ] 104 | 105 | @property 106 | def gyro_in_deg(self): 107 | c = 0.06103 108 | c2 = c * self._ime_yz_coeff 109 | return [ 110 | ( 111 | self.get_gyro_x(i) * c, 112 | self.get_gyro_y(i) * c2, 113 | self.get_gyro_z(i) * c2, 114 | ) 115 | for i in range(3) 116 | ] 117 | 118 | @property 119 | def gyro_in_rad(self): 120 | c = 0.0001694 * 3.1415926536 121 | c2 = c * self._ime_yz_coeff 122 | return [ 123 | ( 124 | self.get_gyro_x(i) * c, 125 | self.get_gyro_y(i) * c2, 126 | self.get_gyro_z(i) * c2, 127 | ) 128 | for i in range(3) 129 | ] 130 | 131 | @property 132 | def gyro_in_rot(self): 133 | c = 0.0001694 134 | c2 = c * self._ime_yz_coeff 135 | return [ 136 | ( 137 | self.get_gyro_x(i) * c, 138 | self.get_gyro_y(i) * c2, 139 | self.get_gyro_z(i) * c2, 140 | ) 141 | for i in range(3) 142 | ] 143 | --------------------------------------------------------------------------------