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 ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ PadRelay - A client-server application for transmitting gamepad input over a network
3
+ """
@@ -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)
@@ -0,0 +1,10 @@
1
+ """Constants used by the client portion of the bridge"""
2
+
3
+ # Reconnection delay in seconds
4
+ RECONNECT_DELAY = 5
5
+
6
+ # Default update rate in Hz
7
+ DEFAULT_UPDATE_RATE = 60
8
+
9
+ # Default joystick index
10
+ DEFAULT_JOYSTICK_INDEX = 0
@@ -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"""