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.
- phystack_hub_client-6.16.0/CHANGELOG.md +47 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/PKG-INFO +30 -1
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/README.md +29 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/pyproject.toml +1 -1
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/__init__.py +14 -1
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/client.py +108 -0
- phystack_hub_client-6.16.0/src/phystack/hub_client/services/web_session_code.py +468 -0
- phystack_hub_client-6.16.0/tests/test_web_session_code.py +354 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/.gitignore +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/examples/browser_test_backend.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/scripts/python-node-test.sh +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/scripts/webrtc-test.sh +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/advisory_validation.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/cloud_twins.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/__init__.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/base_instance.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/edge_instance.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/instances/peripheral_instance.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/scoped_hardware_id.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/__init__.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/cloud_app_connection.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/cloud_app_token.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/phyhub_connection.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/signals.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/__init__.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/data_channel.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/data_responder.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_responder.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_source.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/webrtc/media_stream.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_messaging.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_registry.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/__init__.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/twin_types.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_advisory_validation.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_client_integration.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_cloud_app_token.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_cloud_twins.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_communication.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_data_channel.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_instance_listeners.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_media_kinds.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_media_resilience.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_media_responder.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_media_source.py +0 -0
- {phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/tests/test_peripheral_lifecycle.py +0 -0
- {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.
|
|
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.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/__init__.py
RENAMED
|
@@ -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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/cloud_twins.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/services/signals.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_messaging.py
RENAMED
|
File without changes
|
{phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/twin_registry.py
RENAMED
|
File without changes
|
{phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/__init__.py
RENAMED
|
File without changes
|
{phystack_hub_client-6.8.0 → phystack_hub_client-6.16.0}/src/phystack/hub_client/types/twin_types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|