padrelay 1.0.0__py3-none-any.whl
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.
- padrelay/__init__.py +3 -0
- padrelay/client/__init__.py +1 -0
- padrelay/client/client_app.py +406 -0
- padrelay/client/constants.py +10 -0
- padrelay/client/input.py +95 -0
- padrelay/core/__init__.py +1 -0
- padrelay/core/config.py +214 -0
- padrelay/core/exceptions.py +107 -0
- padrelay/core/logging_utils.py +61 -0
- padrelay/protocol/__init__.py +1 -0
- padrelay/protocol/constants.py +13 -0
- padrelay/protocol/messages.py +261 -0
- padrelay/protocol/tcp.py +82 -0
- padrelay/protocol/udp.py +137 -0
- padrelay/scripts/__init__.py +3 -0
- padrelay/scripts/client.py +65 -0
- padrelay/scripts/key_mapper.py +699 -0
- padrelay/scripts/server.py +86 -0
- padrelay/security/__init__.py +3 -0
- padrelay/security/auth.py +204 -0
- padrelay/security/rate_limiting.py +133 -0
- padrelay/server/__init__.py +1 -0
- padrelay/server/constants.py +19 -0
- padrelay/server/input_processor.py +152 -0
- padrelay/server/server_app.py +427 -0
- padrelay/server/virtual_gamepad.py +155 -0
- padrelay-1.0.0.dist-info/METADATA +169 -0
- padrelay-1.0.0.dist-info/RECORD +32 -0
- padrelay-1.0.0.dist-info/WHEEL +5 -0
- padrelay-1.0.0.dist-info/entry_points.txt +3 -0
- padrelay-1.0.0.dist-info/licenses/LICENSE +21 -0
- padrelay-1.0.0.dist-info/top_level.txt +1 -0
padrelay/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Client-side utilities for the PadRelay"""
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Client application"""
|
|
2
|
+
import asyncio
|
|
3
|
+
import time
|
|
4
|
+
import random
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import socket
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from ..core.logging_utils import get_logger
|
|
10
|
+
from ..core.exceptions import AuthenticationError, ConnectionError
|
|
11
|
+
from ..protocol.constants import HEARTBEAT_INTERVAL, PROTOCOL_VERSION
|
|
12
|
+
from ..protocol.tcp import TCPProtocolHandler
|
|
13
|
+
from ..protocol.udp import UDPProtocolHandler
|
|
14
|
+
from ..security.auth import Authenticator
|
|
15
|
+
from .constants import RECONNECT_DELAY
|
|
16
|
+
from .input import GamepadInput
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UDPClientProtocol(asyncio.DatagramProtocol):
|
|
22
|
+
"""Datagram protocol that queues incoming packets"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.queue: asyncio.Queue[bytes] = asyncio.Queue()
|
|
26
|
+
|
|
27
|
+
def datagram_received(self, data: bytes, addr) -> None: # type: ignore[override]
|
|
28
|
+
self.queue.put_nowait(data)
|
|
29
|
+
|
|
30
|
+
def error_received(self, exc: Exception) -> None: # type: ignore[override]
|
|
31
|
+
logger.error(f"UDP client error received: {exc}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VirtualGamepadClient:
|
|
35
|
+
"""Client controller"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
server_ip: str,
|
|
40
|
+
server_port: int,
|
|
41
|
+
protocol: str,
|
|
42
|
+
gamepad: GamepadInput,
|
|
43
|
+
update_rate: int = 60,
|
|
44
|
+
password: Optional[str] = None,
|
|
45
|
+
config: Optional[object] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Construct the client and configure networking"""
|
|
48
|
+
self.server_ip = server_ip
|
|
49
|
+
self.server_port = server_port
|
|
50
|
+
self.protocol = protocol.lower()
|
|
51
|
+
self.gamepad = gamepad
|
|
52
|
+
self.update_rate = update_rate
|
|
53
|
+
self.config = config
|
|
54
|
+
|
|
55
|
+
# Security
|
|
56
|
+
self.authenticator = Authenticator(password)
|
|
57
|
+
|
|
58
|
+
# Connection state
|
|
59
|
+
self.connected = False
|
|
60
|
+
self.running = False
|
|
61
|
+
self.shutdown_event = asyncio.Event()
|
|
62
|
+
|
|
63
|
+
# Tasks
|
|
64
|
+
self.main_task = None
|
|
65
|
+
self.heartbeat_task = None
|
|
66
|
+
|
|
67
|
+
logger.info(f"Client initialized with {self.protocol.upper()} protocol")
|
|
68
|
+
logger.info(f"Server: {self.server_ip}:{self.server_port}")
|
|
69
|
+
logger.info(f"Update rate: {self.update_rate} Hz")
|
|
70
|
+
|
|
71
|
+
async def run(self) -> bool:
|
|
72
|
+
"""Run the client using the chosen protocol"""
|
|
73
|
+
self.running = True
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
if self.protocol == 'tcp':
|
|
77
|
+
await self._run_tcp()
|
|
78
|
+
else:
|
|
79
|
+
await self._run_udp()
|
|
80
|
+
return True
|
|
81
|
+
except asyncio.CancelledError:
|
|
82
|
+
logger.info("Client cancelled")
|
|
83
|
+
return False
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Client error: {e}")
|
|
86
|
+
return False
|
|
87
|
+
finally:
|
|
88
|
+
self.running = False
|
|
89
|
+
|
|
90
|
+
async def shutdown(self) -> bool:
|
|
91
|
+
"""Shut down tasks and clean up resources"""
|
|
92
|
+
logger.info("Shutdown initiated...")
|
|
93
|
+
self.shutdown_event.set()
|
|
94
|
+
|
|
95
|
+
# Cancel heartbeat task
|
|
96
|
+
if self.heartbeat_task and not self.heartbeat_task.done():
|
|
97
|
+
self.heartbeat_task.cancel()
|
|
98
|
+
try:
|
|
99
|
+
await self.heartbeat_task
|
|
100
|
+
except asyncio.CancelledError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# Cancel main task
|
|
104
|
+
if self.main_task and not self.main_task.done():
|
|
105
|
+
self.main_task.cancel()
|
|
106
|
+
try:
|
|
107
|
+
await self.main_task
|
|
108
|
+
except asyncio.CancelledError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
# Cleanup gamepad
|
|
112
|
+
self.gamepad.cleanup()
|
|
113
|
+
|
|
114
|
+
logger.info("Shutdown complete")
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
async def _run_tcp(self) -> None:
|
|
118
|
+
"""Main loop for TCP mode"""
|
|
119
|
+
while not self.shutdown_event.is_set():
|
|
120
|
+
try:
|
|
121
|
+
# Connect to server
|
|
122
|
+
logger.info(f"Connecting to TCP server at {self.server_ip}:{self.server_port}...")
|
|
123
|
+
reader, writer = await asyncio.open_connection(self.server_ip, self.server_port)
|
|
124
|
+
|
|
125
|
+
# Initialize protocol handler
|
|
126
|
+
tcp_handler = TCPProtocolHandler(reader, writer)
|
|
127
|
+
|
|
128
|
+
# Optimize socket settings
|
|
129
|
+
sock = writer.get_extra_info('socket')
|
|
130
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
131
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 65536)
|
|
132
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 65536)
|
|
133
|
+
|
|
134
|
+
# Authenticate
|
|
135
|
+
if not await self._tcp_auth(tcp_handler):
|
|
136
|
+
logger.error("Authentication failed, retrying in 5 seconds...")
|
|
137
|
+
await asyncio.sleep(RECONNECT_DELAY)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Start heartbeat task
|
|
141
|
+
self.heartbeat_task = asyncio.create_task(
|
|
142
|
+
self._tcp_heartbeat_loop(tcp_handler)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Set connection state
|
|
146
|
+
self.connected = True
|
|
147
|
+
|
|
148
|
+
# Main sending loop
|
|
149
|
+
send_interval = 1.0 / self.update_rate
|
|
150
|
+
last_send_time = time.time()
|
|
151
|
+
batch_counter = 0
|
|
152
|
+
|
|
153
|
+
while not self.shutdown_event.is_set():
|
|
154
|
+
# Poll gamepad and send update
|
|
155
|
+
message = self.gamepad.poll()
|
|
156
|
+
if message:
|
|
157
|
+
# Strip unnecessary fields to reduce message size
|
|
158
|
+
if batch_counter % 10 != 0: # Only include full data every 10th message
|
|
159
|
+
if "timestamp" in message:
|
|
160
|
+
del message["timestamp"]
|
|
161
|
+
|
|
162
|
+
batch_counter += 1
|
|
163
|
+
if not await tcp_handler.send_message(message):
|
|
164
|
+
break
|
|
165
|
+
if not await tcp_handler.drain():
|
|
166
|
+
break
|
|
167
|
+
if tcp_handler.writer.is_closing():
|
|
168
|
+
logger.warning("Server closed the connection")
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
# Calculate sleep time to maintain consistent update rate
|
|
172
|
+
current_time = time.time()
|
|
173
|
+
elapsed = current_time - last_send_time
|
|
174
|
+
sleep_time = max(0, send_interval - elapsed)
|
|
175
|
+
|
|
176
|
+
# Adaptive timing - if we're behind schedule, catch up gradually
|
|
177
|
+
if sleep_time < 0:
|
|
178
|
+
sleep_time = 0
|
|
179
|
+
# Reset timing if we fall too far behind
|
|
180
|
+
if elapsed > send_interval * 3:
|
|
181
|
+
logger.warning("Timing drift detected, resetting schedule")
|
|
182
|
+
last_send_time = current_time
|
|
183
|
+
else:
|
|
184
|
+
last_send_time = current_time + sleep_time
|
|
185
|
+
|
|
186
|
+
await asyncio.sleep(sleep_time)
|
|
187
|
+
|
|
188
|
+
# Check if heartbeat task failed
|
|
189
|
+
if self.heartbeat_task.done():
|
|
190
|
+
if self.heartbeat_task.exception():
|
|
191
|
+
logger.error(f"Heartbeat task failed: {self.heartbeat_task.exception()}")
|
|
192
|
+
else:
|
|
193
|
+
logger.warning("Heartbeat task ended")
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
except asyncio.CancelledError:
|
|
197
|
+
logger.info("TCP send loop cancelled")
|
|
198
|
+
break
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.error(f"TCP connection error: {e}")
|
|
201
|
+
|
|
202
|
+
# Update connection state
|
|
203
|
+
self.connected = False
|
|
204
|
+
|
|
205
|
+
# Cleanup
|
|
206
|
+
try:
|
|
207
|
+
if 'tcp_handler' in locals():
|
|
208
|
+
tcp_handler.close()
|
|
209
|
+
await tcp_handler.wait_closed()
|
|
210
|
+
if self.heartbeat_task and not self.heartbeat_task.done():
|
|
211
|
+
self.heartbeat_task.cancel()
|
|
212
|
+
try:
|
|
213
|
+
await self.heartbeat_task
|
|
214
|
+
except asyncio.CancelledError:
|
|
215
|
+
pass
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Error during cleanup: {e}")
|
|
218
|
+
|
|
219
|
+
# Reconnect after delay
|
|
220
|
+
if not self.shutdown_event.is_set():
|
|
221
|
+
logger.info(f"Reconnecting in {RECONNECT_DELAY} seconds...")
|
|
222
|
+
await asyncio.sleep(RECONNECT_DELAY)
|
|
223
|
+
|
|
224
|
+
async def _tcp_auth(self, tcp_handler) -> bool:
|
|
225
|
+
"""Authenticate with the server over TCP"""
|
|
226
|
+
try:
|
|
227
|
+
# Wait for auth challenge from server
|
|
228
|
+
challenge_msg = await tcp_handler.read_message()
|
|
229
|
+
if not challenge_msg or challenge_msg.get("type") != "auth_challenge":
|
|
230
|
+
logger.error("Authentication protocol error: Invalid challenge")
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
# Extract challenge and verify protocol version
|
|
234
|
+
challenge = challenge_msg.get("challenge")
|
|
235
|
+
salt = challenge_msg.get("salt")
|
|
236
|
+
iterations = challenge_msg.get("iterations")
|
|
237
|
+
if salt and iterations:
|
|
238
|
+
self.authenticator.set_parameters(salt, int(iterations))
|
|
239
|
+
if challenge_msg.get("protocol_version") != PROTOCOL_VERSION:
|
|
240
|
+
logger.error(f"Protocol version mismatch: server={challenge_msg.get('protocol_version')}, client={PROTOCOL_VERSION}")
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
# Calculate HMAC response
|
|
244
|
+
response = self.authenticator.generate_tcp_response(challenge)
|
|
245
|
+
|
|
246
|
+
# Send auth response
|
|
247
|
+
auth_response = {
|
|
248
|
+
"type": "auth_response",
|
|
249
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
250
|
+
"response": response
|
|
251
|
+
}
|
|
252
|
+
await tcp_handler.send_message(auth_response)
|
|
253
|
+
await tcp_handler.drain()
|
|
254
|
+
|
|
255
|
+
# Wait for auth result
|
|
256
|
+
result = await tcp_handler.read_message()
|
|
257
|
+
if not result:
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
if result.get("type") == "auth_success":
|
|
261
|
+
logger.info("Authentication successful")
|
|
262
|
+
return True
|
|
263
|
+
else:
|
|
264
|
+
logger.error(f"Authentication failed: {result.get('message', 'Unknown error')}")
|
|
265
|
+
return False
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.error(f"Authentication error: {e}")
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
async def _tcp_heartbeat_loop(
|
|
271
|
+
self, tcp_handler, interval: int = HEARTBEAT_INTERVAL
|
|
272
|
+
) -> bool:
|
|
273
|
+
"""Send heartbeats at ``interval`` seconds"""
|
|
274
|
+
while not self.shutdown_event.is_set():
|
|
275
|
+
try:
|
|
276
|
+
heartbeat = {
|
|
277
|
+
"type": "heartbeat",
|
|
278
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
279
|
+
"timestamp": datetime.now().isoformat()
|
|
280
|
+
}
|
|
281
|
+
await tcp_handler.send_message(heartbeat)
|
|
282
|
+
await tcp_handler.drain()
|
|
283
|
+
|
|
284
|
+
# Wait for heartbeat acknowledgment with timeout
|
|
285
|
+
try:
|
|
286
|
+
response = await asyncio.wait_for(tcp_handler.read_message(), timeout=5.0)
|
|
287
|
+
if not response or response.get("type") != "heartbeat_ack":
|
|
288
|
+
logger.warning("Invalid heartbeat response")
|
|
289
|
+
return False
|
|
290
|
+
except asyncio.TimeoutError:
|
|
291
|
+
logger.warning("Heartbeat acknowledgment timeout")
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
await asyncio.sleep(interval)
|
|
295
|
+
except asyncio.CancelledError:
|
|
296
|
+
logger.info("Heartbeat task cancelled")
|
|
297
|
+
return True
|
|
298
|
+
except Exception as e:
|
|
299
|
+
logger.error(f"Heartbeat error: {e}")
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
async def _run_udp(self) -> None:
|
|
303
|
+
"""Main loop for UDP mode"""
|
|
304
|
+
loop = asyncio.get_running_loop()
|
|
305
|
+
|
|
306
|
+
while not self.shutdown_event.is_set():
|
|
307
|
+
try:
|
|
308
|
+
protocol = UDPClientProtocol()
|
|
309
|
+
transport, _ = await loop.create_datagram_endpoint(
|
|
310
|
+
lambda: protocol,
|
|
311
|
+
remote_addr=(self.server_ip, self.server_port)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Initialize UDP handler
|
|
315
|
+
udp_handler = UDPProtocolHandler(transport, (self.server_ip, self.server_port))
|
|
316
|
+
|
|
317
|
+
logger.info(f"UDP client sending to {self.server_ip}:{self.server_port}")
|
|
318
|
+
|
|
319
|
+
# Set connection state
|
|
320
|
+
self.connected = True
|
|
321
|
+
|
|
322
|
+
# Retrieve hashing parameters if the client only has the plaintex
|
|
323
|
+
# password. If the server does not respond, fall back to using
|
|
324
|
+
# the plaintext key.
|
|
325
|
+
if self.authenticator.password_plain:
|
|
326
|
+
req = {
|
|
327
|
+
"type": "auth_params_request",
|
|
328
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
329
|
+
"timestamp": datetime.now().isoformat(),
|
|
330
|
+
}
|
|
331
|
+
udp_handler.send_message(req)
|
|
332
|
+
try:
|
|
333
|
+
data = await asyncio.wait_for(protocol.queue.get(), timeout=0.2)
|
|
334
|
+
except asyncio.TimeoutError:
|
|
335
|
+
logger.debug("No auth parameters received; assuming plaintext mode")
|
|
336
|
+
else:
|
|
337
|
+
resp = json.loads(data.decode("utf-8"))
|
|
338
|
+
if resp.get("type") == "auth_params":
|
|
339
|
+
salt = resp.get("salt")
|
|
340
|
+
iterations = resp.get("iterations")
|
|
341
|
+
if salt and iterations is not None:
|
|
342
|
+
self.authenticator.set_parameters(str(salt), int(iterations))
|
|
343
|
+
# Use the derived key for tokens
|
|
344
|
+
self.authenticator.password_plain = None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
last_heartbeat = time.time()
|
|
348
|
+
send_interval = 1.0 / self.update_rate
|
|
349
|
+
|
|
350
|
+
while not self.shutdown_event.is_set():
|
|
351
|
+
try:
|
|
352
|
+
current_time = time.time()
|
|
353
|
+
|
|
354
|
+
if current_time - last_heartbeat >= HEARTBEAT_INTERVAL:
|
|
355
|
+
heartbeat = {
|
|
356
|
+
"type": "heartbeat",
|
|
357
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
358
|
+
"timestamp": datetime.now().isoformat(),
|
|
359
|
+
}
|
|
360
|
+
auth_token = self.authenticator.generate_udp_token()
|
|
361
|
+
if auth_token:
|
|
362
|
+
heartbeat["auth_token"] = auth_token
|
|
363
|
+
|
|
364
|
+
udp_handler.send_message(heartbeat)
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
data = await asyncio.wait_for(protocol.queue.get(), timeout=5.0)
|
|
368
|
+
response = json.loads(data.decode("utf-8"))
|
|
369
|
+
if response.get("type") != "heartbeat_ack":
|
|
370
|
+
logger.warning("Invalid heartbeat response")
|
|
371
|
+
raise ConnectionError("Invalid heartbeat ack")
|
|
372
|
+
except asyncio.TimeoutError:
|
|
373
|
+
logger.warning("Heartbeat acknowledgment timeout")
|
|
374
|
+
raise ConnectionError("Heartbeat timeout")
|
|
375
|
+
|
|
376
|
+
last_heartbeat = current_time
|
|
377
|
+
|
|
378
|
+
message = self.gamepad.poll()
|
|
379
|
+
if message:
|
|
380
|
+
auth_token = self.authenticator.generate_udp_token()
|
|
381
|
+
if auth_token:
|
|
382
|
+
message["auth_token"] = auth_token
|
|
383
|
+
udp_handler.send_message(message)
|
|
384
|
+
|
|
385
|
+
await asyncio.sleep(send_interval)
|
|
386
|
+
|
|
387
|
+
except asyncio.CancelledError:
|
|
388
|
+
logger.info("UDP send loop cancelled")
|
|
389
|
+
raise
|
|
390
|
+
except Exception as e:
|
|
391
|
+
logger.error(f"Error in UDP send loop: {e}")
|
|
392
|
+
raise
|
|
393
|
+
|
|
394
|
+
except asyncio.CancelledError:
|
|
395
|
+
logger.info("UDP client cancelled")
|
|
396
|
+
break
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.error(f"UDP endpoint error: {e}")
|
|
399
|
+
finally:
|
|
400
|
+
self.connected = False
|
|
401
|
+
if 'udp_handler' in locals():
|
|
402
|
+
udp_handler.close()
|
|
403
|
+
|
|
404
|
+
if not self.shutdown_event.is_set():
|
|
405
|
+
logger.info(f"Reconnecting in {RECONNECT_DELAY} seconds...")
|
|
406
|
+
await asyncio.sleep(RECONNECT_DELAY)
|
padrelay/client/input.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Gamepad input helpers for the PadRelay"""
|
|
2
|
+
import pygame
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from ..core.logging_utils import get_logger
|
|
6
|
+
from ..protocol.constants import PROTOCOL_VERSION
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
class GamepadInput:
|
|
11
|
+
"""Collects input from a local gamepad"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, joystick_index: int = 0) -> None:
|
|
14
|
+
"""Set up input polling for the given joystick index"""
|
|
15
|
+
self.joystick_index = joystick_index
|
|
16
|
+
self.joystick = None
|
|
17
|
+
self.initialized = False
|
|
18
|
+
|
|
19
|
+
def initialize(self) -> bool:
|
|
20
|
+
"""Initialize pygame and open the joystick"""
|
|
21
|
+
try:
|
|
22
|
+
pygame.init()
|
|
23
|
+
pygame.joystick.init()
|
|
24
|
+
|
|
25
|
+
if pygame.joystick.get_count() == 0:
|
|
26
|
+
logger.error("No gamepad detected! Please connect a gamepad and try again.")
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
self.joystick = pygame.joystick.Joystick(self.joystick_index)
|
|
30
|
+
self.joystick.init()
|
|
31
|
+
logger.info(f"Gamepad initialized: {self.joystick.get_name()}")
|
|
32
|
+
|
|
33
|
+
# Log detailed controller information
|
|
34
|
+
logger.info(f"Controller details:")
|
|
35
|
+
logger.info(f" - Number of axes: {self.joystick.get_numaxes()}")
|
|
36
|
+
logger.info(f" - Number of buttons: {self.joystick.get_numbuttons()}")
|
|
37
|
+
logger.info(f" - Number of hats: {self.joystick.get_numhats()}")
|
|
38
|
+
|
|
39
|
+
self.initialized = True
|
|
40
|
+
return True
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.error(f"Error initializing gamepad: {e}")
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
def poll(self) -> Optional[Dict[str, Any]]:
|
|
46
|
+
"""Return the current gamepad state or ``None`` on error"""
|
|
47
|
+
if not self.initialized or not self.joystick:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
pygame.event.pump()
|
|
51
|
+
try:
|
|
52
|
+
# Get buttons state (true if pressed)
|
|
53
|
+
buttons = [bool(self.joystick.get_button(i)) for i in range(self.joystick.get_numbuttons())]
|
|
54
|
+
|
|
55
|
+
# Get axes values (between -1.0 and 1.0)
|
|
56
|
+
axes = [self.joystick.get_axis(i) for i in range(self.joystick.get_numaxes())]
|
|
57
|
+
|
|
58
|
+
# Get hat values (directions)
|
|
59
|
+
hats = [self.joystick.get_hat(i) for i in range(self.joystick.get_numhats())]
|
|
60
|
+
|
|
61
|
+
# Calculate trigger values if this is an Xbox-like controller
|
|
62
|
+
# Typically, Xbox controllers use axis 2 for left trigger and axis 5 for right trigger
|
|
63
|
+
trigger_left = None
|
|
64
|
+
trigger_right = None
|
|
65
|
+
if self.joystick.get_numaxes() > 2:
|
|
66
|
+
trigger_left = (axes[2] + 1) / 2.0
|
|
67
|
+
if self.joystick.get_numaxes() > 5:
|
|
68
|
+
trigger_right = (axes[5] + 1) / 2.0
|
|
69
|
+
|
|
70
|
+
message = {
|
|
71
|
+
"type": "input",
|
|
72
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
73
|
+
"timestamp": datetime.now().isoformat(),
|
|
74
|
+
"buttons": buttons,
|
|
75
|
+
"axes": axes,
|
|
76
|
+
"hats": hats
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Add triggers if available
|
|
80
|
+
if trigger_left is not None and trigger_right is not None:
|
|
81
|
+
message["triggers"] = [trigger_left, trigger_right]
|
|
82
|
+
|
|
83
|
+
return message
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Error polling gamepad: {e}")
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def cleanup(self) -> None:
|
|
89
|
+
"""Release Pygame resources"""
|
|
90
|
+
try:
|
|
91
|
+
pygame.quit()
|
|
92
|
+
self.initialized = False
|
|
93
|
+
self.joystick = None
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.error(f"Error during pygame cleanup: {e}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared core utilities for the PadRelay"""
|