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.
Files changed (44) hide show
  1. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/PKG-INFO +2 -2
  2. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/pyproject.toml +1 -1
  3. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/__init__.py +20 -1
  4. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/client.py +210 -2
  5. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/instances/base_instance.py +19 -2
  6. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/instances/edge_instance.py +138 -19
  7. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/instances/peripheral_instance.py +188 -7
  8. phystack_hub_client-6.8.0/src/phystack/hub_client/scoped_hardware_id.py +103 -0
  9. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/__init__.py +4 -0
  10. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/media_stream.py +113 -4
  11. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/twin_messaging.py +55 -1
  12. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/twin_registry.py +16 -0
  13. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/types/twin_types.py +43 -0
  14. phystack_hub_client-6.8.0/tests/test_media_kinds.py +231 -0
  15. phystack_hub_client-6.8.0/tests/test_peripheral_lifecycle.py +451 -0
  16. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/.gitignore +0 -0
  17. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/README.md +0 -0
  18. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/examples/browser_test_backend.py +0 -0
  19. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/scripts/python-node-test.sh +0 -0
  20. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/scripts/webrtc-test.sh +0 -0
  21. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/advisory_validation.py +0 -0
  22. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/cloud_twins.py +0 -0
  23. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/instances/__init__.py +0 -0
  24. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/__init__.py +0 -0
  25. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/cloud_app_connection.py +0 -0
  26. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/cloud_app_token.py +0 -0
  27. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/phyhub_connection.py +0 -0
  28. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/signals.py +0 -0
  29. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/data_channel.py +0 -0
  30. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/data_responder.py +0 -0
  31. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/media_responder.py +0 -0
  32. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/services/webrtc/media_source.py +0 -0
  33. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/src/phystack/hub_client/types/__init__.py +0 -0
  34. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_advisory_validation.py +0 -0
  35. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_client_integration.py +0 -0
  36. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_cloud_app_token.py +0 -0
  37. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_cloud_twins.py +0 -0
  38. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_communication.py +0 -0
  39. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_data_channel.py +0 -0
  40. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_instance_listeners.py +0 -0
  41. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_media_resilience.py +0 -0
  42. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_media_responder.py +0 -0
  43. {phystack_hub_client-6.6.0 → phystack_hub_client-6.8.0}/tests/test_media_source.py +0 -0
  44. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: phystack-hub-client
3
- Version: 6.6.0
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.6.0"
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.5.0"
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.on(event_type, callback)
136
+ return self._messaging.off(event_type, callback)
120
137
 
121
138
  def to(self, target_twin_id: str) -> TargetedEmitter:
122
139
  """Target a specific twin for messaging.
@@ -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 a new peripheral twin associated with this edge.
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
- descriptors: Optional descriptors for the peripheral.
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
- Created peripheral twin data.
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
- ValueError: If peripheral with same hardware_id already exists.
222
+ RuntimeError: On an error ack (server rejection reason in the
223
+ message) or a missing response.
164
224
  """
165
- # Check for existing peripheral with same hardware_id
166
- existing = await self.get_peripheral_twins()
167
- for twin in existing:
168
- props = twin.get("properties", {}).get("desired", {})
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
- return result.get("twin", {}) if result else {}
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 for this edge instance.
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
- return result.get("twins", []) if result else []
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