phystack-hub-client 6.6.0__tar.gz → 6.8.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.6.0 → phystack_hub_client-6.8.0}/PKG-INFO +2 -2
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/pyproject.toml +1 -1
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/__init__.py +20 -1
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/client.py +210 -2
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/instances/base_instance.py +19 -2
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/instances/edge_instance.py +138 -19
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/instances/peripheral_instance.py +188 -7
- phystack_hub_client-6.8.0/src/phystack/hub_client/scoped_hardware_id.py +103 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/__init__.py +4 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/media_stream.py +113 -4
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/twin_messaging.py +55 -1
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/twin_registry.py +16 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/types/twin_types.py +43 -0
- phystack_hub_client-6.8.0/tests/test_media_kinds.py +231 -0
- phystack_hub_client-6.8.0/tests/test_peripheral_lifecycle.py +451 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/.gitignore +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/README.md +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/examples/browser_test_backend.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/scripts/python-node-test.sh +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/scripts/webrtc-test.sh +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/advisory_validation.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/cloud_twins.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/instances/__init__.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/__init__.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/cloud_app_connection.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/cloud_app_token.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/phyhub_connection.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/signals.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/data_channel.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/data_responder.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/media_responder.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/media_source.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/types/__init__.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_advisory_validation.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_client_integration.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_cloud_app_token.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_cloud_twins.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_communication.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_data_channel.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_instance_listeners.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_media_resilience.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_media_responder.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_media_source.py +0 -0
- {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_signals_rollover.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: phystack-hub-client
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.8.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
|
|
@@ -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.8.0"
|
|
11
11
|
description = "Python client for PhyHub - events, actions, DataChannel, and MediaStream"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
license = "MIT"
|
|
@@ -46,12 +46,20 @@ Or with context manager:
|
|
|
46
46
|
|
|
47
47
|
from phystack.hub_client.client import PhyHubClient, connect_phy_client
|
|
48
48
|
from phystack.hub_client.types.twin_types import (
|
|
49
|
+
PeripheralCreateResult,
|
|
50
|
+
PeripheralHandover,
|
|
51
|
+
PeripheralOwnershipLoss,
|
|
49
52
|
TwinMessageResult,
|
|
50
53
|
TwinMessageResultStatus,
|
|
51
54
|
TwinProperties,
|
|
52
55
|
TwinTypeEnum,
|
|
53
56
|
TwinStatusEnum,
|
|
54
57
|
)
|
|
58
|
+
from phystack.hub_client.scoped_hardware_id import (
|
|
59
|
+
HARDWARE_ID_SCOPES,
|
|
60
|
+
HARDWARE_ID_USER_PART_MAX_LENGTH,
|
|
61
|
+
format_scoped_hardware_id,
|
|
62
|
+
)
|
|
55
63
|
from phystack.hub_client.instances import (
|
|
56
64
|
BaseInstance,
|
|
57
65
|
EdgeInstance,
|
|
@@ -66,6 +74,8 @@ from phystack.hub_client.services.webrtc.data_channel import (
|
|
|
66
74
|
from phystack.hub_client.services.webrtc.media_stream import (
|
|
67
75
|
MediaStreamHandler,
|
|
68
76
|
MediaStreamOptions,
|
|
77
|
+
MediaTrackKind,
|
|
78
|
+
MissingMediaTrackError,
|
|
69
79
|
PhygridMediaStream,
|
|
70
80
|
WebcamVideoTrack,
|
|
71
81
|
)
|
|
@@ -113,7 +123,7 @@ from phystack.hub_client.services.signals import (
|
|
|
113
123
|
|
|
114
124
|
# Kept in a fixed version group with @phystack/hub-client (npm) — same
|
|
115
125
|
# contract, same release version.
|
|
116
|
-
__version__ = "6.
|
|
126
|
+
__version__ = "6.8.0"
|
|
117
127
|
__all__ = [
|
|
118
128
|
# Client
|
|
119
129
|
"PhyHubClient",
|
|
@@ -129,12 +139,21 @@ __all__ = [
|
|
|
129
139
|
"TwinProperties",
|
|
130
140
|
"TwinTypeEnum",
|
|
131
141
|
"TwinStatusEnum",
|
|
142
|
+
# Peripheral identity + ownership (TECH-1394)
|
|
143
|
+
"PeripheralCreateResult",
|
|
144
|
+
"PeripheralHandover",
|
|
145
|
+
"PeripheralOwnershipLoss",
|
|
146
|
+
"HARDWARE_ID_SCOPES",
|
|
147
|
+
"HARDWARE_ID_USER_PART_MAX_LENGTH",
|
|
148
|
+
"format_scoped_hardware_id",
|
|
132
149
|
# WebRTC
|
|
133
150
|
"DataChannelHandler",
|
|
134
151
|
"DataChannelOptions",
|
|
135
152
|
"PhygridDataChannel",
|
|
136
153
|
"MediaStreamHandler",
|
|
137
154
|
"MediaStreamOptions",
|
|
155
|
+
"MediaTrackKind",
|
|
156
|
+
"MissingMediaTrackError",
|
|
138
157
|
"PhygridMediaStream",
|
|
139
158
|
"WebcamVideoTrack",
|
|
140
159
|
"FrameSourceTrack",
|
|
@@ -83,6 +83,7 @@ class PhyHubClient:
|
|
|
83
83
|
DISCONNECT = "disconnect"
|
|
84
84
|
TWIN_MESSAGE = "twinMessage"
|
|
85
85
|
TWIN_SUBSCRIBE = "twinSubscribe"
|
|
86
|
+
TWIN_UNSUBSCRIBE = "twinUnsubscribe"
|
|
86
87
|
TWIN_UPDATED = "twinUpdated"
|
|
87
88
|
GET_TWIN_BY_ID = "getTwinById"
|
|
88
89
|
REPORT_SCREEN_PROPERTIES = "reportScreenTwinProperties"
|
|
@@ -210,7 +211,15 @@ class PhyHubClient:
|
|
|
210
211
|
return self
|
|
211
212
|
|
|
212
213
|
async def disconnect(self) -> None:
|
|
213
|
-
"""Disconnect from PhyHub.
|
|
214
|
+
"""Disconnect from PhyHub.
|
|
215
|
+
|
|
216
|
+
npm 6.7.0 parity: clears the twin listener registries, cached
|
|
217
|
+
instances, subscribed-twin set, and the emit queue — listeners
|
|
218
|
+
registered before a disconnect() do not survive into a later
|
|
219
|
+
reconnect, and queued emits fail instead of hanging. The client
|
|
220
|
+
stays reusable. WebRTC connections are NOT torn down — hold the
|
|
221
|
+
stop()/close() handles from the responder/stream APIs.
|
|
222
|
+
"""
|
|
214
223
|
if self._cloud_app_connection:
|
|
215
224
|
await self._cloud_app_connection.disconnect()
|
|
216
225
|
self._cloud_app_connection = None
|
|
@@ -221,6 +230,15 @@ class PhyHubClient:
|
|
|
221
230
|
self._instance = None
|
|
222
231
|
if self._registry:
|
|
223
232
|
self._registry.clear()
|
|
233
|
+
self._twin_message_listeners.clear()
|
|
234
|
+
self._twin_update_listeners.clear()
|
|
235
|
+
self._subscribed_twins.clear()
|
|
236
|
+
pending_emits, self._emit_queue = self._emit_queue, []
|
|
237
|
+
for _method, _payload, _needs_ack, pending_future in pending_emits:
|
|
238
|
+
if pending_future and not pending_future.done():
|
|
239
|
+
pending_future.set_exception(
|
|
240
|
+
ConnectionError("disconnect() dropped the queued emit")
|
|
241
|
+
)
|
|
224
242
|
|
|
225
243
|
# =========================================================================
|
|
226
244
|
# Cloud-app mode (npm PhyHubClientParams.cloudApp parity)
|
|
@@ -347,10 +365,23 @@ class PhyHubClient:
|
|
|
347
365
|
if not twin_id:
|
|
348
366
|
return
|
|
349
367
|
|
|
368
|
+
# Ownership-loss detection (TECH-1394): peripheral hardwareIds
|
|
369
|
+
# are unique tenant-wide, so another device — or another instance
|
|
370
|
+
# on THIS device — registering the same id takes our twin over.
|
|
371
|
+
# Level-triggered against the twin's current owner identity
|
|
372
|
+
# (deviceId + desired.instanceId), not edge-triggered on the
|
|
373
|
+
# takeover event itself: every later twinUpdated re-delivers the
|
|
374
|
+
# signal, so a takeover missed during a disconnect self-heals on
|
|
375
|
+
# the next twin activity. Only instances ACQUIRED as owner are
|
|
376
|
+
# considered — a consumer holding a sibling instance's peripheral
|
|
377
|
+
# legitimately sees a foreign instanceId forever.
|
|
378
|
+
if isinstance(twin_data, dict) and twin_data.get("deviceId"):
|
|
379
|
+
self._detect_peripheral_ownership_loss(twin_id, twin_data)
|
|
380
|
+
|
|
350
381
|
# Notify twin update listeners
|
|
351
382
|
listeners = self._twin_update_listeners.get(twin_id)
|
|
352
383
|
if listeners:
|
|
353
|
-
for callback in listeners:
|
|
384
|
+
for callback in list(listeners):
|
|
354
385
|
try:
|
|
355
386
|
callback(twin_data)
|
|
356
387
|
except Exception as e:
|
|
@@ -670,6 +701,8 @@ class PhyHubClient:
|
|
|
670
701
|
Returns:
|
|
671
702
|
Updated twin data.
|
|
672
703
|
"""
|
|
704
|
+
from phystack.hub_client.types.twin_types import PeripheralOwnershipLoss
|
|
705
|
+
|
|
673
706
|
# Dispatch to correct event based on twin type (matches Node.js behavior)
|
|
674
707
|
if twin_type == TwinTypeEnum.Screen:
|
|
675
708
|
event = self.EVENTS.REPORT_SCREEN_PROPERTIES
|
|
@@ -680,6 +713,19 @@ class PhyHubClient:
|
|
|
680
713
|
|
|
681
714
|
payload = {"twinId": twin_id, "data": properties}
|
|
682
715
|
result = await self._emit_to_socket(event, payload, callback=True)
|
|
716
|
+
if result and result.get("status") not in (None, "success"):
|
|
717
|
+
# Ownership-loss fallback (TECH-1394): a report rejected with
|
|
718
|
+
# PeripheralOwnershipError means another device took the twin
|
|
719
|
+
# over while we were offline — the twinUpdated notification
|
|
720
|
+
# never reached us, so revoke here.
|
|
721
|
+
if result.get("errorType") == "PeripheralOwnershipError":
|
|
722
|
+
self._handle_peripheral_ownership_lost(
|
|
723
|
+
twin_id, PeripheralOwnershipLoss()
|
|
724
|
+
)
|
|
725
|
+
# npm 6.5.1 parity: carry the server's reason from the ack.
|
|
726
|
+
raise RuntimeError(
|
|
727
|
+
result.get("message") or "updateReportedProperties failed"
|
|
728
|
+
)
|
|
683
729
|
return result.get("twin") if result else None
|
|
684
730
|
|
|
685
731
|
# =========================================================================
|
|
@@ -788,6 +834,168 @@ class PhyHubClient:
|
|
|
788
834
|
self._device_status_cache = (now, result)
|
|
789
835
|
return result
|
|
790
836
|
|
|
837
|
+
async def resolve_device_space_id(self, timeout_seconds: float = 3.0) -> str:
|
|
838
|
+
"""Resolve the device's current spaceId for space-scoped hardwareId
|
|
839
|
+
minting (TECH-1394). Bypasses get_device_status's 10s cache window
|
|
840
|
+
unless the cached status already carries a spaceId; otherwise asks
|
|
841
|
+
directly with its own timeout.
|
|
842
|
+
|
|
843
|
+
Raises:
|
|
844
|
+
RuntimeError: When the status request yields no answer in time or
|
|
845
|
+
the response carries no spaceId.
|
|
846
|
+
"""
|
|
847
|
+
if self._device_status_cache:
|
|
848
|
+
cached_space_id = self._device_status_cache[1].get("spaceId")
|
|
849
|
+
if cached_space_id:
|
|
850
|
+
return str(cached_space_id)
|
|
851
|
+
|
|
852
|
+
try:
|
|
853
|
+
status = await asyncio.wait_for(
|
|
854
|
+
self._emit_to_socket(self.EVENTS.GET_DEVICE_STATUS, {}, callback=True),
|
|
855
|
+
timeout=timeout_seconds,
|
|
856
|
+
)
|
|
857
|
+
except asyncio.TimeoutError as timeout_error:
|
|
858
|
+
raise RuntimeError(
|
|
859
|
+
"generate_scoped_hardware_id: could not get spaceId — "
|
|
860
|
+
f"getDeviceStatus gave no answer within {timeout_seconds}s"
|
|
861
|
+
) from timeout_error
|
|
862
|
+
|
|
863
|
+
space_id = (status or {}).get("spaceId")
|
|
864
|
+
if not space_id:
|
|
865
|
+
raise RuntimeError(
|
|
866
|
+
"generate_scoped_hardware_id: the device status response "
|
|
867
|
+
"did not include a spaceId"
|
|
868
|
+
)
|
|
869
|
+
return str(space_id)
|
|
870
|
+
|
|
871
|
+
def _detect_peripheral_ownership_loss(
|
|
872
|
+
self, twin_id: str, twin_data: dict[str, Any]
|
|
873
|
+
) -> None:
|
|
874
|
+
"""Compare an updated twin's owner identity against a pooled
|
|
875
|
+
owner-acquired peripheral instance; revoke on mismatch."""
|
|
876
|
+
from phystack.hub_client.types.twin_types import PeripheralOwnershipLoss
|
|
877
|
+
|
|
878
|
+
registry = self._registry
|
|
879
|
+
owned_peripheral = (
|
|
880
|
+
registry.get_cached_peripheral_instance(twin_id) if registry else None
|
|
881
|
+
)
|
|
882
|
+
if owned_peripheral is None or not owned_peripheral.acquired_as_owner:
|
|
883
|
+
return
|
|
884
|
+
|
|
885
|
+
own_device_id = owned_peripheral.edge_instance.device_id
|
|
886
|
+
own_instance_id = owned_peripheral.edge_instance.id
|
|
887
|
+
if not own_device_id:
|
|
888
|
+
return
|
|
889
|
+
|
|
890
|
+
updated_device_id = twin_data.get("deviceId")
|
|
891
|
+
updated_instance_id = (
|
|
892
|
+
twin_data.get("properties", {}).get("desired", {}).get("instanceId")
|
|
893
|
+
)
|
|
894
|
+
lost_to_device = updated_device_id != own_device_id
|
|
895
|
+
lost_to_instance = (
|
|
896
|
+
not lost_to_device
|
|
897
|
+
and own_instance_id is not None
|
|
898
|
+
and updated_instance_id is not None
|
|
899
|
+
and updated_instance_id != own_instance_id
|
|
900
|
+
)
|
|
901
|
+
if not lost_to_device and not lost_to_instance:
|
|
902
|
+
return
|
|
903
|
+
|
|
904
|
+
self._handle_peripheral_ownership_lost(
|
|
905
|
+
twin_id,
|
|
906
|
+
PeripheralOwnershipLoss(
|
|
907
|
+
new_owner_device_id=updated_device_id if lost_to_device else None,
|
|
908
|
+
new_owner_instance_id=(
|
|
909
|
+
updated_instance_id if lost_to_instance else None
|
|
910
|
+
),
|
|
911
|
+
),
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
def _handle_peripheral_ownership_lost(
|
|
915
|
+
self, twin_id: str, loss: "PeripheralOwnershipLoss"
|
|
916
|
+
) -> None:
|
|
917
|
+
"""Revoke a peripheral this app lost to another device or to another
|
|
918
|
+
instance on this device (TECH-1394):
|
|
919
|
+
|
|
920
|
+
- mark the pooled PeripheralInstance ownership-lost (its
|
|
921
|
+
emit/update_reported start raising locally, its listeners are
|
|
922
|
+
dropped, on_ownership_lost fires so the app releases the hardware);
|
|
923
|
+
- drop it from the registry pool, so a later re-acquire (after taking
|
|
924
|
+
the twin back via create_peripheral_twin) builds a fresh instance;
|
|
925
|
+
- unsubscribe from the twin room, server-side AND locally — removing
|
|
926
|
+
the id from _subscribed_twins is what stops the reconnect
|
|
927
|
+
re-subscribe from silently rejoining the room we were evicted from.
|
|
928
|
+
"""
|
|
929
|
+
registry = self._registry
|
|
930
|
+
peripheral_instance = (
|
|
931
|
+
registry.get_cached_peripheral_instance(twin_id) if registry else None
|
|
932
|
+
)
|
|
933
|
+
if peripheral_instance is None:
|
|
934
|
+
return
|
|
935
|
+
|
|
936
|
+
if loss.new_owner_device_id:
|
|
937
|
+
new_owner_label = f" by device {loss.new_owner_device_id}"
|
|
938
|
+
elif loss.new_owner_instance_id:
|
|
939
|
+
new_owner_label = (
|
|
940
|
+
f" by instance {loss.new_owner_instance_id} on this device"
|
|
941
|
+
)
|
|
942
|
+
else:
|
|
943
|
+
new_owner_label = ""
|
|
944
|
+
logger.warning(
|
|
945
|
+
"peripheral %s was taken over%s — revoking the local instance",
|
|
946
|
+
twin_id,
|
|
947
|
+
new_owner_label,
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
self._subscribed_twins.discard(twin_id)
|
|
951
|
+
if registry:
|
|
952
|
+
registry.remove_peripheral_instance(twin_id)
|
|
953
|
+
|
|
954
|
+
# Best-effort server-side unsubscribe: phyhub already evicted our
|
|
955
|
+
# sockets from the twin room at takeover; this covers the case where
|
|
956
|
+
# that eviction failed or raced our reconnect.
|
|
957
|
+
try:
|
|
958
|
+
unsubscribe_payload = {
|
|
959
|
+
"twinId": twin_id,
|
|
960
|
+
"sourceTwinId": peripheral_instance.edge_instance.id,
|
|
961
|
+
}
|
|
962
|
+
asyncio.create_task(
|
|
963
|
+
self._emit_to_socket(self.EVENTS.TWIN_UNSUBSCRIBE, unsubscribe_payload)
|
|
964
|
+
)
|
|
965
|
+
except Exception as unsubscribe_error:
|
|
966
|
+
logger.warning(
|
|
967
|
+
"Failed to send twinUnsubscribe for taken-over peripheral %s: %s",
|
|
968
|
+
twin_id,
|
|
969
|
+
unsubscribe_error,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
peripheral_instance.mark_ownership_lost(loss)
|
|
973
|
+
|
|
974
|
+
def stop_responders_for_twin(self, twin_id: str) -> None:
|
|
975
|
+
"""Stop any armed media/data responders publishing on a twin.
|
|
976
|
+
|
|
977
|
+
Best-effort: used by ownership-loss revocation so a taken-over
|
|
978
|
+
peripheral stops serving viewers the new owner should serve.
|
|
979
|
+
"""
|
|
980
|
+
for key in list(self._media_responders):
|
|
981
|
+
if key.startswith(f"{twin_id}:"):
|
|
982
|
+
responder = self._media_responders.pop(key)
|
|
983
|
+
try:
|
|
984
|
+
asyncio.create_task(responder.stop())
|
|
985
|
+
except Exception as stop_error:
|
|
986
|
+
logger.warning(
|
|
987
|
+
"Failed to stop media responder %s: %s", key, stop_error
|
|
988
|
+
)
|
|
989
|
+
for key in list(self._data_responders):
|
|
990
|
+
if key.startswith(f"{twin_id}:"):
|
|
991
|
+
responder = self._data_responders.pop(key)
|
|
992
|
+
try:
|
|
993
|
+
asyncio.create_task(responder.stop())
|
|
994
|
+
except Exception as stop_error:
|
|
995
|
+
logger.warning(
|
|
996
|
+
"Failed to stop data responder %s: %s", key, stop_error
|
|
997
|
+
)
|
|
998
|
+
|
|
791
999
|
async def get_device_instance(self) -> Optional[dict[str, Any]]:
|
|
792
1000
|
"""Get device instance data.
|
|
793
1001
|
|
|
@@ -109,14 +109,31 @@ class BaseInstance:
|
|
|
109
109
|
self,
|
|
110
110
|
event_type: str,
|
|
111
111
|
callback: Callable[[Any, Optional[Callable[[TwinMessageResult], None]]], None],
|
|
112
|
-
) -> None:
|
|
112
|
+
) -> Callable[[], None]:
|
|
113
113
|
"""Listen for events, with optional respond callback for request-response.
|
|
114
114
|
|
|
115
115
|
Args:
|
|
116
116
|
event_type: Type of event to listen for.
|
|
117
117
|
callback: Handler function (data, respond?) => None
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
An unsubscribe callable that removes exactly this listener
|
|
121
|
+
(npm 6.7.0 parity).
|
|
122
|
+
"""
|
|
123
|
+
return self._messaging.on(event_type, callback)
|
|
124
|
+
|
|
125
|
+
def off(
|
|
126
|
+
self,
|
|
127
|
+
event_type: str,
|
|
128
|
+
callback: Callable[[Any, Optional[Callable[[TwinMessageResult], None]]], None],
|
|
129
|
+
) -> bool:
|
|
130
|
+
"""Remove a listener registered with on(), matched by the caller's
|
|
131
|
+
original callback identity (npm 6.7.0 parity).
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True when a listener was found and removed.
|
|
118
135
|
"""
|
|
119
|
-
self._messaging.
|
|
136
|
+
return self._messaging.off(event_type, callback)
|
|
120
137
|
|
|
121
138
|
def to(self, target_twin_id: str) -> TargetedEmitter:
|
|
122
139
|
"""Target a specific twin for messaging.
|
|
@@ -11,6 +11,7 @@ from phystack.hub_client.instances.base_instance import BaseInstance
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
12
|
from phystack.hub_client.client import PhyHubClient
|
|
13
13
|
from phystack.hub_client.twin_messaging import TwinMessaging
|
|
14
|
+
from phystack.hub_client.types.twin_types import PeripheralCreateResult
|
|
14
15
|
|
|
15
16
|
logger = logging.getLogger("phystack.hub_client.instances.edge")
|
|
16
17
|
|
|
@@ -66,30 +67,46 @@ class EdgeInstance(BaseInstance):
|
|
|
66
67
|
|
|
67
68
|
def on_update_reported(
|
|
68
69
|
self, callback: Callable[[dict[str, Any]], None]
|
|
69
|
-
) -> None:
|
|
70
|
+
) -> Callable[[], None]:
|
|
70
71
|
"""Subscribe to reported property changes.
|
|
71
72
|
|
|
72
73
|
Args:
|
|
73
74
|
callback: Handler called with new reported properties when they change.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
An unsubscribe callable (npm 6.7.0 parity).
|
|
74
78
|
"""
|
|
75
79
|
self._update_reported_listeners.add(callback)
|
|
76
80
|
|
|
77
81
|
# Set up twin update listener if not already set
|
|
78
82
|
self._setup_property_listeners()
|
|
79
83
|
|
|
84
|
+
def unsubscribe() -> None:
|
|
85
|
+
self._update_reported_listeners.discard(callback)
|
|
86
|
+
|
|
87
|
+
return unsubscribe
|
|
88
|
+
|
|
80
89
|
def on_update_desired(
|
|
81
90
|
self, callback: Callable[[dict[str, Any]], None]
|
|
82
|
-
) -> None:
|
|
91
|
+
) -> Callable[[], None]:
|
|
83
92
|
"""Subscribe to desired property changes.
|
|
84
93
|
|
|
85
94
|
Args:
|
|
86
95
|
callback: Handler called with new desired properties when they change.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
An unsubscribe callable (npm 6.7.0 parity).
|
|
87
99
|
"""
|
|
88
100
|
self._update_desired_listeners.add(callback)
|
|
89
101
|
|
|
90
102
|
# Set up twin update listener if not already set
|
|
91
103
|
self._setup_property_listeners()
|
|
92
104
|
|
|
105
|
+
def unsubscribe() -> None:
|
|
106
|
+
self._update_desired_listeners.discard(callback)
|
|
107
|
+
|
|
108
|
+
return unsubscribe
|
|
109
|
+
|
|
93
110
|
def _setup_property_listeners(self) -> None:
|
|
94
111
|
"""Set up twin update listener for property change detection.
|
|
95
112
|
|
|
@@ -148,28 +165,67 @@ class EdgeInstance(BaseInstance):
|
|
|
148
165
|
desired_properties: Optional[dict[str, Any]] = None,
|
|
149
166
|
descriptors: Optional[dict[str, str]] = None,
|
|
150
167
|
) -> dict[str, Any]:
|
|
151
|
-
"""Create
|
|
168
|
+
"""Create or update the peripheral twin for a hardwareId (npm parity).
|
|
169
|
+
|
|
170
|
+
The server's create-or-update (keyed tenant-wide by hardwareId) is
|
|
171
|
+
the uniqueness authority — there is no client-side existence check,
|
|
172
|
+
and re-calling create is the supported re-registration path: refresh
|
|
173
|
+
the name, re-point the owner instance, update descriptor pins.
|
|
174
|
+
Requires a phyhub with the peripheral upsert (TECH-408); against
|
|
175
|
+
older phyhub versions an unconditional create can duplicate twins.
|
|
152
176
|
|
|
153
177
|
Args:
|
|
154
178
|
name: Name for the peripheral.
|
|
155
|
-
hardware_id: Unique hardware identifier.
|
|
156
|
-
desired_properties: Optional initial desired properties
|
|
157
|
-
|
|
179
|
+
hardware_id: Unique hardware identifier (tenant-wide).
|
|
180
|
+
desired_properties: Optional initial desired properties (seed the
|
|
181
|
+
twin on first create only — operator-set values survive
|
|
182
|
+
re-registration).
|
|
183
|
+
descriptors: Optional descriptor version pins.
|
|
158
184
|
|
|
159
185
|
Returns:
|
|
160
|
-
|
|
186
|
+
The peripheral twin data (created or updated).
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
RuntimeError: On an error ack, carrying the server's rejection
|
|
190
|
+
reason.
|
|
191
|
+
"""
|
|
192
|
+
result = await self.create_peripheral_twin_detailed(
|
|
193
|
+
name, hardware_id, desired_properties, descriptors
|
|
194
|
+
)
|
|
195
|
+
if result.handover is not None:
|
|
196
|
+
previous_owner = (
|
|
197
|
+
f"device {result.handover.previous_device_id}"
|
|
198
|
+
if result.handover.previous_device_id
|
|
199
|
+
else f"instance {result.handover.previous_instance_id}"
|
|
200
|
+
)
|
|
201
|
+
logger.warning(
|
|
202
|
+
"create_peripheral_twin(): took over peripheral %s from %s "
|
|
203
|
+
"— expected once after an app move; repeated takeovers mean "
|
|
204
|
+
"two deployments share this hardwareId",
|
|
205
|
+
hardware_id,
|
|
206
|
+
previous_owner,
|
|
207
|
+
)
|
|
208
|
+
return result.twin
|
|
209
|
+
|
|
210
|
+
async def create_peripheral_twin_detailed(
|
|
211
|
+
self,
|
|
212
|
+
name: str,
|
|
213
|
+
hardware_id: str,
|
|
214
|
+
desired_properties: Optional[dict[str, Any]] = None,
|
|
215
|
+
descriptors: Optional[dict[str, str]] = None,
|
|
216
|
+
) -> "PeripheralCreateResult":
|
|
217
|
+
"""Create or update a peripheral twin, resolving the full ack (npm
|
|
218
|
+
createPeripheralTwinDetailed parity): the twin plus `created` and, on
|
|
219
|
+
a takeover, the `handover` describing the previous owner.
|
|
161
220
|
|
|
162
221
|
Raises:
|
|
163
|
-
|
|
222
|
+
RuntimeError: On an error ack (server rejection reason in the
|
|
223
|
+
message) or a missing response.
|
|
164
224
|
"""
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if props.get("hardwareId") == hardware_id:
|
|
170
|
-
raise ValueError(
|
|
171
|
-
f"Peripheral with hardwareId {hardware_id} already exists"
|
|
172
|
-
)
|
|
225
|
+
from phystack.hub_client.types.twin_types import (
|
|
226
|
+
PeripheralCreateResult,
|
|
227
|
+
PeripheralHandover,
|
|
228
|
+
)
|
|
173
229
|
|
|
174
230
|
payload = {
|
|
175
231
|
"data": {
|
|
@@ -186,19 +242,82 @@ class EdgeInstance(BaseInstance):
|
|
|
186
242
|
result = await self._client._emit_to_socket(
|
|
187
243
|
"createPeripheralTwin", payload, callback=True
|
|
188
244
|
)
|
|
189
|
-
|
|
245
|
+
if not result:
|
|
246
|
+
raise RuntimeError(
|
|
247
|
+
f"createPeripheralTwin: no response for hardwareId {hardware_id}"
|
|
248
|
+
)
|
|
249
|
+
if result.get("status") != "success":
|
|
250
|
+
raise RuntimeError(
|
|
251
|
+
result.get("message")
|
|
252
|
+
or f"createPeripheralTwin failed for hardwareId {hardware_id}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
handover_data = result.get("handover")
|
|
256
|
+
return PeripheralCreateResult(
|
|
257
|
+
twin=result.get("twin", {}),
|
|
258
|
+
# Absent on older phyhub — treated as an update, not a create.
|
|
259
|
+
created=bool(result.get("created")),
|
|
260
|
+
handover=(
|
|
261
|
+
PeripheralHandover.from_dict(handover_data)
|
|
262
|
+
if isinstance(handover_data, dict)
|
|
263
|
+
else None
|
|
264
|
+
),
|
|
265
|
+
)
|
|
190
266
|
|
|
191
267
|
async def get_peripheral_twins(self) -> list[dict[str, Any]]:
|
|
192
|
-
"""Get all peripheral twins
|
|
268
|
+
"""Get all peripheral twins owned by this edge instance.
|
|
269
|
+
|
|
270
|
+
Raises on an error ack instead of returning [] — a failed lookup used
|
|
271
|
+
to be indistinguishable from "no peripherals exist", which drove
|
|
272
|
+
find-or-create logic into duplicate registrations (npm 6.5.1 parity).
|
|
273
|
+
Callers polling in a loop must catch.
|
|
193
274
|
|
|
194
275
|
Returns:
|
|
195
276
|
List of peripheral twin data.
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
RuntimeError: On an error ack, carrying the server's reason.
|
|
196
280
|
"""
|
|
197
281
|
payload = {"data": {"instanceId": self.id}}
|
|
198
282
|
result = await self._client._emit_to_socket(
|
|
199
283
|
"getPeripheralTwins", payload, callback=True
|
|
200
284
|
)
|
|
201
|
-
|
|
285
|
+
if not result:
|
|
286
|
+
raise RuntimeError("getPeripheralTwins: no response from server")
|
|
287
|
+
if result.get("status") != "success":
|
|
288
|
+
raise RuntimeError(
|
|
289
|
+
result.get("message") or "getPeripheralTwins failed"
|
|
290
|
+
)
|
|
291
|
+
return result.get("twins") or []
|
|
292
|
+
|
|
293
|
+
async def generate_scoped_hardware_id(
|
|
294
|
+
self, user_id: str, scope: str = "global"
|
|
295
|
+
) -> str:
|
|
296
|
+
"""Mint a hardwareId carrying its uniqueness scope (TECH-1394).
|
|
297
|
+
|
|
298
|
+
Formats: `g:<id>` (global, default), `s-<spaceId>:<id>` (space —
|
|
299
|
+
survives device moves within the space), `d-<deviceId-no-dashes>:<id>`
|
|
300
|
+
(device-pinned). Violations and unresolvable scope ids raise — the
|
|
301
|
+
helper never silently falls back to an unscoped id. The pure
|
|
302
|
+
formatter is exported as `format_scoped_hardware_id`.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
user_id: Natural identity (zone name, vendor UID, hashed URL, …);
|
|
306
|
+
max 64 chars of [a-zA-Z0-9._-].
|
|
307
|
+
scope: 'global' (default), 'space', or 'device'.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ValueError: Bad user_id/scope input.
|
|
311
|
+
RuntimeError: 'space' scope when the spaceId cannot be resolved.
|
|
312
|
+
"""
|
|
313
|
+
from phystack.hub_client.scoped_hardware_id import format_scoped_hardware_id
|
|
314
|
+
|
|
315
|
+
if scope == "space":
|
|
316
|
+
space_id = await self._client.resolve_device_space_id()
|
|
317
|
+
return format_scoped_hardware_id(user_id, scope, space_id)
|
|
318
|
+
if scope == "device":
|
|
319
|
+
return format_scoped_hardware_id(user_id, scope, self.device_id)
|
|
320
|
+
return format_scoped_hardware_id(user_id, scope)
|
|
202
321
|
|
|
203
322
|
# =========================================================================
|
|
204
323
|
# Settings API
|