wsljoy 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*.*.*"
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write
11
+
12
+ jobs:
13
+ publish:
14
+ name: Build and publish
15
+ runs-on: ubuntu-latest
16
+ environment: pypi
17
+
18
+ steps:
19
+ - name: Check out repository
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v5
24
+
25
+ - name: Set up Python 3.10
26
+ run: uv python install 3.10
27
+
28
+ - name: Build package
29
+ run: uv build
30
+
31
+ - name: Publish to PyPI
32
+ run: uv publish
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .venv/
7
+ build/
8
+ dist/
9
+ *.egg-info/
@@ -0,0 +1 @@
1
+ 3.10
@@ -0,0 +1,20 @@
1
+ {
2
+ "configurations": [
3
+ {
4
+ "browse": {
5
+ "databaseFilename": "${workspaceFolder}/.vscode/browse.vc.db",
6
+ "limitSymbolsToIncludedHeaders": false
7
+ },
8
+ "includePath": [
9
+ "/opt/ros/humble/include/**",
10
+ "/usr/include/**"
11
+ ],
12
+ "name": "ros2",
13
+ "intelliSenseMode": "gcc-x64",
14
+ "compilerPath": "/usr/bin/gcc",
15
+ "cStandard": "gnu11",
16
+ "cppStandard": "c++17"
17
+ }
18
+ ],
19
+ "version": 4
20
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "ROS2.distro": "humble",
3
+ "python.autoComplete.extraPaths": [
4
+ "/home/avula/workspaces/waywiser_ws/.venv/lib/python3.10/site-packages",
5
+ "/opt/ros/humble/lib/python3.10/site-packages",
6
+ "/opt/ros/humble/local/lib/python3.10/dist-packages"
7
+ ],
8
+ "python.analysis.extraPaths": [
9
+ "/home/avula/workspaces/waywiser_ws/.venv/lib/python3.10/site-packages",
10
+ "/opt/ros/humble/lib/python3.10/site-packages",
11
+ "/opt/ros/humble/local/lib/python3.10/dist-packages"
12
+ ]
13
+ }
wsljoy-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ramana Avula
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
wsljoy-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: wsljoy
3
+ Version: 0.1.1
4
+ Summary: Mirror a Windows USB or Bluetooth game controller into WSL2 as a Linux joystick.
5
+ Author: wsljoy contributors
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: <3.11,>=3.10
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == 'dev'
11
+ Provides-Extra: linux
12
+ Provides-Extra: windows
13
+ Requires-Dist: hidapi>=0.14; extra == 'windows'
14
+ Requires-Dist: pygame>=2.5; extra == 'windows'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # wsljoy
18
+
19
+ `wsljoy` mirrors a controller connected to Windows over USB or Bluetooth into WSL2 as a normal Linux joystick device.
20
+
21
+ ## Python
22
+
23
+ Use Python 3.10. The project is pinned to Python 3.10 because the Windows controller dependencies have the most reliable wheel support there.
24
+
25
+ ## Windows Host
26
+
27
+ With `uv`:
28
+
29
+ ```cmd
30
+ uv python install 3.10
31
+ uv sync --extra windows
32
+ uv run python -m wsljoy list
33
+ uv run python -m wsljoy host
34
+ ```
35
+
36
+ With standard `venv` and `pip`:
37
+
38
+ ```cmd
39
+ py -3.10 -m venv .venv
40
+ .venv\Scripts\activate.bat
41
+ python -m pip install --upgrade pip
42
+ python -m pip install -e ".[windows]"
43
+ python -m wsljoy list
44
+ python -m wsljoy host
45
+ ```
46
+
47
+ ## WSL2 Guest
48
+
49
+ With `uv`:
50
+
51
+ ```bash
52
+ uv sync --extra linux
53
+ uv run python -m wsljoy setup-uinput
54
+ uv run python -m wsljoy guest
55
+ ```
56
+
57
+ With standard `venv` and `pip`:
58
+
59
+ ```bash
60
+ python3.10 -m venv .venv
61
+ . .venv/bin/activate
62
+ python -m pip install --upgrade pip
63
+ python -m pip install -e ".[linux]"
64
+ python -m wsljoy setup-uinput
65
+ python -m wsljoy guest
66
+ ```
67
+
68
+ `setup-uinput` first checks whether `/dev/uinput` is already writable. It only asks for sudo when your user needs to be added to the `input` group; after that, start a new WSL shell or run `newgrp input`, then run the guest without sudo.
69
+
70
+ It creates a virtual Linux input device through `/dev/uinput` using the Linux uinput API. The `/dev/input/event*` device appears after the guest receives the first packet from Windows. The `/dev/input/js*` device appears when the Linux `joydev` module is available and loaded.
71
+
72
+ Check the guest side:
73
+
74
+ ```bash
75
+ ls -l /dev/uinput
76
+ groups
77
+ python -m wsljoy guest
78
+ ```
79
+
80
+ In a second WSL terminal, after the Windows host is running:
81
+
82
+ ```bash
83
+ ls -l /dev/input/event* /dev/input/js*
84
+ ```
85
+
86
+ If `event*` exists but `js*` does not, try:
87
+
88
+ ```bash
89
+ sudo modprobe joydev
90
+ ```
91
+
92
+ ## Network Setup
93
+
94
+ By default the Windows host targets WSL2. On Windows, `wsljoy` resolves the current WSL2 IP by running `wsl.exe hostname -I`.
95
+
96
+ With `uv`:
97
+
98
+ ```cmd
99
+ uv run python -m wsljoy host
100
+ ```
101
+
102
+ ```bash
103
+ uv run python -m wsljoy guest --listen 0.0.0.0 --port 27414
104
+ ```
105
+
106
+ With an activated venv:
107
+
108
+ ```cmd
109
+ python -m wsljoy host
110
+ ```
111
+
112
+ ```bash
113
+ python -m wsljoy guest --listen 0.0.0.0 --port 27414
114
+ ```
115
+
116
+ If you have multiple WSL distros, name the one running the guest:
117
+
118
+ ```cmd
119
+ python -m wsljoy host --wsl-distro Ubuntu-22.04
120
+ ```
121
+
122
+ To bypass WSL auto-resolution, pass an explicit IP address:
123
+
124
+ ```cmd
125
+ python -m wsljoy host --target 172.25.121.7
126
+ ```
127
+
128
+ ## Controller Support
129
+
130
+ The Windows host has two reader backends:
131
+
132
+ - `ds4-hid`: exact DualShock 4 HID parser for USB and Bluetooth, preferred automatically for DS4 devices.
133
+ - `sdl`: generic SDL/pygame joystick reader, used for common Xbox, PlayStation, 8BitDo, Nintendo, Logitech, PowerA, Razer, and compatible controllers.
134
+
135
+ If the same controller is visible through both APIs, `list` shows the exact backend and hides the duplicate SDL entry. `host --backend auto` prefers `ds4-hid`; use `host --backend sdl` to force SDL.
136
+
137
+ Auto detection is the default:
138
+
139
+ ```cmd
140
+ python -m wsljoy list
141
+ python -m wsljoy host --backend auto
142
+ ```
143
+
144
+ Force the generic backend:
145
+
146
+ ```cmd
147
+ python -m wsljoy host --backend sdl
148
+ ```
149
+
150
+ The exact DS4 path supports:
151
+
152
+ - Sony vendor ID `054c`
153
+ - Product IDs `05c4` and `09cc`
154
+ - USB reports and Bluetooth reports
155
+
156
+ The SDL path covers many usual-suspect controllers but depends on the mapping SDL exposes for that device. The WSL side creates a virtual Linux gamepad with the detected vendor/product IDs when available and Linux-style axes/buttons available through `/dev/input/event*` and `/dev/input/js*`.
157
+
158
+ ## Development
159
+
160
+ With `uv`:
161
+
162
+ ```bash
163
+ uv python install 3.10
164
+ uv sync --all-extras --dev
165
+ uv run pytest
166
+ ```
167
+
168
+ With standard `venv` and `pip`:
169
+
170
+ ```bash
171
+ python3.10 -m venv .venv
172
+ . .venv/bin/activate
173
+ python -m pip install --upgrade pip
174
+ python -m pip install -e ".[dev]"
175
+ python -m pytest
176
+ ```
wsljoy-0.1.1/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # wsljoy
2
+
3
+ `wsljoy` mirrors a controller connected to Windows over USB or Bluetooth into WSL2 as a normal Linux joystick device.
4
+
5
+ ## Python
6
+
7
+ Use Python 3.10. The project is pinned to Python 3.10 because the Windows controller dependencies have the most reliable wheel support there.
8
+
9
+ ## Windows Host
10
+
11
+ With `uv`:
12
+
13
+ ```cmd
14
+ uv python install 3.10
15
+ uv sync --extra windows
16
+ uv run python -m wsljoy list
17
+ uv run python -m wsljoy host
18
+ ```
19
+
20
+ With standard `venv` and `pip`:
21
+
22
+ ```cmd
23
+ py -3.10 -m venv .venv
24
+ .venv\Scripts\activate.bat
25
+ python -m pip install --upgrade pip
26
+ python -m pip install -e ".[windows]"
27
+ python -m wsljoy list
28
+ python -m wsljoy host
29
+ ```
30
+
31
+ ## WSL2 Guest
32
+
33
+ With `uv`:
34
+
35
+ ```bash
36
+ uv sync --extra linux
37
+ uv run python -m wsljoy setup-uinput
38
+ uv run python -m wsljoy guest
39
+ ```
40
+
41
+ With standard `venv` and `pip`:
42
+
43
+ ```bash
44
+ python3.10 -m venv .venv
45
+ . .venv/bin/activate
46
+ python -m pip install --upgrade pip
47
+ python -m pip install -e ".[linux]"
48
+ python -m wsljoy setup-uinput
49
+ python -m wsljoy guest
50
+ ```
51
+
52
+ `setup-uinput` first checks whether `/dev/uinput` is already writable. It only asks for sudo when your user needs to be added to the `input` group; after that, start a new WSL shell or run `newgrp input`, then run the guest without sudo.
53
+
54
+ It creates a virtual Linux input device through `/dev/uinput` using the Linux uinput API. The `/dev/input/event*` device appears after the guest receives the first packet from Windows. The `/dev/input/js*` device appears when the Linux `joydev` module is available and loaded.
55
+
56
+ Check the guest side:
57
+
58
+ ```bash
59
+ ls -l /dev/uinput
60
+ groups
61
+ python -m wsljoy guest
62
+ ```
63
+
64
+ In a second WSL terminal, after the Windows host is running:
65
+
66
+ ```bash
67
+ ls -l /dev/input/event* /dev/input/js*
68
+ ```
69
+
70
+ If `event*` exists but `js*` does not, try:
71
+
72
+ ```bash
73
+ sudo modprobe joydev
74
+ ```
75
+
76
+ ## Network Setup
77
+
78
+ By default the Windows host targets WSL2. On Windows, `wsljoy` resolves the current WSL2 IP by running `wsl.exe hostname -I`.
79
+
80
+ With `uv`:
81
+
82
+ ```cmd
83
+ uv run python -m wsljoy host
84
+ ```
85
+
86
+ ```bash
87
+ uv run python -m wsljoy guest --listen 0.0.0.0 --port 27414
88
+ ```
89
+
90
+ With an activated venv:
91
+
92
+ ```cmd
93
+ python -m wsljoy host
94
+ ```
95
+
96
+ ```bash
97
+ python -m wsljoy guest --listen 0.0.0.0 --port 27414
98
+ ```
99
+
100
+ If you have multiple WSL distros, name the one running the guest:
101
+
102
+ ```cmd
103
+ python -m wsljoy host --wsl-distro Ubuntu-22.04
104
+ ```
105
+
106
+ To bypass WSL auto-resolution, pass an explicit IP address:
107
+
108
+ ```cmd
109
+ python -m wsljoy host --target 172.25.121.7
110
+ ```
111
+
112
+ ## Controller Support
113
+
114
+ The Windows host has two reader backends:
115
+
116
+ - `ds4-hid`: exact DualShock 4 HID parser for USB and Bluetooth, preferred automatically for DS4 devices.
117
+ - `sdl`: generic SDL/pygame joystick reader, used for common Xbox, PlayStation, 8BitDo, Nintendo, Logitech, PowerA, Razer, and compatible controllers.
118
+
119
+ If the same controller is visible through both APIs, `list` shows the exact backend and hides the duplicate SDL entry. `host --backend auto` prefers `ds4-hid`; use `host --backend sdl` to force SDL.
120
+
121
+ Auto detection is the default:
122
+
123
+ ```cmd
124
+ python -m wsljoy list
125
+ python -m wsljoy host --backend auto
126
+ ```
127
+
128
+ Force the generic backend:
129
+
130
+ ```cmd
131
+ python -m wsljoy host --backend sdl
132
+ ```
133
+
134
+ The exact DS4 path supports:
135
+
136
+ - Sony vendor ID `054c`
137
+ - Product IDs `05c4` and `09cc`
138
+ - USB reports and Bluetooth reports
139
+
140
+ The SDL path covers many usual-suspect controllers but depends on the mapping SDL exposes for that device. The WSL side creates a virtual Linux gamepad with the detected vendor/product IDs when available and Linux-style axes/buttons available through `/dev/input/event*` and `/dev/input/js*`.
141
+
142
+ ## Development
143
+
144
+ With `uv`:
145
+
146
+ ```bash
147
+ uv python install 3.10
148
+ uv sync --all-extras --dev
149
+ uv run pytest
150
+ ```
151
+
152
+ With standard `venv` and `pip`:
153
+
154
+ ```bash
155
+ python3.10 -m venv .venv
156
+ . .venv/bin/activate
157
+ python -m pip install --upgrade pip
158
+ python -m pip install -e ".[dev]"
159
+ python -m pytest
160
+ ```
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wsljoy"
7
+ version = "0.1.1"
8
+ description = "Mirror a Windows USB or Bluetooth game controller into WSL2 as a Linux joystick."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10,<3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "wsljoy contributors" }]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ windows = ["hidapi>=0.14", "pygame>=2.5"]
17
+ linux = []
18
+ dev = ["pytest>=8"]
19
+
20
+ [dependency-groups]
21
+ dev = ["pytest>=8"]
22
+
23
+ [project.scripts]
24
+ wsljoy-host = "wsljoy.cli:host_main"
25
+ wsljoy-guest = "wsljoy.cli:guest_main"
26
+ wsljoy-list = "wsljoy.cli:list_main"
27
+ wsljoy-setup-uinput = "wsljoy.setup_uinput:main"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/wsljoy"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ """Mirror Windows game controller input into WSL2."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Callable
6
+
7
+
8
+ COMMANDS = {
9
+ "list": "List supported Windows controllers.",
10
+ "host": "Send Windows controller state to WSL2.",
11
+ "guest": "Create a virtual gamepad in Linux/WSL2.",
12
+ "setup-uinput": "Configure /dev/uinput permissions.",
13
+ }
14
+
15
+
16
+ def _load_handler(command: str) -> Callable[[list[str] | None], None]:
17
+ if command == "list":
18
+ from .cli import list_main
19
+
20
+ return list_main
21
+ if command == "host":
22
+ from .cli import host_main
23
+
24
+ return host_main
25
+ if command == "guest":
26
+ from .cli import guest_main
27
+
28
+ return guest_main
29
+ if command == "setup-uinput":
30
+ from .setup_uinput import main as setup_uinput_main
31
+
32
+ return setup_uinput_main
33
+ choices = ", ".join(COMMANDS)
34
+ raise SystemExit(f"unknown command: {command}\nchoose one of: {choices}")
35
+
36
+
37
+ def main(argv: list[str] | None = None) -> None:
38
+ args = list(sys.argv[1:] if argv is None else argv)
39
+ if not args or args[0] in {"-h", "--help"}:
40
+ parser = argparse.ArgumentParser(prog="python -m wsljoy")
41
+ subcommands = parser.add_subparsers(dest="command")
42
+ for name, help_text in COMMANDS.items():
43
+ subcommands.add_parser(name, help=help_text)
44
+ parser.print_help()
45
+ return
46
+
47
+ command = args.pop(0)
48
+ handler = _load_handler(command)
49
+ try:
50
+ handler(args)
51
+ except KeyboardInterrupt:
52
+ print("\nStopped.")
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+
6
+ def list_main(argv: list[str] | None = None) -> None:
7
+ from .controllers.common import decode_hid_path
8
+ from .windows import list_controllers
9
+
10
+ devices = list_controllers()
11
+ if not devices:
12
+ print("No supported game controllers found.")
13
+ return
14
+ for index, device in enumerate(devices):
15
+ product = device.get("product_string") or "Wireless Controller"
16
+ path = decode_hid_path(device.get("path"))
17
+ vendor_id = int(device.get("vendor_id") or 0)
18
+ product_id = int(device.get("product_id") or 0)
19
+ print(
20
+ f"{index}: [{device.get('backend', 'unknown')}] {product} "
21
+ f"vid={vendor_id:04x} pid={product_id:04x} "
22
+ f"path={path}"
23
+ )
24
+
25
+
26
+ def host_main(argv: list[str] | None = None) -> None:
27
+ parser = argparse.ArgumentParser(description="Send Windows controller state to WSL2.")
28
+ parser.add_argument("--target", default="wsl", help="WSL/Linux UDP target address. Defaults to `wsl`, resolved via wsl.exe; explicit IPs are also allowed.")
29
+ parser.add_argument("--wsl-distro", help="WSL distro name to use when --target is wsl/wsl2.")
30
+ parser.add_argument("--port", type=int, default=27414, help="UDP target port.")
31
+ parser.add_argument("--path", help="Controller path from wsljoy-list.")
32
+ parser.add_argument(
33
+ "--backend",
34
+ choices=("auto", "ds4-hid", "sdl"),
35
+ default="auto",
36
+ help="Controller reader backend.",
37
+ )
38
+ parser.add_argument("--rate", type=float, default=250.0, help="Maximum send rate in Hz.")
39
+ args = parser.parse_args(argv)
40
+
41
+ from .windows import run_host
42
+
43
+ try:
44
+ run_host(
45
+ target=args.target,
46
+ port=args.port,
47
+ path=args.path,
48
+ backend=args.backend,
49
+ rate_limit_hz=args.rate,
50
+ wsl_distro=args.wsl_distro,
51
+ )
52
+ except KeyboardInterrupt:
53
+ print("\nStopped.")
54
+
55
+
56
+ def guest_main(argv: list[str] | None = None) -> None:
57
+ parser = argparse.ArgumentParser(description="Create a virtual gamepad in Linux/WSL2.")
58
+ parser.add_argument("--listen", default="0.0.0.0", help="UDP listen address.")
59
+ parser.add_argument("--port", type=int, default=27414, help="UDP listen port.")
60
+ parser.add_argument("--stale-after", type=float, default=1.0, help="Neutralize after silence.")
61
+ args = parser.parse_args(argv)
62
+
63
+ from .linux import run_guest
64
+
65
+ try:
66
+ run_guest(listen=args.listen, port=args.port, stale_after=args.stale_after)
67
+ except KeyboardInterrupt:
68
+ print("\nStopped.")
@@ -0,0 +1,2 @@
1
+ """Controller backends used by the Windows host."""
2
+
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ KNOWN_GAMEPAD_VENDORS = {
5
+ 0x044F: "Thrustmaster",
6
+ 0x045E: "Microsoft",
7
+ 0x046D: "Logitech",
8
+ 0x054C: "Sony",
9
+ 0x057E: "Nintendo",
10
+ 0x0738: "Mad Catz",
11
+ 0x0E6F: "PDP",
12
+ 0x1532: "Razer",
13
+ 0x20D6: "PowerA",
14
+ 0x24C6: "PowerA",
15
+ 0x2DC8: "8BitDo",
16
+ }
17
+
18
+
19
+ def is_known_gamepad(device: dict) -> bool:
20
+ vendor_id = int(device.get("vendor_id") or 0)
21
+ usage_page = int(device.get("usage_page") or 0)
22
+ usage = int(device.get("usage") or 0)
23
+ product = str(device.get("product_string") or "").lower()
24
+ manufacturer = str(device.get("manufacturer_string") or "").lower()
25
+
26
+ if vendor_id in KNOWN_GAMEPAD_VENDORS:
27
+ return True
28
+ if usage_page == 0x01 and usage in {0x04, 0x05}:
29
+ return True
30
+ return any(
31
+ token in f"{manufacturer} {product}"
32
+ for token in (
33
+ "xbox",
34
+ "dualshock",
35
+ "dualsense",
36
+ "wireless controller",
37
+ "8bitdo",
38
+ "gamepad",
39
+ "controller",
40
+ )
41
+ )
42
+
43
+
44
+ def decode_hid_path(path: object) -> str:
45
+ if isinstance(path, bytes):
46
+ return path.decode(errors="replace")
47
+ return str(path or "")
48
+
49
+
50
+ def hid_connection(device: dict) -> str:
51
+ bus_type = int(device.get("bus_type") or 0)
52
+ if bus_type == 1:
53
+ return "usb"
54
+ if bus_type == 2:
55
+ return "bluetooth"
56
+
57
+ path = decode_hid_path(device.get("path")).lower()
58
+ if "bth" in path or "bluetooth" in path:
59
+ return "bluetooth"
60
+ if "usb" in path or "vid_" in path:
61
+ return "usb"
62
+ return "unknown"