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,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()
|