@nearcade/virtual-gamepad 1.0.0
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.
- package/README.md +128 -0
- package/config/game_profiles.csv +339 -0
- package/config/kbm_bindings.json +50 -0
- package/index.js +10 -0
- package/lib/InputOrchestrator.js +829 -0
- package/native/binding.gyp +18 -0
- package/native/build/Release/uinputBridge.node +0 -0
- package/native/uinputBridge.cpp +613 -0
- package/package.json +56 -0
- package/python/__init__.py +1 -0
- package/python/linux_uinput.py +697 -0
- package/python/mac_gamepad_bridge.py +532 -0
- package/python/read_gamepads.py +291 -0
- package/python/windows_hidmaestro.py +394 -0
- package/python/windows_vigem.py +544 -0
- package/windows/HmBridge/HmBridge.csproj +14 -0
- package/windows/HmBridge/HmBridge.exe +0 -0
- package/windows/HmBridge/Program.cs +164 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""
|
|
2
|
+
macOS KBM-Gamepad Bridge: Translates gamepad input to synthetic keyboard/mouse events
|
|
3
|
+
|
|
4
|
+
Since macOS lacks a stable gamepad injection API (unlike Linux uinput or Windows ViGEmBus),
|
|
5
|
+
this bridge converts incoming gamepad packets to keyboard and mouse events using pynput.
|
|
6
|
+
|
|
7
|
+
This allows Steam Input and other applications to work with remapped gamepad inputs.
|
|
8
|
+
|
|
9
|
+
DEPENDENCIES: pip install pynput
|
|
10
|
+
PERMISSIONS: Requires Accessibility permission (System Settings > Security & Privacy > Accessibility)
|
|
11
|
+
|
|
12
|
+
BUTTON MAPPING:
|
|
13
|
+
A → Space (action/jump)
|
|
14
|
+
B → Escape (menu/back)
|
|
15
|
+
X → R (reload/interact)
|
|
16
|
+
Y → E (equipment/ability)
|
|
17
|
+
LB → Left Mouse Click
|
|
18
|
+
RB → Right Mouse Click
|
|
19
|
+
LT/RT → Shift + mouse buttons for modified actions
|
|
20
|
+
|
|
21
|
+
STICK MAPPING:
|
|
22
|
+
Left Stick → W/A/S/D (movement with deadzone)
|
|
23
|
+
Right Stick → Mouse movement (relative)
|
|
24
|
+
Triggers → Mouse scroll / modifier keys
|
|
25
|
+
|
|
26
|
+
FEATURES:
|
|
27
|
+
[OK] Cross-architecture (Intel & Apple Silicon M1/M2/M3)
|
|
28
|
+
[OK] Deadzone handling (0.1) to prevent stick drift
|
|
29
|
+
[OK] Smooth mouse acceleration
|
|
30
|
+
[OK] Graceful shutdown on stream disconnect
|
|
31
|
+
[OK] Permission warnings if Accessibility not granted
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import sys
|
|
35
|
+
import json
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
import socket
|
|
39
|
+
import struct
|
|
40
|
+
import queue
|
|
41
|
+
from typing import Dict, Set, Tuple, Optional
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from pynput.keyboard import Controller as KeyboardController, Key
|
|
45
|
+
from pynput.mouse import Controller as MouseController, Button
|
|
46
|
+
except ImportError:
|
|
47
|
+
print(
|
|
48
|
+
"[gamepad-bridge] ERROR: pynput not installed. Install with: pip install pynput",
|
|
49
|
+
flush=True,
|
|
50
|
+
)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
# ── Configuration ────────────────────────────────────────────────────────────
|
|
54
|
+
DEADZONE = 0.1 # Stick values below this are treated as neutral
|
|
55
|
+
MOUSE_SPEED_MULTIPLIER = 3.0 # Right stick to mouse speed
|
|
56
|
+
MOUSE_SENSITIVITY = 2000 # Pixels per second
|
|
57
|
+
UPDATE_RATE = 0.016 # ~60 FPS update loop
|
|
58
|
+
|
|
59
|
+
# ── Button Mapping (W3C Gamepad standard → macOS keycodes) ──────────────────
|
|
60
|
+
BUTTON_MAP = {
|
|
61
|
+
0: "space", # A button → Space (action/jump)
|
|
62
|
+
1: "esc", # B button → Escape (menu)
|
|
63
|
+
2: "r", # X button → R (reload)
|
|
64
|
+
3: "e", # Y button → E (equipment)
|
|
65
|
+
4: "shift", # LB (Left Bumper) → Shift (modifier for LMB)
|
|
66
|
+
5: "cmd", # RB (Right Bumper) → Cmd (modifier for RMB)
|
|
67
|
+
8: "tab", # Back → Tab (scoreboard)
|
|
68
|
+
9: "enter", # Start → Enter (confirm)
|
|
69
|
+
10: "z", # Left Stick Click → Z (crouch/alt action)
|
|
70
|
+
11: "c", # Right Stick Click → C (emote/action)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# ── Mouse Button Mapping ─────────────────────────────────────────────────────
|
|
74
|
+
MOUSE_BUTTON_MAP = {
|
|
75
|
+
"left": Button.left,
|
|
76
|
+
"right": Button.right,
|
|
77
|
+
"middle": Button.middle,
|
|
78
|
+
"scroll_up": "scroll_up",
|
|
79
|
+
"scroll_down": "scroll_down",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# ── State Tracking ───────────────────────────────────────────────────────────
|
|
83
|
+
class GamepadState:
|
|
84
|
+
def __init__(self):
|
|
85
|
+
self.buttons_pressed: Set[int] = set() # Currently held button indices
|
|
86
|
+
self.key_held: Dict[str, bool] = {} # Track which keys are actively pressed
|
|
87
|
+
self.mouse_held: Dict[str, bool] = {} # Track mouse buttons
|
|
88
|
+
self.left_stick = (0.0, 0.0) # (x, y) normalized to -1.0..1.0
|
|
89
|
+
self.right_stick = (0.0, 0.0)
|
|
90
|
+
self.left_trigger = 0.0 # 0.0..1.0
|
|
91
|
+
self.right_trigger = 0.0
|
|
92
|
+
self.last_mouse_pos = (0, 0)
|
|
93
|
+
self.last_update_time = time.time()
|
|
94
|
+
self.lock = threading.Lock()
|
|
95
|
+
|
|
96
|
+
# ── Global state ─────────────────────────────────────────────────────────────
|
|
97
|
+
gamepad_state = GamepadState()
|
|
98
|
+
keyboard = KeyboardController()
|
|
99
|
+
mouse = MouseController()
|
|
100
|
+
running = True
|
|
101
|
+
viewer_ids: Set[str] = set() # Track connected viewer IDs
|
|
102
|
+
event_queue = queue.Queue()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def clamp(val: float, min_val: float, max_val: float) -> float:
|
|
106
|
+
"""Clamp value to range."""
|
|
107
|
+
return max(min_val, min(max_val, val))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def apply_deadzone(value: float) -> float:
|
|
111
|
+
"""Apply deadzone to prevent stick drift."""
|
|
112
|
+
if abs(value) < DEADZONE:
|
|
113
|
+
return 0.0
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def normalize_stick(x: float, y: float) -> Tuple[float, float]:
|
|
118
|
+
"""Normalize stick values and apply deadzone."""
|
|
119
|
+
# Clamp to -1..1 range
|
|
120
|
+
x = clamp(x, -1.0, 1.0)
|
|
121
|
+
y = clamp(y, -1.0, 1.0)
|
|
122
|
+
# Apply deadzone
|
|
123
|
+
x = apply_deadzone(x)
|
|
124
|
+
y = apply_deadzone(y)
|
|
125
|
+
return (x, y)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def handle_button_event(button_idx: int, pressed: bool):
|
|
129
|
+
"""
|
|
130
|
+
Handle gamepad button press/release and emit corresponding keyboard events.
|
|
131
|
+
"""
|
|
132
|
+
with gamepad_state.lock:
|
|
133
|
+
if pressed:
|
|
134
|
+
gamepad_state.buttons_pressed.add(button_idx)
|
|
135
|
+
else:
|
|
136
|
+
gamepad_state.buttons_pressed.discard(button_idx)
|
|
137
|
+
|
|
138
|
+
key_name = BUTTON_MAP.get(button_idx)
|
|
139
|
+
if not key_name:
|
|
140
|
+
return # Unmapped button, ignore
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
if key_name == "shift":
|
|
144
|
+
if pressed:
|
|
145
|
+
keyboard.press(Key.shift)
|
|
146
|
+
gamepad_state.key_held[key_name] = True
|
|
147
|
+
else:
|
|
148
|
+
keyboard.release(Key.shift)
|
|
149
|
+
gamepad_state.key_held[key_name] = False
|
|
150
|
+
|
|
151
|
+
elif key_name == "cmd":
|
|
152
|
+
if pressed:
|
|
153
|
+
keyboard.press(Key.cmd)
|
|
154
|
+
gamepad_state.key_held[key_name] = True
|
|
155
|
+
else:
|
|
156
|
+
keyboard.release(Key.cmd)
|
|
157
|
+
gamepad_state.key_held[key_name] = False
|
|
158
|
+
|
|
159
|
+
elif key_name == "esc":
|
|
160
|
+
if pressed:
|
|
161
|
+
keyboard.press(Key.esc)
|
|
162
|
+
gamepad_state.key_held[key_name] = True
|
|
163
|
+
else:
|
|
164
|
+
keyboard.release(Key.esc)
|
|
165
|
+
gamepad_state.key_held[key_name] = False
|
|
166
|
+
|
|
167
|
+
elif key_name == "tab":
|
|
168
|
+
if pressed:
|
|
169
|
+
keyboard.press(Key.tab)
|
|
170
|
+
gamepad_state.key_held[key_name] = True
|
|
171
|
+
else:
|
|
172
|
+
keyboard.release(Key.tab)
|
|
173
|
+
gamepad_state.key_held[key_name] = False
|
|
174
|
+
|
|
175
|
+
elif key_name == "enter":
|
|
176
|
+
if pressed:
|
|
177
|
+
keyboard.press(Key.enter)
|
|
178
|
+
gamepad_state.key_held[key_name] = True
|
|
179
|
+
else:
|
|
180
|
+
keyboard.release(Key.enter)
|
|
181
|
+
gamepad_state.key_held[key_name] = False
|
|
182
|
+
|
|
183
|
+
else:
|
|
184
|
+
# Regular letter key
|
|
185
|
+
if pressed:
|
|
186
|
+
keyboard.press(key_name)
|
|
187
|
+
gamepad_state.key_held[key_name] = True
|
|
188
|
+
else:
|
|
189
|
+
keyboard.release(key_name)
|
|
190
|
+
gamepad_state.key_held[key_name] = False
|
|
191
|
+
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f"[gamepad-bridge] Error handling button {button_idx}: {e}", flush=True)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def handle_stick_movement(stick_name: str, x: float, y: float):
|
|
197
|
+
"""
|
|
198
|
+
Handle left/right stick movement.
|
|
199
|
+
Left stick → W/A/S/D
|
|
200
|
+
Right stick → mouse movement
|
|
201
|
+
"""
|
|
202
|
+
x, y = normalize_stick(x, y)
|
|
203
|
+
|
|
204
|
+
if stick_name == "left":
|
|
205
|
+
# Map to WASD movement
|
|
206
|
+
# -Y = up (W), +Y = down (S), -X = left (A), +X = right (D)
|
|
207
|
+
with gamepad_state.lock:
|
|
208
|
+
gamepad_state.left_stick = (x, y)
|
|
209
|
+
|
|
210
|
+
# Handle W/S (up/down)
|
|
211
|
+
if y < -DEADZONE: # Forward
|
|
212
|
+
if "w" not in gamepad_state.key_held or not gamepad_state.key_held["w"]:
|
|
213
|
+
keyboard.press("w")
|
|
214
|
+
gamepad_state.key_held["w"] = True
|
|
215
|
+
else:
|
|
216
|
+
if gamepad_state.key_held.get("w", False):
|
|
217
|
+
keyboard.release("w")
|
|
218
|
+
gamepad_state.key_held["w"] = False
|
|
219
|
+
|
|
220
|
+
if y > DEADZONE: # Backward
|
|
221
|
+
if "s" not in gamepad_state.key_held or not gamepad_state.key_held["s"]:
|
|
222
|
+
keyboard.press("s")
|
|
223
|
+
gamepad_state.key_held["s"] = True
|
|
224
|
+
else:
|
|
225
|
+
if gamepad_state.key_held.get("s", False):
|
|
226
|
+
keyboard.release("s")
|
|
227
|
+
gamepad_state.key_held["s"] = False
|
|
228
|
+
|
|
229
|
+
# Handle A/D (left/right)
|
|
230
|
+
if x < -DEADZONE: # Left
|
|
231
|
+
if "a" not in gamepad_state.key_held or not gamepad_state.key_held["a"]:
|
|
232
|
+
keyboard.press("a")
|
|
233
|
+
gamepad_state.key_held["a"] = True
|
|
234
|
+
else:
|
|
235
|
+
if gamepad_state.key_held.get("a", False):
|
|
236
|
+
keyboard.release("a")
|
|
237
|
+
gamepad_state.key_held["a"] = False
|
|
238
|
+
|
|
239
|
+
if x > DEADZONE: # Right
|
|
240
|
+
if "d" not in gamepad_state.key_held or not gamepad_state.key_held["d"]:
|
|
241
|
+
keyboard.press("d")
|
|
242
|
+
gamepad_state.key_held["d"] = True
|
|
243
|
+
else:
|
|
244
|
+
if gamepad_state.key_held.get("d", False):
|
|
245
|
+
keyboard.release("d")
|
|
246
|
+
gamepad_state.key_held["d"] = False
|
|
247
|
+
|
|
248
|
+
elif stick_name == "right":
|
|
249
|
+
# Map to mouse movement
|
|
250
|
+
with gamepad_state.lock:
|
|
251
|
+
gamepad_state.right_stick = (x, y)
|
|
252
|
+
current_pos = mouse.position
|
|
253
|
+
gamepad_state.last_mouse_pos = current_pos
|
|
254
|
+
|
|
255
|
+
# Calculate mouse delta
|
|
256
|
+
now = time.time()
|
|
257
|
+
dt = now - gamepad_state.last_update_time
|
|
258
|
+
if dt > 0:
|
|
259
|
+
mouse_x = int(x * MOUSE_SENSITIVITY * dt * MOUSE_SPEED_MULTIPLIER)
|
|
260
|
+
mouse_y = int(y * MOUSE_SENSITIVITY * dt * MOUSE_SPEED_MULTIPLIER)
|
|
261
|
+
|
|
262
|
+
if mouse_x != 0 or mouse_y != 0:
|
|
263
|
+
try:
|
|
264
|
+
new_x = current_pos[0] + mouse_x
|
|
265
|
+
new_y = current_pos[1] + mouse_y
|
|
266
|
+
mouse.position = (new_x, new_y)
|
|
267
|
+
except Exception as e:
|
|
268
|
+
print(f"[gamepad-bridge] Mouse movement error: {e}", flush=True)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def handle_trigger(trigger_name: str, value: float):
|
|
272
|
+
"""
|
|
273
|
+
Handle trigger input (0.0 to 1.0).
|
|
274
|
+
LT → Left click (or scroll)
|
|
275
|
+
RT → Right click (or scroll)
|
|
276
|
+
"""
|
|
277
|
+
value = clamp(value, 0.0, 1.0)
|
|
278
|
+
|
|
279
|
+
if trigger_name == "left":
|
|
280
|
+
with gamepad_state.lock:
|
|
281
|
+
gamepad_state.left_trigger = value
|
|
282
|
+
|
|
283
|
+
# If trigger > 0.5, hold left mouse button
|
|
284
|
+
if value > 0.5:
|
|
285
|
+
if not gamepad_state.mouse_held.get("left", False):
|
|
286
|
+
try:
|
|
287
|
+
mouse.press(Button.left)
|
|
288
|
+
gamepad_state.mouse_held["left"] = True
|
|
289
|
+
except Exception as e:
|
|
290
|
+
print(f"[gamepad-bridge] LMB press error: {e}", flush=True)
|
|
291
|
+
else:
|
|
292
|
+
if gamepad_state.mouse_held.get("left", False):
|
|
293
|
+
try:
|
|
294
|
+
mouse.release(Button.left)
|
|
295
|
+
gamepad_state.mouse_held["left"] = False
|
|
296
|
+
except Exception as e:
|
|
297
|
+
print(f"[gamepad-bridge] LMB release error: {e}", flush=True)
|
|
298
|
+
|
|
299
|
+
elif trigger_name == "right":
|
|
300
|
+
with gamepad_state.lock:
|
|
301
|
+
gamepad_state.right_trigger = value
|
|
302
|
+
|
|
303
|
+
# If trigger > 0.5, hold right mouse button
|
|
304
|
+
if value > 0.5:
|
|
305
|
+
if not gamepad_state.mouse_held.get("right", False):
|
|
306
|
+
try:
|
|
307
|
+
mouse.press(Button.right)
|
|
308
|
+
gamepad_state.mouse_held["right"] = True
|
|
309
|
+
except Exception as e:
|
|
310
|
+
print(f"[gamepad-bridge] RMB press error: {e}", flush=True)
|
|
311
|
+
else:
|
|
312
|
+
if gamepad_state.mouse_held.get("right", False):
|
|
313
|
+
try:
|
|
314
|
+
mouse.release(Button.right)
|
|
315
|
+
gamepad_state.mouse_held["right"] = False
|
|
316
|
+
except Exception as e:
|
|
317
|
+
print(f"[gamepad-bridge] RMB release error: {e}", flush=True)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def reset_all_keys():
|
|
321
|
+
"""
|
|
322
|
+
Emergency panic key: Release all held keys and mouse buttons.
|
|
323
|
+
Called on disconnect or shutdown.
|
|
324
|
+
"""
|
|
325
|
+
print("[gamepad-bridge] Releasing all held keys/buttons", flush=True)
|
|
326
|
+
with gamepad_state.lock:
|
|
327
|
+
# Release all keyboard keys
|
|
328
|
+
for key_name in list(gamepad_state.key_held.keys()):
|
|
329
|
+
try:
|
|
330
|
+
if key_name == "shift":
|
|
331
|
+
keyboard.release(Key.shift)
|
|
332
|
+
elif key_name == "cmd":
|
|
333
|
+
keyboard.release(Key.cmd)
|
|
334
|
+
elif key_name == "esc":
|
|
335
|
+
keyboard.release(Key.esc)
|
|
336
|
+
elif key_name == "tab":
|
|
337
|
+
keyboard.release(Key.tab)
|
|
338
|
+
elif key_name == "enter":
|
|
339
|
+
keyboard.release(Key.enter)
|
|
340
|
+
else:
|
|
341
|
+
keyboard.release(key_name)
|
|
342
|
+
except Exception:
|
|
343
|
+
pass
|
|
344
|
+
gamepad_state.key_held[key_name] = False
|
|
345
|
+
|
|
346
|
+
# Release all mouse buttons
|
|
347
|
+
for btn_name in list(gamepad_state.mouse_held.keys()):
|
|
348
|
+
try:
|
|
349
|
+
if btn_name == "left":
|
|
350
|
+
mouse.release(Button.left)
|
|
351
|
+
elif btn_name == "right":
|
|
352
|
+
mouse.release(Button.right)
|
|
353
|
+
elif btn_name == "middle":
|
|
354
|
+
mouse.release(Button.middle)
|
|
355
|
+
except Exception:
|
|
356
|
+
pass
|
|
357
|
+
gamepad_state.mouse_held[btn_name] = False
|
|
358
|
+
|
|
359
|
+
gamepad_state.buttons_pressed.clear()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def process_gamepad_event(msg: dict):
|
|
363
|
+
"""
|
|
364
|
+
Process a gamepad event from the network packet.
|
|
365
|
+
Expected format:
|
|
366
|
+
{"type": "gamepad", "viewer_id": "viewer_1", "button": 0, "pressed": true}
|
|
367
|
+
{"type": "gamepad", "viewer_id": "viewer_1", "axis": 0, "value": 0.5}
|
|
368
|
+
{"type": "gamepad", "viewer_id": "viewer_1", "stick": "left", "x": 0.2, "y": 0.3}
|
|
369
|
+
{"type": "gamepad", "viewer_id": "viewer_1", "trigger": "left", "value": 0.8}
|
|
370
|
+
"""
|
|
371
|
+
viewer_id = str(msg.get("viewer_id", "unknown"))
|
|
372
|
+
with gamepad_state.lock:
|
|
373
|
+
if viewer_id not in viewer_ids:
|
|
374
|
+
viewer_ids.add(viewer_id)
|
|
375
|
+
print(f"[gamepad-bridge] Viewer {viewer_id} connected", flush=True)
|
|
376
|
+
|
|
377
|
+
# Button event
|
|
378
|
+
if "button" in msg:
|
|
379
|
+
button_idx = msg["button"]
|
|
380
|
+
pressed = bool(msg.get("pressed", False))
|
|
381
|
+
handle_button_event(button_idx, pressed)
|
|
382
|
+
|
|
383
|
+
# Stick event (combined X/Y)
|
|
384
|
+
elif "stick" in msg:
|
|
385
|
+
stick_name = msg["stick"] # "left" or "right"
|
|
386
|
+
x = float(msg.get("x", 0.0))
|
|
387
|
+
y = float(msg.get("y", 0.0))
|
|
388
|
+
handle_stick_movement(stick_name, x, y)
|
|
389
|
+
|
|
390
|
+
# Trigger event
|
|
391
|
+
elif "trigger" in msg:
|
|
392
|
+
trigger_name = msg["trigger"] # "left" or "right"
|
|
393
|
+
value = float(msg.get("value", 0.0))
|
|
394
|
+
handle_trigger(trigger_name, value)
|
|
395
|
+
|
|
396
|
+
# Axis event (single axis value)
|
|
397
|
+
elif "axis" in msg:
|
|
398
|
+
axis_idx = msg["axis"]
|
|
399
|
+
value = float(msg.get("value", 0.0))
|
|
400
|
+
# Map axis index to appropriate handler
|
|
401
|
+
# 0-1: left stick X/Y, 2-3: right stick X/Y, 4-5: triggers
|
|
402
|
+
if axis_idx in (0, 1): # Left stick components
|
|
403
|
+
handle_stick_movement("left", value if axis_idx == 0 else 0, value if axis_idx == 1 else 0)
|
|
404
|
+
elif axis_idx in (2, 3): # Right stick components
|
|
405
|
+
handle_stick_movement("right", value if axis_idx == 2 else 0, value if axis_idx == 3 else 0)
|
|
406
|
+
elif axis_idx == 4: # Left trigger
|
|
407
|
+
handle_trigger("left", value)
|
|
408
|
+
elif axis_idx == 5: # Right trigger
|
|
409
|
+
handle_trigger("right", value)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def check_permissions():
|
|
413
|
+
"""
|
|
414
|
+
Warn user if Accessibility permissions are not granted.
|
|
415
|
+
On macOS, pynput requires accessibility permissions to function.
|
|
416
|
+
"""
|
|
417
|
+
print("[gamepad-bridge] ========================================", flush=True)
|
|
418
|
+
print("[gamepad-bridge] macOS Gamepad Bridge Initialized", flush=True)
|
|
419
|
+
print("[gamepad-bridge] ========================================", flush=True)
|
|
420
|
+
print("[gamepad-bridge] ⚠ PERMISSION REQUIRED: Accessibility", flush=True)
|
|
421
|
+
print("[gamepad-bridge] Please grant access in System Settings →", flush=True)
|
|
422
|
+
print("[gamepad-bridge] Security & Privacy → Accessibility", flush=True)
|
|
423
|
+
print("[gamepad-bridge] Add this application to the list", flush=True)
|
|
424
|
+
print("[gamepad-bridge] ========================================", flush=True)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _emit_gp_binary(slot, payload):
|
|
428
|
+
magic, lx, ly, rx, ry, lt, rt, cppBtns, hx, hy, slot_check = struct.unpack('<BhhhhBBHbbB', payload)
|
|
429
|
+
|
|
430
|
+
def _bit(mask): return bool(cppBtns & mask)
|
|
431
|
+
|
|
432
|
+
handle_button_event(0, _bit(1 << 0)) # A
|
|
433
|
+
handle_button_event(1, _bit(1 << 1)) # B
|
|
434
|
+
handle_button_event(2, _bit(1 << 2)) # X
|
|
435
|
+
handle_button_event(3, _bit(1 << 3)) # Y
|
|
436
|
+
handle_button_event(4, _bit(1 << 4)) # LB
|
|
437
|
+
handle_button_event(5, _bit(1 << 5)) # RB
|
|
438
|
+
handle_button_event(8, _bit(1 << 8)) # BACK
|
|
439
|
+
handle_button_event(9, _bit(1 << 9)) # START
|
|
440
|
+
handle_button_event(10, _bit(1 << 10)) # L3
|
|
441
|
+
handle_button_event(11, _bit(1 << 11)) # R3
|
|
442
|
+
|
|
443
|
+
handle_button_event(12, hy == -1)
|
|
444
|
+
handle_button_event(13, hy == 1)
|
|
445
|
+
handle_button_event(14, hx == -1)
|
|
446
|
+
handle_button_event(15, hx == 1)
|
|
447
|
+
|
|
448
|
+
lx_f = lx / 32767.0
|
|
449
|
+
ly_f = ly / 32767.0
|
|
450
|
+
handle_stick_movement("left", lx_f, -ly_f)
|
|
451
|
+
|
|
452
|
+
rx_f = rx / 32767.0
|
|
453
|
+
ry_f = ry / 32767.0
|
|
454
|
+
handle_stick_movement("right", rx_f, -ry_f)
|
|
455
|
+
|
|
456
|
+
lt_f = lt / 255.0
|
|
457
|
+
rt_f = rt / 255.0
|
|
458
|
+
handle_trigger("left", lt_f)
|
|
459
|
+
handle_trigger("right", rt_f)
|
|
460
|
+
|
|
461
|
+
def stdin_thread():
|
|
462
|
+
stdin_raw = open(sys.stdin.fileno(), 'rb', buffering=0)
|
|
463
|
+
for raw_line in stdin_raw:
|
|
464
|
+
line = raw_line.decode('utf-8', errors='replace').strip()
|
|
465
|
+
if line:
|
|
466
|
+
event_queue.put(('json', line))
|
|
467
|
+
|
|
468
|
+
def udp_thread(sock):
|
|
469
|
+
while True:
|
|
470
|
+
try:
|
|
471
|
+
data, _ = sock.recvfrom(1024)
|
|
472
|
+
if data and len(data) == 16 and data[0] == 0x01:
|
|
473
|
+
slot = data[15]
|
|
474
|
+
event_queue.put(('binary', slot, data))
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
|
|
478
|
+
def run():
|
|
479
|
+
global running
|
|
480
|
+
check_permissions()
|
|
481
|
+
|
|
482
|
+
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
483
|
+
udp_sock.bind(('127.0.0.1', 0))
|
|
484
|
+
udp_port = udp_sock.getsockname()[1]
|
|
485
|
+
print(json.dumps({"type": "udp_ready", "udp_port": udp_port}), flush=True)
|
|
486
|
+
|
|
487
|
+
threading.Thread(target=stdin_thread, daemon=True).start()
|
|
488
|
+
threading.Thread(target=udp_thread, args=(udp_sock,), daemon=True).start()
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
while True:
|
|
492
|
+
ev = event_queue.get()
|
|
493
|
+
if ev[0] == 'binary':
|
|
494
|
+
_emit_gp_binary(ev[1], ev[2])
|
|
495
|
+
continue
|
|
496
|
+
|
|
497
|
+
line = ev[1]
|
|
498
|
+
try:
|
|
499
|
+
msg = json.loads(line)
|
|
500
|
+
|
|
501
|
+
# ── Disconnect / cleanup ────────────────────────────────────
|
|
502
|
+
if msg.get("type") in ["disconnect_viewer", "flush_neutral"]:
|
|
503
|
+
vid = str(msg.get("viewer_id", ""))
|
|
504
|
+
if vid in viewer_ids:
|
|
505
|
+
viewer_ids.discard(vid)
|
|
506
|
+
print(f"[gamepad-bridge] Viewer {vid} disconnected", flush=True)
|
|
507
|
+
if not viewer_ids:
|
|
508
|
+
reset_all_keys()
|
|
509
|
+
continue
|
|
510
|
+
|
|
511
|
+
# ── Gamepad event ───────────────────────────────────────────
|
|
512
|
+
if msg.get("type") == "gamepad":
|
|
513
|
+
process_gamepad_event(msg)
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
# ── Config messages (ignored for gamepad bridge) ────────────
|
|
517
|
+
if msg.get("type") in ["set_force_xboxone", "set_enable_dualshock", "set_enable_motion", "set-input-mode"]:
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
except json.JSONDecodeError:
|
|
521
|
+
pass # Ignore invalid JSON
|
|
522
|
+
except Exception as e:
|
|
523
|
+
print(f"[gamepad-bridge] Error: {e}", flush=True)
|
|
524
|
+
|
|
525
|
+
except KeyboardInterrupt:
|
|
526
|
+
print("[gamepad-bridge] Shutting down gracefully", flush=True)
|
|
527
|
+
reset_all_keys()
|
|
528
|
+
sys.exit(0)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
if __name__ == "__main__":
|
|
532
|
+
run()
|