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,701 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PhyHub Client
|
|
3
|
+
|
|
4
|
+
Main client for connecting to PhyHub and communicating with twins.
|
|
5
|
+
Provides events, actions, DataChannel, and MediaStream functionality.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Any, Callable, Optional, Set
|
|
10
|
+
|
|
11
|
+
from phystack.hub_client.services.phyhub_connection import (
|
|
12
|
+
PhyHubConnection,
|
|
13
|
+
DirectConnectionConfig,
|
|
14
|
+
is_direct_mode,
|
|
15
|
+
)
|
|
16
|
+
from phystack.hub_client.twin_messaging import (
|
|
17
|
+
TwinMessaging,
|
|
18
|
+
TwinMessagingContext,
|
|
19
|
+
create_twin_messaging,
|
|
20
|
+
)
|
|
21
|
+
from phystack.hub_client.types.twin_types import (
|
|
22
|
+
TwinMessageResult,
|
|
23
|
+
TwinMessageResultStatus,
|
|
24
|
+
TwinResponse,
|
|
25
|
+
TwinProperties,
|
|
26
|
+
EventPayload,
|
|
27
|
+
)
|
|
28
|
+
from phystack.hub_client.services.webrtc.data_channel import (
|
|
29
|
+
DataChannelHandler,
|
|
30
|
+
DataChannelOptions,
|
|
31
|
+
PhygridDataChannel,
|
|
32
|
+
TwinMessagingInterface as DataChannelMessaging,
|
|
33
|
+
)
|
|
34
|
+
from phystack.hub_client.services.webrtc.media_stream import (
|
|
35
|
+
MediaStreamHandler,
|
|
36
|
+
MediaStreamOptions,
|
|
37
|
+
PhygridMediaStream,
|
|
38
|
+
TwinMessagingInterface as MediaStreamMessaging,
|
|
39
|
+
)
|
|
40
|
+
from phystack.hub_client.instances.edge_instance import EdgeInstance
|
|
41
|
+
from phystack.hub_client.instances.peripheral_instance import PeripheralInstance
|
|
42
|
+
from phystack.hub_client.twin_registry import TwinRegistry
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PhyHubClient:
|
|
46
|
+
"""Client for connecting to PhyHub.
|
|
47
|
+
|
|
48
|
+
Provides:
|
|
49
|
+
- Connection management
|
|
50
|
+
- Twin messaging (events and actions)
|
|
51
|
+
- DataChannel (WebRTC P2P data)
|
|
52
|
+
- MediaStream (WebRTC P2P video/audio)
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
async with PhyHubClient.from_env() as client:
|
|
56
|
+
instance = await client.get_instance()
|
|
57
|
+
instance.on("my-event", lambda data, respond: print(data))
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Socket.IO event names
|
|
61
|
+
class EVENTS:
|
|
62
|
+
CONNECT = "connect"
|
|
63
|
+
DISCONNECT = "disconnect"
|
|
64
|
+
TWIN_MESSAGE = "twinMessage"
|
|
65
|
+
TWIN_SUBSCRIBE = "twinSubscribe"
|
|
66
|
+
TWIN_UPDATED = "twinUpdated"
|
|
67
|
+
GET_TWIN_BY_ID = "getTwinById"
|
|
68
|
+
REPORT_PROPERTIES = "reportPeripheralTwinProperties"
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
device_id: Optional[str] = None,
|
|
73
|
+
access_key: Optional[str] = None,
|
|
74
|
+
phyhub_url: Optional[str] = None,
|
|
75
|
+
instance_id: Optional[str] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Initialize PhyHub client.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
device_id: Device identifier.
|
|
81
|
+
access_key: Access key for authentication.
|
|
82
|
+
phyhub_url: PhyHub server URL.
|
|
83
|
+
instance_id: Optional specific twin ID to use.
|
|
84
|
+
"""
|
|
85
|
+
self._connection: Optional[PhyHubConnection] = None
|
|
86
|
+
self._instance_id: Optional[str] = instance_id
|
|
87
|
+
self._instance: Optional[EdgeInstance] = None
|
|
88
|
+
self._subscribed_twins: Set[str] = set()
|
|
89
|
+
self._twin_message_listeners: dict[str, Set[Callable[[dict[str, Any]], None]]] = {}
|
|
90
|
+
self._twin_update_listeners: dict[str, Set[Callable[[dict[str, Any]], None]]] = {}
|
|
91
|
+
self._settings_update_listeners: Set[Callable[[dict[str, Any]], None]] = set()
|
|
92
|
+
self._twin_data_cache: dict[str, dict[str, Any]] = {}
|
|
93
|
+
self._connected = False
|
|
94
|
+
self._registry: Optional[TwinRegistry] = None
|
|
95
|
+
|
|
96
|
+
if device_id and access_key:
|
|
97
|
+
self._connection = PhyHubConnection(
|
|
98
|
+
DirectConnectionConfig(
|
|
99
|
+
device_id=device_id,
|
|
100
|
+
access_key=access_key,
|
|
101
|
+
phyhub_url=phyhub_url or "https://phyhub.eu.omborigrid.net",
|
|
102
|
+
instance_id=instance_id,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_env(cls) -> "PhyHubClient":
|
|
108
|
+
"""Create client from environment variables.
|
|
109
|
+
|
|
110
|
+
Environment variables:
|
|
111
|
+
DEVICE_ID or PHYGRID_DEVICE_ID: Device identifier
|
|
112
|
+
ACCESS_KEY or PHYGRID_DEVICE_KEY: Access key
|
|
113
|
+
PHYHUB_URL or PHYHUB_REGION: Server URL or region (eu, us, etc.)
|
|
114
|
+
TWIN_ID or INSTANCE_ID: Optional specific twin ID
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Configured PhyHubClient instance.
|
|
118
|
+
"""
|
|
119
|
+
client = cls()
|
|
120
|
+
client._connection = PhyHubConnection.from_env()
|
|
121
|
+
client._instance_id = client._connection.instance_id
|
|
122
|
+
return client
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_connected(self) -> bool:
|
|
126
|
+
"""Check if connected to PhyHub."""
|
|
127
|
+
return self._connected and self._connection is not None
|
|
128
|
+
|
|
129
|
+
async def connect(self) -> "PhyHubClient":
|
|
130
|
+
"""Connect to PhyHub.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Self for chaining.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ConnectionError: If connection fails.
|
|
137
|
+
"""
|
|
138
|
+
if not self._connection:
|
|
139
|
+
raise ValueError("No connection configured. Use from_env() or provide credentials.")
|
|
140
|
+
|
|
141
|
+
await self._connection.connect()
|
|
142
|
+
self._connected = True
|
|
143
|
+
|
|
144
|
+
# Create twin registry
|
|
145
|
+
self._registry = TwinRegistry(self)
|
|
146
|
+
|
|
147
|
+
# Set up event handlers
|
|
148
|
+
self._setup_socket_listeners()
|
|
149
|
+
|
|
150
|
+
# Authenticate and connect device
|
|
151
|
+
await self._initialize_device_twin()
|
|
152
|
+
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
async def disconnect(self) -> None:
|
|
156
|
+
"""Disconnect from PhyHub."""
|
|
157
|
+
if self._connection:
|
|
158
|
+
await self._connection.disconnect()
|
|
159
|
+
self._connected = False
|
|
160
|
+
self._instance = None
|
|
161
|
+
if self._registry:
|
|
162
|
+
self._registry.clear()
|
|
163
|
+
|
|
164
|
+
def _setup_socket_listeners(self) -> None:
|
|
165
|
+
"""Set up Socket.IO event handlers."""
|
|
166
|
+
if not self._connection:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
def on_twin_message(payload: dict[str, Any], callback: Any = None) -> Any:
|
|
170
|
+
"""Handle incoming twin messages."""
|
|
171
|
+
twin_id = payload.get("twinId")
|
|
172
|
+
result: Optional[TwinMessageResult] = None
|
|
173
|
+
|
|
174
|
+
if twin_id and payload.get("data"):
|
|
175
|
+
listeners = self._twin_message_listeners.get(twin_id)
|
|
176
|
+
if listeners:
|
|
177
|
+
for listener in listeners:
|
|
178
|
+
try:
|
|
179
|
+
listener(payload)
|
|
180
|
+
result = TwinMessageResult(
|
|
181
|
+
status=TwinMessageResultStatus.SUCCESS,
|
|
182
|
+
message="Action completed",
|
|
183
|
+
)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
result = TwinMessageResult(
|
|
186
|
+
status=TwinMessageResultStatus.ERROR,
|
|
187
|
+
message=str(e),
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
result = TwinMessageResult(
|
|
191
|
+
status=TwinMessageResultStatus.WARNING,
|
|
192
|
+
message=f"No listeners found for twin {twin_id}",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if callback and result:
|
|
196
|
+
return {"status": result.status.value, "message": result.message}
|
|
197
|
+
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
self._connection.on(self.EVENTS.TWIN_MESSAGE, on_twin_message)
|
|
201
|
+
|
|
202
|
+
def on_twin_updated(payload: dict[str, Any]) -> None:
|
|
203
|
+
"""Handle twin property update events."""
|
|
204
|
+
twin_data = payload.get("data") or payload
|
|
205
|
+
twin_id = payload.get("twinId") or (twin_data.get("id") if isinstance(twin_data, dict) else None)
|
|
206
|
+
|
|
207
|
+
if not twin_id:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
# Notify twin update listeners
|
|
211
|
+
listeners = self._twin_update_listeners.get(twin_id)
|
|
212
|
+
if listeners:
|
|
213
|
+
for callback in listeners:
|
|
214
|
+
try:
|
|
215
|
+
callback(twin_data)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
print(f"[PhyHubClient] Error in twin update listener: {e}")
|
|
218
|
+
|
|
219
|
+
# Check for settings updates on edge instance
|
|
220
|
+
if twin_id == self._instance_id and isinstance(twin_data, dict):
|
|
221
|
+
props = twin_data.get("properties", {})
|
|
222
|
+
desired = props.get("desired", {})
|
|
223
|
+
settings = desired.get("settings", {})
|
|
224
|
+
if settings and self._settings_update_listeners:
|
|
225
|
+
for callback in self._settings_update_listeners:
|
|
226
|
+
try:
|
|
227
|
+
callback(settings)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
print(f"[PhyHubClient] Error in settings update listener: {e}")
|
|
230
|
+
|
|
231
|
+
self._connection.on(self.EVENTS.TWIN_UPDATED, on_twin_updated)
|
|
232
|
+
|
|
233
|
+
async def _initialize_device_twin(self) -> None:
|
|
234
|
+
"""Initialize device twin via authentication."""
|
|
235
|
+
if not self._connection:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Authenticate - server responds with deviceAuthenticated event
|
|
239
|
+
print("[PhyHubClient] Authenticating...")
|
|
240
|
+
auth_result = await self._connection.emit(
|
|
241
|
+
"authenticate",
|
|
242
|
+
{
|
|
243
|
+
"deviceId": self._connection.device_id,
|
|
244
|
+
"accessKey": self._connection.config.access_key,
|
|
245
|
+
},
|
|
246
|
+
response_event="deviceAuthenticated",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if auth_result.get("status") != "success":
|
|
250
|
+
raise ConnectionError(f"Authentication failed: {auth_result.get('message')}")
|
|
251
|
+
|
|
252
|
+
print(f"[PhyHubClient] Authenticated: {auth_result}")
|
|
253
|
+
|
|
254
|
+
# Connect device - server uses callback pattern (no payload needed)
|
|
255
|
+
print("[PhyHubClient] Connecting device...")
|
|
256
|
+
connect_result = await self._connection.emit(
|
|
257
|
+
"connectDevice",
|
|
258
|
+
callback=True,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if connect_result.get("status") != "success":
|
|
262
|
+
raise ConnectionError(f"Device connection failed: {connect_result.get('message')}")
|
|
263
|
+
|
|
264
|
+
# Extract twin info - prefer server response for device twin ID
|
|
265
|
+
twins = connect_result.get("twins", [])
|
|
266
|
+
if twins:
|
|
267
|
+
device_twin = next(
|
|
268
|
+
(t for t in twins if t.get("type") == "Device"),
|
|
269
|
+
twins[0],
|
|
270
|
+
)
|
|
271
|
+
# Use server's twin ID unless user explicitly set a different one
|
|
272
|
+
server_twin_id = device_twin.get("id")
|
|
273
|
+
if server_twin_id:
|
|
274
|
+
self._instance_id = server_twin_id
|
|
275
|
+
print(f"[PhyHubClient] Connected. Twin ID: {self._instance_id}")
|
|
276
|
+
|
|
277
|
+
async def get_instance(self) -> Optional[EdgeInstance]:
|
|
278
|
+
"""Get or create the edge instance.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
EdgeInstance for messaging, or None if not connected.
|
|
282
|
+
"""
|
|
283
|
+
if not self._connected or not self._registry:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
if self._instance:
|
|
287
|
+
return self._instance
|
|
288
|
+
|
|
289
|
+
self._instance = await self._registry.get_edge_instance()
|
|
290
|
+
return self._instance
|
|
291
|
+
|
|
292
|
+
async def get_peripheral_instance(self, twin_id: str) -> Optional[PeripheralInstance]:
|
|
293
|
+
"""Get a peripheral instance by twin ID.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
twin_id: ID of the peripheral twin.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
PeripheralInstance or None if not found.
|
|
300
|
+
"""
|
|
301
|
+
if not self._registry:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
return await self._registry.get_peripheral_instance(twin_id)
|
|
305
|
+
|
|
306
|
+
def _send_event(self, target_twin_id: str, payload: dict[str, Any]) -> None:
|
|
307
|
+
"""Send event to a twin (fire-and-forget).
|
|
308
|
+
|
|
309
|
+
In direct mode: socket.emit('twinMessage', payload)
|
|
310
|
+
In PhyOS mode: socket.emit(instanceId, { method: 'twinMessage', ...payload })
|
|
311
|
+
"""
|
|
312
|
+
if not self._connection:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
event_payload = {
|
|
316
|
+
"twinId": target_twin_id,
|
|
317
|
+
"sourceTwinId": self._instance_id,
|
|
318
|
+
"sourceDeviceId": self._connection.device_id if self._connection else None,
|
|
319
|
+
"data": payload,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# Fire and forget - use asyncio to schedule
|
|
323
|
+
asyncio.create_task(self._emit_to_socket(self.EVENTS.TWIN_MESSAGE, event_payload))
|
|
324
|
+
|
|
325
|
+
async def _emit_to_socket(self, method: str, payload: dict[str, Any]) -> None:
|
|
326
|
+
"""Emit to socket with proper mode handling.
|
|
327
|
+
|
|
328
|
+
In direct mode: socket.emit(method, payload)
|
|
329
|
+
In PhyOS mode: socket.emit(instanceId, { method, ...payload })
|
|
330
|
+
"""
|
|
331
|
+
if not self._connection:
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
if is_direct_mode():
|
|
335
|
+
# Direct mode: emit to event name directly
|
|
336
|
+
await self._connection.emit(method, payload)
|
|
337
|
+
else:
|
|
338
|
+
# PhyOS mode: emit to instanceId channel with method in payload
|
|
339
|
+
phyos_payload = {"method": method, **payload}
|
|
340
|
+
await self._connection.emit(self._instance_id or "", phyos_payload)
|
|
341
|
+
|
|
342
|
+
def on_twin_message(
|
|
343
|
+
self,
|
|
344
|
+
twin_id: str,
|
|
345
|
+
callback: Callable[[dict[str, Any]], None],
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Register a callback for messages to a twin.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
twin_id: Twin ID to listen for.
|
|
351
|
+
callback: Handler function.
|
|
352
|
+
"""
|
|
353
|
+
if twin_id not in self._twin_message_listeners:
|
|
354
|
+
self._twin_message_listeners[twin_id] = set()
|
|
355
|
+
self._twin_message_listeners[twin_id].add(callback)
|
|
356
|
+
|
|
357
|
+
def off_twin_message(
|
|
358
|
+
self,
|
|
359
|
+
twin_id: str,
|
|
360
|
+
callback: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
361
|
+
) -> bool:
|
|
362
|
+
"""Remove a message callback.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
twin_id: Twin ID.
|
|
366
|
+
callback: Specific callback to remove, or None for all.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
True if removed, False otherwise.
|
|
370
|
+
"""
|
|
371
|
+
if twin_id not in self._twin_message_listeners:
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
if callback:
|
|
375
|
+
self._twin_message_listeners[twin_id].discard(callback)
|
|
376
|
+
if not self._twin_message_listeners[twin_id]:
|
|
377
|
+
del self._twin_message_listeners[twin_id]
|
|
378
|
+
return True
|
|
379
|
+
else:
|
|
380
|
+
del self._twin_message_listeners[twin_id]
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
async def subscribe_twin(self, target_twin_id: str) -> dict[str, Any]:
|
|
384
|
+
"""Subscribe to receive messages from a twin.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
target_twin_id: Twin ID to subscribe to.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Subscription response.
|
|
391
|
+
"""
|
|
392
|
+
if not self._connection:
|
|
393
|
+
raise ConnectionError("Not connected")
|
|
394
|
+
|
|
395
|
+
# Skip if already subscribed
|
|
396
|
+
if target_twin_id in self._subscribed_twins:
|
|
397
|
+
return {"status": "success", "message": "Already subscribed"}
|
|
398
|
+
|
|
399
|
+
self._subscribed_twins.add(target_twin_id)
|
|
400
|
+
|
|
401
|
+
payload = {
|
|
402
|
+
"twinId": target_twin_id,
|
|
403
|
+
"sourceTwinId": self._instance_id,
|
|
404
|
+
"sourceDeviceId": self._connection.device_id,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
# Fire-and-forget subscription using unified emit
|
|
408
|
+
asyncio.create_task(self._emit_to_socket(self.EVENTS.TWIN_SUBSCRIBE, payload))
|
|
409
|
+
|
|
410
|
+
return {"status": "success", "message": "Subscribed"}
|
|
411
|
+
|
|
412
|
+
async def get_twin_by_id(self, twin_id: str) -> Optional[dict[str, Any]]:
|
|
413
|
+
"""Get twin data by ID.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
twin_id: Twin ID to fetch.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Twin data dictionary.
|
|
420
|
+
"""
|
|
421
|
+
if not self._connection:
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
result = await self._connection.emit(
|
|
425
|
+
self.EVENTS.GET_TWIN_BY_ID,
|
|
426
|
+
{"data": {"twinId": twin_id}},
|
|
427
|
+
callback=True,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return result.get("twin") if result else None
|
|
431
|
+
|
|
432
|
+
# =========================================================================
|
|
433
|
+
# Settings API
|
|
434
|
+
# =========================================================================
|
|
435
|
+
|
|
436
|
+
async def get_settings(self) -> dict[str, Any]:
|
|
437
|
+
"""Get settings from edge instance desired properties.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Settings dictionary.
|
|
441
|
+
"""
|
|
442
|
+
instance = await self.get_instance()
|
|
443
|
+
if instance:
|
|
444
|
+
return instance.get_settings()
|
|
445
|
+
return {}
|
|
446
|
+
|
|
447
|
+
async def get_edge_settings(self) -> dict[str, Any]:
|
|
448
|
+
"""Alias for get_settings().
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Settings dictionary.
|
|
452
|
+
"""
|
|
453
|
+
return await self.get_settings()
|
|
454
|
+
|
|
455
|
+
def on_settings_update(
|
|
456
|
+
self, callback: Callable[[dict[str, Any]], None]
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Subscribe to settings changes in edge desired properties.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
callback: Handler called with new settings when they change.
|
|
462
|
+
"""
|
|
463
|
+
self._settings_update_listeners.add(callback)
|
|
464
|
+
|
|
465
|
+
# Subscribe to edge twin updates if connected
|
|
466
|
+
if self._instance_id:
|
|
467
|
+
asyncio.create_task(self.subscribe_twin(self._instance_id))
|
|
468
|
+
|
|
469
|
+
def off_settings_update(
|
|
470
|
+
self, callback: Callable[[dict[str, Any]], None]
|
|
471
|
+
) -> bool:
|
|
472
|
+
"""Remove settings update callback.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
callback: Callback to remove.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
True if removed, False otherwise.
|
|
479
|
+
"""
|
|
480
|
+
if callback in self._settings_update_listeners:
|
|
481
|
+
self._settings_update_listeners.discard(callback)
|
|
482
|
+
return True
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
# =========================================================================
|
|
486
|
+
# Twin Update API
|
|
487
|
+
# =========================================================================
|
|
488
|
+
|
|
489
|
+
def on_twin_update(
|
|
490
|
+
self,
|
|
491
|
+
twin_id: str,
|
|
492
|
+
callback: Callable[[dict[str, Any]], None],
|
|
493
|
+
) -> None:
|
|
494
|
+
"""Register callback for twin property updates.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
twin_id: Twin ID to listen for updates.
|
|
498
|
+
callback: Handler called with twin data when properties change.
|
|
499
|
+
"""
|
|
500
|
+
if twin_id not in self._twin_update_listeners:
|
|
501
|
+
self._twin_update_listeners[twin_id] = set()
|
|
502
|
+
self._twin_update_listeners[twin_id].add(callback)
|
|
503
|
+
|
|
504
|
+
# Ensure subscribed
|
|
505
|
+
asyncio.create_task(self.subscribe_twin(twin_id))
|
|
506
|
+
|
|
507
|
+
def off_twin_update(
|
|
508
|
+
self,
|
|
509
|
+
twin_id: str,
|
|
510
|
+
callback: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
511
|
+
) -> bool:
|
|
512
|
+
"""Remove twin update callback.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
twin_id: Twin ID.
|
|
516
|
+
callback: Specific callback to remove, or None for all.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
True if removed, False otherwise.
|
|
520
|
+
"""
|
|
521
|
+
if twin_id not in self._twin_update_listeners:
|
|
522
|
+
return False
|
|
523
|
+
|
|
524
|
+
if callback:
|
|
525
|
+
self._twin_update_listeners[twin_id].discard(callback)
|
|
526
|
+
if not self._twin_update_listeners[twin_id]:
|
|
527
|
+
del self._twin_update_listeners[twin_id]
|
|
528
|
+
return True
|
|
529
|
+
else:
|
|
530
|
+
del self._twin_update_listeners[twin_id]
|
|
531
|
+
return True
|
|
532
|
+
|
|
533
|
+
# =========================================================================
|
|
534
|
+
# Properties API
|
|
535
|
+
# =========================================================================
|
|
536
|
+
|
|
537
|
+
async def update_reported_properties(
|
|
538
|
+
self, twin_id: str, properties: dict[str, Any]
|
|
539
|
+
) -> Optional[dict[str, Any]]:
|
|
540
|
+
"""Update reported properties for a twin.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
twin_id: Twin ID to update.
|
|
544
|
+
properties: Properties to set.
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
Updated twin data.
|
|
548
|
+
"""
|
|
549
|
+
if not self._connection:
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
payload = {"twinId": twin_id, "data": properties}
|
|
553
|
+
result = await self._connection.emit(
|
|
554
|
+
self.EVENTS.REPORT_PROPERTIES, payload, callback=True
|
|
555
|
+
)
|
|
556
|
+
return result.get("twin") if result else None
|
|
557
|
+
|
|
558
|
+
# =========================================================================
|
|
559
|
+
# WebRTC Helpers
|
|
560
|
+
# =========================================================================
|
|
561
|
+
|
|
562
|
+
def _create_twin_messaging_interface(self) -> DataChannelMessaging:
|
|
563
|
+
"""Create a twin messaging interface for WebRTC handlers.
|
|
564
|
+
|
|
565
|
+
This interface matches the npm TwinMessagingInterface and is used
|
|
566
|
+
for WebRTC signaling (offer/answer/ice exchange).
|
|
567
|
+
"""
|
|
568
|
+
async def send_message(target_twin_id: str, payload: dict[str, Any]) -> None:
|
|
569
|
+
"""Send a twin message for signaling.
|
|
570
|
+
|
|
571
|
+
Unlike _send_event (fire-and-forget), this awaits the emit
|
|
572
|
+
to ensure signaling messages are sent before proceeding.
|
|
573
|
+
"""
|
|
574
|
+
if not self._connection:
|
|
575
|
+
return
|
|
576
|
+
|
|
577
|
+
event_payload = {
|
|
578
|
+
"twinId": target_twin_id,
|
|
579
|
+
"sourceTwinId": self._instance_id,
|
|
580
|
+
"sourceDeviceId": self._connection.device_id if self._connection else None,
|
|
581
|
+
"data": payload,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
# Use unified emit pattern
|
|
585
|
+
await self._emit_to_socket(self.EVENTS.TWIN_MESSAGE, event_payload)
|
|
586
|
+
|
|
587
|
+
async def subscribe(twin_id: str) -> None:
|
|
588
|
+
await self.subscribe_twin(twin_id)
|
|
589
|
+
|
|
590
|
+
def get_own_twin_id() -> str:
|
|
591
|
+
if not self._instance_id:
|
|
592
|
+
raise ValueError("Instance ID not set")
|
|
593
|
+
return self._instance_id
|
|
594
|
+
|
|
595
|
+
return DataChannelMessaging(
|
|
596
|
+
send_message=send_message,
|
|
597
|
+
subscribe=subscribe,
|
|
598
|
+
on_message=self.on_twin_message,
|
|
599
|
+
off_message=lambda tid, cb: self.off_twin_message(tid, cb),
|
|
600
|
+
get_own_twin_id=get_own_twin_id,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
async def create_data_channel(
|
|
604
|
+
self,
|
|
605
|
+
target_twin_id: str,
|
|
606
|
+
is_initiator: bool = True,
|
|
607
|
+
options: Optional[DataChannelOptions] = None,
|
|
608
|
+
channel_name: str = "default",
|
|
609
|
+
blocking: bool = True,
|
|
610
|
+
) -> PhygridDataChannel:
|
|
611
|
+
"""Create a WebRTC DataChannel connection to another twin.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
target_twin_id: ID of the twin to connect to.
|
|
615
|
+
is_initiator: Whether this side initiates the connection.
|
|
616
|
+
options: DataChannel configuration options.
|
|
617
|
+
channel_name: Name for the data channel.
|
|
618
|
+
blocking: If True (default), wait for connection to complete.
|
|
619
|
+
If False, return immediately and connect in background.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
PhygridDataChannel for sending/receiving data.
|
|
623
|
+
"""
|
|
624
|
+
if not self._connected:
|
|
625
|
+
raise ConnectionError("Not connected to PhyHub")
|
|
626
|
+
|
|
627
|
+
# Subscribe to target twin
|
|
628
|
+
await self.subscribe_twin(target_twin_id)
|
|
629
|
+
|
|
630
|
+
# Create messaging interface
|
|
631
|
+
messaging = self._create_twin_messaging_interface()
|
|
632
|
+
|
|
633
|
+
# Create handler
|
|
634
|
+
handler = DataChannelHandler(
|
|
635
|
+
target_twin_id=target_twin_id,
|
|
636
|
+
is_initiator=is_initiator,
|
|
637
|
+
twin_messaging=messaging,
|
|
638
|
+
options=options,
|
|
639
|
+
channel_name=channel_name,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
# Connect and return
|
|
643
|
+
return await handler.connect(blocking=blocking)
|
|
644
|
+
|
|
645
|
+
async def create_media_stream(
|
|
646
|
+
self,
|
|
647
|
+
target_twin_id: str,
|
|
648
|
+
is_initiator: bool = True,
|
|
649
|
+
options: Optional[MediaStreamOptions] = None,
|
|
650
|
+
stream_name: str = "default",
|
|
651
|
+
blocking: bool = True,
|
|
652
|
+
) -> PhygridMediaStream:
|
|
653
|
+
"""Create a WebRTC MediaStream connection to another twin.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
target_twin_id: ID of the twin to connect to.
|
|
657
|
+
is_initiator: Whether this side initiates the connection.
|
|
658
|
+
options: MediaStream configuration options.
|
|
659
|
+
stream_name: Name for the media stream.
|
|
660
|
+
blocking: If True (default), wait for connection to complete.
|
|
661
|
+
If False, return immediately and connect in background.
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
PhygridMediaStream for video/audio streaming.
|
|
665
|
+
"""
|
|
666
|
+
if not self._connected:
|
|
667
|
+
raise ConnectionError("Not connected to PhyHub")
|
|
668
|
+
|
|
669
|
+
# Subscribe to target twin
|
|
670
|
+
await self.subscribe_twin(target_twin_id)
|
|
671
|
+
|
|
672
|
+
# Create messaging interface (same structure as DataChannel)
|
|
673
|
+
messaging_interface = self._create_twin_messaging_interface()
|
|
674
|
+
media_messaging = MediaStreamMessaging(
|
|
675
|
+
send_message=messaging_interface.send_message,
|
|
676
|
+
subscribe=messaging_interface.subscribe,
|
|
677
|
+
on_message=messaging_interface.on_message,
|
|
678
|
+
off_message=messaging_interface.off_message,
|
|
679
|
+
get_own_twin_id=messaging_interface.get_own_twin_id,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
# Create handler
|
|
683
|
+
handler = MediaStreamHandler(
|
|
684
|
+
target_twin_id=target_twin_id,
|
|
685
|
+
is_initiator=is_initiator,
|
|
686
|
+
twin_messaging=media_messaging,
|
|
687
|
+
options=options,
|
|
688
|
+
stream_name=stream_name,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
# Connect and return
|
|
692
|
+
return await handler.connect(blocking=blocking)
|
|
693
|
+
|
|
694
|
+
async def __aenter__(self) -> "PhyHubClient":
|
|
695
|
+
"""Async context manager entry."""
|
|
696
|
+
await self.connect()
|
|
697
|
+
return self
|
|
698
|
+
|
|
699
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
700
|
+
"""Async context manager exit."""
|
|
701
|
+
await self.disconnect()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Instance classes for twin management."""
|
|
2
|
+
|
|
3
|
+
from phystack.hub_client.instances.base_instance import BaseInstance
|
|
4
|
+
from phystack.hub_client.instances.edge_instance import EdgeInstance
|
|
5
|
+
from phystack.hub_client.instances.peripheral_instance import PeripheralInstance
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BaseInstance",
|
|
9
|
+
"EdgeInstance",
|
|
10
|
+
"PeripheralInstance",
|
|
11
|
+
]
|