phystack-hub-client 0.2.6__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.
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/PKG-INFO +1 -1
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/pyproject.toml +4 -1
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/__init__.py +55 -1
- phystack_hub_client-6.6.0/src/phystack/hub_client/advisory_validation.py +114 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/client.py +323 -7
- phystack_hub_client-6.6.0/src/phystack/hub_client/cloud_twins.py +173 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/instances/base_instance.py +72 -19
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/instances/edge_instance.py +30 -33
- phystack_hub_client-6.6.0/src/phystack/hub_client/instances/peripheral_instance.py +305 -0
- phystack_hub_client-6.6.0/src/phystack/hub_client/services/cloud_app_connection.py +365 -0
- phystack_hub_client-6.6.0/src/phystack/hub_client/services/cloud_app_token.py +198 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/services/phyhub_connection.py +99 -11
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/services/signals.py +128 -17
- phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/data_channel.py +832 -0
- phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/data_responder.py +544 -0
- phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/media_responder.py +620 -0
- phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/media_source.py +120 -0
- phystack_hub_client-6.6.0/src/phystack/hub_client/services/webrtc/media_stream.py +996 -0
- phystack_hub_client-6.6.0/tests/test_advisory_validation.py +260 -0
- phystack_hub_client-6.6.0/tests/test_client_integration.py +114 -0
- phystack_hub_client-6.6.0/tests/test_cloud_app_token.py +594 -0
- phystack_hub_client-6.6.0/tests/test_cloud_twins.py +249 -0
- phystack_hub_client-6.6.0/tests/test_data_channel.py +354 -0
- phystack_hub_client-6.6.0/tests/test_instance_listeners.py +118 -0
- phystack_hub_client-6.6.0/tests/test_media_resilience.py +498 -0
- phystack_hub_client-6.6.0/tests/test_media_responder.py +318 -0
- phystack_hub_client-6.6.0/tests/test_media_source.py +86 -0
- phystack_hub_client-6.6.0/tests/test_signals_rollover.py +213 -0
- phystack_hub_client-0.2.6/src/phystack/hub_client/instances/peripheral_instance.py +0 -190
- phystack_hub_client-0.2.6/src/phystack/hub_client/services/webrtc/data_channel.py +0 -570
- phystack_hub_client-0.2.6/src/phystack/hub_client/services/webrtc/media_stream.py +0 -614
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/.gitignore +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/README.md +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/examples/browser_test_backend.py +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/scripts/python-node-test.sh +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/scripts/webrtc-test.sh +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/instances/__init__.py +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/services/__init__.py +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/services/webrtc/__init__.py +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/twin_messaging.py +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/twin_registry.py +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/types/__init__.py +0 -0
- {phystack_hub_client-0.2.6 → phystack_hub_client-6.6.0}/src/phystack/hub_client/types/twin_types.py +0 -0
- {phystack_hub_client-0.2.6 → 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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|