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.
- phystack/hub_client/__init__.py +90 -0
- phystack/hub_client/client.py +701 -0
- phystack/hub_client/instances/__init__.py +11 -0
- phystack/hub_client/instances/base_instance.py +225 -0
- phystack/hub_client/instances/edge_instance.py +212 -0
- phystack/hub_client/instances/peripheral_instance.py +186 -0
- phystack/hub_client/services/__init__.py +5 -0
- phystack/hub_client/services/phyhub_connection.py +272 -0
- phystack/hub_client/services/webrtc/__init__.py +23 -0
- phystack/hub_client/services/webrtc/data_channel.py +527 -0
- phystack/hub_client/services/webrtc/media_stream.py +571 -0
- phystack/hub_client/twin_messaging.py +287 -0
- phystack/hub_client/twin_registry.py +158 -0
- phystack/hub_client/types/__init__.py +15 -0
- phystack/hub_client/types/twin_types.py +136 -0
- phystack_hub_client-0.2.0.dist-info/METADATA +174 -0
- phystack_hub_client-0.2.0.dist-info/RECORD +18 -0
- phystack_hub_client-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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()
|