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.
Files changed (47) hide show
  1. phystack_hub_client-6.16.0/CHANGELOG.md +47 -0
  2. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/PKG-INFO +31 -2
  3. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/README.md +29 -0
  4. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/pyproject.toml +1 -1
  5. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/__init__.py +33 -1
  6. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/client.py +318 -2
  7. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/base_instance.py +19 -2
  8. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/edge_instance.py +138 -19
  9. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/peripheral_instance.py +188 -7
  10. phystack_hub_client-6.16.0/src/phystack/hub_client/scoped_hardware_id.py +103 -0
  11. phystack_hub_client-6.16.0/src/phystack/hub_client/services/web_session_code.py +468 -0
  12. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/__init__.py +4 -0
  13. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_stream.py +113 -4
  14. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_messaging.py +55 -1
  15. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_registry.py +16 -0
  16. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/twin_types.py +43 -0
  17. phystack_hub_client-6.16.0/tests/test_media_kinds.py +231 -0
  18. phystack_hub_client-6.16.0/tests/test_peripheral_lifecycle.py +451 -0
  19. phystack_hub_client-6.16.0/tests/test_web_session_code.py +354 -0
  20. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/.gitignore +0 -0
  21. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/examples/browser_test_backend.py +0 -0
  22. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/scripts/python-node-test.sh +0 -0
  23. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/scripts/webrtc-test.sh +0 -0
  24. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/advisory_validation.py +0 -0
  25. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/cloud_twins.py +0 -0
  26. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/__init__.py +0 -0
  27. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/__init__.py +0 -0
  28. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/cloud_app_connection.py +0 -0
  29. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/cloud_app_token.py +0 -0
  30. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/phyhub_connection.py +0 -0
  31. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/signals.py +0 -0
  32. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/data_channel.py +0 -0
  33. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/data_responder.py +0 -0
  34. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_responder.py +0 -0
  35. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_source.py +0 -0
  36. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/__init__.py +0 -0
  37. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_advisory_validation.py +0 -0
  38. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_client_integration.py +0 -0
  39. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_cloud_app_token.py +0 -0
  40. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_cloud_twins.py +0 -0
  41. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_communication.py +0 -0
  42. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_data_channel.py +0 -0
  43. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_instance_listeners.py +0 -0
  44. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_media_resilience.py +0 -0
  45. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_media_responder.py +0 -0
  46. {phystack_hub_client-6.6.0 → phystack_hub_client-6.16.0}/tests/test_media_source.py +0 -0
  47. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: phystack-hub-client
3
- Version: 6.6.0
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.6.0"
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"
@@ -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.5.0"
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.on(event_type, callback)
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.