phystack-hub-client 0.2.7__tar.gz → 6.6.0__tar.gz

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.
Files changed (44) hide show
  1. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/PKG-INFO +1 -1
  2. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/pyproject.toml +4 -1
  3. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/__init__.py +55 -1
  4. phystack_hub_client-6.6.0/src/phystack/hub_client/advisory_validation.py +114 -0
  5. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/client.py +323 -7
  6. phystack_hub_client-6.6.0/src/phystack/hub_client/cloud_twins.py +173 -0
  7. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/instances/base_instance.py +72 -19
  8. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/instances/edge_instance.py +30 -33
  9. phystack_hub_client-6.6.0/src/phystack/hub_client/instances/peripheral_instance.py +305 -0
  10. phystack_hub_client-6.6.0/src/phystack/hub_client/services/cloud_app_connection.py +365 -0
  11. phystack_hub_client-6.6.0/src/phystack/hub_client/services/cloud_app_token.py +198 -0
  12. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/services/phyhub_connection.py +92 -57
  13. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/services/signals.py +128 -17
  14. phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/data_channel.py +832 -0
  15. phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/data_responder.py +544 -0
  16. phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/media_responder.py +620 -0
  17. phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/media_source.py +120 -0
  18. phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/media_stream.py +996 -0
  19. phystack_hub_client-6.6.0/tests/test_advisory_validation.py +260 -0
  20. phystack_hub_client-6.6.0/tests/test_client_integration.py +114 -0
  21. phystack_hub_client-6.6.0/tests/test_cloud_app_token.py +594 -0
  22. phystack_hub_client-6.6.0/tests/test_cloud_twins.py +249 -0
  23. phystack_hub_client-6.6.0/tests/test_data_channel.py +354 -0
  24. phystack_hub_client-6.6.0/tests/test_instance_listeners.py +118 -0
  25. phystack_hub_client-6.6.0/tests/test_media_resilience.py +498 -0
  26. phystack_hub_client-6.6.0/tests/test_media_responder.py +318 -0
  27. phystack_hub_client-6.6.0/tests/test_media_source.py +86 -0
  28. phystack_hub_client-6.6.0/tests/test_signals_rollover.py +213 -0
  29. phystack_hub_client-0.2.7/src/phystack/hub_client/instances/peripheral_instance.py +0 -190
  30. phystack_hub_client-0.2.7/src/phystack/hub_client/services/webrtc/data_channel.py +0 -570
  31. phystack_hub_client-0.2.7/src/phystack/hub_client/services/webrtc/media_stream.py +0 -614
  32. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/.gitignore +0 -0
  33. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/README.md +0 -0
  34. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/examples/browser_test_backend.py +0 -0
  35. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/scripts/python-node-test.sh +0 -0
  36. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/scripts/webrtc-test.sh +0 -0
  37. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/instances/__init__.py +0 -0
  38. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/services/__init__.py +0 -0
  39. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/services/webrtc/__init__.py +0 -0
  40. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/twin_messaging.py +0 -0
  41. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/twin_registry.py +0 -0
  42. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/types/__init__.py +0 -0
  43. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/src/phystack/hub_client/types/twin_types.py +0 -0
  44. {phystack_hub_client-0.2.7 → phystack_hub_client-6.6.0}/tests/test_communication.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phystack-hub-client
3
- Version: 0.2.7
3
+ Version: 6.6.0
4
4
  Summary: Python client for PhyHub - events, actions, DataChannel, and MediaStream
5
5
  Project-URL: Homepage, https://github.com/phystack/phystack-pypi
6
6
  Project-URL: Documentation, https://docs.phystack.com
@@ -4,7 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "phystack-hub-client"
7
- version = "0.2.7"
7
+ # Fixed version group with the Node hub-client (@phystack/hub-client in
8
+ # phystack/ps-platform): both clients implement the same twin/WebRTC contract
9
+ # and are released together under the same version.
10
+ version = "6.6.0"
8
11
  description = "Python client for PhyHub - events, actions, DataChannel, and MediaStream"
9
12
  readme = "README.md"
10
13
  license = "MIT"
@@ -69,6 +69,37 @@ from phystack.hub_client.services.webrtc.media_stream import (
69
69
  PhygridMediaStream,
70
70
  WebcamVideoTrack,
71
71
  )
72
+ from phystack.hub_client.services.webrtc.media_source import (
73
+ FrameSourceTrack,
74
+ VideoFrameSource,
75
+ )
76
+ from phystack.hub_client.services.webrtc.media_responder import (
77
+ MediaStreamResponder,
78
+ MediaStreamResponderOptions,
79
+ PeerCapReachedError,
80
+ ResponderPeer,
81
+ )
82
+ from phystack.hub_client.services.webrtc.data_responder import (
83
+ DataChannelResponder,
84
+ DataChannelResponderOptions,
85
+ ResponderChannel,
86
+ )
87
+ from phystack.hub_client.advisory_validation import (
88
+ CompiledValidator,
89
+ DescriptorValidator,
90
+ validate_emitted_facet,
91
+ )
92
+ from phystack.hub_client.services.cloud_app_token import (
93
+ CloudAppToken,
94
+ CloudAppTokenExchangeError,
95
+ exchange_cloud_app_token,
96
+ )
97
+ from phystack.hub_client.services.cloud_app_connection import (
98
+ CloudAppConnection,
99
+ CloudAppConnectionParams,
100
+ CloudAppConnectResult,
101
+ )
102
+ from phystack.hub_client.cloud_twins import CloudTwinStore
72
103
  from phystack.hub_client.services.signals import (
73
104
  SignalsService,
74
105
  InitSignalPayload,
@@ -80,7 +111,9 @@ from phystack.hub_client.services.signals import (
80
111
  PurchaseProps,
81
112
  )
82
113
 
83
- __version__ = "0.1.0"
114
+ # Kept in a fixed version group with @phystack/hub-client (npm) — same
115
+ # contract, same release version.
116
+ __version__ = "6.5.0"
84
117
  __all__ = [
85
118
  # Client
86
119
  "PhyHubClient",
@@ -104,6 +137,27 @@ __all__ = [
104
137
  "MediaStreamOptions",
105
138
  "PhygridMediaStream",
106
139
  "WebcamVideoTrack",
140
+ "FrameSourceTrack",
141
+ "VideoFrameSource",
142
+ "MediaStreamResponder",
143
+ "MediaStreamResponderOptions",
144
+ "PeerCapReachedError",
145
+ "ResponderPeer",
146
+ "DataChannelResponder",
147
+ "DataChannelResponderOptions",
148
+ "ResponderChannel",
149
+ # Advisory validation
150
+ "CompiledValidator",
151
+ "DescriptorValidator",
152
+ "validate_emitted_facet",
153
+ # Cloud-app mode
154
+ "CloudAppToken",
155
+ "CloudAppTokenExchangeError",
156
+ "exchange_cloud_app_token",
157
+ "CloudAppConnection",
158
+ "CloudAppConnectionParams",
159
+ "CloudAppConnectResult",
160
+ "CloudTwinStore",
107
161
  # Signals
108
162
  "SignalsService",
109
163
  "InitSignalPayload",
@@ -0,0 +1,114 @@
1
+ """Advisory descriptor validation (opt-in, on-device).
2
+
3
+ Python port of the npm hub-client ``advisory-validation.ts`` module. Validates
4
+ the facets an edge app EMITS (reported properties, event payloads, action
5
+ returns) against precompiled validators the app supplies from its own compiled
6
+ descriptor schema (the build-time-embed approach).
7
+
8
+ Hard rules this module enforces (npm parity):
9
+
10
+ - **Opt-in.** No validator supplied -> no validation; behavior is identical to
11
+ a non-validating client.
12
+ - **Advisory + routing-independent.** On a mismatch a warning is logged and the
13
+ caller STILL emits/sends/stores. The validation outcome never affects whether
14
+ or how a message goes on the wire.
15
+ - **Fail-open.** Missing validator for a facet -> skip; any error raised inside
16
+ the validation path -> caught, logged, and skipped. This module never raises.
17
+
18
+ Like the npm package (which deliberately does not depend on Ajv), this module
19
+ carries no JSON-schema runtime. The opt-in contract is a set of precompiled
20
+ validate callables: ``Callable[[Any], bool]`` that may expose the last run's
21
+ errors on an ``errors`` attribute (duck-typed, mirroring Ajv's compiled shape).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Callable, Optional
29
+
30
+ # A precompiled validate function: returns True/False for a payload and may
31
+ # expose the last run's errors on an `errors` attribute (duck-typed).
32
+ CompiledValidator = Callable[[Any], bool]
33
+
34
+ # Facet names mirror the npm module: 'reported' | 'event' | 'actionReturns'.
35
+ FACET_REPORTED = "reported"
36
+ FACET_EVENT = "event"
37
+ FACET_ACTION_RETURNS = "actionReturns"
38
+
39
+ _default_logger = logging.getLogger("phystack.hub_client.advisory_validation")
40
+
41
+
42
+ @dataclass
43
+ class DescriptorValidator:
44
+ """Per-facet precompiled validators supplied by the edge app.
45
+
46
+ Every field is optional: a facet with no validator is simply not checked
47
+ (fail-open). Event validators are keyed by event type; action-return
48
+ validators by action name.
49
+ """
50
+
51
+ reported: Optional[CompiledValidator] = None
52
+ events: dict[str, CompiledValidator] = field(default_factory=dict)
53
+ action_returns: dict[str, CompiledValidator] = field(default_factory=dict)
54
+
55
+
56
+ def _describe(descriptor_label: str, facet: str, facet_key: Optional[str]) -> str:
57
+ facet_part = f"{facet} '{facet_key}'" if facet_key else facet
58
+ return f"{facet_part} on '{descriptor_label}'"
59
+
60
+
61
+ def validate_emitted_facet(
62
+ validator: Optional[CompiledValidator],
63
+ data: Any,
64
+ descriptor_label: str,
65
+ facet: str,
66
+ facet_key: Optional[str] = None,
67
+ logger: Optional[logging.Logger] = None,
68
+ ) -> bool:
69
+ """Run a single precompiled validator over ``data``, advisory + fail-open.
70
+
71
+ NEVER raises and NEVER signals "block this message" — the return value is
72
+ purely informational (True = conformed/skipped, False = a mismatch was
73
+ logged) and callers MUST emit regardless of it. A ``validator`` of ``None``
74
+ means the app didn't supply one for this facet -> skip (fail-open). Any
75
+ error raised by the validator itself is caught, logged, and treated as a
76
+ skip (fail-open) so a broken schema can never block telemetry.
77
+
78
+ Args:
79
+ validator: Precompiled validate callable, or None to skip.
80
+ data: The emitted payload to check.
81
+ descriptor_label: Identifies the descriptor/peripheral in the warning.
82
+ facet: Which emitted facet this is ('reported'/'event'/'actionReturns').
83
+ facet_key: Event type or action name, when applicable.
84
+ logger: Logger for warnings; defaults to the module logger.
85
+
86
+ Returns:
87
+ True if the data conformed or validation was skipped, False on mismatch.
88
+ """
89
+ if validator is None:
90
+ # Opt-out / no schema for this facet -> skip, behave like a
91
+ # non-validating client.
92
+ return True
93
+
94
+ active_logger = logger if logger is not None else _default_logger
95
+
96
+ try:
97
+ conforms = bool(validator(data))
98
+ except Exception as error: # noqa: BLE001 - fail-open by contract
99
+ # Fail-open: a broken validator must never block an emit.
100
+ active_logger.warning(
101
+ "Failed to run advisory descriptor validation for %s — skipping (fail-open): %r",
102
+ _describe(descriptor_label, facet, facet_key),
103
+ error,
104
+ )
105
+ return True
106
+
107
+ if not conforms:
108
+ active_logger.warning(
109
+ "Advisory descriptor validation mismatch for %s — sending anyway (advisory): %r",
110
+ _describe(descriptor_label, facet, facet_key),
111
+ getattr(validator, "errors", None),
112
+ )
113
+
114
+ return conforms
@@ -6,6 +6,8 @@ Provides events, actions, DataChannel, and MediaStream functionality.
6
6
  """
7
7
 
8
8
  import asyncio
9
+ import logging
10
+ import time
9
11
  from typing import Any, Callable, Optional, Set
10
12
 
11
13
  from phystack.hub_client.services.phyhub_connection import (
@@ -38,10 +40,27 @@ from phystack.hub_client.services.webrtc.media_stream import (
38
40
  PhygridMediaStream,
39
41
  TwinMessagingInterface as MediaStreamMessaging,
40
42
  )
43
+ from phystack.hub_client.services.webrtc.media_responder import (
44
+ MediaStreamResponder,
45
+ MediaStreamResponderOptions,
46
+ ResponderPeer,
47
+ )
48
+ from phystack.hub_client.services.webrtc.data_responder import (
49
+ DataChannelResponder,
50
+ DataChannelResponderOptions,
51
+ ResponderChannel,
52
+ )
53
+ from phystack.hub_client.services.cloud_app_connection import (
54
+ CloudAppConnection,
55
+ CloudAppConnectionParams,
56
+ )
57
+ from phystack.hub_client.cloud_twins import CloudTwin, CloudTwinStore
41
58
  from phystack.hub_client.instances.edge_instance import EdgeInstance
42
59
  from phystack.hub_client.instances.peripheral_instance import PeripheralInstance
43
60
  from phystack.hub_client.twin_registry import TwinRegistry
44
61
 
62
+ logger = logging.getLogger("phystack.hub_client.client")
63
+
45
64
 
46
65
  class PhyHubClient:
47
66
  """Client for connecting to PhyHub.
@@ -72,6 +91,7 @@ class PhyHubClient:
72
91
  GET_DEVICE_STATUS = "getDeviceStatus"
73
92
  GET_DEVICE_INSTANCE = "getDeviceInstance"
74
93
  GET_DEVICE_NETWORKS = "getDeviceNetworks"
94
+ GET_ICE_SERVERS = "getIceServers"
75
95
 
76
96
  def __init__(
77
97
  self,
@@ -79,6 +99,7 @@ class PhyHubClient:
79
99
  access_key: Optional[str] = None,
80
100
  phyhub_url: Optional[str] = None,
81
101
  instance_id: Optional[str] = None,
102
+ cloud_app: Optional[CloudAppConnectionParams] = None,
82
103
  ) -> None:
83
104
  """Initialize PhyHub client.
84
105
 
@@ -87,6 +108,9 @@ class PhyHubClient:
87
108
  access_key: Access key for authentication.
88
109
  phyhub_url: PhyHub server URL.
89
110
  instance_id: Optional specific twin ID to use.
111
+ cloud_app: Cloud-app connection params (npm PhyHubClientParams
112
+ parity). When set, connect() runs the cloud-app branch (JWT
113
+ exchange + cloud twin lifecycle) instead of the device path.
90
114
  """
91
115
  self._connection: Optional[PhyHubConnection] = None
92
116
  self._instance_id: Optional[str] = instance_id
@@ -97,6 +121,22 @@ class PhyHubClient:
97
121
  self._twin_data_cache: dict[str, dict[str, Any]] = {}
98
122
  self._connected = False
99
123
  self._registry: Optional[TwinRegistry] = None
124
+ # (refresh_at_monotonic, servers) per use case ('media' / 'data')
125
+ self._cached_ice_servers: dict[str, tuple[float, list[dict[str, Any]]]] = {}
126
+ # Armed publisher fan-outs, keyed "{twin_id}:{stream_name}" (npm parity)
127
+ self._media_responders: dict[str, MediaStreamResponder] = {}
128
+ # Armed data-channel fan-outs, keyed "{twin_id}:{channel_name}" (npm parity)
129
+ self._data_responders: dict[str, DataChannelResponder] = {}
130
+ # npm parity (emitQueue): emits issued while the socket is down are
131
+ # queued and flushed on (re)connect instead of failing.
132
+ self._emit_queue: list[
133
+ tuple[str, dict[str, Any], bool, Optional["asyncio.Future[Any]"]]
134
+ ] = []
135
+ # npm parity: getDeviceStatus responses are cached for 10s.
136
+ self._device_status_cache: Optional[tuple[float, dict[str, Any]]] = None
137
+ self._cloud_app_params = cloud_app
138
+ self._cloud_app_connection: Optional[CloudAppConnection] = None
139
+ self._cloud_twin_store: Optional[CloudTwinStore] = None
100
140
 
101
141
  if device_id and access_key:
102
142
  self._connection = PhyHubConnection(
@@ -141,6 +181,9 @@ class PhyHubClient:
141
181
  Raises:
142
182
  ConnectionError: If connection fails.
143
183
  """
184
+ if self._cloud_app_params is not None:
185
+ return await self._connect_cloud_app()
186
+
144
187
  if not self._connection:
145
188
  raise ValueError("No connection configured. Use from_env() or provide credentials.")
146
189
 
@@ -153,6 +196,10 @@ class PhyHubClient:
153
196
  # Set up event handlers
154
197
  self._setup_socket_listeners()
155
198
 
199
+ # npm parity: on every (re)connect, re-subscribe twins (the server
200
+ # loses subscriptions across reconnects) and flush queued emits.
201
+ self._connection.on("connect", self._handle_socket_connected)
202
+
156
203
  # Only direct mode needs explicit authenticate/connectDevice handshake.
157
204
  # PhyOS and Simulator handle auth at Socket.IO connection time.
158
205
  if self._connection.is_direct:
@@ -164,6 +211,10 @@ class PhyHubClient:
164
211
 
165
212
  async def disconnect(self) -> None:
166
213
  """Disconnect from PhyHub."""
214
+ if self._cloud_app_connection:
215
+ await self._cloud_app_connection.disconnect()
216
+ self._cloud_app_connection = None
217
+ self._cloud_twin_store = None
167
218
  if self._connection:
168
219
  await self._connection.disconnect()
169
220
  self._connected = False
@@ -171,6 +222,85 @@ class PhyHubClient:
171
222
  if self._registry:
172
223
  self._registry.clear()
173
224
 
225
+ # =========================================================================
226
+ # Cloud-app mode (npm PhyHubClientParams.cloudApp parity)
227
+ # =========================================================================
228
+
229
+ async def _connect_cloud_app(self) -> "PhyHubClient":
230
+ """Connect the cloud-app branch: JWT exchange + cloud twin lifecycle."""
231
+ assert self._cloud_app_params is not None
232
+ self._cloud_app_connection = CloudAppConnection(self._cloud_app_params)
233
+ self._cloud_twin_store = CloudTwinStore()
234
+ result = await self._cloud_app_connection.connect()
235
+ self._cloud_twin_store.hydrate(result.initial_twins)
236
+
237
+ store = self._cloud_twin_store
238
+ socket = result.socket
239
+
240
+ def on_cloud_authenticated(payload: Any) -> None:
241
+ twins = payload.get("twins") if isinstance(payload, dict) else None
242
+ store.hydrate(twins if isinstance(twins, list) else None)
243
+
244
+ socket.on("cloudAppAuthenticated", on_cloud_authenticated)
245
+ socket.on("twinCreated", store.apply_created)
246
+ socket.on("twinUpdated", store.apply_updated)
247
+ socket.on("twinDeleted", store.apply_deleted)
248
+
249
+ self._connected = True
250
+ return self
251
+
252
+ def get_cloud_twins(self) -> list[CloudTwin]:
253
+ """Snapshot of the cloud twin store (cloud-app mode only, else [])."""
254
+ if self._cloud_twin_store is None:
255
+ return []
256
+ return self._cloud_twin_store.snapshot()
257
+
258
+ def on_cloud_twin_created(self, callback: Callable[[Any], None]) -> Callable[[], None]:
259
+ """Cloud twin lifecycle listener; returns an unsubscribe callable."""
260
+ if self._cloud_twin_store is None:
261
+ return lambda: None
262
+ return self._cloud_twin_store.on_created(callback)
263
+
264
+ def on_cloud_twin_updated(self, callback: Callable[[Any], None]) -> Callable[[], None]:
265
+ """Cloud twin lifecycle listener; returns an unsubscribe callable."""
266
+ if self._cloud_twin_store is None:
267
+ return lambda: None
268
+ return self._cloud_twin_store.on_updated(callback)
269
+
270
+ def on_cloud_twin_deleted(self, callback: Callable[[Any], None]) -> Callable[[], None]:
271
+ """Cloud twin lifecycle listener; returns an unsubscribe callable."""
272
+ if self._cloud_twin_store is None:
273
+ return lambda: None
274
+ return self._cloud_twin_store.on_deleted(callback)
275
+
276
+ async def _handle_socket_connected(self) -> None:
277
+ """Reconnect hook: re-subscribe twins, then flush the emit queue."""
278
+ for twin_id in list(self._subscribed_twins):
279
+ payload = {
280
+ "twinId": twin_id,
281
+ "sourceTwinId": self._instance_id,
282
+ "sourceDeviceId": self._connection.device_id if self._connection else None,
283
+ }
284
+ try:
285
+ await self._emit_to_socket(self.EVENTS.TWIN_SUBSCRIBE, payload, callback=True)
286
+ except Exception as resubscribe_error:
287
+ logger.warning(
288
+ "Failed to re-subscribe twin %s after reconnect: %s",
289
+ twin_id,
290
+ resubscribe_error,
291
+ )
292
+
293
+ pending, self._emit_queue = self._emit_queue, []
294
+ for method, payload, callback, future in pending:
295
+ try:
296
+ result = await self._emit_to_socket(method, payload, callback=callback)
297
+ if future and not future.done():
298
+ future.set_result(result)
299
+ except Exception as flush_error:
300
+ logger.warning("Failed to flush queued emit %s: %s", method, flush_error)
301
+ if future and not future.done():
302
+ future.set_exception(flush_error)
303
+
174
304
  def _setup_socket_listeners(self) -> None:
175
305
  """Set up Socket.IO event handlers."""
176
306
  if not self._connection:
@@ -287,6 +417,14 @@ class PhyHubClient:
287
417
  self._instance = await self._registry.get_edge_instance()
288
418
  return self._instance
289
419
 
420
+ async def get_edge_instance(self) -> Optional[EdgeInstance]:
421
+ """Alias for get_instance (npm parity)."""
422
+ return await self.get_instance()
423
+
424
+ async def get_screen_instance(self) -> Optional[EdgeInstance]:
425
+ """Alias for get_instance (npm parity — no distinct screen instance)."""
426
+ return await self.get_instance()
427
+
290
428
  async def get_peripheral_instance(self, twin_id: str) -> Optional[PeripheralInstance]:
291
429
  """Get a peripheral instance by twin ID.
292
430
 
@@ -339,6 +477,17 @@ class PhyHubClient:
339
477
  if not self._connection:
340
478
  return None
341
479
 
480
+ # npm parity (emitQueue): while the socket is down, queue instead of
481
+ # failing. Fire-and-forget entries return immediately; ack-style calls
482
+ # await a future resolved when the queue flushes on reconnect.
483
+ if not self._connection.is_connected:
484
+ if not callback:
485
+ self._emit_queue.append((method, payload, False, None))
486
+ return None
487
+ pending_future: "asyncio.Future[Any]" = asyncio.get_event_loop().create_future()
488
+ self._emit_queue.append((method, payload, True, pending_future))
489
+ return await pending_future
490
+
342
491
  if self._connection.is_direct:
343
492
  # Direct mode: emit to event name directly
344
493
  return await self._connection.emit(method, payload, callback=callback)
@@ -626,12 +775,18 @@ class PhyHubClient:
626
775
  async def get_device_status(self) -> Optional[dict[str, Any]]:
627
776
  """Get device status (IP, serial, env, etc.).
628
777
 
778
+ Responses are cached for 10 seconds (npm parity).
779
+
629
780
  Returns:
630
781
  Device status dictionary.
631
782
  """
632
- return await self._emit_to_socket(
633
- self.EVENTS.GET_DEVICE_STATUS, {}, callback=True
634
- )
783
+ now = time.monotonic()
784
+ if self._device_status_cache and now - self._device_status_cache[0] < 10.0:
785
+ return self._device_status_cache[1]
786
+ result = await self._emit_to_socket(self.EVENTS.GET_DEVICE_STATUS, {}, callback=True)
787
+ if result is not None:
788
+ self._device_status_cache = (now, result)
789
+ return result
635
790
 
636
791
  async def get_device_instance(self) -> Optional[dict[str, Any]]:
637
792
  """Get device instance data.
@@ -657,11 +812,20 @@ class PhyHubClient:
657
812
  # WebRTC Helpers
658
813
  # =========================================================================
659
814
 
660
- def _create_twin_messaging_interface(self) -> DataChannelMessaging:
815
+ def _create_twin_messaging_interface(
816
+ self, identity_twin_id: Optional[str] = None
817
+ ) -> DataChannelMessaging:
661
818
  """Create a twin messaging interface for WebRTC handlers.
662
819
 
663
820
  This interface matches the npm TwinMessagingInterface and is used
664
821
  for WebRTC signaling (offer/answer/ice exchange).
822
+
823
+ Args:
824
+ identity_twin_id: Signaling identity override. A publisher on a
825
+ peripheral twin must sign its signaling with the PERIPHERAL
826
+ twin id (npm parity: peripheral-twin.ts wires a dedicated
827
+ WebRTC transport for this) — viewers filter incoming signals
828
+ by the twin they dialed. Defaults to the edge instance id.
665
829
  """
666
830
  async def send_message(target_twin_id: str, payload: dict[str, Any]) -> None:
667
831
  """Send a twin message for signaling.
@@ -674,7 +838,7 @@ class PhyHubClient:
674
838
 
675
839
  event_payload = {
676
840
  "twinId": target_twin_id,
677
- "sourceTwinId": self._instance_id,
841
+ "sourceTwinId": identity_twin_id or self._instance_id,
678
842
  "sourceDeviceId": self._connection.device_id if self._connection else None,
679
843
  "data": payload,
680
844
  }
@@ -686,9 +850,10 @@ class PhyHubClient:
686
850
  await self.subscribe_twin(twin_id)
687
851
 
688
852
  def get_own_twin_id() -> str:
689
- if not self._instance_id:
853
+ own_twin_id = identity_twin_id or self._instance_id
854
+ if not own_twin_id:
690
855
  raise ValueError("Instance ID not set")
691
- return self._instance_id
856
+ return own_twin_id
692
857
 
693
858
  return DataChannelMessaging(
694
859
  send_message=send_message,
@@ -698,6 +863,139 @@ class PhyHubClient:
698
863
  get_own_twin_id=get_own_twin_id,
699
864
  )
700
865
 
866
+ # =========================================================================
867
+ # ICE Servers API
868
+ # =========================================================================
869
+
870
+ async def get_ice_servers(self, use_case: str = "media") -> list[dict[str, Any]]:
871
+ """Fetch ICE servers (STUN + short-lived TURN credentials) from phyhub.
872
+
873
+ Mirrors the npm client's getIceServers: emits the `getIceServers`
874
+ socket event ({data: {useCase}}), caches credential-bearing results
875
+ until shortly before their TTL expires, and raises on timeout so
876
+ callers can fall back to static STUN (npm parity).
877
+
878
+ Returns:
879
+ List of {urls, username?, credential?} dicts.
880
+ """
881
+ cached = self._cached_ice_servers.get(use_case)
882
+ if cached and time.monotonic() < cached[0]:
883
+ return cached[1]
884
+
885
+ result = await asyncio.wait_for(
886
+ self._emit_to_socket(
887
+ self.EVENTS.GET_ICE_SERVERS, {"data": {"useCase": use_case}}, callback=True
888
+ ),
889
+ timeout=10.0,
890
+ )
891
+ if not result or not isinstance(result.get("iceServers"), list):
892
+ raise ValueError("getIceServers(): no iceServers in response")
893
+
894
+ servers: list[dict[str, Any]] = result["iceServers"]
895
+ ttl_seconds = result.get("ttlSeconds") or 0
896
+ if ttl_seconds > 0:
897
+ # Refresh a margin before expiry so a fan-out never hands out
898
+ # nearly-expired credentials (npm parity).
899
+ refresh_margin = min(30.0, ttl_seconds * 0.5)
900
+ refresh_at = time.monotonic() + ttl_seconds - refresh_margin
901
+ else:
902
+ # Short negative-cache for unconfigured regions.
903
+ refresh_at = time.monotonic() + 30.0
904
+ self._cached_ice_servers[use_case] = (refresh_at, servers)
905
+ return servers
906
+
907
+ # =========================================================================
908
+ # Media Responder (publisher fan-out) API
909
+ # =========================================================================
910
+
911
+ async def accept_media_streams(
912
+ self,
913
+ twin_id: str,
914
+ callback: Callable[[ResponderPeer], None],
915
+ options: Optional[MediaStreamResponderOptions] = None,
916
+ stream_name: str = "default",
917
+ ) -> MediaStreamResponder:
918
+ """Arm a multi-viewer publisher fan-out on a twin (npm acceptMediaStream).
919
+
920
+ Every viewer that dials the twin gets its own peer connection with a
921
+ fresh track from options.create_local_track. Re-arming an existing
922
+ (twin_id, stream_name) stops the previous responder first.
923
+ """
924
+ responder_options = options or MediaStreamResponderOptions()
925
+ if responder_options.ice_servers_provider is None:
926
+ async def default_ice_provider() -> list[dict[str, Any]]:
927
+ return await self.get_ice_servers("media")
928
+
929
+ responder_options.ice_servers_provider = default_ice_provider
930
+
931
+ responder_key = f"{twin_id}:{stream_name}"
932
+ previous = self._media_responders.pop(responder_key, None)
933
+ if previous:
934
+ previous.stop()
935
+
936
+ responder = MediaStreamResponder(
937
+ twin_id=twin_id,
938
+ twin_messaging=self._create_twin_messaging_interface(identity_twin_id=twin_id),
939
+ callback=callback,
940
+ options=responder_options,
941
+ stream_name=stream_name,
942
+ )
943
+ await responder.start()
944
+ self._media_responders[responder_key] = responder
945
+ return responder
946
+
947
+ def get_media_responder(
948
+ self, twin_id: str, stream_name: str = "default"
949
+ ) -> Optional[MediaStreamResponder]:
950
+ """Look up an armed responder (e.g. to answer a connectStream offer)."""
951
+ return self._media_responders.get(f"{twin_id}:{stream_name}")
952
+
953
+ # =========================================================================
954
+ # Data-channel Responder (fan-out) API
955
+ # =========================================================================
956
+
957
+ async def accept_data_channels(
958
+ self,
959
+ twin_id: str,
960
+ callback: Callable[[ResponderChannel], None],
961
+ options: Optional[DataChannelResponderOptions] = None,
962
+ channel_name: str = "default",
963
+ ) -> DataChannelResponder:
964
+ """Arm a multi-peer data-channel fan-out on a twin (npm acceptDataChannel).
965
+
966
+ Every initiator that dials the twin gets its own peer connection and
967
+ channel; the callback fires once per opened channel. Re-arming an
968
+ existing (twin_id, channel_name) stops the previous responder first.
969
+ """
970
+ responder_options = options or DataChannelResponderOptions()
971
+ if responder_options.ice_servers_provider is None:
972
+ async def default_ice_provider() -> list[dict[str, Any]]:
973
+ return await self.get_ice_servers("media")
974
+
975
+ responder_options.ice_servers_provider = default_ice_provider
976
+
977
+ responder_key = f"{twin_id}:{channel_name}"
978
+ previous = self._data_responders.pop(responder_key, None)
979
+ if previous:
980
+ previous.stop()
981
+
982
+ responder = DataChannelResponder(
983
+ twin_id=twin_id,
984
+ twin_messaging=self._create_twin_messaging_interface(identity_twin_id=twin_id),
985
+ callback=callback,
986
+ options=responder_options,
987
+ channel_name=channel_name,
988
+ )
989
+ await responder.start()
990
+ self._data_responders[responder_key] = responder
991
+ return responder
992
+
993
+ def get_data_responder(
994
+ self, twin_id: str, channel_name: str = "default"
995
+ ) -> Optional[DataChannelResponder]:
996
+ """Look up an armed data-channel responder."""
997
+ return self._data_responders.get(f"{twin_id}:{channel_name}")
998
+
701
999
  async def create_data_channel(
702
1000
  self,
703
1001
  target_twin_id: str,
@@ -722,6 +1020,15 @@ class PhyHubClient:
722
1020
  if not self._connected:
723
1021
  raise ConnectionError("Not connected to PhyHub")
724
1022
 
1023
+ # npm parity: every connection gets phyhub TURN credentials by default
1024
+ # (getWebRTCManager wires getIceServers('media') as the ICE provider).
1025
+ options = options or DataChannelOptions()
1026
+ if options.ice_servers_provider is None:
1027
+ async def default_data_ice_provider() -> list[dict[str, Any]]:
1028
+ return await self.get_ice_servers("media")
1029
+
1030
+ options.ice_servers_provider = default_data_ice_provider
1031
+
725
1032
  # Subscribe to target twin
726
1033
  await self.subscribe_twin(target_twin_id)
727
1034
 
@@ -764,6 +1071,15 @@ class PhyHubClient:
764
1071
  if not self._connected:
765
1072
  raise ConnectionError("Not connected to PhyHub")
766
1073
 
1074
+ # npm parity: viewers fetch phyhub TURN credentials by default — the
1075
+ # gap that made Python consumers fail cross-network where npm worked.
1076
+ options = options or MediaStreamOptions()
1077
+ if options.ice_servers_provider is None:
1078
+ async def default_media_ice_provider() -> list[dict[str, Any]]:
1079
+ return await self.get_ice_servers("media")
1080
+
1081
+ options.ice_servers_provider = default_media_ice_provider
1082
+
767
1083
  # Subscribe to target twin
768
1084
  await self.subscribe_twin(target_twin_id)
769
1085