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,571 @@
1
+ """
2
+ MediaStreamHandler
3
+
4
+ Manages WebRTC MediaStream connections including:
5
+ - Video/audio track handling
6
+ - Webcam capture via OpenCV
7
+ - Stream transmission to remote peers
8
+ """
9
+
10
+ import asyncio
11
+ import fractions
12
+ import json
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Callable, Optional, Set
16
+
17
+ import cv2
18
+ import numpy as np
19
+ from aiortc import (
20
+ RTCPeerConnection,
21
+ RTCSessionDescription,
22
+ RTCIceCandidate,
23
+ RTCConfiguration,
24
+ RTCIceServer,
25
+ MediaStreamTrack,
26
+ VideoStreamTrack,
27
+ )
28
+ from aiortc.contrib.media import MediaPlayer, MediaRecorder
29
+ from av import VideoFrame
30
+
31
+
32
+ @dataclass
33
+ class MediaStreamOptions:
34
+ """Options for MediaStream connection."""
35
+
36
+ use_stun: bool = True
37
+ stun_servers: list[str] = field(
38
+ default_factory=lambda: ["stun:stun.l.google.com:19302"]
39
+ )
40
+ connection_timeout: float = 15.0
41
+ initial_retry_delay: float = 1.0
42
+ max_retry_delay: float = 30.0
43
+ video_device: int = 0 # Default webcam index
44
+ video_width: int = 640
45
+ video_height: int = 480
46
+ video_fps: int = 30
47
+
48
+
49
+ class WebcamVideoTrack(VideoStreamTrack):
50
+ """Video track that captures from webcam using OpenCV."""
51
+
52
+ kind = "video"
53
+
54
+ def __init__(self, device: int = 0, width: int = 640, height: int = 480, fps: int = 30) -> None:
55
+ super().__init__()
56
+ self._device = device
57
+ self._width = width
58
+ self._height = height
59
+ self._fps = fps
60
+ self._cap: Optional[cv2.VideoCapture] = None
61
+ self._started = False
62
+ self._timestamp = 0
63
+ self._time_base = fractions.Fraction(1, 90000)
64
+
65
+ def _start_capture(self) -> None:
66
+ """Start video capture."""
67
+ if self._cap is None:
68
+ self._cap = cv2.VideoCapture(self._device)
69
+ self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self._width)
70
+ self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self._height)
71
+ self._cap.set(cv2.CAP_PROP_FPS, self._fps)
72
+ self._started = True
73
+ print(f"[WebcamVideoTrack] Started capture from device {self._device}")
74
+
75
+ async def recv(self) -> VideoFrame:
76
+ """Receive the next video frame."""
77
+ if not self._started:
78
+ self._start_capture()
79
+
80
+ pts, time_base = await self.next_timestamp()
81
+
82
+ if self._cap is None or not self._cap.isOpened():
83
+ # Return black frame if capture failed
84
+ frame = VideoFrame(width=self._width, height=self._height, format="rgb24")
85
+ frame.pts = pts
86
+ frame.time_base = time_base
87
+ return frame
88
+
89
+ ret, img = self._cap.read()
90
+
91
+ if not ret:
92
+ # Return black frame if read failed
93
+ img = np.zeros((self._height, self._width, 3), dtype=np.uint8)
94
+
95
+ # Convert BGR to RGB
96
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
97
+
98
+ # Create VideoFrame
99
+ frame = VideoFrame.from_ndarray(img, format="rgb24")
100
+ frame.pts = pts
101
+ frame.time_base = time_base
102
+
103
+ return frame
104
+
105
+ def stop(self) -> None:
106
+ """Stop video capture."""
107
+ super().stop()
108
+ if self._cap is not None:
109
+ self._cap.release()
110
+ self._cap = None
111
+ self._started = False
112
+ print("[WebcamVideoTrack] Stopped capture")
113
+
114
+
115
+ class TwinMessagingInterface:
116
+ """Interface for twin messaging operations."""
117
+
118
+ def __init__(
119
+ self,
120
+ send_message: Callable[[str, dict[str, Any]], Any],
121
+ subscribe: Callable[[str], Any],
122
+ on_message: Callable[[str, Callable[[dict[str, Any]], None]], None],
123
+ off_message: Callable[[str, Callable[[dict[str, Any]], None]], None],
124
+ get_own_twin_id: Callable[[], str],
125
+ ) -> None:
126
+ self.send_message = send_message
127
+ self.subscribe = subscribe
128
+ self.on_message = on_message
129
+ self.off_message = off_message
130
+ self.get_own_twin_id = get_own_twin_id
131
+
132
+
133
+ class PhygridMediaStream:
134
+ """High-level MediaStream interface."""
135
+
136
+ def __init__(self, handler: "MediaStreamHandler") -> None:
137
+ self._handler = handler
138
+
139
+ def on_track(self, callback: Callable[[MediaStreamTrack], None]) -> None:
140
+ """Register a track handler for incoming media tracks.
141
+
142
+ Args:
143
+ callback: Handler function for incoming tracks.
144
+ """
145
+ self._handler.on_track(callback)
146
+
147
+ def off_track(self, callback: Callable[[MediaStreamTrack], None]) -> None:
148
+ """Remove a track handler."""
149
+ self._handler.off_track(callback)
150
+
151
+ def on_close(self, callback: Callable[[], None]) -> None:
152
+ """Register a close handler."""
153
+ self._handler.on_close(callback)
154
+
155
+ def off_close(self, callback: Callable[[], None]) -> None:
156
+ """Remove a close handler."""
157
+ self._handler.off_close(callback)
158
+
159
+ def close(self) -> None:
160
+ """Close the media stream."""
161
+ self._handler.close()
162
+
163
+ def is_open(self) -> bool:
164
+ """Check if stream is open."""
165
+ return self._handler.is_open
166
+
167
+ def is_connecting(self) -> bool:
168
+ """Check if currently connecting."""
169
+ return self._handler.is_connecting
170
+
171
+ @property
172
+ def target_twin_id(self) -> str:
173
+ """Get target twin ID."""
174
+ return self._handler.target_twin_id
175
+
176
+ @property
177
+ def stream_name(self) -> str:
178
+ """Get stream name."""
179
+ return self._handler.stream_name
180
+
181
+ @property
182
+ def video_track(self) -> Optional[WebcamVideoTrack]:
183
+ """Get the local video track if any."""
184
+ return self._handler.video_track
185
+
186
+
187
+ class MediaStreamHandler:
188
+ """Handles WebRTC MediaStream connections."""
189
+
190
+ def __init__(
191
+ self,
192
+ target_twin_id: str,
193
+ is_initiator: bool,
194
+ twin_messaging: TwinMessagingInterface,
195
+ options: Optional[MediaStreamOptions] = None,
196
+ stream_name: str = "default",
197
+ ) -> None:
198
+ self.target_twin_id = target_twin_id
199
+ self.stream_name = stream_name
200
+ self._is_initiator = is_initiator
201
+ self._twin_messaging = twin_messaging
202
+ self._options = options or MediaStreamOptions()
203
+
204
+ self._pc: Optional[RTCPeerConnection] = None
205
+ self._video_track: Optional[WebcamVideoTrack] = None
206
+ self._is_open = False
207
+ self._is_closed = False
208
+ self._is_connecting = False
209
+
210
+ self._track_listeners: Set[Callable[[MediaStreamTrack], None]] = set()
211
+ self._close_listeners: Set[Callable[[], None]] = set()
212
+
213
+ # Callbacks
214
+ self._on_connected: Optional[Callable[[], None]] = None
215
+ self._on_disconnected: Optional[Callable[[], None]] = None
216
+ self._on_error: Optional[Callable[[Exception], None]] = None
217
+
218
+ # Message handler reference for cleanup
219
+ self._message_handler: Optional[Callable[[dict[str, Any]], None]] = None
220
+
221
+ @property
222
+ def is_open(self) -> bool:
223
+ """Check if stream is open."""
224
+ return self._is_open
225
+
226
+ @property
227
+ def is_connecting(self) -> bool:
228
+ """Check if currently connecting."""
229
+ return self._is_connecting
230
+
231
+ @property
232
+ def video_track(self) -> Optional[WebcamVideoTrack]:
233
+ """Get the local video track."""
234
+ return self._video_track
235
+
236
+ def set_callbacks(
237
+ self,
238
+ on_connected: Optional[Callable[[], None]] = None,
239
+ on_disconnected: Optional[Callable[[], None]] = None,
240
+ on_error: Optional[Callable[[Exception], None]] = None,
241
+ ) -> None:
242
+ """Set connection callbacks."""
243
+ self._on_connected = on_connected
244
+ self._on_disconnected = on_disconnected
245
+ self._on_error = on_error
246
+
247
+ async def connect(self, blocking: bool = True) -> PhygridMediaStream:
248
+ """Connect and return a PhygridMediaStream.
249
+
250
+ Args:
251
+ blocking: If True (default), wait for connection to complete.
252
+ If False, return immediately and connect in background.
253
+
254
+ Returns:
255
+ PhygridMediaStream instance.
256
+
257
+ Raises:
258
+ RuntimeError: If already closed.
259
+ """
260
+ if self._is_closed:
261
+ raise RuntimeError("MediaStreamHandler has been closed")
262
+
263
+ self._is_connecting = True
264
+ own_twin_id = self._twin_messaging.get_own_twin_id()
265
+
266
+ # Stream prefix for signaling messages - matches npm hub-client format
267
+ # npm uses "media-" prefix, filter matches media-{name}-*
268
+ stream_prefix = f"media-{self.stream_name}"
269
+
270
+ # Configure STUN servers
271
+ ice_servers = []
272
+ if self._options.use_stun:
273
+ for server in self._options.stun_servers:
274
+ ice_servers.append(RTCIceServer(urls=server))
275
+
276
+ # Create peer connection with proper RTCConfiguration
277
+ config = RTCConfiguration(iceServers=ice_servers) if ice_servers else None
278
+ self._pc = RTCPeerConnection(configuration=config)
279
+
280
+ # Set up message handler for signaling
281
+ self._message_handler = self._create_signaling_handler(stream_prefix)
282
+ self._twin_messaging.on_message(own_twin_id, self._message_handler)
283
+
284
+ # Set up track handler
285
+ @self._pc.on("track")
286
+ def on_track(track: MediaStreamTrack) -> None:
287
+ print(f"[MediaStreamHandler] Received track: {track.kind}")
288
+ self._notify_track_listeners(track)
289
+
290
+ # Set up connection state handler
291
+ @self._pc.on("connectionstatechange")
292
+ async def on_connection_state_change() -> None:
293
+ if self._pc:
294
+ state = self._pc.connectionState
295
+ print(f"[MediaStreamHandler] Connection state: {state}")
296
+ if state == "connected":
297
+ self._is_open = True
298
+ self._is_connecting = False
299
+ if self._on_connected:
300
+ self._on_connected()
301
+ elif state in ("failed", "closed", "disconnected"):
302
+ self._is_open = False
303
+ if not self._is_closed and self._on_disconnected:
304
+ self._on_disconnected()
305
+
306
+ async def do_connect() -> None:
307
+ """Perform the connection in background."""
308
+ try:
309
+ if self._is_initiator:
310
+ await self._initiate_connection(stream_prefix)
311
+ else:
312
+ await self._wait_for_connection(stream_prefix)
313
+ except Exception as e:
314
+ self._is_connecting = False
315
+ if self._on_error:
316
+ self._on_error(e)
317
+ if blocking:
318
+ raise
319
+
320
+ if blocking:
321
+ await do_connect()
322
+ else:
323
+ # Non-blocking: start connection in background
324
+ asyncio.create_task(do_connect())
325
+
326
+ return PhygridMediaStream(self)
327
+
328
+ def _create_signaling_handler(
329
+ self, stream_prefix: str
330
+ ) -> Callable[[dict[str, Any]], None]:
331
+ """Create signaling message handler."""
332
+
333
+ def handler(payload: dict[str, Any]) -> None:
334
+ # Filter by source twin - only process messages FROM the target peer
335
+ source_twin_id = payload.get("sourceTwinId")
336
+ if source_twin_id != self.target_twin_id:
337
+ return
338
+
339
+ data = payload.get("data", {})
340
+ msg_type = data.get("type", "")
341
+
342
+ # Check if this message is for our stream
343
+ # Format: {streamPrefix}-{twinId}:{signalType}
344
+ prefix_match = f"{stream_prefix}-"
345
+ if not msg_type.startswith(prefix_match):
346
+ return
347
+
348
+ signal_type = msg_type.split(":")[-1]
349
+ signal_data = data.get("data", {})
350
+ print(f"[MediaStreamHandler] Received signal: {signal_type} from {source_twin_id}")
351
+
352
+ asyncio.create_task(self._handle_signal(signal_type, signal_data))
353
+
354
+ return handler
355
+
356
+ async def _handle_signal(self, signal_type: str, data: dict[str, Any]) -> None:
357
+ """Handle incoming signaling message."""
358
+ if not self._pc:
359
+ return
360
+
361
+ try:
362
+ if signal_type == "offer":
363
+ print(f"[MediaStreamHandler] Setting remote description (offer)")
364
+ await self._pc.setRemoteDescription(
365
+ RTCSessionDescription(sdp=data["sdp"], type=data["type"])
366
+ )
367
+
368
+ # Add local video track for responder
369
+ if self._is_initiator is False:
370
+ self._add_video_track()
371
+
372
+ print(f"[MediaStreamHandler] Creating answer...")
373
+ answer = await self._pc.createAnswer()
374
+ print(f"[MediaStreamHandler] Setting local description (answer)")
375
+ await self._pc.setLocalDescription(answer)
376
+ await self._send_signal("answer", {
377
+ "sdp": self._pc.localDescription.sdp,
378
+ "type": self._pc.localDescription.type,
379
+ })
380
+ print(f"[MediaStreamHandler] Answer sent")
381
+
382
+ elif signal_type == "answer":
383
+ # Ignore duplicate answers if already in stable state
384
+ if self._pc.signalingState == "stable":
385
+ print(f"[MediaStreamHandler] Ignoring duplicate answer (already stable)")
386
+ return
387
+ print(f"[MediaStreamHandler] Setting remote description (answer)")
388
+ await self._pc.setRemoteDescription(
389
+ RTCSessionDescription(sdp=data["sdp"], type=data["type"])
390
+ )
391
+
392
+ elif signal_type == "ice":
393
+ if data:
394
+ # Parse ICE candidate from signaling data (npm format)
395
+ candidate_str = data.get("candidate")
396
+ sdp_mid = data.get("sdpMid")
397
+ sdp_mline_index = data.get("sdpMLineIndex")
398
+
399
+ if candidate_str:
400
+ candidate = RTCIceCandidate(
401
+ component=1,
402
+ foundation="",
403
+ ip="",
404
+ port=0,
405
+ priority=0,
406
+ protocol="udp",
407
+ type="host",
408
+ sdpMid=sdp_mid,
409
+ sdpMLineIndex=sdp_mline_index,
410
+ )
411
+ candidate.candidate = candidate_str
412
+ await self._pc.addIceCandidate(candidate)
413
+
414
+ except Exception as e:
415
+ print(f"[MediaStreamHandler] Error handling signal {signal_type}: {e}")
416
+ import traceback
417
+ traceback.print_exc()
418
+
419
+ async def _send_signal(self, signal_type: str, data: dict[str, Any]) -> None:
420
+ """Send signaling message."""
421
+ # Message type format: media-{name}-{targetTwinId}:{signalType}
422
+ # This matches npm hub-client format
423
+ stream_id = f"media-{self.stream_name}-{self.target_twin_id}"
424
+
425
+ await self._twin_messaging.send_message(
426
+ self.target_twin_id,
427
+ {
428
+ "type": f"{stream_id}:{signal_type}",
429
+ "data": data,
430
+ },
431
+ )
432
+
433
+ def _add_video_track(self) -> None:
434
+ """Add webcam video track to peer connection."""
435
+ if not self._pc or self._video_track:
436
+ return
437
+
438
+ self._video_track = WebcamVideoTrack(
439
+ device=self._options.video_device,
440
+ width=self._options.video_width,
441
+ height=self._options.video_height,
442
+ fps=self._options.video_fps,
443
+ )
444
+
445
+ self._pc.addTrack(self._video_track)
446
+ print(f"[MediaStreamHandler] Added video track from webcam {self._options.video_device}")
447
+
448
+ async def _initiate_connection(self, stream_prefix: str) -> None:
449
+ """Initiate WebRTC connection as the caller."""
450
+ if not self._pc:
451
+ return
452
+
453
+ # Add local video track before creating offer
454
+ self._add_video_track()
455
+
456
+ # Set up ICE candidate handler
457
+ @self._pc.on("icecandidate")
458
+ async def on_ice_candidate(candidate: Optional[RTCIceCandidate]) -> None:
459
+ if candidate:
460
+ await self._send_signal("ice", {
461
+ "candidate": candidate.candidate,
462
+ "sdpMid": candidate.sdpMid,
463
+ "sdpMLineIndex": candidate.sdpMLineIndex,
464
+ })
465
+
466
+ # Create and send offer
467
+ print(f"[MediaStreamHandler] Creating offer...")
468
+ offer = await self._pc.createOffer()
469
+ print(f"[MediaStreamHandler] Setting local description (offer)")
470
+ await self._pc.setLocalDescription(offer)
471
+ await self._send_signal("offer", {
472
+ "sdp": self._pc.localDescription.sdp,
473
+ "type": self._pc.localDescription.type,
474
+ })
475
+ print(f"[MediaStreamHandler] Offer sent")
476
+
477
+ # Wait for connection
478
+ await self._wait_for_connected()
479
+
480
+ async def _wait_for_connection(self, stream_prefix: str) -> None:
481
+ """Wait for incoming WebRTC connection as the callee."""
482
+ if not self._pc:
483
+ return
484
+
485
+ print(f"[MediaStreamHandler] Waiting for connection as responder...")
486
+
487
+ # Set up ICE candidate handler
488
+ @self._pc.on("icecandidate")
489
+ async def on_ice_candidate(candidate: Optional[RTCIceCandidate]) -> None:
490
+ if candidate:
491
+ print(f"[MediaStreamHandler] Sending ICE candidate")
492
+ await self._send_signal("ice", {
493
+ "candidate": candidate.candidate,
494
+ "sdpMid": candidate.sdpMid,
495
+ "sdpMLineIndex": candidate.sdpMLineIndex,
496
+ })
497
+
498
+ # Wait for connection to be established
499
+ await self._wait_for_connected()
500
+
501
+ async def _wait_for_connected(self) -> None:
502
+ """Wait for WebRTC connection to be established."""
503
+ timeout = self._options.connection_timeout
504
+ start = asyncio.get_event_loop().time()
505
+
506
+ while not self._is_open:
507
+ if asyncio.get_event_loop().time() - start > timeout:
508
+ raise TimeoutError(f"Connection timeout after {timeout}s")
509
+ await asyncio.sleep(0.1)
510
+
511
+ def on_track(self, callback: Callable[[MediaStreamTrack], None]) -> None:
512
+ """Register a track handler."""
513
+ self._track_listeners.add(callback)
514
+
515
+ def off_track(self, callback: Callable[[MediaStreamTrack], None]) -> None:
516
+ """Remove a track handler."""
517
+ self._track_listeners.discard(callback)
518
+
519
+ def _notify_track_listeners(self, track: MediaStreamTrack) -> None:
520
+ """Notify all track listeners."""
521
+ for listener in self._track_listeners:
522
+ try:
523
+ listener(track)
524
+ except Exception as e:
525
+ print(f"[MediaStreamHandler] Error in track listener: {e}")
526
+
527
+ def on_close(self, callback: Callable[[], None]) -> None:
528
+ """Register a close handler."""
529
+ self._close_listeners.add(callback)
530
+
531
+ def off_close(self, callback: Callable[[], None]) -> None:
532
+ """Remove a close handler."""
533
+ self._close_listeners.discard(callback)
534
+
535
+ def _notify_close_listeners(self) -> None:
536
+ """Notify all close listeners."""
537
+ for listener in self._close_listeners:
538
+ try:
539
+ listener()
540
+ except Exception as e:
541
+ print(f"[MediaStreamHandler] Error in close listener: {e}")
542
+
543
+ def close(self) -> None:
544
+ """Close the stream and clean up."""
545
+ if self._is_closed:
546
+ return
547
+
548
+ self._is_closed = True
549
+ self._is_open = False
550
+
551
+ # Remove signaling handler
552
+ if self._message_handler:
553
+ own_twin_id = self._twin_messaging.get_own_twin_id()
554
+ self._twin_messaging.off_message(own_twin_id, self._message_handler)
555
+
556
+ # Stop video track
557
+ if self._video_track:
558
+ try:
559
+ self._video_track.stop()
560
+ except Exception as e:
561
+ print(f"[MediaStreamHandler] Error stopping video track: {e}")
562
+ self._video_track = None
563
+
564
+ # Close peer connection
565
+ if self._pc:
566
+ asyncio.create_task(self._pc.close())
567
+ self._pc = None
568
+
569
+ self._notify_close_listeners()
570
+ self._track_listeners.clear()
571
+ self._close_listeners.clear()