phystack-hub-client 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,527 @@
1
+ """
2
+ DataChannelHandler
3
+
4
+ Manages RTCDataChannel lifecycle including:
5
+ - Channel creation (initiator) or reception (responder)
6
+ - Message serialization/deserialization
7
+ - Persistent channel abstraction across reconnections
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Callable, Optional, Set
14
+
15
+ from aiortc import RTCPeerConnection, RTCSessionDescription, RTCIceCandidate, RTCConfiguration, RTCIceServer
16
+
17
+
18
+ @dataclass
19
+ class DataChannelOptions:
20
+ """Options for DataChannel connection."""
21
+
22
+ use_stun: bool = True
23
+ stun_servers: list[str] = field(
24
+ default_factory=lambda: ["stun:stun.l.google.com:19302"]
25
+ )
26
+ connection_timeout: float = 15.0
27
+ initial_retry_delay: float = 1.0
28
+ max_retry_delay: float = 30.0
29
+
30
+
31
+ class PhygridDataChannel:
32
+ """High-level DataChannel interface."""
33
+
34
+ def __init__(self, handler: "DataChannelHandler") -> None:
35
+ self._handler = handler
36
+
37
+ def send(self, data: str | bytes | dict[str, Any]) -> None:
38
+ """Send data through the channel.
39
+
40
+ Args:
41
+ data: String, bytes, or dict (will be JSON serialized).
42
+ """
43
+ self._handler.send(data)
44
+
45
+ def on_message(self, callback: Callable[[Any], None]) -> None:
46
+ """Register a message handler.
47
+
48
+ Args:
49
+ callback: Handler function.
50
+ """
51
+ self._handler.on_message(callback)
52
+
53
+ def off_message(self, callback: Callable[[Any], None]) -> None:
54
+ """Remove a message handler."""
55
+ self._handler.off_message(callback)
56
+
57
+ def on_close(self, callback: Callable[[], None]) -> None:
58
+ """Register a close handler."""
59
+ self._handler.on_close(callback)
60
+
61
+ def off_close(self, callback: Callable[[], None]) -> None:
62
+ """Remove a close handler."""
63
+ self._handler.off_close(callback)
64
+
65
+ def close(self) -> None:
66
+ """Close the channel."""
67
+ self._handler.close()
68
+
69
+ def is_open(self) -> bool:
70
+ """Check if channel is open."""
71
+ return self._handler.is_open
72
+
73
+ def is_connecting(self) -> bool:
74
+ """Check if currently connecting."""
75
+ return self._handler.is_connecting
76
+
77
+ @property
78
+ def target_twin_id(self) -> str:
79
+ """Get target twin ID."""
80
+ return self._handler.target_twin_id
81
+
82
+ @property
83
+ def channel_name(self) -> str:
84
+ """Get channel name."""
85
+ return self._handler.channel_name
86
+
87
+
88
+ class TwinMessagingInterface:
89
+ """Interface for twin messaging operations."""
90
+
91
+ def __init__(
92
+ self,
93
+ send_message: Callable[[str, dict[str, Any]], Any],
94
+ subscribe: Callable[[str], Any],
95
+ on_message: Callable[[str, Callable[[dict[str, Any]], None]], None],
96
+ off_message: Callable[[str, Callable[[dict[str, Any]], None]], None],
97
+ get_own_twin_id: Callable[[], str],
98
+ ) -> None:
99
+ self.send_message = send_message
100
+ self.subscribe = subscribe
101
+ self.on_message = on_message
102
+ self.off_message = off_message
103
+ self.get_own_twin_id = get_own_twin_id
104
+
105
+
106
+ class DataChannelHandler:
107
+ """Handles WebRTC DataChannel connections."""
108
+
109
+ def __init__(
110
+ self,
111
+ target_twin_id: str,
112
+ is_initiator: bool,
113
+ twin_messaging: TwinMessagingInterface,
114
+ options: Optional[DataChannelOptions] = None,
115
+ channel_name: str = "default",
116
+ ) -> None:
117
+ self.target_twin_id = target_twin_id
118
+ self.channel_name = channel_name
119
+ self._is_initiator = is_initiator
120
+ self._twin_messaging = twin_messaging
121
+ self._options = options or DataChannelOptions()
122
+
123
+ self._pc: Optional[RTCPeerConnection] = None
124
+ self._data_channel: Optional[Any] = None # aiortc.RTCDataChannel
125
+ self._is_open = False
126
+ self._is_closed = False
127
+ self._is_connecting = False
128
+
129
+ self._message_listeners: Set[Callable[[Any], None]] = set()
130
+ self._close_listeners: Set[Callable[[], None]] = set()
131
+
132
+ # Callbacks
133
+ self._on_connected: Optional[Callable[[], None]] = None
134
+ self._on_disconnected: Optional[Callable[[], None]] = None
135
+ self._on_error: Optional[Callable[[Exception], None]] = None
136
+
137
+ # Message handler reference for cleanup
138
+ self._message_handler: Optional[Callable[[dict[str, Any]], None]] = None
139
+
140
+ @property
141
+ def is_open(self) -> bool:
142
+ """Check if channel is open."""
143
+ return self._is_open
144
+
145
+ @property
146
+ def is_connecting(self) -> bool:
147
+ """Check if currently connecting."""
148
+ return self._is_connecting
149
+
150
+ def set_callbacks(
151
+ self,
152
+ on_connected: Optional[Callable[[], None]] = None,
153
+ on_disconnected: Optional[Callable[[], None]] = None,
154
+ on_error: Optional[Callable[[Exception], None]] = None,
155
+ ) -> None:
156
+ """Set connection callbacks."""
157
+ self._on_connected = on_connected
158
+ self._on_disconnected = on_disconnected
159
+ self._on_error = on_error
160
+
161
+ async def connect(self, blocking: bool = True) -> PhygridDataChannel:
162
+ """Connect and return a PhygridDataChannel.
163
+
164
+ Args:
165
+ blocking: If True (default), wait for connection to complete.
166
+ If False, return immediately and connect in background.
167
+
168
+ Returns:
169
+ PhygridDataChannel instance.
170
+
171
+ Raises:
172
+ RuntimeError: If already closed.
173
+ """
174
+ if self._is_closed:
175
+ raise RuntimeError("DataChannelHandler has been closed")
176
+
177
+ self._is_connecting = True
178
+ own_twin_id = self._twin_messaging.get_own_twin_id()
179
+
180
+ # Channel prefix for signaling messages - matches npm hub-client format
181
+ # Prefix is just dc-{name}, filter matches dc-{name}-*
182
+ channel_prefix = f"dc-{self.channel_name}"
183
+
184
+ # Configure STUN servers
185
+ ice_servers = []
186
+ if self._options.use_stun:
187
+ for server in self._options.stun_servers:
188
+ ice_servers.append(RTCIceServer(urls=server))
189
+
190
+ # Create peer connection with proper RTCConfiguration
191
+ config = RTCConfiguration(iceServers=ice_servers) if ice_servers else None
192
+ self._pc = RTCPeerConnection(configuration=config)
193
+
194
+ # Set up message handler for signaling
195
+ self._message_handler = self._create_signaling_handler(channel_prefix)
196
+ self._twin_messaging.on_message(own_twin_id, self._message_handler)
197
+
198
+ async def do_connect() -> None:
199
+ """Perform the connection in background."""
200
+ try:
201
+ if self._is_initiator:
202
+ await self._initiate_connection(channel_prefix)
203
+ else:
204
+ await self._wait_for_connection(channel_prefix)
205
+ except Exception as e:
206
+ self._is_connecting = False
207
+ if self._on_error:
208
+ self._on_error(e)
209
+ if blocking:
210
+ raise
211
+
212
+ if blocking:
213
+ await do_connect()
214
+ else:
215
+ # Non-blocking: start connection in background
216
+ asyncio.create_task(do_connect())
217
+
218
+ return PhygridDataChannel(self)
219
+
220
+ def _create_signaling_handler(
221
+ self, channel_prefix: str
222
+ ) -> Callable[[dict[str, Any]], None]:
223
+ """Create signaling message handler."""
224
+ own_twin_id = self._twin_messaging.get_own_twin_id()
225
+
226
+ def handler(payload: dict[str, Any]) -> None:
227
+ # Filter by source twin - only process messages FROM the target peer
228
+ source_twin_id = payload.get("sourceTwinId")
229
+ if source_twin_id != self.target_twin_id:
230
+ return
231
+
232
+ data = payload.get("data", {})
233
+ msg_type = data.get("type", "")
234
+
235
+ # Check if this message is for our channel
236
+ # Format: {channelPrefix}-{twinId}:{signalType}
237
+ prefix_match = f"{channel_prefix}-"
238
+ if not msg_type.startswith(prefix_match):
239
+ return
240
+
241
+ signal_type = msg_type.split(":")[-1]
242
+ signal_data = data.get("data", {})
243
+ print(f"[DataChannelHandler] Received signal: {signal_type} from {source_twin_id}")
244
+
245
+ asyncio.create_task(self._handle_signal(signal_type, signal_data))
246
+
247
+ return handler
248
+
249
+ async def _handle_signal(self, signal_type: str, data: dict[str, Any]) -> None:
250
+ """Handle incoming signaling message."""
251
+ if not self._pc:
252
+ return
253
+
254
+ try:
255
+ if signal_type == "offer":
256
+ print(f"[DataChannelHandler] Setting remote description (offer)")
257
+ await self._pc.setRemoteDescription(
258
+ RTCSessionDescription(sdp=data["sdp"], type=data["type"])
259
+ )
260
+ print(f"[DataChannelHandler] Creating answer...")
261
+ answer = await self._pc.createAnswer()
262
+ print(f"[DataChannelHandler] Setting local description (answer)")
263
+ await self._pc.setLocalDescription(answer)
264
+ await self._send_signal("answer", {
265
+ "sdp": self._pc.localDescription.sdp,
266
+ "type": self._pc.localDescription.type,
267
+ })
268
+ print(f"[DataChannelHandler] Answer sent")
269
+
270
+ elif signal_type == "answer":
271
+ # Ignore duplicate answers if already in stable state
272
+ if self._pc.signalingState == "stable":
273
+ print(f"[DataChannelHandler] Ignoring duplicate answer (already stable)")
274
+ return
275
+ print(f"[DataChannelHandler] Setting remote description (answer)")
276
+ await self._pc.setRemoteDescription(
277
+ RTCSessionDescription(sdp=data["sdp"], type=data["type"])
278
+ )
279
+
280
+ elif signal_type == "ice":
281
+ if data:
282
+ # Parse ICE candidate from signaling data
283
+ # Data might contain the candidate string or full candidate object
284
+ candidate_str = data.get("candidate")
285
+ sdp_mid = data.get("sdpMid")
286
+ sdp_mline_index = data.get("sdpMLineIndex")
287
+
288
+ if candidate_str:
289
+ # aiortc expects RTCIceCandidate constructed from string
290
+ # Parse the candidate string to extract fields
291
+ candidate = RTCIceCandidate(
292
+ component=1, # RTP
293
+ foundation="",
294
+ ip="",
295
+ port=0,
296
+ priority=0,
297
+ protocol="udp",
298
+ type="host",
299
+ sdpMid=sdp_mid,
300
+ sdpMLineIndex=sdp_mline_index,
301
+ )
302
+ # Set the candidate string for aiortc to parse
303
+ candidate.candidate = candidate_str
304
+ await self._pc.addIceCandidate(candidate)
305
+
306
+ except Exception as e:
307
+ print(f"[DataChannelHandler] Error handling signal {signal_type}: {e}")
308
+ import traceback
309
+ traceback.print_exc()
310
+
311
+ async def _send_signal(self, signal_type: str, data: dict[str, Any]) -> None:
312
+ """Send signaling message."""
313
+ # Message type format: dc-{name}-{targetTwinId}:{signalType}
314
+ # This matches npm hub-client format
315
+ channel_id = f"dc-{self.channel_name}-{self.target_twin_id}"
316
+
317
+ await self._twin_messaging.send_message(
318
+ self.target_twin_id,
319
+ {
320
+ "type": f"{channel_id}:{signal_type}",
321
+ "data": data,
322
+ },
323
+ )
324
+
325
+ async def _initiate_connection(self, channel_prefix: str) -> None:
326
+ """Initiate WebRTC connection as the caller."""
327
+ if not self._pc:
328
+ return
329
+
330
+ # Create data channel before creating offer (like npm)
331
+ channel_id = f"dc-{self.channel_name}-{self.target_twin_id}"
332
+ print(f"[DataChannelHandler] Creating data channel: {channel_id}")
333
+ self._data_channel = self._pc.createDataChannel(channel_id)
334
+ self._attach_channel_handlers()
335
+
336
+ # Set up ICE candidate handler
337
+ @self._pc.on("icecandidate")
338
+ async def on_ice_candidate(candidate: Optional[RTCIceCandidate]) -> None:
339
+ if candidate:
340
+ print(f"[DataChannelHandler] Sending ICE candidate")
341
+ # Send in npm format: { candidate, sdpMid, sdpMLineIndex }
342
+ await self._send_signal("ice", {
343
+ "candidate": candidate.candidate,
344
+ "sdpMid": candidate.sdpMid,
345
+ "sdpMLineIndex": candidate.sdpMLineIndex,
346
+ })
347
+
348
+ # Create and send offer
349
+ print(f"[DataChannelHandler] Creating offer...")
350
+ offer = await self._pc.createOffer()
351
+ print(f"[DataChannelHandler] Setting local description (offer)")
352
+ await self._pc.setLocalDescription(offer)
353
+ await self._send_signal("offer", {
354
+ "sdp": self._pc.localDescription.sdp,
355
+ "type": self._pc.localDescription.type,
356
+ })
357
+ print(f"[DataChannelHandler] Offer sent")
358
+
359
+ # Wait for channel to open
360
+ await self._wait_for_open()
361
+
362
+ async def _wait_for_connection(self, channel_prefix: str) -> None:
363
+ """Wait for incoming WebRTC connection as the callee."""
364
+ if not self._pc:
365
+ return
366
+
367
+ print(f"[DataChannelHandler] Waiting for connection as responder...")
368
+
369
+ # Set up data channel handler for incoming channel BEFORE receiving offer
370
+ @self._pc.on("datachannel")
371
+ def on_datachannel(channel: Any) -> None:
372
+ print(f"[DataChannelHandler] Received data channel: {channel.label}")
373
+ self._data_channel = channel
374
+ self._attach_channel_handlers()
375
+
376
+ # Set up ICE candidate handler
377
+ @self._pc.on("icecandidate")
378
+ async def on_ice_candidate(candidate: Optional[RTCIceCandidate]) -> None:
379
+ if candidate:
380
+ print(f"[DataChannelHandler] Sending ICE candidate")
381
+ await self._send_signal("ice", {
382
+ "candidate": candidate.candidate,
383
+ "sdpMid": candidate.sdpMid,
384
+ "sdpMLineIndex": candidate.sdpMLineIndex,
385
+ })
386
+
387
+ # Wait for channel to open
388
+ await self._wait_for_open()
389
+
390
+ def _attach_channel_handlers(self) -> None:
391
+ """Attach event handlers to data channel."""
392
+ if not self._data_channel:
393
+ return
394
+
395
+ # Check if channel is already open (common for responder receiving datachannel)
396
+ if hasattr(self._data_channel, 'readyState') and self._data_channel.readyState == "open":
397
+ print("[DataChannelHandler] Channel already open")
398
+ self._is_open = True
399
+ self._is_connecting = False
400
+ if self._on_connected:
401
+ self._on_connected()
402
+
403
+ @self._data_channel.on("open")
404
+ def on_open() -> None:
405
+ print("[DataChannelHandler] Channel opened")
406
+ self._is_open = True
407
+ self._is_connecting = False
408
+ if self._on_connected:
409
+ self._on_connected()
410
+
411
+ @self._data_channel.on("close")
412
+ def on_close() -> None:
413
+ print("[DataChannelHandler] Channel closed")
414
+ self._is_open = False
415
+ if not self._is_closed and self._on_disconnected:
416
+ self._on_disconnected()
417
+ self._notify_close_listeners()
418
+
419
+ @self._data_channel.on("message")
420
+ def on_message(message: str | bytes) -> None:
421
+ self._handle_message(message)
422
+
423
+ async def _wait_for_open(self) -> None:
424
+ """Wait for data channel to open."""
425
+ timeout = self._options.connection_timeout
426
+ start = asyncio.get_event_loop().time()
427
+
428
+ while not self._is_open:
429
+ if asyncio.get_event_loop().time() - start > timeout:
430
+ raise TimeoutError(f"Connection timeout after {timeout}s")
431
+ await asyncio.sleep(0.1)
432
+
433
+ def _handle_message(self, data: str | bytes) -> None:
434
+ """Handle incoming message."""
435
+ parsed_data: Any = data
436
+
437
+ # Try to parse JSON strings
438
+ if isinstance(data, str):
439
+ try:
440
+ parsed_data = json.loads(data)
441
+ except json.JSONDecodeError:
442
+ pass # Not JSON, use as-is
443
+
444
+ for listener in self._message_listeners:
445
+ try:
446
+ listener(parsed_data)
447
+ except Exception as e:
448
+ print(f"[DataChannelHandler] Error in message listener: {e}")
449
+
450
+ def send(self, data: str | bytes | dict[str, Any]) -> None:
451
+ """Send data through the channel."""
452
+ if not self._data_channel:
453
+ print("[DataChannelHandler] Cannot send: no data channel")
454
+ return
455
+
456
+ # Check actual channel state, not just our flag
457
+ channel_state = getattr(self._data_channel, 'readyState', None)
458
+ if channel_state == "open":
459
+ self._is_open = True # Sync our flag
460
+ elif not self._is_open:
461
+ print(f"[DataChannelHandler] Cannot send: channel not open (state={channel_state})")
462
+ return
463
+
464
+ if isinstance(data, dict):
465
+ message = json.dumps(data)
466
+ else:
467
+ message = data
468
+
469
+ try:
470
+ self._data_channel.send(message)
471
+ print(f"[DataChannelHandler] Message sent successfully")
472
+ except Exception as e:
473
+ print(f"[DataChannelHandler] Error sending message: {e}")
474
+
475
+ def on_message(self, callback: Callable[[Any], None]) -> None:
476
+ """Register a message handler."""
477
+ self._message_listeners.add(callback)
478
+
479
+ def off_message(self, callback: Callable[[Any], None]) -> None:
480
+ """Remove a message handler."""
481
+ self._message_listeners.discard(callback)
482
+
483
+ def on_close(self, callback: Callable[[], None]) -> None:
484
+ """Register a close handler."""
485
+ self._close_listeners.add(callback)
486
+
487
+ def off_close(self, callback: Callable[[], None]) -> None:
488
+ """Remove a close handler."""
489
+ self._close_listeners.discard(callback)
490
+
491
+ def _notify_close_listeners(self) -> None:
492
+ """Notify all close listeners."""
493
+ for listener in self._close_listeners:
494
+ try:
495
+ listener()
496
+ except Exception as e:
497
+ print(f"[DataChannelHandler] Error in close listener: {e}")
498
+
499
+ def close(self) -> None:
500
+ """Close the channel and clean up."""
501
+ if self._is_closed:
502
+ return
503
+
504
+ self._is_closed = True
505
+ self._is_open = False
506
+
507
+ # Remove signaling handler
508
+ if self._message_handler:
509
+ own_twin_id = self._twin_messaging.get_own_twin_id()
510
+ self._twin_messaging.off_message(own_twin_id, self._message_handler)
511
+
512
+ # Close data channel
513
+ if self._data_channel:
514
+ try:
515
+ self._data_channel.close()
516
+ except Exception as e:
517
+ print(f"[DataChannelHandler] Error closing data channel: {e}")
518
+ self._data_channel = None
519
+
520
+ # Close peer connection
521
+ if self._pc:
522
+ asyncio.create_task(self._pc.close())
523
+ self._pc = None
524
+
525
+ self._notify_close_listeners()
526
+ self._message_listeners.clear()
527
+ self._close_listeners.clear()