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,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
+ ]