phystack-hub-client 6.6.0__tar.gz → 6.16.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-6.16.0/CHANGELOG.md +47 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/PKG-INFO +31 -2
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/README.md +29 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/pyproject.toml +1 -1
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/__init__.py +33 -1
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/client.py +318 -2
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/base_instance.py +19 -2
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/edge_instance.py +138 -19
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/peripheral_instance.py +188 -7
- phystack_hub_client-6.16.0/src/phystack/hub_client/scoped_hardware_id.py +103 -0
- phystack_hub_client-6.16.0/src/phystack/hub_client/services/web_session_code.py +468 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/__init__.py +4 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_stream.py +113 -4
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_messaging.py +55 -1
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_registry.py +16 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/twin_types.py +43 -0
- phystack_hub_client-6.16.0/tests/test_media_kinds.py +231 -0
- phystack_hub_client-6.16.0/tests/test_peripheral_lifecycle.py +451 -0
- phystack_hub_client-6.16.0/tests/test_web_session_code.py +354 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/.gitignore +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/examples/browser_test_backend.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/scripts/python-node-test.sh +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/scripts/webrtc-test.sh +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/advisory_validation.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/cloud_twins.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/__init__.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/__init__.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/cloud_app_connection.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/cloud_app_token.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/phyhub_connection.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/signals.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/data_channel.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/data_responder.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_responder.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_source.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/__init__.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_advisory_validation.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_client_integration.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_cloud_app_token.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_cloud_twins.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_communication.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_data_channel.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_instance_listeners.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_media_resilience.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_media_responder.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_media_source.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_signals_rollover.py +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog — phystack-hub-client (Python)
|
|
2
|
+
|
|
3
|
+
Fixed version group with npm `@phystack/hub-client` (phystack/ps-platform): same
|
|
4
|
+
twin/WebRTC contract, released under the same version. The PyPI workflow only
|
|
5
|
+
publishes even minors from `main`, so a Python release is the next even minor at
|
|
6
|
+
or above the npm version it matches; the entries below name the npm version and
|
|
7
|
+
PRs each change was ported from.
|
|
8
|
+
|
|
9
|
+
## 6.16.0 — parity with npm 6.15.1 (TECH-1483)
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `PhyHubClient.create_web_session_code(twin_id=… | endpoint_id=…)` and
|
|
14
|
+
`PhyHubClient.subscribe_web_session_code(listener, …)`: the device-side
|
|
15
|
+
web-session claim-code issuer (npm 6.10.0 / 6.11.0, ps-platform #893, #896).
|
|
16
|
+
Same wire event (`createWebSessionCode`) and ack contract as npm; the
|
|
17
|
+
rotating issuer renews at a fraction of the code TTL with downward jitter,
|
|
18
|
+
re-mints after a socket reconnect, retries failures with capped exponential
|
|
19
|
+
backoff, and reports `unavailable` states (terminal for a non-Web twin).
|
|
20
|
+
Device sessions only, as in npm (`services/web_session_code.py`).
|
|
21
|
+
|
|
22
|
+
### Parity audit — device-session semantics vs npm 6.14.0 / 6.15.0 / 6.15.1
|
|
23
|
+
|
|
24
|
+
npm 6.9.0–6.15.x is mostly web-session work (browser sessions behind a web
|
|
25
|
+
endpoint), which has no Python counterpart and is not needed for edge apps.
|
|
26
|
+
Verified, no code change required:
|
|
27
|
+
|
|
28
|
+
- Twin messages from a device are sent fire-and-forget (`_send_event` →
|
|
29
|
+
`twinMessage` without an ack callback; requests settle on the `response` twin
|
|
30
|
+
message or the 10 s timeout). npm 6.15.0 added ack-based fail-fast and
|
|
31
|
+
6.15.1 (ps-platform #927) reverted it for device sessions because the
|
|
32
|
+
edge-hub relays acks that do not describe delivery. Python never had it.
|
|
33
|
+
- Consumer media dials use the client's own identity
|
|
34
|
+
(`PhyHubClient.create_media_stream(twin_id)`); the responder replies to the
|
|
35
|
+
offer's `sourceTwinId`, so this matches the 6.15.1 device behaviour.
|
|
36
|
+
- Owner-only peripheral methods (`update_reported`, `update_desired`,
|
|
37
|
+
`remove`) are not gated client-side; the server decides (npm 6.15.1).
|
|
38
|
+
- `disconnect()` leaves the client reusable (npm 6.14.0 fixed a cached dead
|
|
39
|
+
client on the Node side; Python re-connects through `PhyHubConnection`).
|
|
40
|
+
|
|
41
|
+
Not ported (web-session only): `connect({ webApp })`, `getWebInstance()`,
|
|
42
|
+
session persistence/resume, web `getPeripheralInstance()`, the `media:view`
|
|
43
|
+
signaling gate (phyhub-side).
|
|
44
|
+
|
|
45
|
+
## 6.8.0 — parity with npm 6.8.0 (TECH-1417)
|
|
46
|
+
|
|
47
|
+
See the release notes on GitHub.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: phystack-hub-client
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.16.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
|
|
@@ -103,6 +103,35 @@ def handle_action(data, respond):
|
|
|
103
103
|
instance.on("my-action", handle_action)
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
### Web-session claim codes (QR for web apps)
|
|
107
|
+
|
|
108
|
+
A screen/edge app can mint the claim codes a phone scans to open a web app
|
|
109
|
+
session (the device side of the web-app QR flow). Identify the Web endpoint by
|
|
110
|
+
exactly one of `twin_id` (the Web twin's `_id`, what a settings twin picker
|
|
111
|
+
stores) or `endpoint_id` (the Web twin's `deviceId`, as shown by the Console
|
|
112
|
+
and `phy web-endpoint get`).
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# One code — the URL is the complete link to render as a QR
|
|
116
|
+
issued = await client.create_web_session_code(twin_id=settings["remoteWeb"]["id"])
|
|
117
|
+
print(issued.url, issued.expires_in)
|
|
118
|
+
|
|
119
|
+
# Rotating codes for a permanently displayed QR: the listener gets the initial
|
|
120
|
+
# code, every rotation, a re-mint after each reconnect, and `unavailable`
|
|
121
|
+
# states while minting fails (hide the QR instead of showing a dead code).
|
|
122
|
+
def render(state):
|
|
123
|
+
if state.status == "active":
|
|
124
|
+
show_qr(state.url)
|
|
125
|
+
else:
|
|
126
|
+
hide_qr(state.message)
|
|
127
|
+
|
|
128
|
+
subscription = client.subscribe_web_session_code(render, endpoint_id="863d9307-…")
|
|
129
|
+
subscription.refresh() # mint now
|
|
130
|
+
subscription.stop() # release timers and the reconnect listener
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Device sessions only: phyhub registers `createWebSessionCode` on device sockets, so a cloud-app session raises immediately.
|
|
134
|
+
|
|
106
135
|
### WebRTC DataChannel
|
|
107
136
|
|
|
108
137
|
```python
|
|
@@ -72,6 +72,35 @@ def handle_action(data, respond):
|
|
|
72
72
|
instance.on("my-action", handle_action)
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
### Web-session claim codes (QR for web apps)
|
|
76
|
+
|
|
77
|
+
A screen/edge app can mint the claim codes a phone scans to open a web app
|
|
78
|
+
session (the device side of the web-app QR flow). Identify the Web endpoint by
|
|
79
|
+
exactly one of `twin_id` (the Web twin's `_id`, what a settings twin picker
|
|
80
|
+
stores) or `endpoint_id` (the Web twin's `deviceId`, as shown by the Console
|
|
81
|
+
and `phy web-endpoint get`).
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# One code — the URL is the complete link to render as a QR
|
|
85
|
+
issued = await client.create_web_session_code(twin_id=settings["remoteWeb"]["id"])
|
|
86
|
+
print(issued.url, issued.expires_in)
|
|
87
|
+
|
|
88
|
+
# Rotating codes for a permanently displayed QR: the listener gets the initial
|
|
89
|
+
# code, every rotation, a re-mint after each reconnect, and `unavailable`
|
|
90
|
+
# states while minting fails (hide the QR instead of showing a dead code).
|
|
91
|
+
def render(state):
|
|
92
|
+
if state.status == "active":
|
|
93
|
+
show_qr(state.url)
|
|
94
|
+
else:
|
|
95
|
+
hide_qr(state.message)
|
|
96
|
+
|
|
97
|
+
subscription = client.subscribe_web_session_code(render, endpoint_id="863d9307-…")
|
|
98
|
+
subscription.refresh() # mint now
|
|
99
|
+
subscription.stop() # release timers and the reconnect listener
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Device sessions only: phyhub registers `createWebSessionCode` on device sockets, so a cloud-app session raises immediately.
|
|
103
|
+
|
|
75
104
|
### WebRTC DataChannel
|
|
76
105
|
|
|
77
106
|
```python
|
|
@@ -7,7 +7,7 @@ name = "phystack-hub-client"
|
|
|
7
7
|
# Fixed version group with the Node hub-client (@phystack/hub-client in
|
|
8
8
|
# phystack/ps-platform): both clients implement the same twin/WebRTC contract
|
|
9
9
|
# and are released together under the same version.
|
|
10
|
-
version = "6.
|
|
10
|
+
version = "6.16.0"
|
|
11
11
|
description = "Python client for PhyHub - events, actions, DataChannel, and MediaStream"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
license = "MIT"
|
{phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/__init__.py
RENAMED
|
@@ -46,12 +46,20 @@ Or with context manager:
|
|
|
46
46
|
|
|
47
47
|
from phystack.hub_client.client import PhyHubClient, connect_phy_client
|
|
48
48
|
from phystack.hub_client.types.twin_types import (
|
|
49
|
+
PeripheralCreateResult,
|
|
50
|
+
PeripheralHandover,
|
|
51
|
+
PeripheralOwnershipLoss,
|
|
49
52
|
TwinMessageResult,
|
|
50
53
|
TwinMessageResultStatus,
|
|
51
54
|
TwinProperties,
|
|
52
55
|
TwinTypeEnum,
|
|
53
56
|
TwinStatusEnum,
|
|
54
57
|
)
|
|
58
|
+
from phystack.hub_client.scoped_hardware_id import (
|
|
59
|
+
HARDWARE_ID_SCOPES,
|
|
60
|
+
HARDWARE_ID_USER_PART_MAX_LENGTH,
|
|
61
|
+
format_scoped_hardware_id,
|
|
62
|
+
)
|
|
55
63
|
from phystack.hub_client.instances import (
|
|
56
64
|
BaseInstance,
|
|
57
65
|
EdgeInstance,
|
|
@@ -66,6 +74,8 @@ from phystack.hub_client.services.webrtc.data_channel import (
|
|
|
66
74
|
from phystack.hub_client.services.webrtc.media_stream import (
|
|
67
75
|
MediaStreamHandler,
|
|
68
76
|
MediaStreamOptions,
|
|
77
|
+
MediaTrackKind,
|
|
78
|
+
MissingMediaTrackError,
|
|
69
79
|
PhygridMediaStream,
|
|
70
80
|
WebcamVideoTrack,
|
|
71
81
|
)
|
|
@@ -100,6 +110,13 @@ from phystack.hub_client.services.cloud_app_connection import (
|
|
|
100
110
|
CloudAppConnectResult,
|
|
101
111
|
)
|
|
102
112
|
from phystack.hub_client.cloud_twins import CloudTwinStore
|
|
113
|
+
from phystack.hub_client.services.web_session_code import (
|
|
114
|
+
WebSessionCode,
|
|
115
|
+
WebSessionCodeConfigError,
|
|
116
|
+
WebSessionCodeIssuer,
|
|
117
|
+
WebSessionCodeListener,
|
|
118
|
+
WebSessionCodeState,
|
|
119
|
+
)
|
|
103
120
|
from phystack.hub_client.services.signals import (
|
|
104
121
|
SignalsService,
|
|
105
122
|
InitSignalPayload,
|
|
@@ -113,7 +130,7 @@ from phystack.hub_client.services.signals import (
|
|
|
113
130
|
|
|
114
131
|
# Kept in a fixed version group with @phystack/hub-client (npm) — same
|
|
115
132
|
# contract, same release version.
|
|
116
|
-
__version__ = "6.
|
|
133
|
+
__version__ = "6.16.0"
|
|
117
134
|
__all__ = [
|
|
118
135
|
# Client
|
|
119
136
|
"PhyHubClient",
|
|
@@ -129,12 +146,21 @@ __all__ = [
|
|
|
129
146
|
"TwinProperties",
|
|
130
147
|
"TwinTypeEnum",
|
|
131
148
|
"TwinStatusEnum",
|
|
149
|
+
# Peripheral identity + ownership (TECH-1394)
|
|
150
|
+
"PeripheralCreateResult",
|
|
151
|
+
"PeripheralHandover",
|
|
152
|
+
"PeripheralOwnershipLoss",
|
|
153
|
+
"HARDWARE_ID_SCOPES",
|
|
154
|
+
"HARDWARE_ID_USER_PART_MAX_LENGTH",
|
|
155
|
+
"format_scoped_hardware_id",
|
|
132
156
|
# WebRTC
|
|
133
157
|
"DataChannelHandler",
|
|
134
158
|
"DataChannelOptions",
|
|
135
159
|
"PhygridDataChannel",
|
|
136
160
|
"MediaStreamHandler",
|
|
137
161
|
"MediaStreamOptions",
|
|
162
|
+
"MediaTrackKind",
|
|
163
|
+
"MissingMediaTrackError",
|
|
138
164
|
"PhygridMediaStream",
|
|
139
165
|
"WebcamVideoTrack",
|
|
140
166
|
"FrameSourceTrack",
|
|
@@ -158,6 +184,12 @@ __all__ = [
|
|
|
158
184
|
"CloudAppConnectionParams",
|
|
159
185
|
"CloudAppConnectResult",
|
|
160
186
|
"CloudTwinStore",
|
|
187
|
+
# Web-session claim codes (device-side QR issuer)
|
|
188
|
+
"WebSessionCode",
|
|
189
|
+
"WebSessionCodeConfigError",
|
|
190
|
+
"WebSessionCodeIssuer",
|
|
191
|
+
"WebSessionCodeListener",
|
|
192
|
+
"WebSessionCodeState",
|
|
161
193
|
# Signals
|
|
162
194
|
"SignalsService",
|
|
163
195
|
"InitSignalPayload",
|
|
@@ -50,6 +50,14 @@ from phystack.hub_client.services.webrtc.data_responder import (
|
|
|
50
50
|
DataChannelResponderOptions,
|
|
51
51
|
ResponderChannel,
|
|
52
52
|
)
|
|
53
|
+
from phystack.hub_client.services.web_session_code import (
|
|
54
|
+
DEFAULT_RENEW_AT_FRACTION,
|
|
55
|
+
DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
|
56
|
+
WebSessionCode,
|
|
57
|
+
WebSessionCodeIssuer,
|
|
58
|
+
WebSessionCodeListener,
|
|
59
|
+
create_web_session_code,
|
|
60
|
+
)
|
|
53
61
|
from phystack.hub_client.services.cloud_app_connection import (
|
|
54
62
|
CloudAppConnection,
|
|
55
63
|
CloudAppConnectionParams,
|
|
@@ -83,6 +91,7 @@ class PhyHubClient:
|
|
|
83
91
|
DISCONNECT = "disconnect"
|
|
84
92
|
TWIN_MESSAGE = "twinMessage"
|
|
85
93
|
TWIN_SUBSCRIBE = "twinSubscribe"
|
|
94
|
+
TWIN_UNSUBSCRIBE = "twinUnsubscribe"
|
|
86
95
|
TWIN_UPDATED = "twinUpdated"
|
|
87
96
|
GET_TWIN_BY_ID = "getTwinById"
|
|
88
97
|
REPORT_SCREEN_PROPERTIES = "reportScreenTwinProperties"
|
|
@@ -210,7 +219,15 @@ class PhyHubClient:
|
|
|
210
219
|
return self
|
|
211
220
|
|
|
212
221
|
async def disconnect(self) -> None:
|
|
213
|
-
"""Disconnect from PhyHub.
|
|
222
|
+
"""Disconnect from PhyHub.
|
|
223
|
+
|
|
224
|
+
npm 6.7.0 parity: clears the twin listener registries, cached
|
|
225
|
+
instances, subscribed-twin set, and the emit queue — listeners
|
|
226
|
+
registered before a disconnect() do not survive into a later
|
|
227
|
+
reconnect, and queued emits fail instead of hanging. The client
|
|
228
|
+
stays reusable. WebRTC connections are NOT torn down — hold the
|
|
229
|
+
stop()/close() handles from the responder/stream APIs.
|
|
230
|
+
"""
|
|
214
231
|
if self._cloud_app_connection:
|
|
215
232
|
await self._cloud_app_connection.disconnect()
|
|
216
233
|
self._cloud_app_connection = None
|
|
@@ -221,6 +238,15 @@ class PhyHubClient:
|
|
|
221
238
|
self._instance = None
|
|
222
239
|
if self._registry:
|
|
223
240
|
self._registry.clear()
|
|
241
|
+
self._twin_message_listeners.clear()
|
|
242
|
+
self._twin_update_listeners.clear()
|
|
243
|
+
self._subscribed_twins.clear()
|
|
244
|
+
pending_emits, self._emit_queue = self._emit_queue, []
|
|
245
|
+
for _method, _payload, _needs_ack, pending_future in pending_emits:
|
|
246
|
+
if pending_future and not pending_future.done():
|
|
247
|
+
pending_future.set_exception(
|
|
248
|
+
ConnectionError("disconnect() dropped the queued emit")
|
|
249
|
+
)
|
|
224
250
|
|
|
225
251
|
# =========================================================================
|
|
226
252
|
# Cloud-app mode (npm PhyHubClientParams.cloudApp parity)
|
|
@@ -347,10 +373,23 @@ class PhyHubClient:
|
|
|
347
373
|
if not twin_id:
|
|
348
374
|
return
|
|
349
375
|
|
|
376
|
+
# Ownership-loss detection (TECH-1394): peripheral hardwareIds
|
|
377
|
+
# are unique tenant-wide, so another device — or another instance
|
|
378
|
+
# on THIS device — registering the same id takes our twin over.
|
|
379
|
+
# Level-triggered against the twin's current owner identity
|
|
380
|
+
# (deviceId + desired.instanceId), not edge-triggered on the
|
|
381
|
+
# takeover event itself: every later twinUpdated re-delivers the
|
|
382
|
+
# signal, so a takeover missed during a disconnect self-heals on
|
|
383
|
+
# the next twin activity. Only instances ACQUIRED as owner are
|
|
384
|
+
# considered — a consumer holding a sibling instance's peripheral
|
|
385
|
+
# legitimately sees a foreign instanceId forever.
|
|
386
|
+
if isinstance(twin_data, dict) and twin_data.get("deviceId"):
|
|
387
|
+
self._detect_peripheral_ownership_loss(twin_id, twin_data)
|
|
388
|
+
|
|
350
389
|
# Notify twin update listeners
|
|
351
390
|
listeners = self._twin_update_listeners.get(twin_id)
|
|
352
391
|
if listeners:
|
|
353
|
-
for callback in listeners:
|
|
392
|
+
for callback in list(listeners):
|
|
354
393
|
try:
|
|
355
394
|
callback(twin_data)
|
|
356
395
|
except Exception as e:
|
|
@@ -585,6 +624,78 @@ class PhyHubClient:
|
|
|
585
624
|
|
|
586
625
|
return result.get("twin") if result else None
|
|
587
626
|
|
|
627
|
+
# =========================================================================
|
|
628
|
+
# Web-session claim codes (device sessions only) — npm 6.10.0 / 6.11.0 parity
|
|
629
|
+
# =========================================================================
|
|
630
|
+
|
|
631
|
+
def _assert_device_session(self, operation: str) -> None:
|
|
632
|
+
"""Claim-code issuing is a device-session capability: phyhub registers
|
|
633
|
+
`createWebSessionCode` only on authenticated device sockets (a cloud
|
|
634
|
+
session would hang with no handler) — fail fast instead."""
|
|
635
|
+
if self._cloud_app_params is not None:
|
|
636
|
+
raise RuntimeError(
|
|
637
|
+
f"{operation} is only available for device sessions (screen/edge apps)"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
def _web_session_code_adapter(self) -> "_WebSessionCodeAdapter":
|
|
641
|
+
return _WebSessionCodeAdapter(self)
|
|
642
|
+
|
|
643
|
+
async def create_web_session_code(
|
|
644
|
+
self,
|
|
645
|
+
*,
|
|
646
|
+
twin_id: Optional[str] = None,
|
|
647
|
+
endpoint_id: Optional[str] = None,
|
|
648
|
+
request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
|
649
|
+
) -> WebSessionCode:
|
|
650
|
+
"""Mint ONE claim code for a Web endpoint (device sessions only). The
|
|
651
|
+
returned ``url`` is the complete public link
|
|
652
|
+
(``{root}/{urlId}/#code={code}``) to render as a QR. Identify the
|
|
653
|
+
endpoint by exactly one of:
|
|
654
|
+
|
|
655
|
+
- ``twin_id`` — the Web twin's ``_id``, as stored by a settings twin picker
|
|
656
|
+
- ``endpoint_id`` — the Web twin's ``deviceId``, as shown in Console/CLI
|
|
657
|
+
|
|
658
|
+
Codes expire after the endpoint's ``codeTtlSeconds``; for a
|
|
659
|
+
continuously displayed QR use :meth:`subscribe_web_session_code`, which
|
|
660
|
+
rotates them.
|
|
661
|
+
"""
|
|
662
|
+
self._assert_device_session("create_web_session_code()")
|
|
663
|
+
return await create_web_session_code(
|
|
664
|
+
self._web_session_code_adapter(),
|
|
665
|
+
twin_id=twin_id,
|
|
666
|
+
endpoint_id=endpoint_id,
|
|
667
|
+
request_timeout_seconds=request_timeout_seconds,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
def subscribe_web_session_code(
|
|
671
|
+
self,
|
|
672
|
+
listener: WebSessionCodeListener,
|
|
673
|
+
*,
|
|
674
|
+
twin_id: Optional[str] = None,
|
|
675
|
+
endpoint_id: Optional[str] = None,
|
|
676
|
+
renew_at_fraction: float = DEFAULT_RENEW_AT_FRACTION,
|
|
677
|
+
request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
|
678
|
+
) -> WebSessionCodeIssuer:
|
|
679
|
+
"""Keep a fresh claim code available for a QR display (device sessions
|
|
680
|
+
only). ``listener`` receives a :class:`WebSessionCodeState` for the
|
|
681
|
+
initial code, every rotation (at ``renew_at_fraction`` of the code
|
|
682
|
+
TTL, with downward jitter), after every socket reconnect, and as
|
|
683
|
+
``unavailable`` while minting fails (capped exponential backoff; a
|
|
684
|
+
non-Web twin is terminal). Returns the issuer: call ``refresh()`` to
|
|
685
|
+
mint now, ``stop()`` to release it. Must be called from a running
|
|
686
|
+
event loop.
|
|
687
|
+
"""
|
|
688
|
+
self._assert_device_session("subscribe_web_session_code()")
|
|
689
|
+
issuer = WebSessionCodeIssuer(
|
|
690
|
+
self._web_session_code_adapter(),
|
|
691
|
+
listener,
|
|
692
|
+
twin_id=twin_id,
|
|
693
|
+
endpoint_id=endpoint_id,
|
|
694
|
+
renew_at_fraction=renew_at_fraction,
|
|
695
|
+
request_timeout_seconds=request_timeout_seconds,
|
|
696
|
+
)
|
|
697
|
+
return issuer.start()
|
|
698
|
+
|
|
588
699
|
# =========================================================================
|
|
589
700
|
# Settings API
|
|
590
701
|
# =========================================================================
|
|
@@ -670,6 +781,8 @@ class PhyHubClient:
|
|
|
670
781
|
Returns:
|
|
671
782
|
Updated twin data.
|
|
672
783
|
"""
|
|
784
|
+
from phystack.hub_client.types.twin_types import PeripheralOwnershipLoss
|
|
785
|
+
|
|
673
786
|
# Dispatch to correct event based on twin type (matches Node.js behavior)
|
|
674
787
|
if twin_type == TwinTypeEnum.Screen:
|
|
675
788
|
event = self.EVENTS.REPORT_SCREEN_PROPERTIES
|
|
@@ -680,6 +793,19 @@ class PhyHubClient:
|
|
|
680
793
|
|
|
681
794
|
payload = {"twinId": twin_id, "data": properties}
|
|
682
795
|
result = await self._emit_to_socket(event, payload, callback=True)
|
|
796
|
+
if result and result.get("status") not in (None, "success"):
|
|
797
|
+
# Ownership-loss fallback (TECH-1394): a report rejected with
|
|
798
|
+
# PeripheralOwnershipError means another device took the twin
|
|
799
|
+
# over while we were offline — the twinUpdated notification
|
|
800
|
+
# never reached us, so revoke here.
|
|
801
|
+
if result.get("errorType") == "PeripheralOwnershipError":
|
|
802
|
+
self._handle_peripheral_ownership_lost(
|
|
803
|
+
twin_id, PeripheralOwnershipLoss()
|
|
804
|
+
)
|
|
805
|
+
# npm 6.5.1 parity: carry the server's reason from the ack.
|
|
806
|
+
raise RuntimeError(
|
|
807
|
+
result.get("message") or "updateReportedProperties failed"
|
|
808
|
+
)
|
|
683
809
|
return result.get("twin") if result else None
|
|
684
810
|
|
|
685
811
|
# =========================================================================
|
|
@@ -788,6 +914,168 @@ class PhyHubClient:
|
|
|
788
914
|
self._device_status_cache = (now, result)
|
|
789
915
|
return result
|
|
790
916
|
|
|
917
|
+
async def resolve_device_space_id(self, timeout_seconds: float = 3.0) -> str:
|
|
918
|
+
"""Resolve the device's current spaceId for space-scoped hardwareId
|
|
919
|
+
minting (TECH-1394). Bypasses get_device_status's 10s cache window
|
|
920
|
+
unless the cached status already carries a spaceId; otherwise asks
|
|
921
|
+
directly with its own timeout.
|
|
922
|
+
|
|
923
|
+
Raises:
|
|
924
|
+
RuntimeError: When the status request yields no answer in time or
|
|
925
|
+
the response carries no spaceId.
|
|
926
|
+
"""
|
|
927
|
+
if self._device_status_cache:
|
|
928
|
+
cached_space_id = self._device_status_cache[1].get("spaceId")
|
|
929
|
+
if cached_space_id:
|
|
930
|
+
return str(cached_space_id)
|
|
931
|
+
|
|
932
|
+
try:
|
|
933
|
+
status = await asyncio.wait_for(
|
|
934
|
+
self._emit_to_socket(self.EVENTS.GET_DEVICE_STATUS, {}, callback=True),
|
|
935
|
+
timeout=timeout_seconds,
|
|
936
|
+
)
|
|
937
|
+
except asyncio.TimeoutError as timeout_error:
|
|
938
|
+
raise RuntimeError(
|
|
939
|
+
"generate_scoped_hardware_id: could not get spaceId — "
|
|
940
|
+
f"getDeviceStatus gave no answer within {timeout_seconds}s"
|
|
941
|
+
) from timeout_error
|
|
942
|
+
|
|
943
|
+
space_id = (status or {}).get("spaceId")
|
|
944
|
+
if not space_id:
|
|
945
|
+
raise RuntimeError(
|
|
946
|
+
"generate_scoped_hardware_id: the device status response "
|
|
947
|
+
"did not include a spaceId"
|
|
948
|
+
)
|
|
949
|
+
return str(space_id)
|
|
950
|
+
|
|
951
|
+
def _detect_peripheral_ownership_loss(
|
|
952
|
+
self, twin_id: str, twin_data: dict[str, Any]
|
|
953
|
+
) -> None:
|
|
954
|
+
"""Compare an updated twin's owner identity against a pooled
|
|
955
|
+
owner-acquired peripheral instance; revoke on mismatch."""
|
|
956
|
+
from phystack.hub_client.types.twin_types import PeripheralOwnershipLoss
|
|
957
|
+
|
|
958
|
+
registry = self._registry
|
|
959
|
+
owned_peripheral = (
|
|
960
|
+
registry.get_cached_peripheral_instance(twin_id) if registry else None
|
|
961
|
+
)
|
|
962
|
+
if owned_peripheral is None or not owned_peripheral.acquired_as_owner:
|
|
963
|
+
return
|
|
964
|
+
|
|
965
|
+
own_device_id = owned_peripheral.edge_instance.device_id
|
|
966
|
+
own_instance_id = owned_peripheral.edge_instance.id
|
|
967
|
+
if not own_device_id:
|
|
968
|
+
return
|
|
969
|
+
|
|
970
|
+
updated_device_id = twin_data.get("deviceId")
|
|
971
|
+
updated_instance_id = (
|
|
972
|
+
twin_data.get("properties", {}).get("desired", {}).get("instanceId")
|
|
973
|
+
)
|
|
974
|
+
lost_to_device = updated_device_id != own_device_id
|
|
975
|
+
lost_to_instance = (
|
|
976
|
+
not lost_to_device
|
|
977
|
+
and own_instance_id is not None
|
|
978
|
+
and updated_instance_id is not None
|
|
979
|
+
and updated_instance_id != own_instance_id
|
|
980
|
+
)
|
|
981
|
+
if not lost_to_device and not lost_to_instance:
|
|
982
|
+
return
|
|
983
|
+
|
|
984
|
+
self._handle_peripheral_ownership_lost(
|
|
985
|
+
twin_id,
|
|
986
|
+
PeripheralOwnershipLoss(
|
|
987
|
+
new_owner_device_id=updated_device_id if lost_to_device else None,
|
|
988
|
+
new_owner_instance_id=(
|
|
989
|
+
updated_instance_id if lost_to_instance else None
|
|
990
|
+
),
|
|
991
|
+
),
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
def _handle_peripheral_ownership_lost(
|
|
995
|
+
self, twin_id: str, loss: "PeripheralOwnershipLoss"
|
|
996
|
+
) -> None:
|
|
997
|
+
"""Revoke a peripheral this app lost to another device or to another
|
|
998
|
+
instance on this device (TECH-1394):
|
|
999
|
+
|
|
1000
|
+
- mark the pooled PeripheralInstance ownership-lost (its
|
|
1001
|
+
emit/update_reported start raising locally, its listeners are
|
|
1002
|
+
dropped, on_ownership_lost fires so the app releases the hardware);
|
|
1003
|
+
- drop it from the registry pool, so a later re-acquire (after taking
|
|
1004
|
+
the twin back via create_peripheral_twin) builds a fresh instance;
|
|
1005
|
+
- unsubscribe from the twin room, server-side AND locally — removing
|
|
1006
|
+
the id from _subscribed_twins is what stops the reconnect
|
|
1007
|
+
re-subscribe from silently rejoining the room we were evicted from.
|
|
1008
|
+
"""
|
|
1009
|
+
registry = self._registry
|
|
1010
|
+
peripheral_instance = (
|
|
1011
|
+
registry.get_cached_peripheral_instance(twin_id) if registry else None
|
|
1012
|
+
)
|
|
1013
|
+
if peripheral_instance is None:
|
|
1014
|
+
return
|
|
1015
|
+
|
|
1016
|
+
if loss.new_owner_device_id:
|
|
1017
|
+
new_owner_label = f" by device {loss.new_owner_device_id}"
|
|
1018
|
+
elif loss.new_owner_instance_id:
|
|
1019
|
+
new_owner_label = (
|
|
1020
|
+
f" by instance {loss.new_owner_instance_id} on this device"
|
|
1021
|
+
)
|
|
1022
|
+
else:
|
|
1023
|
+
new_owner_label = ""
|
|
1024
|
+
logger.warning(
|
|
1025
|
+
"peripheral %s was taken over%s — revoking the local instance",
|
|
1026
|
+
twin_id,
|
|
1027
|
+
new_owner_label,
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
self._subscribed_twins.discard(twin_id)
|
|
1031
|
+
if registry:
|
|
1032
|
+
registry.remove_peripheral_instance(twin_id)
|
|
1033
|
+
|
|
1034
|
+
# Best-effort server-side unsubscribe: phyhub already evicted our
|
|
1035
|
+
# sockets from the twin room at takeover; this covers the case where
|
|
1036
|
+
# that eviction failed or raced our reconnect.
|
|
1037
|
+
try:
|
|
1038
|
+
unsubscribe_payload = {
|
|
1039
|
+
"twinId": twin_id,
|
|
1040
|
+
"sourceTwinId": peripheral_instance.edge_instance.id,
|
|
1041
|
+
}
|
|
1042
|
+
asyncio.create_task(
|
|
1043
|
+
self._emit_to_socket(self.EVENTS.TWIN_UNSUBSCRIBE, unsubscribe_payload)
|
|
1044
|
+
)
|
|
1045
|
+
except Exception as unsubscribe_error:
|
|
1046
|
+
logger.warning(
|
|
1047
|
+
"Failed to send twinUnsubscribe for taken-over peripheral %s: %s",
|
|
1048
|
+
twin_id,
|
|
1049
|
+
unsubscribe_error,
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
peripheral_instance.mark_ownership_lost(loss)
|
|
1053
|
+
|
|
1054
|
+
def stop_responders_for_twin(self, twin_id: str) -> None:
|
|
1055
|
+
"""Stop any armed media/data responders publishing on a twin.
|
|
1056
|
+
|
|
1057
|
+
Best-effort: used by ownership-loss revocation so a taken-over
|
|
1058
|
+
peripheral stops serving viewers the new owner should serve.
|
|
1059
|
+
"""
|
|
1060
|
+
for key in list(self._media_responders):
|
|
1061
|
+
if key.startswith(f"{twin_id}:"):
|
|
1062
|
+
responder = self._media_responders.pop(key)
|
|
1063
|
+
try:
|
|
1064
|
+
asyncio.create_task(responder.stop())
|
|
1065
|
+
except Exception as stop_error:
|
|
1066
|
+
logger.warning(
|
|
1067
|
+
"Failed to stop media responder %s: %s", key, stop_error
|
|
1068
|
+
)
|
|
1069
|
+
for key in list(self._data_responders):
|
|
1070
|
+
if key.startswith(f"{twin_id}:"):
|
|
1071
|
+
responder = self._data_responders.pop(key)
|
|
1072
|
+
try:
|
|
1073
|
+
asyncio.create_task(responder.stop())
|
|
1074
|
+
except Exception as stop_error:
|
|
1075
|
+
logger.warning(
|
|
1076
|
+
"Failed to stop data responder %s: %s", key, stop_error
|
|
1077
|
+
)
|
|
1078
|
+
|
|
791
1079
|
async def get_device_instance(self) -> Optional[dict[str, Any]]:
|
|
792
1080
|
"""Get device instance data.
|
|
793
1081
|
|
|
@@ -1126,3 +1414,31 @@ async def connect_phy_client() -> PhyHubClient:
|
|
|
1126
1414
|
client = PhyHubClient.from_env()
|
|
1127
1415
|
await client.connect()
|
|
1128
1416
|
return client
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
class _WebSessionCodeAdapter:
|
|
1420
|
+
"""PhyHubClient slice for the web-session code issuer (see
|
|
1421
|
+
services/web_session_code.py). Kept off the client's public surface."""
|
|
1422
|
+
|
|
1423
|
+
def __init__(self, client: PhyHubClient) -> None:
|
|
1424
|
+
self._client = client
|
|
1425
|
+
|
|
1426
|
+
async def ensure_connection(self) -> None:
|
|
1427
|
+
connection = self._client._connection
|
|
1428
|
+
if connection is not None and connection.is_connected:
|
|
1429
|
+
return
|
|
1430
|
+
await self._client.connect()
|
|
1431
|
+
|
|
1432
|
+
async def emit_with_ack(self, method: str, payload: dict[str, Any]) -> Any:
|
|
1433
|
+
return await self._client._emit_to_socket(method, payload, callback=True)
|
|
1434
|
+
|
|
1435
|
+
async def get_twin_by_id(self, twin_id: str) -> Optional[dict[str, Any]]:
|
|
1436
|
+
return await self._client.get_twin_by_id(twin_id)
|
|
1437
|
+
|
|
1438
|
+
def on_connect(self, handler: Callable[[], None]) -> None:
|
|
1439
|
+
if self._client._connection is not None:
|
|
1440
|
+
self._client._connection.on("connect", handler)
|
|
1441
|
+
|
|
1442
|
+
def off_connect(self, handler: Callable[[], None]) -> None:
|
|
1443
|
+
if self._client._connection is not None:
|
|
1444
|
+
self._client._connection.off("connect", handler)
|
|
@@ -109,14 +109,31 @@ class BaseInstance:
|
|
|
109
109
|
self,
|
|
110
110
|
event_type: str,
|
|
111
111
|
callback: Callable[[Any, Optional[Callable[[TwinMessageResult], None]]], None],
|
|
112
|
-
) -> None:
|
|
112
|
+
) -> Callable[[], None]:
|
|
113
113
|
"""Listen for events, with optional respond callback for request-response.
|
|
114
114
|
|
|
115
115
|
Args:
|
|
116
116
|
event_type: Type of event to listen for.
|
|
117
117
|
callback: Handler function (data, respond?) => None
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
An unsubscribe callable that removes exactly this listener
|
|
121
|
+
(npm 6.7.0 parity).
|
|
122
|
+
"""
|
|
123
|
+
return self._messaging.on(event_type, callback)
|
|
124
|
+
|
|
125
|
+
def off(
|
|
126
|
+
self,
|
|
127
|
+
event_type: str,
|
|
128
|
+
callback: Callable[[Any, Optional[Callable[[TwinMessageResult], None]]], None],
|
|
129
|
+
) -> bool:
|
|
130
|
+
"""Remove a listener registered with on(), matched by the caller's
|
|
131
|
+
original callback identity (npm 6.7.0 parity).
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True when a listener was found and removed.
|
|
118
135
|
"""
|
|
119
|
-
self._messaging.
|
|
136
|
+
return self._messaging.off(event_type, callback)
|
|
120
137
|
|
|
121
138
|
def to(self, target_twin_id: str) -> TargetedEmitter:
|
|
122
139
|
"""Target a specific twin for messaging.
|