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,272 @@
1
+ """
2
+ PhyHub Direct Connection Service
3
+
4
+ Allows direct connection to PhyHub for testing purposes,
5
+ bypassing the phyos layer that normally runs on devices.
6
+
7
+ Usage:
8
+ - Set PHYHUB_DIRECT=true to enable direct connection
9
+ - Provide DEVICE_ID and ACCESS_KEY environment variables
10
+ - Optionally set PHYHUB_URL (defaults to EU region)
11
+ """
12
+
13
+ import asyncio
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Callable, Optional
17
+
18
+ import socketio
19
+
20
+
21
+ DEFAULT_PHYHUB_URLS: dict[str, str] = {
22
+ "eu": "https://phyhub.eu.omborigrid.net",
23
+ "us": "https://phyhub.us.omborigrid.net",
24
+ "uae": "https://phyhub.uae.omborigrid.net",
25
+ "au": "https://phyhub.au.omborigrid.net",
26
+ "in": "https://phyhub.in.omborigrid.net",
27
+ "qa": "https://phyhub.qa.omborigrid.net",
28
+ "dev": "https://phyhub.dev.omborigrid.net",
29
+ "local": "http://localhost:3000",
30
+ }
31
+
32
+
33
+ def is_direct_mode() -> bool:
34
+ """Check if running in direct connection mode (vs PhyOS mode)."""
35
+ return os.environ.get("PHYHUB_DIRECT", "").lower() in ("true", "1")
36
+
37
+
38
+ @dataclass
39
+ class DirectConnectionConfig:
40
+ """Configuration for direct PhyHub connection."""
41
+
42
+ device_id: str
43
+ access_key: str
44
+ phyhub_url: str = field(default_factory=lambda: DEFAULT_PHYHUB_URLS["eu"])
45
+ instance_id: Optional[str] = None
46
+
47
+
48
+ class PhyHubConnection:
49
+ """Direct connection to PhyHub using Socket.IO."""
50
+
51
+ def __init__(self, config: DirectConnectionConfig) -> None:
52
+ self.config = config
53
+ self._sio: Optional[socketio.AsyncClient] = None
54
+ self._connected = False
55
+ self._connect_event: Optional[asyncio.Event] = None
56
+ self._listeners: dict[str, list[Callable[..., Any]]] = {}
57
+
58
+ @classmethod
59
+ def from_env(cls) -> "PhyHubConnection":
60
+ """Create a direct connection from environment variables.
61
+
62
+ Raises:
63
+ ValueError: If required environment variables are missing.
64
+ """
65
+ device_id = os.environ.get("DEVICE_ID") or os.environ.get("PHYGRID_DEVICE_ID")
66
+ access_key = os.environ.get("ACCESS_KEY") or os.environ.get("PHYGRID_DEVICE_KEY")
67
+
68
+ if not device_id:
69
+ raise ValueError(
70
+ "[PhyHubConnection] DEVICE_ID or PHYGRID_DEVICE_ID env var required"
71
+ )
72
+ if not access_key:
73
+ raise ValueError(
74
+ "[PhyHubConnection] ACCESS_KEY or PHYGRID_DEVICE_KEY env var required"
75
+ )
76
+
77
+ region = os.environ.get("PHYHUB_REGION", "").lower()
78
+ phyhub_url = os.environ.get("PHYHUB_URL") or DEFAULT_PHYHUB_URLS.get(region)
79
+
80
+ if not phyhub_url:
81
+ valid_regions = ", ".join(DEFAULT_PHYHUB_URLS.keys())
82
+ raise ValueError(
83
+ f"[PhyHubConnection] PHYHUB_URL or valid PHYHUB_REGION required. "
84
+ f"Valid regions: {valid_regions}"
85
+ )
86
+
87
+ return cls(
88
+ DirectConnectionConfig(
89
+ device_id=device_id,
90
+ access_key=access_key,
91
+ phyhub_url=phyhub_url,
92
+ instance_id=os.environ.get("TWIN_ID") or os.environ.get("INSTANCE_ID"),
93
+ )
94
+ )
95
+
96
+ @staticmethod
97
+ def is_enabled() -> bool:
98
+ """Check if direct connection mode is enabled."""
99
+ return os.environ.get("PHYHUB_DIRECT", "").lower() in ("true", "1")
100
+
101
+ @property
102
+ def device_id(self) -> str:
103
+ """Get the device ID (also used as the primary twin ID)."""
104
+ return self.config.device_id
105
+
106
+ @property
107
+ def instance_id(self) -> str:
108
+ """Get the instance/twin ID to use."""
109
+ return self.config.instance_id or self.config.device_id
110
+
111
+ @property
112
+ def is_connected(self) -> bool:
113
+ """Check if currently connected."""
114
+ return self._connected and self._sio is not None
115
+
116
+ async def connect(self) -> socketio.AsyncClient:
117
+ """Connect directly to PhyHub.
118
+
119
+ Returns:
120
+ The connected Socket.IO client.
121
+
122
+ Raises:
123
+ ConnectionError: If connection fails.
124
+ """
125
+ if self._sio and self._connected:
126
+ return self._sio
127
+
128
+ print(f"[PhyHubConnection] Connecting to {self.config.phyhub_url}")
129
+ print(f"[PhyHubConnection] Device ID: {self.config.device_id}")
130
+
131
+ self._sio = socketio.AsyncClient(
132
+ reconnection=True,
133
+ reconnection_attempts=5,
134
+ reconnection_delay=1,
135
+ logger=False,
136
+ engineio_logger=False,
137
+ )
138
+
139
+ self._connect_event = asyncio.Event()
140
+
141
+ @self._sio.event
142
+ async def connect() -> None:
143
+ self._connected = True
144
+ print("[PhyHubConnection] Connected successfully")
145
+ if self._connect_event:
146
+ self._connect_event.set()
147
+
148
+ @self._sio.event
149
+ async def disconnect() -> None:
150
+ self._connected = False
151
+ print("[PhyHubConnection] Disconnected")
152
+
153
+ @self._sio.event
154
+ async def connect_error(data: Any) -> None:
155
+ print(f"[PhyHubConnection] Connection error: {data}")
156
+ if self._connect_event:
157
+ self._connect_event.set()
158
+
159
+ try:
160
+ await self._sio.connect(
161
+ self.config.phyhub_url,
162
+ auth={
163
+ "deviceId": self.config.device_id,
164
+ "accessKey": self.config.access_key,
165
+ },
166
+ wait_timeout=15,
167
+ )
168
+
169
+ # Wait for connection confirmation
170
+ await asyncio.wait_for(self._connect_event.wait(), timeout=15.0)
171
+
172
+ if not self._connected:
173
+ raise ConnectionError("Failed to connect to PhyHub")
174
+
175
+ return self._sio
176
+
177
+ except asyncio.TimeoutError:
178
+ raise ConnectionError("Connection timeout")
179
+
180
+ async def disconnect(self) -> None:
181
+ """Disconnect from PhyHub."""
182
+ if self._sio:
183
+ await self._sio.disconnect()
184
+ self._sio = None
185
+ self._connected = False
186
+
187
+ def get_socket(self) -> Optional[socketio.AsyncClient]:
188
+ """Get the socket instance."""
189
+ return self._sio
190
+
191
+ async def emit(
192
+ self,
193
+ event: str,
194
+ data: Any = None,
195
+ callback: Optional[Callable[..., Any]] = None,
196
+ response_event: Optional[str] = None,
197
+ ) -> Any:
198
+ """Emit an event to the server.
199
+
200
+ Args:
201
+ event: Event name.
202
+ data: Data to send.
203
+ callback: Optional callback for acknowledgement.
204
+ response_event: Optional event name to wait for response.
205
+
206
+ Returns:
207
+ Response from server if callback pattern is used.
208
+ """
209
+ if not self._sio or not self._connected:
210
+ raise ConnectionError("Not connected to PhyHub")
211
+
212
+ if response_event:
213
+ # Wait for a specific response event
214
+ response_future: asyncio.Future[Any] = asyncio.get_event_loop().create_future()
215
+
216
+ def on_response(data: Any) -> None:
217
+ if not response_future.done():
218
+ response_future.set_result(data)
219
+
220
+ self._sio.on(response_event, on_response)
221
+ await self._sio.emit(event, data)
222
+
223
+ try:
224
+ result = await asyncio.wait_for(response_future, timeout=20)
225
+ return result
226
+ finally:
227
+ # Clean up the listener - use handlers dict directly
228
+ if response_event in self._sio.handlers.get("/", {}):
229
+ del self._sio.handlers["/"][response_event]
230
+ elif callback:
231
+ # Use callback pattern (ack-based)
232
+ return await self._sio.call(event, data, timeout=20)
233
+ else:
234
+ await self._sio.emit(event, data)
235
+ return None
236
+
237
+ def on(self, event: str, handler: Callable[..., Any]) -> None:
238
+ """Register an event handler.
239
+
240
+ Args:
241
+ event: Event name to listen for.
242
+ handler: Handler function.
243
+ """
244
+ if self._sio:
245
+ self._sio.on(event, handler)
246
+
247
+ # Also store for reconnection
248
+ if event not in self._listeners:
249
+ self._listeners[event] = []
250
+ self._listeners[event].append(handler)
251
+
252
+ def off(self, event: str, handler: Optional[Callable[..., Any]] = None) -> None:
253
+ """Remove an event handler.
254
+
255
+ Args:
256
+ event: Event name.
257
+ handler: Specific handler to remove, or None to remove all.
258
+ """
259
+ if event in self._listeners:
260
+ if handler:
261
+ self._listeners[event] = [h for h in self._listeners[event] if h != handler]
262
+ else:
263
+ self._listeners[event] = []
264
+
265
+ async def __aenter__(self) -> "PhyHubConnection":
266
+ """Async context manager entry."""
267
+ await self.connect()
268
+ return self
269
+
270
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
271
+ """Async context manager exit."""
272
+ await self.disconnect()
@@ -0,0 +1,23 @@
1
+ """WebRTC services for DataChannel and MediaStream."""
2
+
3
+ from phystack.hub_client.services.webrtc.data_channel import (
4
+ DataChannelHandler,
5
+ DataChannelOptions,
6
+ PhygridDataChannel,
7
+ )
8
+ from phystack.hub_client.services.webrtc.media_stream import (
9
+ MediaStreamHandler,
10
+ MediaStreamOptions,
11
+ PhygridMediaStream,
12
+ WebcamVideoTrack,
13
+ )
14
+
15
+ __all__ = [
16
+ "DataChannelHandler",
17
+ "DataChannelOptions",
18
+ "PhygridDataChannel",
19
+ "MediaStreamHandler",
20
+ "MediaStreamOptions",
21
+ "PhygridMediaStream",
22
+ "WebcamVideoTrack",
23
+ ]