spatialdds 0.3.0__tar.gz → 0.4.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 (148) hide show
  1. {spatialdds-0.3.0 → spatialdds-0.4.0}/PKG-INFO +81 -25
  2. {spatialdds-0.3.0 → spatialdds-0.4.0}/README.md +80 -24
  3. {spatialdds-0.3.0 → spatialdds-0.4.0}/pyproject.toml +1 -1
  4. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/__init__.py +1 -1
  5. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/serve.py +45 -4
  6. spatialdds-0.4.0/spatialdds/server/commands.py +131 -0
  7. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/server/gateway.py +26 -6
  8. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/server/gw_http.py +108 -3
  9. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds.egg-info/PKG-INFO +81 -25
  10. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds.egg-info/SOURCES.txt +1 -0
  11. {spatialdds-0.3.0 → spatialdds-0.4.0}/setup.cfg +0 -0
  12. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/idl/core.idl +0 -0
  13. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/idl/discovery.idl +0 -0
  14. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/idl/types.idl +0 -0
  15. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/builtin_Time.json +0 -0
  16. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorDelta.json +0 -0
  17. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorEntry.json +0 -0
  18. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorSet.json +0 -0
  19. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorSetRequest.json +0 -0
  20. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorSetResponse.json +0 -0
  21. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_argeo_NodeGeo.json +0 -0
  22. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_argeo_QualityRequirements.json +0 -0
  23. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_argeo_VpsRequest.json +0 -0
  24. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_argeo_VpsResponse.json +0 -0
  25. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_AssetRef.json +0 -0
  26. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_FrameRef.json +0 -0
  27. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_KV.json +0 -0
  28. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_MetaKV.json +0 -0
  29. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_Vec2.json +0 -0
  30. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_Aabb3.json +0 -0
  31. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_BlobChunk.json +0 -0
  32. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_BlobRef.json +0 -0
  33. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_ComponentRef.json +0 -0
  34. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_Edge.json +0 -0
  35. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_EntityBinding.json +0 -0
  36. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_FrameTransform.json +0 -0
  37. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_FramedPose.json +0 -0
  38. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_GeoAnchor.json +0 -0
  39. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_GeoPose.json +0 -0
  40. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_NavSatStatus.json +0 -0
  41. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_Node.json +0 -0
  42. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_PlannedTrajectory.json +0 -0
  43. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_PlannedWaypoint.json +0 -0
  44. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_PoseSE3.json +0 -0
  45. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_SnapshotRequest.json +0 -0
  46. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_SnapshotResponse.json +0 -0
  47. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_TileKey.json +0 -0
  48. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_TileMeta.json +0 -0
  49. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_TilePatch.json +0 -0
  50. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_Announce.json +0 -0
  51. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_Capabilities.json +0 -0
  52. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_ContentAnnounce.json +0 -0
  53. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageElement.json +0 -0
  54. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageFilter.json +0 -0
  55. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageHint.json +0 -0
  56. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageQuery.json +0 -0
  57. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageResponse.json +0 -0
  58. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_Depart.json +0 -0
  59. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_KV.json +0 -0
  60. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_ProfileSupport.json +0 -0
  61. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_ServiceSummary.json +0 -0
  62. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_TopicMeta.json +0 -0
  63. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_Transform.json +0 -0
  64. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_ValidityWindow.json +0 -0
  65. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_events_CrossingLine.json +0 -0
  66. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_events_SpatialEvent.json +0 -0
  67. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_events_SpatialZone.json +0 -0
  68. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_events_ZoneState.json +0 -0
  69. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_Edge.json +0 -0
  70. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_MapAlignment.json +0 -0
  71. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_MapEvent.json +0 -0
  72. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_MapMeta.json +0 -0
  73. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_MapQuality.json +0 -0
  74. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_owm_Entity.json +0 -0
  75. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_owm_ModelCommand.json +0 -0
  76. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_owm_ModelPose.json +0 -0
  77. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Detection2D.json +0 -0
  78. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Detection2DSet.json +0 -0
  79. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Detection3D.json +0 -0
  80. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Detection3DSet.json +0 -0
  81. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_FusedTrack.json +0 -0
  82. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_FusedTrackSet.json +0 -0
  83. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Keypoint3D.json +0 -0
  84. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_KeypointLink.json +0 -0
  85. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_Axis.json +0 -0
  86. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_FrameHeader.json +0 -0
  87. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_FrameQuality.json +0 -0
  88. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_Linspace.json +0 -0
  89. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_ROI.json +0 -0
  90. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_ROIReply.json +0 -0
  91. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_ROIRequest.json +0 -0
  92. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_StreamMeta.json +0 -0
  93. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarDetection.json +0 -0
  94. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarDetectionSet.json +0 -0
  95. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarFrame.json +0 -0
  96. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarMeta.json +0 -0
  97. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadDetection.json +0 -0
  98. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadDetectionSet.json +0 -0
  99. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadSensorMeta.json +0 -0
  100. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadTensorFrame.json +0 -0
  101. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadTensorMeta.json +0 -0
  102. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_radio_RadioObservation.json +0 -0
  103. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_radio_RadioScan.json +0 -0
  104. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_radio_RadioSensorMeta.json +0 -0
  105. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rf_beam_RfBeamArraySet.json +0 -0
  106. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rf_beam_RfBeamFrame.json +0 -0
  107. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rf_beam_RfBeamMeta.json +0 -0
  108. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_CamIntrinsics.json +0 -0
  109. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_Keypoint2D.json +0 -0
  110. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_Track2D.json +0 -0
  111. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_VisionDetections.json +0 -0
  112. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_VisionFrame.json +0 -0
  113. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_VisionMeta.json +0 -0
  114. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_CameraInfo.json +0 -0
  115. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_Feature2D.json +0 -0
  116. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_FeatureMatch.json +0 -0
  117. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_KeyframeFeatures.json +0 -0
  118. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_Landmark.json +0 -0
  119. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_MatchSet.json +0 -0
  120. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_TrackObs.json +0 -0
  121. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_Tracklet.json +0 -0
  122. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_FusedState.json +0 -0
  123. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_ImuInfo.json +0 -0
  124. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_ImuSample.json +0 -0
  125. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_MagnetometerSample.json +0 -0
  126. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_SensorFusionSample.json +0 -0
  127. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/artifacts/serializer_tables.json +0 -0
  128. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/canonical.py +0 -0
  129. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/canonjson.py +0 -0
  130. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/client.py +0 -0
  131. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/demo/assets/duck.gltf +0 -0
  132. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/demo/assets/fountain-label.txt +0 -0
  133. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/demo/assets/fountain-marker.gltf +0 -0
  134. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/demo/fountain-anchors.mcap +0 -0
  135. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/demo/manifests/content-fountain-label.json +0 -0
  136. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/demo/manifests/content-fountain-marker.json +0 -0
  137. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/errors.py +0 -0
  138. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/records.py +0 -0
  139. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/server/__init__.py +0 -0
  140. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/server/idlmodel.py +0 -0
  141. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/server/pin.py +0 -0
  142. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/server/publish.py +0 -0
  143. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/server/webjson.py +0 -0
  144. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds/server/xcdr2.py +0 -0
  145. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds.egg-info/dependency_links.txt +0 -0
  146. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds.egg-info/entry_points.txt +0 -0
  147. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds.egg-info/requires.txt +0 -0
  148. {spatialdds-0.3.0 → spatialdds-0.4.0}/spatialdds.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatialdds
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: SpatialDDS over plain HTTPS and a WebSocket: a client for the web binding (Appendix N), and the reference registry behind one command.
5
5
  Author: Open AR Cloud
6
6
  License: Apache-2.0
@@ -43,6 +43,10 @@ not have to assemble member by member.
43
43
  specification versions move independently; `spatialdds.SPEC_COMMIT` is the one
44
44
  that says what the wire looks like.
45
45
 
46
+ The source, the reference gateway, the recordings and every gate behind the
47
+ claims on this page are at [https://github.com/OpenArCloud/spatialdds-web](https://github.com/OpenArCloud/spatialdds-web). Every code block below is executed
48
+ by one of those gates on every run, against an endpoint started for it.
49
+
46
50
  ## Your own registry, in one command
47
51
 
48
52
  ```sh
@@ -72,17 +76,26 @@ Appendix N's schemas make every member of a record required, so an `Announce`
72
76
  is eighteen members, a `ContentAnnounce` sixteen, a `GeoAnchor` nine. The
73
77
  builders take the arguments you actually have:
74
78
 
79
+ <!-- bar: verbs -->
75
80
  ```python
76
81
  import spatialdds as sd
82
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret", scene="mytown")
83
+ a = ep.publish_service(service_id="mytown-vps", kind="VPS",
84
+ coverage=sd.circle(30.2849, -97.7393, 500))
85
+ b = ep.publish_content(
86
+ content_id="duck", title="A duck", position=(30.2849, -97.7393),
87
+ asset=sd.asset_ref("https://cdn.example/duck.gltf",
88
+ media_type="model/gltf+json"))
89
+ c = ep.publish_anchor("anchor/duck-perch", lat=30.2849, lon=-97.7393,
90
+ method="VisualFix", sigma_enu_m=(0.25, 0.25, 0.5),
91
+ content="duck")
92
+ print("service", a.created, "| content", b.created, "| anchor", c.created)
93
+ print("manifest", b.manifest.doc["id"])
94
+ ```
77
95
 
78
- ep = sd.connect("http://127.0.0.1:8787", token="tok", scene="mytown")
79
- ep.publish_service(service_id="mytown-vps", kind="VPS",
80
- coverage=sd.circle(30.2849, -97.7393, 500))
81
- ep.publish_content(content_id="duck", title="Duck", position=(30.2849, -97.7393),
82
- asset="https://cdn.example/duck.gltf", sha256=digest)
83
- ep.publish_anchor("anchor/duck-perch", lat=30.2849, lon=-97.7393,
84
- method="VisualFix", sigma_enu_m=(0.25, 0.25, 0.5),
85
- content="duck")
96
+ ```console
97
+ service True | content True | anchor True
98
+ manifest spatialdds://demo.spatialdds.org/mytown/content/duck
86
99
  ```
87
100
 
88
101
  `publish_service` and `publish_content` are two PUTs each -- the manifest
@@ -91,9 +104,12 @@ with the manifest's own result on `.manifest`.
91
104
 
92
105
  A full service contract is the same one call, with the claims stated:
93
106
 
107
+ <!-- bar: contract -->
94
108
  ```python
109
+ import spatialdds as sd
110
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret", scene="mytown")
95
111
  area = sd.circle(30.2849, -97.7393, 500)
96
- ep.publish_service(
112
+ w = ep.publish_service(
97
113
  service_id="mytown-vps", kind="VPS", coverage=area,
98
114
  caps=sd.capabilities(
99
115
  supported=[sd.profile("spatial.core", minor=8)],
@@ -101,19 +117,28 @@ ep.publish_service(
101
117
  manifest=sd.service_manifest(
102
118
  "mytown-vps", "VPS", authority=ep.authority, scene="mytown",
103
119
  profiles=["Core", "AR+Geo"],
104
- request={{"features_topic": "feat.keyframe"}},
105
- response={{"minimal": "geo.fix"}},
106
- limits={{"max_fps": 10}},
107
- auth={{"scheme": "bearer", "issuer": "https://registry.mytown.example"}},
120
+ request={"features_topic": "feat.keyframe"},
121
+ response={"minimal": "geo.fix"},
122
+ limits={"max_fps": 10},
123
+ auth={"scheme": "bearer",
124
+ "issuer": "https://registry.mytown.example"},
108
125
  coverage=area, cells=sd.cells(area)))
126
+ print("profiles", w.manifest.doc["profiles"])
127
+ print("limits ", w.manifest.doc["limits"])
128
+ ```
129
+
130
+ ```console
131
+ profiles ['Core', 'AR+Geo']
132
+ limits {'max_fps': 10}
109
133
  ```
110
134
 
111
135
  Every one of those is something a service operator knows and a library cannot
112
- guess. [docs/MYTOWN.md](../../docs/MYTOWN.md) publishes that and prints the
113
- whole served document; `gates/builders.py` rebuilds this repository's
114
- hand-authored `austin-vps` and duck records through these calls and compares
115
- them member by member, which is what makes "every member is reachable from an
116
- argument" a check rather than a claim.
136
+ guess. [The mytown walkthrough](https://github.com/OpenArCloud/spatialdds-web/blob/main/docs/MYTOWN.md) publishes that
137
+ and prints the whole served document;
138
+ [`gates/builders.py`](https://github.com/OpenArCloud/spatialdds-web/blob/main/gates/builders.py) rebuilds that
139
+ repository's hand-authored `austin-vps` and duck records through these calls
140
+ and compares them member by member, which is what makes "every member is
141
+ reachable from an argument" a check rather than a claim.
117
142
 
118
143
  **Two forms for an asset, because there are two situations.**
119
144
  `sd.asset(path_or_bytes, media_type=...)` reads the data and computes the hash
@@ -122,7 +147,8 @@ and the byte count itself: a hash nobody typed is a hash a reader can trust.
122
147
  else hosts, and the hash comes from whoever has the bytes. Pass no hash and
123
148
  the reference carries no integrity claim, which costs a reader one thing:
124
149
  `fetch_asset()` raises rather than reporting a check it could not perform
125
- (finding 31). `publish_content` takes either, or a path, or bytes, or a URL.
150
+ ([finding 31](https://github.com/OpenArCloud/spatialdds-web/blob/main/WEB-BINDING-FINDINGS.md)).
151
+ `publish_content` takes either, or a path, or bytes, or a URL.
126
152
 
127
153
  The builders are also usable on their own: `sd.service_announce`,
128
154
  `sd.service_manifest`, `sd.content_announce`, `sd.content_manifest`,
@@ -258,7 +284,7 @@ call goes away and the bar is one line.
258
284
 
259
285
  ## The surface
260
286
 
261
- | | |
287
+ | call | what it does |
262
288
  | --- | --- |
263
289
  | `sd.connect(base, token=None, verify_integrity=True, timeout=15.0, lifted=True, scene="default", authority=None)` | an `Endpoint`; nothing is fetched until you ask |
264
290
  | `ep.search(geohash=None, kinds=None)` | §3.3.0 search → a list of `Hit` |
@@ -309,7 +335,7 @@ One family, built from the endpoint's own N.8 problem documents — `status`,
309
335
  `type`, `detail` carried, not re-invented. Everything below derives from
310
336
  `SpatialDDSError`, so one `except` catches the lot.
311
337
 
312
- | | |
338
+ | exception | raised when |
313
339
  | --- | --- |
314
340
  | `Unreachable` | the endpoint did not answer; names the URL and the serve line |
315
341
  | `Unauthorized` | 401; says to pass `token=` and where the token is printed |
@@ -330,13 +356,43 @@ Latched reads revalidate with the ETag the endpoint gave; `fetch_asset` checks
330
356
  the bytes. Opting out exists and is loud: `verify_integrity=False`, at the call
331
357
  site, nowhere else.
332
358
 
333
- ## Not in 0.3.0
359
+ ## New in 0.4.0: a command surface on the server half
360
+
361
+ `spatialdds[server]` now implements Appendix N.6. A command is a POST to the
362
+ lane that accepts one, and an embedder registers what a verb means:
363
+
364
+ | Part | What it is |
365
+ | --- | --- |
366
+ | the seam | `spatialdds.server.commands.Registry().register(fqn, handler)` |
367
+ | a handler gets | the validated, lifted record — the same JSON a GET would serve |
368
+ | a handler returns | `Outcome.accept()` or `Outcome.decline("why")` |
369
+ | the wire | `202` accepted, `409` declined with the reason, `405` where no handler is registered |
370
+
371
+ `python -m spatialdds.serve --demo` registers one so the surface is reachable
372
+ without writing a backend. An endpoint with no handlers is northbound-only and
373
+ every command POST is a clean `405`, which is what N.6 requires and what every
374
+ earlier version did.
375
+
376
+ **The client half has no command method.** This release adds the surface, not
377
+ a helper for calling it — `requests.post(topic_url, json=record)` with the
378
+ operator token is the whole of it, and `docs/WIRE.md` documents the response
379
+ shapes. Said plainly because "commands are supported" invites a reader to look
380
+ for `hit.command(...)`, and there isn't one.
381
+
382
+ ## Not in 0.4.0
334
383
 
335
384
  Stated so nobody waits for them: no async API, no caching beyond ETag
336
385
  revalidation, no retry policy, no MCAP tooling beyond the journal the server
337
- writes, and no DDS dependency of any kind. The wire is HTTP and a WebSocket.
386
+ writes, no client-side command helper, and no DDS dependency of any kind. The
387
+ wire is HTTP and a WebSocket.
388
+
389
+ 0.3.1 was this page: two code blocks on it had no gate behind them and both
390
+ were broken, and a long description is baked into the artifact, so the page on
391
+ PyPI only heals with a release. Every block here is executed by a gate now,
392
+ and the gate also refuses a relative link, an empty table header and a serve
393
+ line naming a flag that does not exist.
338
394
 
339
- `sd.asset` changed meaning in 0.3.0, which is why the version moved: it now
395
+ `sd.asset` changed meaning in 0.3.0, which is why that version moved: it now
340
396
  reads bytes and computes a hash, where in 0.2.0 it took a URL and a hash you
341
397
  supplied. That second form is `sd.asset_ref`. A published release has to stay
342
398
  reproducible from a public commit, so the API could not change under the same
@@ -19,6 +19,10 @@ not have to assemble member by member.
19
19
  specification versions move independently; `spatialdds.SPEC_COMMIT` is the one
20
20
  that says what the wire looks like.
21
21
 
22
+ The source, the reference gateway, the recordings and every gate behind the
23
+ claims on this page are at [https://github.com/OpenArCloud/spatialdds-web](https://github.com/OpenArCloud/spatialdds-web). Every code block below is executed
24
+ by one of those gates on every run, against an endpoint started for it.
25
+
22
26
  ## Your own registry, in one command
23
27
 
24
28
  ```sh
@@ -48,17 +52,26 @@ Appendix N's schemas make every member of a record required, so an `Announce`
48
52
  is eighteen members, a `ContentAnnounce` sixteen, a `GeoAnchor` nine. The
49
53
  builders take the arguments you actually have:
50
54
 
55
+ <!-- bar: verbs -->
51
56
  ```python
52
57
  import spatialdds as sd
58
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret", scene="mytown")
59
+ a = ep.publish_service(service_id="mytown-vps", kind="VPS",
60
+ coverage=sd.circle(30.2849, -97.7393, 500))
61
+ b = ep.publish_content(
62
+ content_id="duck", title="A duck", position=(30.2849, -97.7393),
63
+ asset=sd.asset_ref("https://cdn.example/duck.gltf",
64
+ media_type="model/gltf+json"))
65
+ c = ep.publish_anchor("anchor/duck-perch", lat=30.2849, lon=-97.7393,
66
+ method="VisualFix", sigma_enu_m=(0.25, 0.25, 0.5),
67
+ content="duck")
68
+ print("service", a.created, "| content", b.created, "| anchor", c.created)
69
+ print("manifest", b.manifest.doc["id"])
70
+ ```
53
71
 
54
- ep = sd.connect("http://127.0.0.1:8787", token="tok", scene="mytown")
55
- ep.publish_service(service_id="mytown-vps", kind="VPS",
56
- coverage=sd.circle(30.2849, -97.7393, 500))
57
- ep.publish_content(content_id="duck", title="Duck", position=(30.2849, -97.7393),
58
- asset="https://cdn.example/duck.gltf", sha256=digest)
59
- ep.publish_anchor("anchor/duck-perch", lat=30.2849, lon=-97.7393,
60
- method="VisualFix", sigma_enu_m=(0.25, 0.25, 0.5),
61
- content="duck")
72
+ ```console
73
+ service True | content True | anchor True
74
+ manifest spatialdds://demo.spatialdds.org/mytown/content/duck
62
75
  ```
63
76
 
64
77
  `publish_service` and `publish_content` are two PUTs each -- the manifest
@@ -67,9 +80,12 @@ with the manifest's own result on `.manifest`.
67
80
 
68
81
  A full service contract is the same one call, with the claims stated:
69
82
 
83
+ <!-- bar: contract -->
70
84
  ```python
85
+ import spatialdds as sd
86
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret", scene="mytown")
71
87
  area = sd.circle(30.2849, -97.7393, 500)
72
- ep.publish_service(
88
+ w = ep.publish_service(
73
89
  service_id="mytown-vps", kind="VPS", coverage=area,
74
90
  caps=sd.capabilities(
75
91
  supported=[sd.profile("spatial.core", minor=8)],
@@ -77,19 +93,28 @@ ep.publish_service(
77
93
  manifest=sd.service_manifest(
78
94
  "mytown-vps", "VPS", authority=ep.authority, scene="mytown",
79
95
  profiles=["Core", "AR+Geo"],
80
- request={{"features_topic": "feat.keyframe"}},
81
- response={{"minimal": "geo.fix"}},
82
- limits={{"max_fps": 10}},
83
- auth={{"scheme": "bearer", "issuer": "https://registry.mytown.example"}},
96
+ request={"features_topic": "feat.keyframe"},
97
+ response={"minimal": "geo.fix"},
98
+ limits={"max_fps": 10},
99
+ auth={"scheme": "bearer",
100
+ "issuer": "https://registry.mytown.example"},
84
101
  coverage=area, cells=sd.cells(area)))
102
+ print("profiles", w.manifest.doc["profiles"])
103
+ print("limits ", w.manifest.doc["limits"])
104
+ ```
105
+
106
+ ```console
107
+ profiles ['Core', 'AR+Geo']
108
+ limits {'max_fps': 10}
85
109
  ```
86
110
 
87
111
  Every one of those is something a service operator knows and a library cannot
88
- guess. [docs/MYTOWN.md](../../docs/MYTOWN.md) publishes that and prints the
89
- whole served document; `gates/builders.py` rebuilds this repository's
90
- hand-authored `austin-vps` and duck records through these calls and compares
91
- them member by member, which is what makes "every member is reachable from an
92
- argument" a check rather than a claim.
112
+ guess. [The mytown walkthrough](https://github.com/OpenArCloud/spatialdds-web/blob/main/docs/MYTOWN.md) publishes that
113
+ and prints the whole served document;
114
+ [`gates/builders.py`](https://github.com/OpenArCloud/spatialdds-web/blob/main/gates/builders.py) rebuilds that
115
+ repository's hand-authored `austin-vps` and duck records through these calls
116
+ and compares them member by member, which is what makes "every member is
117
+ reachable from an argument" a check rather than a claim.
93
118
 
94
119
  **Two forms for an asset, because there are two situations.**
95
120
  `sd.asset(path_or_bytes, media_type=...)` reads the data and computes the hash
@@ -98,7 +123,8 @@ and the byte count itself: a hash nobody typed is a hash a reader can trust.
98
123
  else hosts, and the hash comes from whoever has the bytes. Pass no hash and
99
124
  the reference carries no integrity claim, which costs a reader one thing:
100
125
  `fetch_asset()` raises rather than reporting a check it could not perform
101
- (finding 31). `publish_content` takes either, or a path, or bytes, or a URL.
126
+ ([finding 31](https://github.com/OpenArCloud/spatialdds-web/blob/main/WEB-BINDING-FINDINGS.md)).
127
+ `publish_content` takes either, or a path, or bytes, or a URL.
102
128
 
103
129
  The builders are also usable on their own: `sd.service_announce`,
104
130
  `sd.service_manifest`, `sd.content_announce`, `sd.content_manifest`,
@@ -234,7 +260,7 @@ call goes away and the bar is one line.
234
260
 
235
261
  ## The surface
236
262
 
237
- | | |
263
+ | call | what it does |
238
264
  | --- | --- |
239
265
  | `sd.connect(base, token=None, verify_integrity=True, timeout=15.0, lifted=True, scene="default", authority=None)` | an `Endpoint`; nothing is fetched until you ask |
240
266
  | `ep.search(geohash=None, kinds=None)` | §3.3.0 search → a list of `Hit` |
@@ -285,7 +311,7 @@ One family, built from the endpoint's own N.8 problem documents — `status`,
285
311
  `type`, `detail` carried, not re-invented. Everything below derives from
286
312
  `SpatialDDSError`, so one `except` catches the lot.
287
313
 
288
- | | |
314
+ | exception | raised when |
289
315
  | --- | --- |
290
316
  | `Unreachable` | the endpoint did not answer; names the URL and the serve line |
291
317
  | `Unauthorized` | 401; says to pass `token=` and where the token is printed |
@@ -306,13 +332,43 @@ Latched reads revalidate with the ETag the endpoint gave; `fetch_asset` checks
306
332
  the bytes. Opting out exists and is loud: `verify_integrity=False`, at the call
307
333
  site, nowhere else.
308
334
 
309
- ## Not in 0.3.0
335
+ ## New in 0.4.0: a command surface on the server half
336
+
337
+ `spatialdds[server]` now implements Appendix N.6. A command is a POST to the
338
+ lane that accepts one, and an embedder registers what a verb means:
339
+
340
+ | Part | What it is |
341
+ | --- | --- |
342
+ | the seam | `spatialdds.server.commands.Registry().register(fqn, handler)` |
343
+ | a handler gets | the validated, lifted record — the same JSON a GET would serve |
344
+ | a handler returns | `Outcome.accept()` or `Outcome.decline("why")` |
345
+ | the wire | `202` accepted, `409` declined with the reason, `405` where no handler is registered |
346
+
347
+ `python -m spatialdds.serve --demo` registers one so the surface is reachable
348
+ without writing a backend. An endpoint with no handlers is northbound-only and
349
+ every command POST is a clean `405`, which is what N.6 requires and what every
350
+ earlier version did.
351
+
352
+ **The client half has no command method.** This release adds the surface, not
353
+ a helper for calling it — `requests.post(topic_url, json=record)` with the
354
+ operator token is the whole of it, and `docs/WIRE.md` documents the response
355
+ shapes. Said plainly because "commands are supported" invites a reader to look
356
+ for `hit.command(...)`, and there isn't one.
357
+
358
+ ## Not in 0.4.0
310
359
 
311
360
  Stated so nobody waits for them: no async API, no caching beyond ETag
312
361
  revalidation, no retry policy, no MCAP tooling beyond the journal the server
313
- writes, and no DDS dependency of any kind. The wire is HTTP and a WebSocket.
362
+ writes, no client-side command helper, and no DDS dependency of any kind. The
363
+ wire is HTTP and a WebSocket.
364
+
365
+ 0.3.1 was this page: two code blocks on it had no gate behind them and both
366
+ were broken, and a long description is baked into the artifact, so the page on
367
+ PyPI only heals with a release. Every block here is executed by a gate now,
368
+ and the gate also refuses a relative link, an empty table header and a serve
369
+ line naming a flag that does not exist.
314
370
 
315
- `sd.asset` changed meaning in 0.3.0, which is why the version moved: it now
371
+ `sd.asset` changed meaning in 0.3.0, which is why that version moved: it now
316
372
  reads bytes and computes a hash, where in 0.2.0 it took a URL and a hash you
317
373
  supplied. That second form is `sd.asset_ref`. A published release has to stay
318
374
  reproducible from a public commit, so the API could not change under the same
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spatialdds"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "SpatialDDS over plain HTTPS and a WebSocket: a client for the web binding (Appendix N), and the reference registry behind one command."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -37,7 +37,7 @@ __all__ = [
37
37
  "content_manifest", "geo_anchor", "service_uri", "content_uri",
38
38
  ]
39
39
 
40
- __version__ = "0.3.0"
40
+ __version__ = "0.4.0"
41
41
  #: The SpatialDDS-spec commit whose Appendix N and generated artifacts this
42
42
  #: package implements. Package versions and specification versions move
43
43
  #: independently; this is the one that says what the wire looks like.
@@ -42,10 +42,10 @@ def _server():
42
42
  """The server half, or one sentence saying how to get it."""
43
43
  try:
44
44
  from aiohttp import web
45
- from .server import gateway, gw_http, pin, publish
45
+ from .server import commands, gateway, gw_http, pin, publish
46
46
  except ImportError as e: # pragma: no cover
47
47
  raise SystemExit(f"spatialdds.serve: {MISSING_SERVER}\n\n({e})")
48
- return web, gateway, gw_http, pin, publish
48
+ return web, commands, gateway, gw_http, pin, publish
49
49
 
50
50
 
51
51
  def build_parser(ap: argparse.ArgumentParser) -> argparse.ArgumentParser:
@@ -102,8 +102,33 @@ def build_parser(ap: argparse.ArgumentParser) -> argparse.ArgumentParser:
102
102
  return ap
103
103
 
104
104
 
105
+ def _demo_command(record: dict):
106
+ """The `--demo` command handler: both of M.1's outcomes, no side effects.
107
+
108
+ A reference handler that pretended to act would be worse than none: it
109
+ would make the surface look wired to something. So this decides, says
110
+ why, and changes nothing --- which is all a demo registry can honestly do,
111
+ having no backend behind it.
112
+
113
+ `method == "DECLINE_ME"` is declined on purpose. Without a reachable
114
+ decline, the only exercised path would be acceptance, and M.1's whole
115
+ point is that a refusal is a first-class observable outcome; a surface
116
+ that can only say yes has not implemented it.
117
+ """
118
+ from .server.commands import Outcome # the server half is optional
119
+ if record.get("method") == "DECLINE_ME":
120
+ return Outcome.decline(
121
+ "the demo handler declines this method by arrangement, so a "
122
+ "caller can see M.1's decline path without a backend: a refusal "
123
+ "carries a reason and is not an absence")
124
+ return Outcome.accept(
125
+ "accepted by the --demo handler, which records nothing: this registry "
126
+ "has no backend, and a handler that claimed to act would make the "
127
+ "surface look wired to something it is not")
128
+
129
+
105
130
  def main(argv: list[str] | None = None) -> int:
106
- web, gateway, gw_http, pin, publish = _server()
131
+ web, commands, gateway, gw_http, pin, publish = _server()
107
132
  ap = build_parser(argparse.ArgumentParser(
108
133
  prog="python -m spatialdds.serve",
109
134
  description="Stand up a SpatialDDS registry."))
@@ -130,6 +155,21 @@ def main(argv: list[str] | None = None) -> int:
130
155
  if a.mcap is not None and not a.mcap.is_file():
131
156
  raise SystemExit(f"no such recording: {a.mcap}")
132
157
 
158
+ # The N.6 demo handler. `--demo` exists so the surface is exercisable
159
+ # without a backend, and a command surface nobody can POST to is a route
160
+ # with no evidence behind it -- the conformance suite's first clause had
161
+ # never been exercised by any run in this program precisely because there
162
+ # was nothing to exercise it against.
163
+ #
164
+ # It accepts a `GeoAnchor` command and declines one verb, so both of M.1's
165
+ # outcomes are reachable from a bare `--demo`: acceptance, and a decline
166
+ # carrying a reason. It does nothing to the registry, because `--demo` has
167
+ # no backend to do anything with -- which is the honest shape for a
168
+ # reference handler and the reason its reason string says so.
169
+ cmd_registry = commands.Registry()
170
+ if a.demo:
171
+ cmd_registry.register("spatial.core.GeoAnchor", _demo_command)
172
+
133
173
  token = a.token or secrets.token_urlsafe(24)
134
174
  st = gateway.build_state(
135
175
  a.mcap, a.manifests if a.manifests and a.manifests.is_dir() else None)
@@ -284,7 +324,8 @@ def main(argv: list[str] | None = None) -> int:
284
324
  try:
285
325
  web.run_app(gw_http.make_app(st, base, token, a.publish, jr,
286
326
  a.assets if a.assets
287
- and a.assets.is_dir() else None),
327
+ and a.assets.is_dir() else None,
328
+ command_registry=cmd_registry),
288
329
  host=a.host, port=a.port, print=None)
289
330
  except OSError as e:
290
331
  # An already-bound port is the one failure where everything downstream
@@ -0,0 +1,131 @@
1
+ """N.6 commands: the gateway owns the HTTP leg, a backend supplies the verbs.
2
+
3
+ Appendix N.6 is four sentences long and delegates its conventions to M.1, so
4
+ most of this module is the reasoning that fills the gap between them. The
5
+ ruling this implements: **N.6 is a gateway surface whose semantics a backend
6
+ supplies.** The gateway owns the route, the auth, the validation and the
7
+ response shape; a backend registers a handler per command type and decides
8
+ what a verb means. An endpoint whose backend registers nothing stays
9
+ northbound-only, and its POST stays exactly as conformant as it was before
10
+ this module existed.
11
+
12
+ **Why there is a seam at all.** The first consumer of this surface is
13
+ `SpatialDDS-demo`'s web bridge, which embeds the gateway against a live DDS
14
+ bus: a `goto` arriving as a POST has to leave as a keyed `ModelCommand` on a
15
+ DDS topic, and nothing in this repository knows how to do that or should. So
16
+ the deliverable is not only the route but the boundary — the narrowest thing
17
+ an embedder can implement to own the verbs without owning the HTTP.
18
+
19
+ **What N.6 fixes and what it leaves open.** It fixes that a command is a POST,
20
+ that the response carries the outcome *including an explicit decline*, and
21
+ that an endpoint without a command lane refuses with `405` or `404` and never
22
+ accepts a command it cannot act on. It says nothing about status codes for the
23
+ outcomes themselves, nothing about how a client discovers that commands are
24
+ accepted, and nothing about completion tracking. Those are decided here and
25
+ documented in `docs/WIRE.md`; none of them is invented where the text speaks.
26
+
27
+ **M.1's vocabulary, which N.6 points at.** A decline is "a first-class,
28
+ observable outcome, not an absence", carrying a terminal state and a
29
+ human-readable reason. The value of the pattern is that an observer can
30
+ distinguish *refused* from *not-yet-answered* from *not-for-me*. That is why
31
+ `Outcome` carries a reason on the decline path and why a declined command is
32
+ not reported with a success status: a caller that checks only the status must
33
+ not read a refusal as an acceptance, which would turn M.1's first-class
34
+ outcome back into the absence it exists to replace.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass
39
+ from typing import Any, Callable
40
+
41
+ #: The manifest member that advertises a lane as accepting commands.
42
+ #:
43
+ #: **Implementation-defined, and flagged as a specification gap.** N.7's
44
+ #: topic-entry table is a MUST-carry list of six fields plus two optional
45
+ #: ones, and none of them can say "this lane accepts commands" --- while N.6
46
+ #: says "where an endpoint exposes a command surface" and gives a client no
47
+ #: way to find out whether it does. A client would otherwise have to POST and
48
+ #: read the refusal, which is discovery by trial.
49
+ #:
50
+ #: So this member exists, under this repository's own name, documented in
51
+ #: `docs/WIRE.md`, and filed for 1.9 rather than treated as settled. N.7's
52
+ #: list is a floor rather than a ceiling, so carrying an extra member is
53
+ #: conformant; a *client* may not depend on it until the specification adopts
54
+ #: one, and nothing in this repository reads it except the manifest's own
55
+ #: reader and the conformance suite.
56
+ ADVERTISEMENT = "accepts_commands"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Outcome:
61
+ """What a backend did with a command.
62
+
63
+ Two states and a reason, which is M.1's vocabulary and not a richer one:
64
+ there is no `PENDING` because N.6 requires no completion tracking and
65
+ inventing it would put this gateway ahead of the specification it
66
+ implements. A backend that needs asynchronous completion reports
67
+ acceptance here and publishes the completion on its own lane, which is
68
+ what the DDS side already does.
69
+ """
70
+
71
+ accepted: bool
72
+ #: Required on a decline, and M.1 is why: "a decline is a first-class,
73
+ #: observable outcome, not an absence." A refusal with no reason is an
74
+ #: absence with a status code.
75
+ reason: str = ""
76
+
77
+ def __post_init__(self) -> None:
78
+ if not self.accepted and not self.reason:
79
+ raise ValueError(
80
+ "a declined command must carry a reason: M.1 makes a decline "
81
+ "a first-class outcome, and a refusal nobody can read is the "
82
+ "absence it exists to replace")
83
+
84
+ @classmethod
85
+ def accept(cls, reason: str = "") -> "Outcome":
86
+ return cls(True, reason)
87
+
88
+ @classmethod
89
+ def decline(cls, reason: str) -> "Outcome":
90
+ return cls(False, reason)
91
+
92
+
93
+ #: A handler receives the validated, lifted record --- the same JSON shape a
94
+ #: GET of that type would serve, after N.1 canonicalization and schema
95
+ #: validation --- and returns an `Outcome`. It never sees the request, the
96
+ #: token or the socket: an embedder owns verbs, not HTTP.
97
+ Handler = Callable[[dict[str, Any]], Outcome]
98
+
99
+
100
+ class Registry:
101
+ """The handlers an embedder has registered, keyed by type FQN.
102
+
103
+ Keyed by FQN rather than by topic so that a backend which accepts
104
+ `spatial.owm.ModelCommand` accepts it wherever a scene advertises that
105
+ type, which is how every other type-keyed table in this package works
106
+ (`DURABILITY`, `PUBLISHABLE`, the schema index).
107
+
108
+ Empty is the normal state. A gateway serving a recording has no verbs to
109
+ offer, and N.6 says a conformance suite MUST NOT require a command
110
+ surface to exist.
111
+ """
112
+
113
+ def __init__(self) -> None:
114
+ self._handlers: dict[str, Handler] = {}
115
+
116
+ def register(self, fqn: str, handler: Handler) -> None:
117
+ """Take commands of one type. Re-registering replaces, deliberately:
118
+ an embedder reconfiguring a lane should not have to tear down."""
119
+ self._handlers[fqn] = handler
120
+
121
+ def accepts(self, fqn: str) -> bool:
122
+ return fqn in self._handlers
123
+
124
+ def handler(self, fqn: str) -> Handler | None:
125
+ return self._handlers.get(fqn)
126
+
127
+ def types(self) -> list[str]:
128
+ return sorted(self._handlers)
129
+
130
+ def __bool__(self) -> bool:
131
+ return bool(self._handlers)
@@ -31,11 +31,11 @@ import json
31
31
  import math
32
32
  from dataclasses import dataclass, field
33
33
  from pathlib import Path
34
- from typing import Any, Iterable
34
+ from typing import Any, Callable, Iterable
35
35
 
36
36
  from mcap.reader import make_reader
37
37
 
38
- from . import idlmodel, pin, webjson, xcdr2
38
+ from . import commands, idlmodel, pin, webjson, xcdr2
39
39
 
40
40
  PUBLIC = "public-cacheable"
41
41
  OPERATOR = "operator-scoped"
@@ -553,7 +553,9 @@ def declared_service(st: State, scene: str) -> dict | None:
553
553
 
554
554
 
555
555
  def service_manifest(st: State, scene: str, base: str,
556
- declared: dict | None = None) -> dict:
556
+ declared: dict | None = None,
557
+ accepts_commands: Callable[[str], bool] | None = None
558
+ ) -> dict:
557
559
  """The §8.2.3 service manifest for one scene.
558
560
 
559
561
  `service.connection` is deliberately absent: it carries DDS connection
@@ -587,6 +589,21 @@ def service_manifest(st: State, scene: str, base: str,
587
589
  "access": res.access,
588
590
  "url": base + path,
589
591
  })
592
+ # N.6 asks a client to POST a command to the lane that accepts one,
593
+ # and N.7's topic entry has no member that says which lane that is:
594
+ # its table is six MUST-carry fields plus `qos_profile` and
595
+ # `liveliness`, none of which can carry "verbs welcome here". Without
596
+ # something, a client discovers the command surface by POSTing and
597
+ # reading the refusal, which is discovery by trial.
598
+ #
599
+ # So this member is added, under this repository's own name, and
600
+ # filed as a specification gap rather than treated as settled -- see
601
+ # docs/WIRE.md and the 1.9 candidates entry. N.7's list is a floor
602
+ # and not a ceiling, so an extra member is conformant; it is omitted
603
+ # entirely rather than written `false`, so a manifest from an endpoint
604
+ # with no verbs is byte-identical to one from before this existed.
605
+ if accepts_commands and accepts_commands(res.fqn):
606
+ topics_block[-1][commands.ADVERTISEMENT] = True
590
607
  man: dict[str, Any] = {
591
608
  "id": f"spatialdds://spatialdds-web.local/{scene}/service/gateway",
592
609
  "profile": "spatial.manifest/1.8",
@@ -659,7 +676,8 @@ def service_manifest(st: State, scene: str, base: str,
659
676
 
660
677
  def search(st: State, base: str, geohash: str | None = None,
661
678
  kinds: list[str] | None = None,
662
- coverage: list[dict] | None = None) -> dict:
679
+ coverage: list[dict] | None = None,
680
+ accepts_commands: Callable[[str], bool] | None = None) -> dict:
663
681
  """The §3.3.0 search response: `{"results": [...], "next_page_token": ""}`."""
664
682
  wanted: list[tuple[float, float, float, float]] = []
665
683
  if geohash:
@@ -679,10 +697,12 @@ def search(st: State, base: str, geohash: str | None = None,
679
697
  for scene in st.scenes:
680
698
  declared = declared_services(st, scene)
681
699
  if declared:
682
- manifests.extend(service_manifest(st, scene, base, d)
700
+ manifests.extend(service_manifest(st, scene, base, d,
701
+ accepts_commands)
683
702
  for d in declared)
684
703
  else:
685
- manifests.append(service_manifest(st, scene, base))
704
+ manifests.append(service_manifest(st, scene, base,
705
+ accepts_commands=accepts_commands))
686
706
  for man in manifests:
687
707
  if kinds and man["service"]["kind"] not in kinds:
688
708
  continue