da2-mcp-socket 0.2.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.
@@ -0,0 +1,1103 @@
1
+ """
2
+ Copyright (c) 2026 Danny, DA2 Studio (https://da2.35g.tw)
3
+ Socket Manager - Core TCP/IP socket connection management logic.
4
+
5
+ Handles socket connection, disconnection, and data transmission.
6
+ """
7
+
8
+ import logging
9
+ import socket
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import Any, Optional
16
+
17
+ logger = logging.getLogger("mcp-socket")
18
+
19
+
20
+ @dataclass
21
+ class SocketConfig:
22
+ """Socket connection configuration."""
23
+ host: str
24
+ port: int
25
+ timeout: float = 10.0
26
+ read_timeout: float = 5.0
27
+ buffer_size: int = 4096
28
+ max_buffer_packets: int = 100 # Maximum packets in buffer
29
+ auto_listen: bool = True # Auto start background listener
30
+
31
+
32
+ @dataclass
33
+ class ReceiveProtocol:
34
+ """Receive protocol configuration for packet parsing."""
35
+ mode: str = "line" # raw, line, packet (default: line mode with CRLF)
36
+ start_delimiter: bytes = b"" # Start of packet delimiter (default: empty)
37
+ end_delimiter: bytes = b"\r\n" # End of packet delimiter (default: CRLF = 0D0A)
38
+ include_delimiters: bool = False # Include delimiters in received data
39
+
40
+
41
+ @dataclass
42
+ class OpenSocket:
43
+ """Represents an open socket connection."""
44
+ connection_id: str
45
+ config: SocketConfig
46
+ socket: socket.socket
47
+ protocol: ReceiveProtocol = field(default_factory=ReceiveProtocol)
48
+ opened_at: float = field(default_factory=time.time)
49
+ bytes_sent: int = 0
50
+ bytes_received: int = 0
51
+ receive_buffer: bytes = b"" # Buffer for partial packet data
52
+ # Background listener fields
53
+ packet_buffer: list = field(default_factory=list) # Parsed packets buffer
54
+ listener_thread: Optional[threading.Thread] = None
55
+ listener_running: bool = False
56
+ overflow_count: int = 0 # Count of dropped packets due to overflow
57
+ _packet_lock: threading.Lock = field(default_factory=threading.Lock)
58
+
59
+
60
+ class SocketManager:
61
+ """Manages TCP/IP socket connections and operations."""
62
+
63
+ def __init__(self):
64
+ """Initialize the socket manager."""
65
+ self._connections: dict[str, OpenSocket] = {}
66
+ self._lock = threading.Lock()
67
+
68
+ def _generate_connection_id(self, host: str, port: int) -> str:
69
+ """Generate a unique connection ID."""
70
+ return f"{host}:{port}_{uuid.uuid4().hex[:8]}"
71
+
72
+ def connect(
73
+ self,
74
+ host: str,
75
+ port: int,
76
+ timeout: float = 10.0,
77
+ read_timeout: float = 5.0,
78
+ buffer_size: int = 4096,
79
+ max_buffer_packets: int = 100,
80
+ auto_listen: bool = False
81
+ ) -> dict[str, Any]:
82
+ """Connect to a TCP server.
83
+
84
+ Args:
85
+ host: Target host (IP address or hostname).
86
+ port: Target port number.
87
+ timeout: Connection timeout in seconds.
88
+ read_timeout: Read timeout in seconds.
89
+ buffer_size: Buffer size for receiving data.
90
+ max_buffer_packets: Maximum packets to buffer (default 100).
91
+ auto_listen: Auto start background listener after set_protocol.
92
+
93
+ Returns:
94
+ Dictionary containing success status and connection info.
95
+ """
96
+ with self._lock:
97
+ try:
98
+ # Validate parameters
99
+ if not host:
100
+ return {
101
+ "success": False,
102
+ "error": "Host cannot be empty",
103
+ "error_type": "InvalidParameter"
104
+ }
105
+
106
+ if not (1 <= port <= 65535):
107
+ return {
108
+ "success": False,
109
+ "error": f"Invalid port: {port}. Must be between 1 and 65535.",
110
+ "error_type": "InvalidParameter"
111
+ }
112
+
113
+ # Create socket
114
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
115
+ sock.settimeout(timeout)
116
+
117
+ # Connect to server
118
+ logger.info(f"Connecting to {host}:{port}...")
119
+ sock.connect((host, port))
120
+
121
+ # Set read timeout after connection
122
+ sock.settimeout(read_timeout)
123
+
124
+ # Create configuration
125
+ config = SocketConfig(
126
+ host=host,
127
+ port=port,
128
+ timeout=timeout,
129
+ read_timeout=read_timeout,
130
+ buffer_size=buffer_size,
131
+ max_buffer_packets=max_buffer_packets,
132
+ auto_listen=auto_listen
133
+ )
134
+
135
+ # Generate connection ID
136
+ connection_id = self._generate_connection_id(host, port)
137
+
138
+ # Store the connection
139
+ self._connections[connection_id] = OpenSocket(
140
+ connection_id=connection_id,
141
+ config=config,
142
+ socket=sock
143
+ )
144
+
145
+ logger.info(f"Connected to {host}:{port} with ID: {connection_id}")
146
+
147
+ return {
148
+ "success": True,
149
+ "message": f"Connected to {host}:{port}",
150
+ "connection_id": connection_id,
151
+ "config": {
152
+ "host": host,
153
+ "port": port,
154
+ "timeout": timeout,
155
+ "read_timeout": read_timeout,
156
+ "buffer_size": buffer_size,
157
+ "max_buffer_packets": max_buffer_packets,
158
+ "auto_listen": auto_listen
159
+ }
160
+ }
161
+
162
+ except socket.timeout:
163
+ logger.error(f"Connection to {host}:{port} timed out")
164
+ return {
165
+ "success": False,
166
+ "error": f"Connection to {host}:{port} timed out after {timeout} seconds",
167
+ "error_type": "ConnectionTimeout"
168
+ }
169
+ except socket.gaierror as e:
170
+ logger.error(f"Failed to resolve host {host}: {e}")
171
+ return {
172
+ "success": False,
173
+ "error": f"Failed to resolve host: {host}",
174
+ "error_type": "HostResolutionError"
175
+ }
176
+ except ConnectionRefusedError:
177
+ logger.error(f"Connection refused by {host}:{port}")
178
+ return {
179
+ "success": False,
180
+ "error": f"Connection refused by {host}:{port}",
181
+ "error_type": "ConnectionRefused"
182
+ }
183
+ except Exception as e:
184
+ logger.error(f"Failed to connect to {host}:{port}: {e}")
185
+ return {
186
+ "success": False,
187
+ "error": str(e),
188
+ "error_type": type(e).__name__
189
+ }
190
+
191
+ def disconnect(self, connection_id: str) -> dict[str, Any]:
192
+ """Close a socket connection.
193
+
194
+ Args:
195
+ connection_id: The connection ID to close.
196
+
197
+ Returns:
198
+ Dictionary containing success status and statistics.
199
+ """
200
+ with self._lock:
201
+ if connection_id not in self._connections:
202
+ return {
203
+ "success": False,
204
+ "error": f"Connection {connection_id} not found",
205
+ "error_type": "ConnectionNotFound"
206
+ }
207
+
208
+ try:
209
+ conn = self._connections[connection_id]
210
+ conn.socket.close()
211
+
212
+ # Calculate statistics
213
+ duration = time.time() - conn.opened_at
214
+
215
+ del self._connections[connection_id]
216
+
217
+ logger.info(f"Disconnected: {connection_id}")
218
+
219
+ return {
220
+ "success": True,
221
+ "message": f"Connection {connection_id} closed",
222
+ "connection_id": connection_id,
223
+ "statistics": {
224
+ "duration_seconds": round(duration, 2),
225
+ "bytes_sent": conn.bytes_sent,
226
+ "bytes_received": conn.bytes_received
227
+ }
228
+ }
229
+
230
+ except Exception as e:
231
+ logger.error(f"Error disconnecting {connection_id}: {e}")
232
+ return {
233
+ "success": False,
234
+ "error": str(e),
235
+ "error_type": type(e).__name__
236
+ }
237
+
238
+ def disconnect_all(self) -> dict[str, Any]:
239
+ """Close all socket connections.
240
+
241
+ Returns:
242
+ Dictionary containing success status and closed connections.
243
+ """
244
+ with self._lock:
245
+ closed_connections = []
246
+ errors = []
247
+
248
+ for conn_id in list(self._connections.keys()):
249
+ try:
250
+ self._connections[conn_id].socket.close()
251
+ closed_connections.append(conn_id)
252
+ except Exception as e:
253
+ errors.append({"connection_id": conn_id, "error": str(e)})
254
+
255
+ self._connections.clear()
256
+
257
+ return {
258
+ "success": len(errors) == 0,
259
+ "closed_connections": closed_connections,
260
+ "count": len(closed_connections),
261
+ "errors": errors if errors else None
262
+ }
263
+
264
+ def get_connection_status(self, connection_id: Optional[str] = None) -> dict[str, Any]:
265
+ """Get status of connection(s).
266
+
267
+ Args:
268
+ connection_id: Specific connection to query, or None for all.
269
+
270
+ Returns:
271
+ Dictionary containing connection status information.
272
+ """
273
+ with self._lock:
274
+ if connection_id:
275
+ if connection_id not in self._connections:
276
+ return {
277
+ "success": False,
278
+ "error": f"Connection {connection_id} not found",
279
+ "error_type": "ConnectionNotFound"
280
+ }
281
+
282
+ conn = self._connections[connection_id]
283
+ return {
284
+ "success": True,
285
+ "connection_id": connection_id,
286
+ "config": {
287
+ "host": conn.config.host,
288
+ "port": conn.config.port,
289
+ "timeout": conn.config.timeout,
290
+ "read_timeout": conn.config.read_timeout,
291
+ "buffer_size": conn.config.buffer_size
292
+ },
293
+ "statistics": {
294
+ "opened_at": conn.opened_at,
295
+ "duration_seconds": round(time.time() - conn.opened_at, 2),
296
+ "bytes_sent": conn.bytes_sent,
297
+ "bytes_received": conn.bytes_received
298
+ }
299
+ }
300
+ else:
301
+ # Return all connections
302
+ connections_status = {}
303
+ for conn_id, conn in self._connections.items():
304
+ connections_status[conn_id] = {
305
+ "host": conn.config.host,
306
+ "port": conn.config.port,
307
+ "bytes_sent": conn.bytes_sent,
308
+ "bytes_received": conn.bytes_received,
309
+ "duration_seconds": round(time.time() - conn.opened_at, 2)
310
+ }
311
+
312
+ return {
313
+ "success": True,
314
+ "connections": connections_status,
315
+ "count": len(connections_status)
316
+ }
317
+
318
+ def send_data(
319
+ self,
320
+ connection_id: str,
321
+ data: str,
322
+ encoding: str = "utf-8",
323
+ as_hex: bool = False
324
+ ) -> dict[str, Any]:
325
+ """Send data through a socket connection.
326
+
327
+ Args:
328
+ connection_id: The connection to send through.
329
+ data: Data to send (string or hex string).
330
+ encoding: Text encoding (default utf-8).
331
+ as_hex: If True, interpret data as hex string.
332
+
333
+ Returns:
334
+ Dictionary containing success status and bytes sent.
335
+ """
336
+ with self._lock:
337
+ if connection_id not in self._connections:
338
+ return {
339
+ "success": False,
340
+ "error": f"Connection {connection_id} not found",
341
+ "error_type": "ConnectionNotFound"
342
+ }
343
+
344
+ try:
345
+ conn = self._connections[connection_id]
346
+
347
+ if as_hex:
348
+ # Parse hex string
349
+ hex_str = data.replace(" ", "").replace("0x", "").replace(",", "")
350
+ try:
351
+ byte_data = bytes.fromhex(hex_str)
352
+ except ValueError as e:
353
+ return {
354
+ "success": False,
355
+ "error": f"Invalid hex string: {e}",
356
+ "error_type": "InvalidHexString"
357
+ }
358
+ else:
359
+ byte_data = data.encode(encoding)
360
+
361
+ bytes_sent = conn.socket.send(byte_data)
362
+ conn.bytes_sent += bytes_sent
363
+
364
+ logger.debug(f"Sent {bytes_sent} bytes through {connection_id}")
365
+
366
+ return {
367
+ "success": True,
368
+ "connection_id": connection_id,
369
+ "bytes_sent": bytes_sent,
370
+ "data_hex": byte_data.hex().upper(),
371
+ "data_preview": data[:50] + ("..." if len(data) > 50 else "")
372
+ }
373
+
374
+ except socket.timeout:
375
+ return {
376
+ "success": False,
377
+ "error": "Send timeout",
378
+ "error_type": "SendTimeout"
379
+ }
380
+ except BrokenPipeError:
381
+ return {
382
+ "success": False,
383
+ "error": "Connection broken (remote end closed)",
384
+ "error_type": "BrokenPipe"
385
+ }
386
+ except Exception as e:
387
+ logger.error(f"Error sending to {connection_id}: {e}")
388
+ return {
389
+ "success": False,
390
+ "error": str(e),
391
+ "error_type": type(e).__name__
392
+ }
393
+
394
+ def receive_data(
395
+ self,
396
+ connection_id: str,
397
+ size: Optional[int] = None,
398
+ timeout: Optional[float] = None,
399
+ as_hex: bool = False
400
+ ) -> dict[str, Any]:
401
+ """Receive data from a socket connection.
402
+
403
+ Args:
404
+ connection_id: The connection to receive from.
405
+ size: Maximum bytes to receive (default: buffer_size).
406
+ timeout: Read timeout in seconds (None = use connection default).
407
+ as_hex: If True, return data as hex string only.
408
+
409
+ Returns:
410
+ Dictionary containing success status and received data.
411
+ """
412
+ with self._lock:
413
+ if connection_id not in self._connections:
414
+ return {
415
+ "success": False,
416
+ "error": f"Connection {connection_id} not found",
417
+ "error_type": "ConnectionNotFound"
418
+ }
419
+
420
+ try:
421
+ conn = self._connections[connection_id]
422
+ recv_size = size or conn.config.buffer_size
423
+
424
+ # Temporarily change timeout if specified
425
+ original_timeout = conn.socket.gettimeout()
426
+ if timeout is not None:
427
+ conn.socket.settimeout(timeout)
428
+
429
+ try:
430
+ data = conn.socket.recv(recv_size)
431
+ finally:
432
+ if timeout is not None:
433
+ conn.socket.settimeout(original_timeout)
434
+
435
+ conn.bytes_received += len(data)
436
+
437
+ result = {
438
+ "success": True,
439
+ "connection_id": connection_id,
440
+ "bytes_received": len(data),
441
+ "data_hex": data.hex().upper() if data else ""
442
+ }
443
+
444
+ # Try to decode as text
445
+ if not as_hex:
446
+ try:
447
+ result["data_text"] = data.decode("utf-8", errors="replace")
448
+ except:
449
+ result["data_text"] = None
450
+
451
+ return result
452
+
453
+ except socket.timeout:
454
+ return {
455
+ "success": True,
456
+ "connection_id": connection_id,
457
+ "bytes_received": 0,
458
+ "data_hex": "",
459
+ "data_text": "",
460
+ "message": "No data received (timeout)"
461
+ }
462
+ except Exception as e:
463
+ logger.error(f"Error receiving from {connection_id}: {e}")
464
+ return {
465
+ "success": False,
466
+ "error": str(e),
467
+ "error_type": type(e).__name__
468
+ }
469
+
470
+ def receive_until(
471
+ self,
472
+ connection_id: str,
473
+ terminator: str = "\n",
474
+ size: int = 4096,
475
+ timeout: Optional[float] = None
476
+ ) -> dict[str, Any]:
477
+ """Receive data until a specific terminator is found.
478
+
479
+ Args:
480
+ connection_id: The connection to receive from.
481
+ terminator: Terminator string to look for.
482
+ size: Maximum bytes to receive.
483
+ timeout: Read timeout in seconds.
484
+
485
+ Returns:
486
+ Dictionary containing success status and received data.
487
+ """
488
+ with self._lock:
489
+ if connection_id not in self._connections:
490
+ return {
491
+ "success": False,
492
+ "error": f"Connection {connection_id} not found",
493
+ "error_type": "ConnectionNotFound"
494
+ }
495
+
496
+ try:
497
+ conn = self._connections[connection_id]
498
+ terminator_bytes = terminator.encode("utf-8")
499
+
500
+ # Temporarily change timeout if specified
501
+ original_timeout = conn.socket.gettimeout()
502
+ if timeout is not None:
503
+ conn.socket.settimeout(timeout)
504
+
505
+ try:
506
+ buffer = b""
507
+ while len(buffer) < size:
508
+ chunk = conn.socket.recv(1)
509
+ if not chunk:
510
+ break
511
+ buffer += chunk
512
+ if buffer.endswith(terminator_bytes):
513
+ break
514
+ finally:
515
+ if timeout is not None:
516
+ conn.socket.settimeout(original_timeout)
517
+
518
+ conn.bytes_received += len(buffer)
519
+
520
+ return {
521
+ "success": True,
522
+ "connection_id": connection_id,
523
+ "data_text": buffer.decode("utf-8", errors="replace"),
524
+ "data_hex": buffer.hex().upper(),
525
+ "bytes_received": len(buffer)
526
+ }
527
+
528
+ except socket.timeout:
529
+ return {
530
+ "success": True,
531
+ "connection_id": connection_id,
532
+ "bytes_received": 0,
533
+ "data_hex": "",
534
+ "data_text": "",
535
+ "message": "No data received (timeout)"
536
+ }
537
+ except Exception as e:
538
+ logger.error(f"Error receiving from {connection_id}: {e}")
539
+ return {
540
+ "success": False,
541
+ "error": str(e),
542
+ "error_type": type(e).__name__
543
+ }
544
+
545
+ def receive_line(
546
+ self,
547
+ connection_id: str,
548
+ timeout: Optional[float] = None,
549
+ encoding: str = "utf-8"
550
+ ) -> dict[str, Any]:
551
+ """Receive a line (until newline) from a socket connection.
552
+
553
+ Args:
554
+ connection_id: The connection to receive from.
555
+ timeout: Read timeout in seconds.
556
+ encoding: Text encoding for decoding.
557
+
558
+ Returns:
559
+ Dictionary containing success status and received line.
560
+ """
561
+ result = self.receive_until(
562
+ connection_id=connection_id,
563
+ terminator="\n",
564
+ timeout=timeout
565
+ )
566
+
567
+ if result.get("success") and result.get("data_text"):
568
+ # Strip trailing newline characters
569
+ result["line"] = result["data_text"].rstrip("\r\n")
570
+
571
+ return result
572
+
573
+ def set_protocol(
574
+ self,
575
+ connection_id: str,
576
+ mode: str = "raw",
577
+ start_delimiter: Optional[str] = None,
578
+ end_delimiter: Optional[str] = None,
579
+ start_delimiter_hex: Optional[str] = None,
580
+ end_delimiter_hex: Optional[str] = None,
581
+ include_delimiters: bool = False
582
+ ) -> dict[str, Any]:
583
+ """Set the receive protocol for a connection.
584
+
585
+ Configure how data should be received and parsed.
586
+
587
+ Args:
588
+ connection_id: The connection to configure.
589
+ mode: Receive mode - 'raw', 'line', or 'packet'.
590
+ - raw: Receive data as-is
591
+ - line: Receive data line by line (end_delimiter = newline)
592
+ - packet: Parse packets using start/end delimiters
593
+ start_delimiter: Start of packet delimiter (text format).
594
+ end_delimiter: End of packet delimiter (text format).
595
+ start_delimiter_hex: Start delimiter in hex format (e.g., "0A").
596
+ end_delimiter_hex: End delimiter in hex format (e.g., "0D0A").
597
+ include_delimiters: Include delimiters in received data.
598
+
599
+ Returns:
600
+ Dictionary containing success status and protocol settings.
601
+
602
+ Examples:
603
+ - set_protocol("conn_id", mode="line") # Line mode (\\n terminator)
604
+ - set_protocol("conn_id", mode="packet", end_delimiter_hex="0D0A")
605
+ - set_protocol("conn_id", mode="packet", start_delimiter_hex="0A", end_delimiter_hex="0D0A")
606
+ """
607
+ with self._lock:
608
+ if connection_id not in self._connections:
609
+ return {
610
+ "success": False,
611
+ "error": f"Connection {connection_id} not found",
612
+ "error_type": "ConnectionNotFound"
613
+ }
614
+
615
+ # Validate mode
616
+ valid_modes = ["raw", "line", "packet"]
617
+ if mode not in valid_modes:
618
+ return {
619
+ "success": False,
620
+ "error": f"Invalid mode: {mode}. Valid options: {valid_modes}",
621
+ "error_type": "InvalidParameter"
622
+ }
623
+
624
+ # Parse delimiters
625
+ start_bytes = b""
626
+ end_bytes = b"\n" # Default for line mode
627
+
628
+ if mode == "packet":
629
+ # Parse start delimiter
630
+ if start_delimiter_hex:
631
+ try:
632
+ start_bytes = bytes.fromhex(start_delimiter_hex.replace(" ", ""))
633
+ except ValueError as e:
634
+ return {
635
+ "success": False,
636
+ "error": f"Invalid start_delimiter_hex: {e}",
637
+ "error_type": "InvalidHexString"
638
+ }
639
+ elif start_delimiter:
640
+ start_bytes = start_delimiter.encode("utf-8")
641
+
642
+ # Parse end delimiter
643
+ if end_delimiter_hex:
644
+ try:
645
+ end_bytes = bytes.fromhex(end_delimiter_hex.replace(" ", ""))
646
+ except ValueError as e:
647
+ return {
648
+ "success": False,
649
+ "error": f"Invalid end_delimiter_hex: {e}",
650
+ "error_type": "InvalidHexString"
651
+ }
652
+ elif end_delimiter:
653
+ end_bytes = end_delimiter.encode("utf-8")
654
+
655
+ # Update protocol
656
+ conn = self._connections[connection_id]
657
+ conn.protocol = ReceiveProtocol(
658
+ mode=mode,
659
+ start_delimiter=start_bytes,
660
+ end_delimiter=end_bytes,
661
+ include_delimiters=include_delimiters
662
+ )
663
+
664
+ # Clear receive buffer when changing protocol
665
+ conn.receive_buffer = b""
666
+
667
+ logger.info(f"Set protocol for {connection_id}: mode={mode}, start={start_bytes.hex().upper()}, end={end_bytes.hex().upper()}")
668
+
669
+ return {
670
+ "success": True,
671
+ "connection_id": connection_id,
672
+ "protocol": {
673
+ "mode": mode,
674
+ "start_delimiter_hex": start_bytes.hex().upper() if start_bytes else "",
675
+ "end_delimiter_hex": end_bytes.hex().upper(),
676
+ "include_delimiters": include_delimiters
677
+ }
678
+ }
679
+
680
+ def receive_packet(
681
+ self,
682
+ connection_id: str,
683
+ timeout: Optional[float] = None,
684
+ max_size: int = 4096
685
+ ) -> dict[str, Any]:
686
+ """Receive a complete packet based on the configured protocol.
687
+
688
+ Uses the start/end delimiters configured via set_protocol().
689
+ For packet mode: waits for data between start_delimiter and end_delimiter.
690
+ For line mode: equivalent to receive_line().
691
+ For raw mode: equivalent to receive_data().
692
+
693
+ Args:
694
+ connection_id: The connection to receive from.
695
+ timeout: Read timeout in seconds.
696
+ max_size: Maximum packet size to prevent memory issues.
697
+
698
+ Returns:
699
+ Dictionary containing success status, packet data, and metadata.
700
+ """
701
+ with self._lock:
702
+ if connection_id not in self._connections:
703
+ return {
704
+ "success": False,
705
+ "error": f"Connection {connection_id} not found",
706
+ "error_type": "ConnectionNotFound"
707
+ }
708
+
709
+ conn = self._connections[connection_id]
710
+ protocol = conn.protocol
711
+
712
+ # For raw mode, just receive data
713
+ if protocol.mode == "raw":
714
+ # Release lock temporarily for receive_data
715
+ pass
716
+
717
+ # Handle different modes (outside lock to avoid deadlock with receive_data)
718
+ if protocol.mode == "raw":
719
+ return self.receive_data(connection_id, size=max_size, timeout=timeout)
720
+ elif protocol.mode == "line":
721
+ return self.receive_line(connection_id, timeout=timeout)
722
+
723
+ # Packet mode - parse with start/end delimiters
724
+ with self._lock:
725
+ if connection_id not in self._connections:
726
+ return {
727
+ "success": False,
728
+ "error": f"Connection {connection_id} not found",
729
+ "error_type": "ConnectionNotFound"
730
+ }
731
+
732
+ conn = self._connections[connection_id]
733
+ protocol = conn.protocol
734
+ start_delim = protocol.start_delimiter
735
+ end_delim = protocol.end_delimiter
736
+
737
+ try:
738
+ original_timeout = conn.socket.gettimeout()
739
+ if timeout is not None:
740
+ conn.socket.settimeout(timeout)
741
+
742
+ try:
743
+ buffer = conn.receive_buffer
744
+ packet_found = False
745
+ start_pos = -1
746
+ end_pos = -1
747
+
748
+ while len(buffer) < max_size:
749
+ # Check if we already have a complete packet in buffer
750
+ if start_delim:
751
+ start_pos = buffer.find(start_delim)
752
+ if start_pos >= 0:
753
+ search_start = start_pos + len(start_delim)
754
+ end_pos = buffer.find(end_delim, search_start)
755
+ if end_pos >= 0:
756
+ packet_found = True
757
+ break
758
+ else:
759
+ # No start delimiter, just look for end
760
+ end_pos = buffer.find(end_delim)
761
+ if end_pos >= 0:
762
+ start_pos = 0
763
+ packet_found = True
764
+ break
765
+
766
+ # Need more data
767
+ try:
768
+ chunk = conn.socket.recv(conn.config.buffer_size)
769
+ if not chunk:
770
+ break # Connection closed
771
+ buffer += chunk
772
+ except socket.timeout:
773
+ break
774
+
775
+ # Update connection buffer
776
+ if packet_found:
777
+ # Extract packet
778
+ if start_delim:
779
+ data_start = start_pos + len(start_delim)
780
+ else:
781
+ data_start = 0
782
+
783
+ packet_data = buffer[data_start:end_pos]
784
+
785
+ # Include delimiters if configured
786
+ if protocol.include_delimiters:
787
+ if start_delim:
788
+ packet_with_delim = start_delim + packet_data + end_delim
789
+ else:
790
+ packet_with_delim = packet_data + end_delim
791
+ else:
792
+ packet_with_delim = packet_data
793
+
794
+ # Remove processed data from buffer
795
+ conn.receive_buffer = buffer[end_pos + len(end_delim):]
796
+ conn.bytes_received += len(packet_with_delim)
797
+
798
+ return {
799
+ "success": True,
800
+ "connection_id": connection_id,
801
+ "packet_found": True,
802
+ "data_hex": packet_with_delim.hex().upper(),
803
+ "data_text": packet_with_delim.decode("utf-8", errors="replace"),
804
+ "payload_hex": packet_data.hex().upper(),
805
+ "payload_text": packet_data.decode("utf-8", errors="replace"),
806
+ "bytes_received": len(packet_with_delim)
807
+ }
808
+ else:
809
+ # No complete packet found, save buffer for next call
810
+ conn.receive_buffer = buffer
811
+ return {
812
+ "success": True,
813
+ "connection_id": connection_id,
814
+ "packet_found": False,
815
+ "message": "No complete packet received (timeout or max_size reached)",
816
+ "buffer_size": len(buffer)
817
+ }
818
+
819
+ finally:
820
+ if timeout is not None:
821
+ conn.socket.settimeout(original_timeout)
822
+
823
+ except Exception as e:
824
+ logger.error(f"Error receiving packet from {connection_id}: {e}")
825
+ return {
826
+ "success": False,
827
+ "error": str(e),
828
+ "error_type": type(e).__name__
829
+ }
830
+
831
+ def get_protocol(self, connection_id: str) -> dict[str, Any]:
832
+ """Get the current receive protocol settings for a connection.
833
+
834
+ Args:
835
+ connection_id: The connection to query.
836
+
837
+ Returns:
838
+ Dictionary containing protocol settings.
839
+ """
840
+ with self._lock:
841
+ if connection_id not in self._connections:
842
+ return {
843
+ "success": False,
844
+ "error": f"Connection {connection_id} not found",
845
+ "error_type": "ConnectionNotFound"
846
+ }
847
+
848
+ conn = self._connections[connection_id]
849
+ protocol = conn.protocol
850
+
851
+ return {
852
+ "success": True,
853
+ "connection_id": connection_id,
854
+ "protocol": {
855
+ "mode": protocol.mode,
856
+ "start_delimiter_hex": protocol.start_delimiter.hex().upper() if protocol.start_delimiter else "",
857
+ "end_delimiter_hex": protocol.end_delimiter.hex().upper(),
858
+ "include_delimiters": protocol.include_delimiters
859
+ },
860
+ "buffer_size": len(conn.receive_buffer)
861
+ }
862
+
863
+ # ==================== Background Listener ====================
864
+
865
+ def _listener_loop(self, connection_id: str) -> None:
866
+ """Background thread loop for receiving packets."""
867
+ logger.info(f"Starting background listener for {connection_id}")
868
+
869
+ while True:
870
+ with self._lock:
871
+ if connection_id not in self._connections:
872
+ logger.info(f"Connection {connection_id} closed, stopping listener")
873
+ return
874
+ conn = self._connections[connection_id]
875
+ if not conn.listener_running:
876
+ logger.info(f"Listener stopped for {connection_id}")
877
+ return
878
+
879
+ protocol = conn.protocol
880
+ start_delim = protocol.start_delimiter
881
+ end_delim = protocol.end_delimiter
882
+ max_packets = conn.config.max_buffer_packets
883
+
884
+ try:
885
+ # Receive data outside lock to avoid blocking
886
+ with self._lock:
887
+ if connection_id not in self._connections:
888
+ return
889
+ conn = self._connections[connection_id]
890
+ sock = conn.socket
891
+ buffer = conn.receive_buffer
892
+
893
+ # Try to receive data with short timeout
894
+ try:
895
+ sock.settimeout(0.5)
896
+ chunk = sock.recv(conn.config.buffer_size)
897
+ if chunk:
898
+ buffer += chunk
899
+ except socket.timeout:
900
+ pass
901
+ except (ConnectionResetError, BrokenPipeError, OSError):
902
+ logger.warning(f"Connection {connection_id} lost")
903
+ with self._lock:
904
+ if connection_id in self._connections:
905
+ self._connections[connection_id].listener_running = False
906
+ return
907
+
908
+ # Parse packets from buffer
909
+ packets_found = []
910
+ while True:
911
+ if start_delim:
912
+ start_pos = buffer.find(start_delim)
913
+ if start_pos < 0:
914
+ break
915
+ search_start = start_pos + len(start_delim)
916
+ end_pos = buffer.find(end_delim, search_start)
917
+ if end_pos < 0:
918
+ break
919
+ # Extract packet data
920
+ data_start = start_pos + len(start_delim)
921
+ packet_data = buffer[data_start:end_pos]
922
+ # Include delimiters if configured
923
+ if protocol.include_delimiters:
924
+ packet_with_delim = start_delim + packet_data + end_delim
925
+ else:
926
+ packet_with_delim = packet_data
927
+ packets_found.append(packet_with_delim)
928
+ buffer = buffer[end_pos + len(end_delim):]
929
+ else:
930
+ # No start delimiter
931
+ end_pos = buffer.find(end_delim)
932
+ if end_pos < 0:
933
+ break
934
+ packet_data = buffer[:end_pos]
935
+ if protocol.include_delimiters:
936
+ packet_with_delim = packet_data + end_delim
937
+ else:
938
+ packet_with_delim = packet_data
939
+ packets_found.append(packet_with_delim)
940
+ buffer = buffer[end_pos + len(end_delim):]
941
+
942
+ # Store parsed packets
943
+ with self._lock:
944
+ if connection_id not in self._connections:
945
+ return
946
+ conn = self._connections[connection_id]
947
+ conn.receive_buffer = buffer
948
+
949
+ with conn._packet_lock:
950
+ for packet in packets_found:
951
+ conn.bytes_received += len(packet)
952
+ if len(conn.packet_buffer) >= max_packets:
953
+ # Drop oldest (drop_oldest policy)
954
+ conn.packet_buffer.pop(0)
955
+ conn.overflow_count += 1
956
+ conn.packet_buffer.append({
957
+ "data_hex": packet.hex().upper(),
958
+ "data_text": packet.decode("utf-8", errors="replace"),
959
+ "timestamp": time.time(),
960
+ "size": len(packet)
961
+ })
962
+
963
+ except Exception as e:
964
+ logger.error(f"Listener error for {connection_id}: {e}")
965
+ time.sleep(0.5)
966
+
967
+ def start_listening(self, connection_id: str) -> dict[str, Any]:
968
+ """Start background packet listener for a connection.
969
+
970
+ Args:
971
+ connection_id: The connection ID.
972
+
973
+ Returns:
974
+ Dictionary containing success status.
975
+ """
976
+ with self._lock:
977
+ if connection_id not in self._connections:
978
+ return {
979
+ "success": False,
980
+ "error": f"Connection {connection_id} not found",
981
+ "error_type": "ConnectionNotFound"
982
+ }
983
+
984
+ conn = self._connections[connection_id]
985
+
986
+ if conn.listener_running:
987
+ return {
988
+ "success": True,
989
+ "message": "Listener already running",
990
+ "connection_id": connection_id
991
+ }
992
+
993
+ conn.listener_running = True
994
+ conn.listener_thread = threading.Thread(
995
+ target=self._listener_loop,
996
+ args=(connection_id,),
997
+ daemon=True
998
+ )
999
+ conn.listener_thread.start()
1000
+
1001
+ logger.info(f"Started listener for {connection_id}")
1002
+
1003
+ return {
1004
+ "success": True,
1005
+ "message": "Background listener started",
1006
+ "connection_id": connection_id,
1007
+ "max_buffer_packets": conn.config.max_buffer_packets
1008
+ }
1009
+
1010
+ def stop_listening(self, connection_id: str) -> dict[str, Any]:
1011
+ """Stop background packet listener for a connection.
1012
+
1013
+ Args:
1014
+ connection_id: The connection ID.
1015
+
1016
+ Returns:
1017
+ Dictionary containing success status.
1018
+ """
1019
+ with self._lock:
1020
+ if connection_id not in self._connections:
1021
+ return {
1022
+ "success": False,
1023
+ "error": f"Connection {connection_id} not found",
1024
+ "error_type": "ConnectionNotFound"
1025
+ }
1026
+
1027
+ conn = self._connections[connection_id]
1028
+ conn.listener_running = False
1029
+
1030
+ logger.info(f"Stopped listener for {connection_id}")
1031
+
1032
+ return {
1033
+ "success": True,
1034
+ "message": "Background listener stopped",
1035
+ "connection_id": connection_id
1036
+ }
1037
+
1038
+ def get_packets(self, connection_id: str, clear: bool = True) -> dict[str, Any]:
1039
+ """Get all buffered packets from a connection.
1040
+
1041
+ Args:
1042
+ connection_id: The connection ID.
1043
+ clear: Clear buffer after getting packets (default True).
1044
+
1045
+ Returns:
1046
+ Dictionary containing packets array.
1047
+ """
1048
+ with self._lock:
1049
+ if connection_id not in self._connections:
1050
+ return {
1051
+ "success": False,
1052
+ "error": f"Connection {connection_id} not found",
1053
+ "error_type": "ConnectionNotFound"
1054
+ }
1055
+
1056
+ conn = self._connections[connection_id]
1057
+
1058
+ with conn._packet_lock:
1059
+ packets = list(conn.packet_buffer)
1060
+ if clear:
1061
+ conn.packet_buffer.clear()
1062
+
1063
+ return {
1064
+ "success": True,
1065
+ "connection_id": connection_id,
1066
+ "packets": packets,
1067
+ "count": len(packets),
1068
+ "overflow_count": conn.overflow_count
1069
+ }
1070
+
1071
+ def get_buffer_status(self, connection_id: str) -> dict[str, Any]:
1072
+ """Get buffer status for a connection.
1073
+
1074
+ Args:
1075
+ connection_id: The connection ID.
1076
+
1077
+ Returns:
1078
+ Dictionary containing buffer status.
1079
+ """
1080
+ with self._lock:
1081
+ if connection_id not in self._connections:
1082
+ return {
1083
+ "success": False,
1084
+ "error": f"Connection {connection_id} not found",
1085
+ "error_type": "ConnectionNotFound"
1086
+ }
1087
+
1088
+ conn = self._connections[connection_id]
1089
+
1090
+ with conn._packet_lock:
1091
+ count = len(conn.packet_buffer)
1092
+
1093
+ return {
1094
+ "success": True,
1095
+ "connection_id": connection_id,
1096
+ "buffer": {
1097
+ "count": count,
1098
+ "max": conn.config.max_buffer_packets,
1099
+ "overflow_count": conn.overflow_count,
1100
+ "raw_buffer_size": len(conn.receive_buffer)
1101
+ },
1102
+ "listener_running": conn.listener_running
1103
+ }