client_socket.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. """
  2. Client Socket Implementation for Trixy Network
  3. This module provides client-side socket handling for the Trixy system.
  4. It implements:
  5. - Client connection management with auto-retry
  6. - Satellite authentication and registration
  7. - Command sending and response handling
  8. - Audio stream client management
  9. - Connection monitoring and heartbeat
  10. - Integration with wakeword detection workflow
  11. The client handles the full satellite connection process as specified
  12. in CLAUDE.md including registration and connection establishment.
  13. """
  14. import socket
  15. import threading
  16. import time
  17. import queue
  18. from typing import Dict, Any, Optional, Callable, Tuple
  19. from dataclasses import dataclass, field
  20. from concurrent.futures import ThreadPoolExecutor
  21. import uuid
  22. from .protocol import TrixyProtocol, TrixyMessage, MessageFlags
  23. from .cmd import (
  24. TrixyCommand,
  25. CommandResponse,
  26. SatelliteHelloCommand,
  27. SatelliteRegistrationCommand,
  28. SatelliteConnectionCommand,
  29. create_satellite_hello,
  30. create_satellite_registration,
  31. create_satellite_connection,
  32. create_heartbeat,
  33. create_ping
  34. )
  35. def pprint(message: str) -> None:
  36. """Client socket logging function."""
  37. print(f"[CLIENT_SOCKET] {message}")
  38. class ClientError(Exception):
  39. """Base exception for client errors."""
  40. pass
  41. class ConnectionFailedError(ClientError):
  42. """Raised when connection to server fails."""
  43. pass
  44. class AuthenticationError(ClientError):
  45. """Raised when authentication fails."""
  46. pass
  47. @dataclass
  48. class ConnectionAttempt:
  49. """Information about a connection attempt."""
  50. attempt_number: int
  51. timestamp: float
  52. success: bool
  53. error_message: Optional[str] = None
  54. response_time: Optional[float] = None
  55. class ClientSocketHandler:
  56. """
  57. Handles client-side socket operations.
  58. This class manages connection to the Trixy server, handles authentication,
  59. and processes messages according to the satellite workflow.
  60. """
  61. def __init__(self, application_container):
  62. """
  63. Initialize the client socket handler.
  64. Args:
  65. application_container: Main application container
  66. """
  67. self.application = application_container
  68. self.protocol = TrixyProtocol()
  69. # Connection state
  70. self._connected = False
  71. self._authenticated = False
  72. self._socket: Optional[socket.socket] = None
  73. self._server_address: Optional[Tuple[str, int]] = None
  74. # Client identity
  75. self._room_id = "unknown"
  76. self._alias = "satellite"
  77. self._mac_address = "00:00:00:00:00:00"
  78. self._satellite_id: Optional[str] = None
  79. self._session_id: Optional[str] = None
  80. # Connection management
  81. self._auto_reconnect = True
  82. self._reconnect_delay = 5.0
  83. self._max_reconnect_attempts = 0 # 0 = infinite
  84. self._connection_attempts: list[ConnectionAttempt] = []
  85. # Threading
  86. self._thread_pool = ThreadPoolExecutor(max_workers=10)
  87. self._running = True
  88. self._receive_thread: Optional[threading.Thread] = None
  89. self._heartbeat_thread: Optional[threading.Thread] = None
  90. # Message handling
  91. self._message_queue = queue.Queue()
  92. self._message_handlers: Dict[str, Callable] = {}
  93. self._pending_responses: Dict[str, queue.Queue] = {}
  94. self._setup_default_handlers()
  95. # Audio streams
  96. self._audio_streams: Dict[str, int] = {} # stream_type -> port
  97. # Statistics
  98. self._stats = {
  99. 'connection_attempts': 0,
  100. 'successful_connections': 0,
  101. 'messages_sent': 0,
  102. 'messages_received': 0,
  103. 'bytes_sent': 0,
  104. 'bytes_received': 0,
  105. 'last_ping_time': None,
  106. 'last_pong_time': None
  107. }
  108. pprint("ClientSocketHandler initialized")
  109. def configure_satellite(self, room_id: str, alias: str, mac_address: str) -> None:
  110. """
  111. Configure satellite identity.
  112. Args:
  113. room_id: Room identifier
  114. alias: Satellite alias/name
  115. mac_address: MAC address
  116. """
  117. self._room_id = room_id
  118. self._alias = alias
  119. self._mac_address = mac_address
  120. pprint(f"Satellite configured: {alias} in {room_id} ({mac_address})")
  121. def connect(self, host: str, port: int = 2101, timeout: float = 10.0) -> bool:
  122. """
  123. Connect to the Trixy server.
  124. Args:
  125. host: Server hostname or IP
  126. port: Server port
  127. timeout: Connection timeout
  128. Returns:
  129. bool: True if connected successfully
  130. """
  131. if self._connected:
  132. return True
  133. pprint(f"Connecting to {host}:{port}...")
  134. attempt = ConnectionAttempt(
  135. attempt_number=len(self._connection_attempts) + 1,
  136. timestamp=time.time(),
  137. success=False
  138. )
  139. try:
  140. # Create socket
  141. self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  142. self._socket.settimeout(timeout)
  143. # Connect
  144. start_time = time.time()
  145. self._socket.connect((host, port))
  146. attempt.response_time = time.time() - start_time
  147. self._server_address = (host, port)
  148. self._connected = True
  149. attempt.success = True
  150. # Start message processing
  151. self._start_message_processing()
  152. # Start authentication process
  153. self._thread_pool.submit(self._authenticate)
  154. # Start heartbeat
  155. self._start_heartbeat()
  156. # Update statistics
  157. self._stats['connection_attempts'] += 1
  158. self._stats['successful_connections'] += 1
  159. pprint(f"Connected to server successfully")
  160. # Trigger event
  161. self._trigger_event("client_connected", {
  162. 'server_address': self._server_address,
  163. 'response_time': attempt.response_time
  164. })
  165. return True
  166. except Exception as e:
  167. attempt.error_message = str(e)
  168. pprint(f"Failed to connect: {e}")
  169. if self._socket:
  170. self._socket.close()
  171. self._socket = None
  172. self._stats['connection_attempts'] += 1
  173. return False
  174. finally:
  175. self._connection_attempts.append(attempt)
  176. def disconnect(self) -> None:
  177. """Disconnect from server."""
  178. if not self._connected:
  179. return
  180. pprint("Disconnecting from server...")
  181. self._connected = False
  182. self._authenticated = False
  183. self._auto_reconnect = False
  184. # Close socket
  185. if self._socket:
  186. self._socket.close()
  187. self._socket = None
  188. # Wait for threads
  189. if self._receive_thread and self._receive_thread.is_alive():
  190. self._receive_thread.join(timeout=5.0)
  191. if self._heartbeat_thread and self._heartbeat_thread.is_alive():
  192. self._heartbeat_thread.join(timeout=5.0)
  193. # Clear state
  194. self._server_address = None
  195. self._satellite_id = None
  196. self._session_id = None
  197. self._audio_streams.clear()
  198. pprint("Disconnected from server")
  199. # Trigger event
  200. self._trigger_event("client_disconnected", {})
  201. def send_command(self, command: TrixyCommand, wait_for_response: bool = False, timeout: float = 30.0) -> Optional[CommandResponse]:
  202. """
  203. Send a command to the server.
  204. Args:
  205. command: Command to send
  206. wait_for_response: Whether to wait for response
  207. timeout: Response timeout
  208. Returns:
  209. Optional[CommandResponse]: Response if wait_for_response is True
  210. """
  211. if not self._connected or not self._socket:
  212. raise ClientError("Not connected to server")
  213. try:
  214. # Create protocol message
  215. message = self.protocol.create_message(
  216. command.get_command_name(),
  217. command
  218. )
  219. # Set up response waiting if needed
  220. response_queue = None
  221. if wait_for_response:
  222. response_queue = queue.Queue()
  223. self._pending_responses[command.command_id] = response_queue
  224. # Serialize and send
  225. data = message.serialize()
  226. self._socket.send(data)
  227. # Update statistics
  228. self._stats['messages_sent'] += 1
  229. self._stats['bytes_sent'] += len(data)
  230. pprint(f"Sent command: {command.get_command_name()}")
  231. # Wait for response if requested
  232. if wait_for_response and response_queue:
  233. try:
  234. response = response_queue.get(timeout=timeout)
  235. return response
  236. except queue.Empty:
  237. pprint(f"Timeout waiting for response to {command.get_command_name()}")
  238. return None
  239. finally:
  240. self._pending_responses.pop(command.command_id, None)
  241. return None
  242. except Exception as e:
  243. pprint(f"Error sending command: {e}")
  244. if wait_for_response:
  245. self._pending_responses.pop(command.command_id, None)
  246. raise ClientError(f"Failed to send command: {e}") from e
  247. def send_hardcoded_command(self, command_text: str) -> bool:
  248. """
  249. Send a hard-coded command.
  250. Args:
  251. command_text: Hard-coded command string
  252. Returns:
  253. bool: True if sent successfully
  254. """
  255. if not self._connected or not self._socket:
  256. return False
  257. try:
  258. data = command_text.encode('utf-8')
  259. self._socket.send(data)
  260. self._stats['messages_sent'] += 1
  261. self._stats['bytes_sent'] += len(data)
  262. pprint(f"Sent hard-coded command: {command_text}")
  263. return True
  264. except Exception as e:
  265. pprint(f"Error sending hard-coded command: {e}")
  266. return False
  267. def ping_server(self) -> Optional[float]:
  268. """
  269. Ping the server and return round-trip time.
  270. Returns:
  271. Optional[float]: RTT in seconds or None if failed
  272. """
  273. if not self._connected:
  274. return None
  275. ping_id = str(int(time.time() * 1000))
  276. ping_cmd = create_ping(ping_id)
  277. start_time = time.time()
  278. self._stats['last_ping_time'] = start_time
  279. response = self.send_command(ping_cmd, wait_for_response=True, timeout=10.0)
  280. if response and hasattr(response, 'ping_id') and response.ping_id == ping_id:
  281. rtt = time.time() - start_time
  282. self._stats['last_pong_time'] = time.time()
  283. return rtt
  284. return None
  285. def enable_auto_reconnect(self, enabled: bool = True, delay: float = 5.0, max_attempts: int = 0) -> None:
  286. """
  287. Enable or disable auto-reconnect.
  288. Args:
  289. enabled: Whether to enable auto-reconnect
  290. delay: Delay between reconnect attempts
  291. max_attempts: Maximum attempts (0 = infinite)
  292. """
  293. self._auto_reconnect = enabled
  294. self._reconnect_delay = delay
  295. self._max_reconnect_attempts = max_attempts
  296. pprint(f"Auto-reconnect: {enabled} (delay={delay}s, max_attempts={max_attempts})")
  297. def get_status(self) -> Dict[str, Any]:
  298. """Get client status information."""
  299. return {
  300. 'connected': self._connected,
  301. 'authenticated': self._authenticated,
  302. 'server_address': self._server_address,
  303. 'satellite_id': self._satellite_id,
  304. 'session_id': self._session_id,
  305. 'room_id': self._room_id,
  306. 'alias': self._alias,
  307. 'mac_address': self._mac_address,
  308. 'audio_streams': self._audio_streams,
  309. 'auto_reconnect': self._auto_reconnect,
  310. 'statistics': self._stats,
  311. 'connection_attempts': len(self._connection_attempts)
  312. }
  313. def _start_message_processing(self) -> None:
  314. """Start message processing threads."""
  315. self._receive_thread = threading.Thread(target=self._receive_messages, daemon=True)
  316. self._receive_thread.start()
  317. self._thread_pool.submit(self._process_message_queue)
  318. def _receive_messages(self) -> None:
  319. """Receive messages from server (runs in separate thread)."""
  320. pprint("Started receiving messages")
  321. while self._connected and self._socket:
  322. try:
  323. self._socket.settimeout(1.0)
  324. data = self._socket.recv(4096)
  325. if not data:
  326. pprint("Server closed connection")
  327. break
  328. # Update statistics
  329. self._stats['bytes_received'] += len(data)
  330. self._stats['messages_received'] += 1
  331. # Queue for processing
  332. self._message_queue.put(data)
  333. except socket.timeout:
  334. continue
  335. except Exception as e:
  336. if self._connected:
  337. pprint(f"Error receiving messages: {e}")
  338. break
  339. # Connection lost - attempt reconnect if enabled
  340. if self._auto_reconnect and self._connected:
  341. self._connected = False
  342. self._authenticated = False
  343. self._trigger_event("connection_lost", {})
  344. self._thread_pool.submit(self._attempt_reconnect)
  345. def _process_message_queue(self) -> None:
  346. """Process queued messages."""
  347. while self._running:
  348. try:
  349. data = self._message_queue.get(timeout=1.0)
  350. self._process_received_data(data)
  351. self._message_queue.task_done()
  352. except queue.Empty:
  353. continue
  354. except Exception as e:
  355. pprint(f"Error processing message: {e}")
  356. def _process_received_data(self, data: bytes) -> None:
  357. """Process received data from server."""
  358. try:
  359. # Check for hard-coded commands/responses
  360. if self.protocol.is_hardcoded_command(data):
  361. command, args = self.protocol.parse_hardcoded_command(data)
  362. self._handle_hardcoded_response(command, args)
  363. else:
  364. # Parse protocol message
  365. message = self.protocol.deserialize_message(data)
  366. self._handle_message(message)
  367. except Exception as e:
  368. pprint(f"Error processing received data: {e}")
  369. def _handle_hardcoded_response(self, command: str, args: str) -> None:
  370. """Handle hard-coded command responses."""
  371. if command == "TRXIPONG":
  372. # Pong response to ping
  373. self._stats['last_pong_time'] = time.time()
  374. pprint(f"Received pong: {args}")
  375. def _handle_message(self, message: TrixyMessage) -> None:
  376. """Handle a protocol message."""
  377. command_name = message.class_name
  378. # Check for response to pending command
  379. if hasattr(message.data, 'command_id'):
  380. response_queue = self._pending_responses.get(message.data.command_id)
  381. if response_queue:
  382. response_queue.put(message.data)
  383. return
  384. # Check for registered handler
  385. handler = self._message_handlers.get(command_name)
  386. if handler:
  387. handler(message)
  388. else:
  389. # Trigger event for unhandled messages
  390. self._trigger_event("message_received", {
  391. 'message': message,
  392. 'command_name': command_name
  393. })
  394. def _setup_default_handlers(self) -> None:
  395. """Set up default message handlers."""
  396. # Default handlers can be added here
  397. pass
  398. def _authenticate(self) -> None:
  399. """Perform authentication sequence with server."""
  400. try:
  401. pprint("Starting authentication...")
  402. # Step 1: Send hello command
  403. hello_cmd = create_satellite_hello(
  404. room_id=self._room_id,
  405. alias=self._alias,
  406. mac_address=self._mac_address,
  407. version="1.0.0",
  408. capabilities=["wakeword", "audio_input", "audio_output"]
  409. )
  410. response = self.send_command(hello_cmd, wait_for_response=True, timeout=15.0)
  411. if not response or not response.is_success():
  412. if response and hasattr(response, 'registration_required') and response.registration_required:
  413. pprint("Registration required")
  414. self._trigger_event("registration_required", {})
  415. return
  416. else:
  417. raise AuthenticationError("Hello command failed")
  418. # Extract satellite ID if provided
  419. if hasattr(response, 'assigned_satellite_id') and response.assigned_satellite_id:
  420. self._satellite_id = response.assigned_satellite_id
  421. # Step 2: If registered, send connection command
  422. if self._satellite_id:
  423. conn_cmd = create_satellite_connection(
  424. satellite_id=self._satellite_id,
  425. mac_address=self._mac_address,
  426. requested_audio_ports={
  427. 'input': 0, # Let server assign
  428. 'output': 0,
  429. 'music': 0
  430. }
  431. )
  432. conn_response = self.send_command(conn_cmd, wait_for_response=True, timeout=30.0)
  433. if conn_response and conn_response.is_success():
  434. self._authenticated = True
  435. # Extract session info
  436. if hasattr(conn_response, 'session_id'):
  437. self._session_id = conn_response.session_id
  438. if hasattr(conn_response, 'assigned_audio_ports'):
  439. self._audio_streams = conn_response.assigned_audio_ports
  440. pprint(f"Authentication successful: {self._satellite_id}")
  441. # Trigger event
  442. self._trigger_event("authentication_successful", {
  443. 'satellite_id': self._satellite_id,
  444. 'session_id': self._session_id,
  445. 'audio_streams': self._audio_streams
  446. })
  447. else:
  448. raise AuthenticationError("Connection command failed")
  449. else:
  450. raise AuthenticationError("No satellite ID assigned")
  451. except Exception as e:
  452. pprint(f"Authentication failed: {e}")
  453. self._trigger_event("authentication_failed", {'error': str(e)})
  454. def _start_heartbeat(self) -> None:
  455. """Start heartbeat thread."""
  456. self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
  457. self._heartbeat_thread.start()
  458. def _heartbeat_loop(self) -> None:
  459. """Send periodic heartbeat messages."""
  460. heartbeat_interval = 30.0 # seconds
  461. while self._connected:
  462. try:
  463. time.sleep(heartbeat_interval)
  464. if self._connected and self._socket:
  465. # Send heartbeat
  466. heartbeat = create_heartbeat()
  467. self.send_command(heartbeat)
  468. except Exception as e:
  469. if self._connected:
  470. pprint(f"Error sending heartbeat: {e}")
  471. def _attempt_reconnect(self) -> None:
  472. """Attempt to reconnect to server."""
  473. if not self._auto_reconnect or not self._server_address:
  474. return
  475. pprint("Attempting to reconnect...")
  476. attempt_count = 0
  477. while self._auto_reconnect and not self._connected:
  478. attempt_count += 1
  479. if self._max_reconnect_attempts > 0 and attempt_count > self._max_reconnect_attempts:
  480. pprint("Max reconnect attempts reached")
  481. break
  482. pprint(f"Reconnect attempt {attempt_count}")
  483. if self.connect(self._server_address[0], self._server_address[1]):
  484. pprint("Reconnected successfully")
  485. break
  486. time.sleep(self._reconnect_delay)
  487. if not self._connected:
  488. pprint("Failed to reconnect")
  489. self._trigger_event("reconnect_failed", {
  490. 'attempts': attempt_count
  491. })
  492. def _trigger_event(self, event_name: str, event_data: Dict[str, Any]) -> None:
  493. """Trigger an event through the application event system."""
  494. try:
  495. event_handler = self.application.get_event_handler()
  496. if event_handler:
  497. event_handler.trigger_event(f"client_{event_name}", event_data)
  498. except Exception as e:
  499. pprint(f"Error triggering event {event_name}: {e}")
  500. def shutdown(self) -> None:
  501. """Shutdown the client socket handler."""
  502. pprint("Shutting down client socket handler...")
  503. self._running = False
  504. self._auto_reconnect = False
  505. self.disconnect()
  506. # Shutdown thread pool
  507. self._thread_pool.shutdown(wait=True)
  508. pprint("Client socket handler shutdown complete")
  509. class TrixyClient:
  510. """
  511. High-level Trixy client implementation.
  512. This class provides a simple interface for satellite clients
  513. and integrates with the application container.
  514. """
  515. def __init__(self, application_container):
  516. """Initialize the Trixy client."""
  517. self.application = application_container
  518. self.client_handler = ClientSocketHandler(application_container)
  519. def configure_satellite(self, room_id: str, alias: str, mac_address: str) -> None:
  520. """Configure satellite identity."""
  521. self.client_handler.configure_satellite(room_id, alias, mac_address)
  522. def connect(self, host: str, port: int = 2101) -> bool:
  523. """Connect to server."""
  524. return self.client_handler.connect(host, port)
  525. def disconnect(self) -> None:
  526. """Disconnect from server."""
  527. self.client_handler.disconnect()
  528. def send_command(self, command: TrixyCommand) -> bool:
  529. """Send a command to server."""
  530. try:
  531. self.client_handler.send_command(command)
  532. return True
  533. except:
  534. return False
  535. def ping_server(self) -> Optional[float]:
  536. """Ping the server."""
  537. return self.client_handler.ping_server()
  538. def get_status(self) -> Dict[str, Any]:
  539. """Get client status."""
  540. return self.client_handler.get_status()
  541. def shutdown(self) -> None:
  542. """Shutdown the client."""
  543. self.client_handler.shutdown()
  544. # Module exports
  545. __all__ = [
  546. "TrixyClient",
  547. "ClientSocketHandler",
  548. "ConnectionAttempt",
  549. "ClientError",
  550. "ConnectionFailedError",
  551. "AuthenticationError",
  552. ]