phystack-hub-client 6.8.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.8.0 → phystack_hub_client-6.16.0}/PKG-INFO +30 -1
  3. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/README.md +29 -0
  4. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/pyproject.toml +1 -1
  5. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/__init__.py +14 -1
  6. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/client.py +108 -0
  7. phystack_hub_client-6.16.0/src/phystack/hub_client/services/web_session_code.py +468 -0
  8. phystack_hub_client-6.16.0/tests/test_web_session_code.py +354 -0
  9. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/.gitignore +0 -0
  10. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/examples/browser_test_backend.py +0 -0
  11. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/scripts/python-node-test.sh +0 -0
  12. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/scripts/webrtc-test.sh +0 -0
  13. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/advisory_validation.py +0 -0
  14. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/cloud_twins.py +0 -0
  15. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/__init__.py +0 -0
  16. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/base_instance.py +0 -0
  17. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/edge_instance.py +0 -0
  18. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/peripheral_instance.py +0 -0
  19. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/scoped_hardware_id.py +0 -0
  20. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/__init__.py +0 -0
  21. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/cloud_app_connection.py +0 -0
  22. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/cloud_app_token.py +0 -0
  23. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/phyhub_connection.py +0 -0
  24. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/signals.py +0 -0
  25. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/__init__.py +0 -0
  26. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/data_channel.py +0 -0
  27. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/data_responder.py +0 -0
  28. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_responder.py +0 -0
  29. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_source.py +0 -0
  30. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_stream.py +0 -0
  31. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_messaging.py +0 -0
  32. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_registry.py +0 -0
  33. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/__init__.py +0 -0
  34. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/twin_types.py +0 -0
  35. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_advisory_validation.py +0 -0
  36. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_client_integration.py +0 -0
  37. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_cloud_app_token.py +0 -0
  38. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_cloud_twins.py +0 -0
  39. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_communication.py +0 -0
  40. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_data_channel.py +0 -0
  41. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_instance_listeners.py +0 -0
  42. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_media_kinds.py +0 -0
  43. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_media_resilience.py +0 -0
  44. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_media_responder.py +0 -0
  45. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_media_source.py +0 -0
  46. {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_peripheral_lifecycle.py +0 -0
  47. {phystack_hub_client-6.8.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
1
  Metadata-Version: 2.5
2
2
  Name: phystack-hub-client
3
- Version: 6.8.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.8.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"
@@ -110,6 +110,13 @@ from phystack.hub_client.services.cloud_app_connection import (
110
110
  CloudAppConnectResult,
111
111
  )
112
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
+ )
113
120
  from phystack.hub_client.services.signals import (
114
121
  SignalsService,
115
122
  InitSignalPayload,
@@ -123,7 +130,7 @@ from phystack.hub_client.services.signals import (
123
130
 
124
131
  # Kept in a fixed version group with @phystack/hub-client (npm) — same
125
132
  # contract, same release version.
126
- __version__ = "6.8.0"
133
+ __version__ = "6.16.0"
127
134
  __all__ = [
128
135
  # Client
129
136
  "PhyHubClient",
@@ -177,6 +184,12 @@ __all__ = [
177
184
  "CloudAppConnectionParams",
178
185
  "CloudAppConnectResult",
179
186
  "CloudTwinStore",
187
+ # Web-session claim codes (device-side QR issuer)
188
+ "WebSessionCode",
189
+ "WebSessionCodeConfigError",
190
+ "WebSessionCodeIssuer",
191
+ "WebSessionCodeListener",
192
+ "WebSessionCodeState",
180
193
  # Signals
181
194
  "SignalsService",
182
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,
@@ -616,6 +624,78 @@ class PhyHubClient:
616
624
 
617
625
  return result.get("twin") if result else None
618
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
+
619
699
  # =========================================================================
620
700
  # Settings API
621
701
  # =========================================================================
@@ -1334,3 +1414,31 @@ async def connect_phy_client() -> PhyHubClient:
1334
1414
  client = PhyHubClient.from_env()
1335
1415
  await client.connect()
1336
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)
@@ -0,0 +1,468 @@
1
+ """Web-session claim-code issuer (device side of the web-app QR flow).
2
+
3
+ Port of npm ``@phystack/hub-client`` ``services/web-session-code.service.ts``
4
+ (ps-platform #893 / #896, npm 6.10.0 / 6.11.0).
5
+
6
+ A device session (screen/edge app) asks phyhub to mint a short-lived claim
7
+ code for a Web endpoint via the ``createWebSessionCode`` socket event; the ack
8
+ carries the complete public URL (``{webAppsRoot}/{urlId}/#code={code}``) that
9
+ a phone scans as a QR to start a scoped web session. phyhub registers this
10
+ event on authenticated device sockets only.
11
+
12
+ Codes expire server-side after the endpoint's ``codeTtlSeconds``, so a QR
13
+ display must rotate them. :class:`WebSessionCodeIssuer` owns that lifecycle so
14
+ apps only render state:
15
+
16
+ - resolves the endpointId once (a settings twin picker hands the app the Web
17
+ twin's ``_id``, but phyhub's issuer API requires the twin's ``deviceId``, the
18
+ endpointId; see :func:`resolve_web_session_endpoint_id`)
19
+ - renews at a fraction of the code TTL (with downward jitter so a fleet of
20
+ screens doesn't mint in sync)
21
+ - re-mints after a socket reconnect (the displayed code likely expired while
22
+ offline)
23
+ - retries failures with capped exponential backoff, reporting an explicit
24
+ ``unavailable`` state so the app hides the QR instead of showing a dead code
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import logging
31
+ import random
32
+ from dataclasses import dataclass
33
+ from datetime import datetime, timedelta, timezone
34
+ from typing import Any, Awaitable, Callable, Optional, Protocol
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Wire name of phyhub's device-session issuer event.
39
+ CREATE_WEB_SESSION_CODE_EVENT = "createWebSessionCode"
40
+
41
+ DEFAULT_RENEW_AT_FRACTION = 0.8
42
+ MIN_RENEW_AT_FRACTION = 0.05
43
+ MAX_RENEW_AT_FRACTION = 0.95
44
+ DEFAULT_REQUEST_TIMEOUT_SECONDS = 15.0
45
+ # Downward-only jitter on the rotation delay (never pushes past the TTL fraction).
46
+ RENEWAL_JITTER_FRACTION = 0.1
47
+ RETRY_BACKOFF_BASE_SECONDS = 5.0
48
+ RETRY_BACKOFF_CAP_SECONDS = 60.0
49
+ # Never rotate faster than this, whatever the TTL says.
50
+ MIN_ROTATION_DELAY_SECONDS = 1.0
51
+
52
+
53
+ class WebSessionCodeConfigError(Exception):
54
+ """Configuration the issuer cannot retry its way out of (currently only
55
+ "the picked twin is not a Web twin"). Terminal: the subscription reports
56
+ ``unavailable`` with ``retry_at=None`` and stops."""
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class WebSessionCode:
61
+ """A minted claim code, as delivered by phyhub's ack."""
62
+
63
+ # Claim code (also embedded in ``url``).
64
+ code: str
65
+ # Complete public URL to render as a QR: ``{webAppsRoot}/{urlId}/#code={code}``.
66
+ url: str
67
+ # Code lifetime in seconds (the endpoint's ``codeTtlSeconds``).
68
+ expires_in: int
69
+ # Client-clock expiry (``now + expires_in``), timezone-aware UTC.
70
+ expires_at: datetime
71
+ # The endpointId the code was minted for.
72
+ endpoint_id: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class WebSessionCodeState:
77
+ """What a :class:`WebSessionCodeIssuer` listener receives.
78
+
79
+ ``status == "active"``: ``code``/``url``/``expires_in``/``expires_at``/
80
+ ``endpoint_id`` are set. ``status == "unavailable"``: ``message`` says why;
81
+ ``retry_at`` is when the next automatic retry fires, or ``None`` when the
82
+ issuer gave up (configuration error).
83
+ """
84
+
85
+ status: str
86
+ code: Optional[str] = None
87
+ url: Optional[str] = None
88
+ expires_in: Optional[int] = None
89
+ expires_at: Optional[datetime] = None
90
+ endpoint_id: Optional[str] = None
91
+ message: Optional[str] = None
92
+ retry_at: Optional[datetime] = None
93
+
94
+ @classmethod
95
+ def active(cls, issued: WebSessionCode) -> "WebSessionCodeState":
96
+ return cls(
97
+ status="active",
98
+ code=issued.code,
99
+ url=issued.url,
100
+ expires_in=issued.expires_in,
101
+ expires_at=issued.expires_at,
102
+ endpoint_id=issued.endpoint_id,
103
+ )
104
+
105
+ @classmethod
106
+ def unavailable(cls, message: str, retry_at: Optional[datetime]) -> "WebSessionCodeState":
107
+ return cls(status="unavailable", message=message, retry_at=retry_at)
108
+
109
+
110
+ WebSessionCodeListener = Callable[[WebSessionCodeState], None]
111
+
112
+
113
+ class WebSessionCodeClientAdapter(Protocol):
114
+ """The slice of PhyHubClient the issuer needs, so it is testable without a
115
+ socket. ``emit_with_ack`` follows the client's ack convention: it resolves
116
+ with the raw ack payload (a dict) or ``None``."""
117
+
118
+ async def ensure_connection(self) -> None: ...
119
+
120
+ async def emit_with_ack(self, method: str, payload: dict[str, Any]) -> Any: ...
121
+
122
+ async def get_twin_by_id(self, twin_id: str) -> Optional[dict[str, Any]]: ...
123
+
124
+ def on_connect(self, handler: Callable[[], None]) -> None: ...
125
+
126
+ def off_connect(self, handler: Callable[[], None]) -> None: ...
127
+
128
+
129
+ def _describe_target(twin_id: Optional[str], endpoint_id: Optional[str]) -> str:
130
+ return f"twinId {twin_id}" if twin_id else f"endpointId {endpoint_id}"
131
+
132
+
133
+ def assert_web_session_code_target(twin_id: Optional[str], endpoint_id: Optional[str]) -> None:
134
+ """Exactly one of twin_id / endpoint_id. Raises ``ValueError`` — this is a
135
+ programmer error, not a runtime condition."""
136
+ has_twin_id = bool(twin_id)
137
+ has_endpoint_id = bool(endpoint_id)
138
+ if has_twin_id == has_endpoint_id:
139
+ raise ValueError("Web session code target requires exactly one of twin_id or endpoint_id")
140
+
141
+
142
+ async def resolve_web_session_endpoint_id(
143
+ client: WebSessionCodeClientAdapter,
144
+ twin_id: Optional[str],
145
+ endpoint_id: Optional[str],
146
+ ) -> str:
147
+ """Resolve the target to the endpointId phyhub's issuer requires (the Web
148
+ twin's ``deviceId``). A twin_id that resolves to a non-Web twin is a
149
+ terminal configuration error — a twin's type never changes, so retrying
150
+ cannot fix it."""
151
+ if endpoint_id:
152
+ return endpoint_id
153
+ twin = await client.get_twin_by_id(twin_id or "")
154
+ if not twin:
155
+ raise RuntimeError(f"Twin {twin_id} not found — cannot mint web session codes")
156
+ twin_type = twin.get("type")
157
+ if twin_type != "Web":
158
+ raise WebSessionCodeConfigError(
159
+ f"Twin {twin_id} is a {twin_type} twin, not a Web twin — check the settings picker"
160
+ )
161
+ device_id = twin.get("deviceId")
162
+ if not device_id:
163
+ raise RuntimeError(f"Web twin {twin_id} has no deviceId (endpointId) — cannot mint codes")
164
+ return str(device_id)
165
+
166
+
167
+ async def _bounded(operation: Awaitable[Any], timeout_seconds: float, operation_name: str) -> Any:
168
+ """Bound an awaited step with a timeout so a dropped ack can never wedge
169
+ the mint pipeline (npm ``guardWithTimeout``). A late settlement of the
170
+ underlying awaitable is discarded."""
171
+ try:
172
+ return await asyncio.wait_for(operation, timeout=timeout_seconds)
173
+ except asyncio.TimeoutError as timeout_error:
174
+ raise TimeoutError(
175
+ f"Timed out after {timeout_seconds:g}s waiting for {operation_name}"
176
+ ) from timeout_error
177
+
178
+
179
+ async def request_web_session_code(
180
+ client: WebSessionCodeClientAdapter,
181
+ endpoint_id: str,
182
+ request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
183
+ ) -> WebSessionCode:
184
+ """Ask phyhub to mint one claim code for the endpoint. Raises on an error
185
+ ack, a malformed ack, or when no ack arrives within
186
+ ``request_timeout_seconds`` (the emit may be queued while the socket is
187
+ offline — the timeout keeps callers from hanging on a code that would be
188
+ stale by the time it arrived).
189
+
190
+ Every ack this event can produce (mirrors phyhub's WebSessionCodeAckPayload):
191
+ success ``{status: 'success', message, code, url, expiresIn}``; phyhub
192
+ failure ``{status: 'error', message: 'Failed to create web session code[: reason]'}``
193
+ (opaque unknown/disabled/foreign-tenant endpoint, validation, internal);
194
+ device offline from phyhub ``{status: 'error', message: 'PhyHub instance not found'}``
195
+ (edge-hub default forward); simulator ``{status: 'error', message: 'Unknown method: …'}``.
196
+ """
197
+ response = await _bounded(
198
+ client.emit_with_ack(CREATE_WEB_SESSION_CODE_EVENT, {"data": {"endpointId": endpoint_id}}),
199
+ request_timeout_seconds,
200
+ "the web session code ack",
201
+ )
202
+ if (
203
+ not isinstance(response, dict)
204
+ or response.get("status") != "success"
205
+ or not isinstance(response.get("code"), str)
206
+ or not isinstance(response.get("url"), str)
207
+ or not isinstance(response.get("expiresIn"), (int, float))
208
+ or isinstance(response.get("expiresIn"), bool)
209
+ ):
210
+ reason = (
211
+ response.get("message")
212
+ if isinstance(response, dict) and response.get("message")
213
+ else "malformed ack"
214
+ )
215
+ raise RuntimeError(f"Failed to create web session code: {reason}")
216
+ expires_in = int(response["expiresIn"])
217
+ return WebSessionCode(
218
+ code=response["code"],
219
+ url=response["url"],
220
+ expires_in=expires_in,
221
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=expires_in),
222
+ endpoint_id=endpoint_id,
223
+ )
224
+
225
+
226
+ async def create_web_session_code(
227
+ client: WebSessionCodeClientAdapter,
228
+ *,
229
+ twin_id: Optional[str] = None,
230
+ endpoint_id: Optional[str] = None,
231
+ request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
232
+ ) -> WebSessionCode:
233
+ """One-shot mint: resolve the endpoint, request a single code. Rotation
234
+ lives in :class:`WebSessionCodeIssuer`."""
235
+ assert_web_session_code_target(twin_id, endpoint_id)
236
+ logger.info(
237
+ "[web-session-code] Minting one web session code (%s)",
238
+ _describe_target(twin_id, endpoint_id),
239
+ )
240
+ await _bounded(client.ensure_connection(), request_timeout_seconds, "the hub connection")
241
+ resolved_endpoint_id = await _bounded(
242
+ resolve_web_session_endpoint_id(client, twin_id, endpoint_id),
243
+ request_timeout_seconds,
244
+ "the web endpoint twin lookup",
245
+ )
246
+ if twin_id:
247
+ logger.info(
248
+ "[web-session-code] Resolved twin %s to web endpoint %s", twin_id, resolved_endpoint_id
249
+ )
250
+ issued = await request_web_session_code(client, resolved_endpoint_id, request_timeout_seconds)
251
+ # Logging the URL (which embeds the code) is deliberate: the issuer runs
252
+ # on the device that renders that same URL as an on-screen QR.
253
+ logger.info(
254
+ "[web-session-code] Minted web session code for endpoint %s: %s (expires in %ss)",
255
+ resolved_endpoint_id,
256
+ issued.url,
257
+ issued.expires_in,
258
+ )
259
+ return issued
260
+
261
+
262
+ class WebSessionCodeIssuer:
263
+ """Rotating issuer behind ``PhyHubClient.subscribe_web_session_code()``.
264
+
265
+ The listener receives the initial code and every rotation through the same
266
+ callback; all runtime failures surface as ``unavailable`` states, never as
267
+ exceptions. ``refresh()`` mints now; ``stop()`` releases timers and the
268
+ reconnect listener (idempotent).
269
+ """
270
+
271
+ def __init__(
272
+ self,
273
+ client: WebSessionCodeClientAdapter,
274
+ listener: WebSessionCodeListener,
275
+ *,
276
+ twin_id: Optional[str] = None,
277
+ endpoint_id: Optional[str] = None,
278
+ renew_at_fraction: float = DEFAULT_RENEW_AT_FRACTION,
279
+ request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
280
+ retry_backoff_base_seconds: float = RETRY_BACKOFF_BASE_SECONDS,
281
+ retry_backoff_cap_seconds: float = RETRY_BACKOFF_CAP_SECONDS,
282
+ random_source: Callable[[], float] = random.random,
283
+ ) -> None:
284
+ assert_web_session_code_target(twin_id, endpoint_id)
285
+ self._client = client
286
+ self._listener = listener
287
+ self._twin_id = twin_id
288
+ self._endpoint_id_target = endpoint_id
289
+ self._renew_at_fraction = min(
290
+ max(renew_at_fraction, MIN_RENEW_AT_FRACTION), MAX_RENEW_AT_FRACTION
291
+ )
292
+ self._request_timeout_seconds = request_timeout_seconds
293
+ self._retry_backoff_base_seconds = retry_backoff_base_seconds
294
+ self._retry_backoff_cap_seconds = retry_backoff_cap_seconds
295
+ self._random = random_source
296
+ self._stopped = False
297
+ self._mint_in_flight = False
298
+ self._endpoint_id: Optional[str] = None
299
+ self._consecutive_failures = 0
300
+ self._timer: Optional[asyncio.TimerHandle] = None
301
+ self._mint_task: Optional[asyncio.Task[None]] = None
302
+ self._reconnect_handler: Optional[Callable[[], None]] = None
303
+
304
+ @property
305
+ def endpoint_id(self) -> Optional[str]:
306
+ """The resolved endpointId, once the first mint resolved it."""
307
+ return self._endpoint_id
308
+
309
+ def start(self) -> "WebSessionCodeIssuer":
310
+ """Kick off the first mint and arm the reconnect re-mint. Never raises."""
311
+ logger.info(
312
+ "[web-session-code] Starting code subscription (%s, renew at %d%% of code TTL)",
313
+ _describe_target(self._twin_id, self._endpoint_id_target),
314
+ round(self._renew_at_fraction * 100),
315
+ )
316
+ self._arm_reconnect_listener()
317
+ self._spawn_mint()
318
+ return self
319
+
320
+ def refresh(self) -> None:
321
+ """Mint a new code now (cancels the pending rotation timer)."""
322
+ if self._stopped:
323
+ return
324
+ logger.info("[web-session-code] Refresh requested — minting a fresh code")
325
+ self._cancel_timer()
326
+ self._spawn_mint()
327
+
328
+ def stop(self) -> None:
329
+ """Stop rotating and release timers/listeners. Idempotent."""
330
+ if self._stopped:
331
+ return
332
+ self._stopped = True
333
+ logger.info("[web-session-code] Code subscription stopped")
334
+ self._cancel_timer()
335
+ if self._reconnect_handler is not None:
336
+ self._client.off_connect(self._reconnect_handler)
337
+ self._reconnect_handler = None
338
+
339
+ def _spawn_mint(self) -> None:
340
+ if self._stopped or self._mint_in_flight:
341
+ return
342
+ self._mint_task = asyncio.get_event_loop().create_task(self._run_mint())
343
+
344
+ async def _run_mint(self) -> None:
345
+ if self._stopped or self._mint_in_flight:
346
+ return
347
+ self._mint_in_flight = True
348
+ # Every awaited step below is time-bounded: an await that never
349
+ # settles would hold _mint_in_flight forever, and the single-flight
350
+ # guard would then silently kill the subscription (no retry, no state,
351
+ # refresh() and reconnects all no-ops).
352
+ try:
353
+ await _bounded(
354
+ self._client.ensure_connection(),
355
+ self._request_timeout_seconds,
356
+ "the hub connection",
357
+ )
358
+ # The socket may only exist after ensure_connection — arm late if
359
+ # start() ran before the client connected.
360
+ self._arm_reconnect_listener()
361
+ if self._endpoint_id is None:
362
+ self._endpoint_id = await _bounded(
363
+ resolve_web_session_endpoint_id(
364
+ self._client, self._twin_id, self._endpoint_id_target
365
+ ),
366
+ self._request_timeout_seconds,
367
+ "the web endpoint twin lookup",
368
+ )
369
+ if self._twin_id:
370
+ logger.info(
371
+ "[web-session-code] Resolved twin %s to web endpoint %s",
372
+ self._twin_id,
373
+ self._endpoint_id,
374
+ )
375
+ logger.info("[web-session-code] Requesting a code for endpoint %s", self._endpoint_id)
376
+ issued = await request_web_session_code(
377
+ self._client, self._endpoint_id, self._request_timeout_seconds
378
+ )
379
+ if self._stopped:
380
+ return
381
+ self._consecutive_failures = 0
382
+ logger.info(
383
+ "[web-session-code] Code active: %s (expires %s, in %ss)",
384
+ issued.url,
385
+ issued.expires_at.isoformat(),
386
+ issued.expires_in,
387
+ )
388
+ self._emit_state(WebSessionCodeState.active(issued))
389
+ self._schedule_rotation(issued.expires_in)
390
+ except Exception as error: # noqa: BLE001 — every failure becomes an `unavailable` state
391
+ if self._stopped:
392
+ return
393
+ self._handle_mint_failure(error)
394
+ finally:
395
+ self._mint_in_flight = False
396
+
397
+ def _handle_mint_failure(self, error: Exception) -> None:
398
+ message = str(error)
399
+ if isinstance(error, WebSessionCodeConfigError):
400
+ logger.error(
401
+ "[web-session-code] Failed to mint web session code (terminal config error): %s",
402
+ message,
403
+ )
404
+ self._emit_state(WebSessionCodeState.unavailable(message, None))
405
+ return
406
+ self._consecutive_failures += 1
407
+ backoff_seconds = min(
408
+ self._retry_backoff_base_seconds * (2 ** (self._consecutive_failures - 1)),
409
+ self._retry_backoff_cap_seconds,
410
+ )
411
+ logger.warning(
412
+ "[web-session-code] Failed to mint web session code (attempt %d), retry in %.1fs: %s",
413
+ self._consecutive_failures,
414
+ backoff_seconds,
415
+ message,
416
+ )
417
+ self._emit_state(
418
+ WebSessionCodeState.unavailable(
419
+ message, datetime.now(timezone.utc) + timedelta(seconds=backoff_seconds)
420
+ )
421
+ )
422
+ self._schedule(backoff_seconds)
423
+
424
+ def _schedule_rotation(self, expires_in_seconds: int) -> None:
425
+ self._cancel_timer()
426
+ base_delay = expires_in_seconds * self._renew_at_fraction
427
+ # Downward-only jitter: rotate a bit early, never later than the fraction.
428
+ jittered_delay = base_delay * (1 - RENEWAL_JITTER_FRACTION * self._random())
429
+ delay = max(jittered_delay, MIN_ROTATION_DELAY_SECONDS)
430
+ logger.info("[web-session-code] Next code rotation in %ds", round(delay))
431
+ self._schedule(delay)
432
+
433
+ def _schedule(self, delay_seconds: float) -> None:
434
+ self._cancel_timer()
435
+
436
+ def fire() -> None:
437
+ self._timer = None
438
+ self._spawn_mint()
439
+
440
+ self._timer = asyncio.get_event_loop().call_later(delay_seconds, fire)
441
+
442
+ def _cancel_timer(self) -> None:
443
+ if self._timer is not None:
444
+ self._timer.cancel()
445
+ self._timer = None
446
+
447
+ def _arm_reconnect_listener(self) -> None:
448
+ """Re-mint right after a reconnect: the displayed code likely expired
449
+ while the socket was down, and a fresh one costs a single event."""
450
+ if self._stopped or self._reconnect_handler is not None:
451
+ return
452
+
453
+ def on_connect() -> None:
454
+ if self._stopped:
455
+ return
456
+ logger.info("[web-session-code] Socket reconnected — replacing the displayed code")
457
+ self.refresh()
458
+
459
+ self._reconnect_handler = on_connect
460
+ self._client.on_connect(on_connect)
461
+
462
+ def _emit_state(self, state: WebSessionCodeState) -> None:
463
+ try:
464
+ self._listener(state)
465
+ except Exception as listener_error: # noqa: BLE001 — a bad listener must not stop rotation
466
+ logger.error(
467
+ "[web-session-code] Failed to notify web session code listener: %s", listener_error
468
+ )
@@ -0,0 +1,354 @@
1
+ """Web-session claim-code issuer (device side of the web-app QR flow).
2
+
3
+ Port of npm `web-session-code.test.ts`: target validation, endpoint
4
+ resolution, the mint ack contract, and the rotating issuer's lifecycle
5
+ (rotation, backoff, refresh, reconnect re-mint, stop, listener faults,
6
+ single-flight). Timers run on the real event loop with tiny delays.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Callable, Optional
14
+
15
+ import pytest
16
+
17
+ from phystack.hub_client.services.web_session_code import (
18
+ CREATE_WEB_SESSION_CODE_EVENT,
19
+ WebSessionCodeConfigError,
20
+ WebSessionCodeIssuer,
21
+ WebSessionCodeState,
22
+ assert_web_session_code_target,
23
+ create_web_session_code,
24
+ request_web_session_code,
25
+ resolve_web_session_endpoint_id,
26
+ )
27
+
28
+ ENDPOINT_ID = "863d9307-b06e-4fd0-bef4-2d906bcb5898"
29
+ WEB_TWIN_ID = "6a8ff5721a1a8137a4de69d1"
30
+
31
+
32
+ def success_ack(code: str = "VJSVALA3MB", expires_in: int = 300) -> dict[str, Any]:
33
+ return {
34
+ "status": "success",
35
+ "message": "Web session code created",
36
+ "code": code,
37
+ "url": f"https://w-qa.phystack.com/amco-5119/kp-office-remote/#code={code}",
38
+ "expiresIn": expires_in,
39
+ }
40
+
41
+
42
+ class FakeClient:
43
+ """Scripted WebSessionCodeClientAdapter."""
44
+
45
+ def __init__(self, twin: Optional[dict[str, Any]] = None) -> None:
46
+ self.twin = twin
47
+ self.emits: list[tuple[str, dict[str, Any]]] = []
48
+ self.acks: list[Any] = []
49
+ self.ack_delay_seconds = 0.0
50
+ self.connect_handlers: list[Callable[[], None]] = []
51
+ self.ensure_connection_calls = 0
52
+ self.twin_lookups = 0
53
+ self.hang_twin_lookup = False
54
+
55
+ async def ensure_connection(self) -> None:
56
+ self.ensure_connection_calls += 1
57
+
58
+ async def emit_with_ack(self, method: str, payload: dict[str, Any]) -> Any:
59
+ self.emits.append((method, payload))
60
+ if self.ack_delay_seconds:
61
+ await asyncio.sleep(self.ack_delay_seconds)
62
+ if not self.acks:
63
+ return success_ack()
64
+ ack = self.acks.pop(0)
65
+ if isinstance(ack, Exception):
66
+ raise ack
67
+ return ack
68
+
69
+ async def get_twin_by_id(self, twin_id: str) -> Optional[dict[str, Any]]:
70
+ self.twin_lookups += 1
71
+ if self.hang_twin_lookup:
72
+ await asyncio.sleep(3600)
73
+ return self.twin
74
+
75
+ def on_connect(self, handler: Callable[[], None]) -> None:
76
+ self.connect_handlers.append(handler)
77
+
78
+ def off_connect(self, handler: Callable[[], None]) -> None:
79
+ self.connect_handlers = [
80
+ existing for existing in self.connect_handlers if existing is not handler
81
+ ]
82
+
83
+ def fire_connect(self) -> None:
84
+ for handler in list(self.connect_handlers):
85
+ handler()
86
+
87
+
88
+ async def settle(seconds: float = 0.02) -> None:
89
+ await asyncio.sleep(seconds)
90
+
91
+
92
+ def collect(states: list[WebSessionCodeState]) -> Callable[[WebSessionCodeState], None]:
93
+ return states.append
94
+
95
+
96
+ def test_target_requires_exactly_one_of_twin_id_or_endpoint_id() -> None:
97
+ with pytest.raises(ValueError):
98
+ assert_web_session_code_target(WEB_TWIN_ID, ENDPOINT_ID)
99
+ with pytest.raises(ValueError):
100
+ assert_web_session_code_target(None, None)
101
+ assert_web_session_code_target(None, ENDPOINT_ID)
102
+
103
+
104
+ async def test_explicit_endpoint_id_skips_the_twin_lookup() -> None:
105
+ client = FakeClient()
106
+ assert await resolve_web_session_endpoint_id(client, None, ENDPOINT_ID) == ENDPOINT_ID
107
+ assert client.twin_lookups == 0
108
+
109
+
110
+ async def test_twin_id_resolves_to_the_web_twin_device_id() -> None:
111
+ client = FakeClient(twin={"id": WEB_TWIN_ID, "type": "Web", "deviceId": ENDPOINT_ID})
112
+ assert await resolve_web_session_endpoint_id(client, WEB_TWIN_ID, None) == ENDPOINT_ID
113
+
114
+
115
+ async def test_non_web_twin_is_a_terminal_config_error() -> None:
116
+ client = FakeClient(twin={"id": WEB_TWIN_ID, "type": "Peripheral", "deviceId": "dev"})
117
+ with pytest.raises(WebSessionCodeConfigError):
118
+ await resolve_web_session_endpoint_id(client, WEB_TWIN_ID, None)
119
+
120
+
121
+ async def test_request_emits_the_event_with_the_endpoint_id_and_parses_the_ack() -> None:
122
+ client = FakeClient()
123
+ issued = await request_web_session_code(client, ENDPOINT_ID)
124
+ assert client.emits == [(CREATE_WEB_SESSION_CODE_EVENT, {"data": {"endpointId": ENDPOINT_ID}})]
125
+ assert issued.code == "VJSVALA3MB"
126
+ assert issued.url.endswith("#code=VJSVALA3MB")
127
+ assert issued.expires_in == 300
128
+ assert issued.endpoint_id == ENDPOINT_ID
129
+ assert issued.expires_at.tzinfo is timezone.utc
130
+ assert issued.expires_at > datetime.now(timezone.utc)
131
+
132
+
133
+ async def test_request_raises_on_an_error_ack_with_the_server_message() -> None:
134
+ client = FakeClient()
135
+ client.acks = [
136
+ {
137
+ "status": "error",
138
+ "message": "Failed to create web session code: Web endpoint not found or unauthorized",
139
+ }
140
+ ]
141
+ with pytest.raises(RuntimeError, match="Web endpoint not found or unauthorized"):
142
+ await request_web_session_code(client, ENDPOINT_ID)
143
+
144
+
145
+ async def test_request_raises_on_a_malformed_ack() -> None:
146
+ client = FakeClient()
147
+ client.acks = [{"status": "success", "code": "X"}]
148
+ with pytest.raises(RuntimeError, match="malformed ack"):
149
+ await request_web_session_code(client, ENDPOINT_ID)
150
+
151
+
152
+ async def test_request_times_out_when_no_ack_arrives() -> None:
153
+ client = FakeClient()
154
+ client.ack_delay_seconds = 1.0
155
+ with pytest.raises(TimeoutError, match="web session code ack"):
156
+ await request_web_session_code(client, ENDPOINT_ID, request_timeout_seconds=0.05)
157
+
158
+
159
+ async def test_create_ensures_the_connection_resolves_the_twin_and_mints_once() -> None:
160
+ client = FakeClient(twin={"id": WEB_TWIN_ID, "type": "Web", "deviceId": ENDPOINT_ID})
161
+ issued = await create_web_session_code(client, twin_id=WEB_TWIN_ID)
162
+ assert client.ensure_connection_calls == 1
163
+ assert client.twin_lookups == 1
164
+ assert len(client.emits) == 1
165
+ assert issued.endpoint_id == ENDPOINT_ID
166
+
167
+
168
+ async def test_issuer_delivers_the_initial_code_as_an_active_state() -> None:
169
+ client = FakeClient()
170
+ states: list[WebSessionCodeState] = []
171
+ issuer = WebSessionCodeIssuer(client, collect(states), endpoint_id=ENDPOINT_ID).start()
172
+ await settle()
173
+ assert [state.status for state in states] == ["active"]
174
+ assert states[0].url is not None and states[0].url.endswith("#code=VJSVALA3MB")
175
+ assert issuer.endpoint_id == ENDPOINT_ID
176
+ issuer.stop()
177
+
178
+
179
+ async def test_issuer_rotates_at_the_renew_fraction_of_the_ttl() -> None:
180
+ client = FakeClient()
181
+ # 2 s TTL × 0.5 renew fraction = 1 s… clamped by the 1 s minimum rotation delay.
182
+ client.acks = [success_ack("FIRST", expires_in=2), success_ack("SECOND", expires_in=2)]
183
+ states: list[WebSessionCodeState] = []
184
+ issuer = WebSessionCodeIssuer(
185
+ client,
186
+ collect(states),
187
+ endpoint_id=ENDPOINT_ID,
188
+ renew_at_fraction=0.5,
189
+ random_source=lambda: 0.0,
190
+ ).start()
191
+ await settle()
192
+ assert [state.code for state in states] == ["FIRST"]
193
+ await settle(1.1)
194
+ assert [state.code for state in states] == ["FIRST", "SECOND"]
195
+ issuer.stop()
196
+
197
+
198
+ async def test_issuer_reports_unavailable_with_backoff_and_recovers() -> None:
199
+ client = FakeClient()
200
+ client.acks = [
201
+ {
202
+ "status": "error",
203
+ "message": "Failed to create web session code: PhyHub instance not found",
204
+ },
205
+ success_ack("RECOVERED"),
206
+ ]
207
+ states: list[WebSessionCodeState] = []
208
+ issuer = WebSessionCodeIssuer(
209
+ client, collect(states), endpoint_id=ENDPOINT_ID, retry_backoff_base_seconds=0.05
210
+ ).start()
211
+ await settle()
212
+ assert states[0].status == "unavailable"
213
+ assert states[0].retry_at is not None
214
+ assert "PhyHub instance not found" in (states[0].message or "")
215
+ await settle(0.15)
216
+ assert states[-1].status == "active"
217
+ assert states[-1].code == "RECOVERED"
218
+ issuer.stop()
219
+
220
+
221
+ async def test_issuer_stops_permanently_on_a_non_web_twin() -> None:
222
+ client = FakeClient(twin={"id": WEB_TWIN_ID, "type": "Peripheral", "deviceId": "dev"})
223
+ states: list[WebSessionCodeState] = []
224
+ issuer = WebSessionCodeIssuer(
225
+ client, collect(states), twin_id=WEB_TWIN_ID, retry_backoff_base_seconds=0.01
226
+ ).start()
227
+ await settle()
228
+ assert [state.status for state in states] == ["unavailable"]
229
+ assert states[0].retry_at is None
230
+ await settle(0.1)
231
+ assert len(states) == 1 # no retries
232
+ assert client.emits == []
233
+ issuer.stop()
234
+
235
+
236
+ async def test_refresh_mints_immediately_and_cancels_the_rotation_timer() -> None:
237
+ client = FakeClient()
238
+ client.acks = [success_ack("FIRST", expires_in=3600), success_ack("REFRESHED", expires_in=3600)]
239
+ states: list[WebSessionCodeState] = []
240
+ issuer = WebSessionCodeIssuer(client, collect(states), endpoint_id=ENDPOINT_ID).start()
241
+ await settle()
242
+ issuer.refresh()
243
+ await settle()
244
+ assert [state.code for state in states] == ["FIRST", "REFRESHED"]
245
+ issuer.stop()
246
+
247
+
248
+ async def test_issuer_re_mints_when_the_socket_reconnects() -> None:
249
+ client = FakeClient()
250
+ client.acks = [
251
+ success_ack("FIRST", expires_in=3600),
252
+ success_ack("AFTER_RECONNECT", expires_in=3600),
253
+ ]
254
+ states: list[WebSessionCodeState] = []
255
+ issuer = WebSessionCodeIssuer(client, collect(states), endpoint_id=ENDPOINT_ID).start()
256
+ await settle()
257
+ assert len(client.connect_handlers) == 1
258
+ client.fire_connect()
259
+ await settle()
260
+ assert [state.code for state in states] == ["FIRST", "AFTER_RECONNECT"]
261
+ issuer.stop()
262
+
263
+
264
+ async def test_stop_detaches_the_reconnect_listener_and_silences_further_states() -> None:
265
+ client = FakeClient()
266
+ client.acks = [success_ack("FIRST", expires_in=2)]
267
+ states: list[WebSessionCodeState] = []
268
+ issuer = WebSessionCodeIssuer(
269
+ client,
270
+ collect(states),
271
+ endpoint_id=ENDPOINT_ID,
272
+ renew_at_fraction=0.5,
273
+ random_source=lambda: 0.0,
274
+ ).start()
275
+ await settle()
276
+ issuer.stop()
277
+ issuer.stop() # idempotent
278
+ assert client.connect_handlers == []
279
+ client.fire_connect()
280
+ await settle(1.1)
281
+ assert len(states) == 1
282
+ assert len(client.emits) == 1
283
+
284
+
285
+ async def test_issuer_survives_a_raising_listener_and_keeps_rotating() -> None:
286
+ client = FakeClient()
287
+ client.acks = [success_ack("FIRST", expires_in=2), success_ack("SECOND", expires_in=2)]
288
+ seen: list[str] = []
289
+
290
+ def bad_listener(state: WebSessionCodeState) -> None:
291
+ seen.append(state.code or state.status)
292
+ raise RuntimeError("render failed")
293
+
294
+ issuer = WebSessionCodeIssuer(
295
+ client,
296
+ bad_listener,
297
+ endpoint_id=ENDPOINT_ID,
298
+ renew_at_fraction=0.5,
299
+ random_source=lambda: 0.0,
300
+ ).start()
301
+ await settle(1.15)
302
+ assert seen == ["FIRST", "SECOND"]
303
+ issuer.stop()
304
+
305
+
306
+ async def test_issuer_times_out_a_hung_twin_lookup_and_recovers_on_retry() -> None:
307
+ client = FakeClient(twin={"id": WEB_TWIN_ID, "type": "Web", "deviceId": ENDPOINT_ID})
308
+ client.hang_twin_lookup = True
309
+ states: list[WebSessionCodeState] = []
310
+ issuer = WebSessionCodeIssuer(
311
+ client,
312
+ collect(states),
313
+ twin_id=WEB_TWIN_ID,
314
+ request_timeout_seconds=0.05,
315
+ retry_backoff_base_seconds=0.05,
316
+ ).start()
317
+ await settle(0.1)
318
+ assert states[0].status == "unavailable"
319
+ assert "twin lookup" in (states[0].message or "")
320
+ client.hang_twin_lookup = False
321
+ await settle(0.15)
322
+ assert states[-1].status == "active"
323
+ issuer.stop()
324
+
325
+
326
+ async def test_a_single_mint_stays_in_flight_when_refresh_is_spammed() -> None:
327
+ client = FakeClient()
328
+ client.ack_delay_seconds = 0.05
329
+ states: list[WebSessionCodeState] = []
330
+ issuer = WebSessionCodeIssuer(client, collect(states), endpoint_id=ENDPOINT_ID).start()
331
+ for _ in range(5):
332
+ issuer.refresh()
333
+ await settle(0.15)
334
+ assert len(client.emits) == 1
335
+ assert [state.status for state in states] == ["active"]
336
+ issuer.stop()
337
+
338
+
339
+ async def test_client_methods_are_device_session_only() -> None:
340
+ from phystack.hub_client.client import PhyHubClient
341
+ from phystack.hub_client.services.cloud_app_connection import CloudAppConnectionParams
342
+
343
+ cloud_client = PhyHubClient(
344
+ cloud_app=CloudAppConnectionParams(
345
+ app_registration_id="app-registration-1",
346
+ app_secret="secret",
347
+ core_api_url="http://localhost:14100",
348
+ phyhub_url="http://localhost:14401",
349
+ )
350
+ )
351
+ with pytest.raises(RuntimeError, match="device sessions"):
352
+ await cloud_client.create_web_session_code(endpoint_id=ENDPOINT_ID)
353
+ with pytest.raises(RuntimeError, match="device sessions"):
354
+ cloud_client.subscribe_web_session_code(lambda state: None, endpoint_id=ENDPOINT_ID)