spatialdds-ros 0.3.0__py3-none-any.whl

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 (87) hide show
  1. spatialdds_idl18/PROVENANCE.md +66 -0
  2. spatialdds_idl18/__init__.py +9 -0
  3. spatialdds_idl18/_field_aliases.py +26 -0
  4. spatialdds_idl18/builtin/__init__.py +11 -0
  5. spatialdds_idl18/builtin/_types.py +28 -0
  6. spatialdds_idl18/spatial/__init__.py +33 -0
  7. spatialdds_idl18/spatial/anchors/__init__.py +20 -0
  8. spatialdds_idl18/spatial/anchors/_anchors.py +102 -0
  9. spatialdds_idl18/spatial/argeo/__init__.py +22 -0
  10. spatialdds_idl18/spatial/argeo/_argeo.py +97 -0
  11. spatialdds_idl18/spatial/common/__init__.py +27 -0
  12. spatialdds_idl18/spatial/common/_types.py +104 -0
  13. spatialdds_idl18/spatial/core/GnssService/__init__.py +17 -0
  14. spatialdds_idl18/spatial/core/GnssService/_core.py +27 -0
  15. spatialdds_idl18/spatial/core/__init__.py +40 -0
  16. spatialdds_idl18/spatial/core/_core.py +338 -0
  17. spatialdds_idl18/spatial/disco/__init__.py +34 -0
  18. spatialdds_idl18/spatial/disco/_discovery.py +252 -0
  19. spatialdds_idl18/spatial/events/__init__.py +29 -0
  20. spatialdds_idl18/spatial/events/_events.py +164 -0
  21. spatialdds_idl18/spatial/events/enums/__init__.py +19 -0
  22. spatialdds_idl18/spatial/events/enums/crossing_direction_enum/__init__.py +11 -0
  23. spatialdds_idl18/spatial/events/enums/crossing_direction_enum/_events.py +25 -0
  24. spatialdds_idl18/spatial/events/enums/event_state_enum/__init__.py +11 -0
  25. spatialdds_idl18/spatial/events/enums/event_state_enum/_events.py +26 -0
  26. spatialdds_idl18/spatial/events/enums/event_type_enum/__init__.py +11 -0
  27. spatialdds_idl18/spatial/events/enums/event_type_enum/_events.py +36 -0
  28. spatialdds_idl18/spatial/events/enums/severity_enum/__init__.py +11 -0
  29. spatialdds_idl18/spatial/events/enums/severity_enum/_events.py +26 -0
  30. spatialdds_idl18/spatial/events/enums/zone_kind_enum/__init__.py +11 -0
  31. spatialdds_idl18/spatial/events/enums/zone_kind_enum/_events.py +31 -0
  32. spatialdds_idl18/spatial/mapping/__init__.py +30 -0
  33. spatialdds_idl18/spatial/mapping/_mapping.py +168 -0
  34. spatialdds_idl18/spatial/mapping/enums/__init__.py +19 -0
  35. spatialdds_idl18/spatial/mapping/enums/alignment_method/__init__.py +11 -0
  36. spatialdds_idl18/spatial/mapping/enums/alignment_method/_mapping.py +31 -0
  37. spatialdds_idl18/spatial/mapping/enums/edge_type/__init__.py +11 -0
  38. spatialdds_idl18/spatial/mapping/enums/edge_type/_mapping.py +34 -0
  39. spatialdds_idl18/spatial/mapping/enums/map_event_kind/__init__.py +11 -0
  40. spatialdds_idl18/spatial/mapping/enums/map_event_kind/_mapping.py +29 -0
  41. spatialdds_idl18/spatial/mapping/enums/map_kind/__init__.py +11 -0
  42. spatialdds_idl18/spatial/mapping/enums/map_kind/_mapping.py +32 -0
  43. spatialdds_idl18/spatial/mapping/enums/map_status/__init__.py +11 -0
  44. spatialdds_idl18/spatial/mapping/enums/map_status/_mapping.py +27 -0
  45. spatialdds_idl18/spatial/owm/__init__.py +22 -0
  46. spatialdds_idl18/spatial/owm/_owm.py +103 -0
  47. spatialdds_idl18/spatial/semantics/__init__.py +22 -0
  48. spatialdds_idl18/spatial/semantics/_semantics.py +174 -0
  49. spatialdds_idl18/spatial/sensing/__init__.py +21 -0
  50. spatialdds_idl18/spatial/sensing/common/__init__.py +34 -0
  51. spatialdds_idl18/spatial/sensing/common/_common.py +192 -0
  52. spatialdds_idl18/spatial/sensing/lidar/__init__.py +32 -0
  53. spatialdds_idl18/spatial/sensing/lidar/_lidar.py +135 -0
  54. spatialdds_idl18/spatial/sensing/rad/__init__.py +33 -0
  55. spatialdds_idl18/spatial/sensing/rad/_rad.py +191 -0
  56. spatialdds_idl18/spatial/sensing/radio/__init__.py +22 -0
  57. spatialdds_idl18/spatial/sensing/radio/_radio.py +141 -0
  58. spatialdds_idl18/spatial/sensing/rf_beam/__init__.py +25 -0
  59. spatialdds_idl18/spatial/sensing/rf_beam/_rf_beam.py +117 -0
  60. spatialdds_idl18/spatial/sensing/vision/__init__.py +36 -0
  61. spatialdds_idl18/spatial/sensing/vision/_vision.py +170 -0
  62. spatialdds_idl18/spatial/slam_frontend/__init__.py +22 -0
  63. spatialdds_idl18/spatial/slam_frontend/_slam_frontend.py +144 -0
  64. spatialdds_idl18/spatial/vio/__init__.py +20 -0
  65. spatialdds_idl18/spatial/vio/_vio.py +134 -0
  66. spatialdds_ros/__init__.py +6 -0
  67. spatialdds_ros/adapter.py +336 -0
  68. spatialdds_ros/announce.py +231 -0
  69. spatialdds_ros/config.py +293 -0
  70. spatialdds_ros/frames.py +219 -0
  71. spatialdds_ros/kinematic.py +140 -0
  72. spatialdds_ros/qos.py +101 -0
  73. spatialdds_ros/recording.py +167 -0
  74. spatialdds_ros/robot_node.py +287 -0
  75. spatialdds_ros/standalone.py +221 -0
  76. spatialdds_ros/tempo.py +114 -0
  77. spatialdds_ros/topics.py +64 -0
  78. spatialdds_ros/transport.py +170 -0
  79. spatialdds_ros-0.3.0.data/data/share/ament_index/resource_index/packages/spatialdds_ros +0 -0
  80. spatialdds_ros-0.3.0.data/data/share/spatialdds_ros/package.xml +29 -0
  81. spatialdds_ros-0.3.0.dist-info/METADATA +331 -0
  82. spatialdds_ros-0.3.0.dist-info/RECORD +87 -0
  83. spatialdds_ros-0.3.0.dist-info/WHEEL +5 -0
  84. spatialdds_ros-0.3.0.dist-info/entry_points.txt +3 -0
  85. spatialdds_ros-0.3.0.dist-info/licenses/LICENSE +21 -0
  86. spatialdds_ros-0.3.0.dist-info/top_level.txt +2 -0
  87. spatialdds_ros-0.3.0.dist-info/zip-safe +1 -0
@@ -0,0 +1,231 @@
1
+ """
2
+ The robot's presence on the discovery lane.
3
+
4
+ A robot that publishes its pose and accepts goals is discoverable in exactly
5
+ the way a VPS or a catalogue is: something that wants to drive it should be
6
+ able to find it without being told its topic names. The prototype's robot does
7
+ *not* announce — discovery there belongs to the VPS and catalogue servers — so
8
+ this capability is new rather than extracted, and the catalogue server's
9
+ announce is the shape it follows.
10
+
11
+ **On `kind`.** 1.8's `ServiceKind` is VPS, MAPPING, RELOCAL, SEMANTICS,
12
+ STORAGE, CONTENT, ANCHOR_REGISTRY, SENSING, INFRASTRUCTURE, FUSION, OTHER.
13
+ None of them is an agent that reports where it is and accepts commands about
14
+ where to go. `SENSING` is the nearest and it overstates — it reads as a service
15
+ offering sensor feeds, which this is not. So the default is `OTHER`, which is
16
+ accurate, and `kind` is a parameter so an operator whose robot genuinely is one
17
+ of the others can say so.
18
+
19
+ That gap is filed rather than papered over: see `docs/spec-queue.md`. Choosing
20
+ `SENSING` because it is closest would make every robot on the bus advertise a
21
+ capability it does not have, and the next reader would have no way to know the
22
+ enum had been stretched.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from typing import Optional
27
+
28
+ # **Resolved at their definitions, not through `spatial.disco`.** That module
29
+ # re-exports most of what an announce needs, and six of the names it re-exports
30
+ # are annotated typedef aliases that report zero members and raise
31
+ # `TypeError: 'ForwardRef' object is not callable` when constructed — Aabb3,
32
+ # FrameRef, Time, PoseSE3, SpatialUri and MetaKV among them. The convenient
33
+ # import is the wrong one. `test/test_types_resolve_at_definitions.py` is the
34
+ # gate; this trap has cost four separate debugging rounds across this
35
+ # programme, so it is enforced rather than remembered.
36
+ from spatialdds_idl18.builtin import Time
37
+ from spatialdds_idl18.spatial.common import FrameRef
38
+ from spatialdds_idl18.spatial.core import Aabb3, PoseSE3
39
+ from spatialdds_idl18.spatial.disco import (
40
+ Announce, Capabilities, CoverageElement, ProfileSupport, ServiceKind,
41
+ TopicMeta, Transform, ValidityWindow)
42
+
43
+ from spatialdds_ros.config import Config
44
+ from spatialdds_ros.frames import (
45
+ earth_fixed_frame, enu_to_ecef_pose, now_time, robot_frame)
46
+ from spatialdds_ros.qos import MODEL_COMMAND, MODEL_FAST, MODEL_LATCHED
47
+ from spatialdds_ros.topics import Topics
48
+
49
+ #: An absent bbox still needs four numbers, because `BBox2D` is a fixed
50
+ #: `double[4]`. Paired with `has_bbox=False`, so nothing reads them.
51
+ _NO_BBOX = [0.0, 0.0, 0.0, 0.0]
52
+
53
+ #: How long an announce stands before a consumer should stop believing it.
54
+ #: Mapped onto DDS Lifespan by the writer, so the middleware expires it rather
55
+ #: than every consumer re-deriving staleness.
56
+ DEFAULT_TTL_SEC = 300
57
+
58
+ #: What this library speaks. Stated as a range because that is what
59
+ #: `ProfileSupport` is for, and narrow because claiming 1.7 would invite a 1.7
60
+ #: client to assume the four members 1.8 appended are absent.
61
+ PROFILES = (("spatial.core", 1, 8, 8), ("spatial.owm", 0, 1, 1))
62
+
63
+
64
+ def _topics(config: Config) -> list[TopicMeta]:
65
+ """
66
+ The lanes this robot reads and writes, with their QoS profiles named.
67
+
68
+ `qos_profile` being advertised is what lets a consumer build a matching
69
+ endpoint without guessing — and the names here are the same ones
70
+ `qos.PROFILES` resolves, so the advertisement is true on the wire rather
71
+ than decorative. `target_rate_hz` is 0.0 where there is no steady rate:
72
+ 0 means unspecified, which is a different statement from a slow rate.
73
+ """
74
+ names = Topics(config.scene)
75
+ return [
76
+ TopicMeta(name=names.entity, type="spatial.owm.Entity", version="v1",
77
+ qos_profile=MODEL_LATCHED.name, target_rate_hz=0.0,
78
+ max_chunk_bytes=0, durability="TRANSIENT_LOCAL"),
79
+ TopicMeta(name=names.pose, type="spatial.owm.ModelPose", version="v1",
80
+ qos_profile=MODEL_FAST.name, target_rate_hz=0.0,
81
+ max_chunk_bytes=0, durability="VOLATILE"),
82
+ TopicMeta(name=names.command, type="spatial.owm.ModelCommand",
83
+ version="v1", qos_profile=MODEL_COMMAND.name,
84
+ target_rate_hz=0.0, max_chunk_bytes=0,
85
+ durability="VOLATILE"),
86
+ ]
87
+
88
+
89
+ def _coverage(config: Config) -> list[CoverageElement]:
90
+ """
91
+ Where this robot is useful.
92
+
93
+ **With no anchor there is no geographic coverage to claim**, and a
94
+ `_global` element would be a loud lie — a robot in one building
95
+ advertising the planet. So the coverage is expressed in the robot's own
96
+ frame instead: a frame-referenced element with no bbox, which says "within
97
+ this frame" and nothing about the earth. That is the same honesty the
98
+ missing `FrameTransform` observes, carried onto the discovery lane.
99
+
100
+ With an anchor, a degenerate bbox at the anchor point: the robot moves, so
101
+ any extent would be a guess about how far, and a point is the one claim
102
+ that is true.
103
+ """
104
+ if config.anchor is None:
105
+ return [CoverageElement(
106
+ has_crs=False, crs="", has_bbox=False,
107
+ bbox=_NO_BBOX,
108
+ has_aabb=False,
109
+ aabb=Aabb3(min_xyz=[0.0, 0.0, 0.0], max_xyz=[0.0, 0.0, 0.0]),
110
+ _global=False,
111
+ has_frame_ref=True, frame_ref=robot_frame(config.frame_id),
112
+ has_coverage_window=False,
113
+ coverage_window_start=Time(sec=0, nanosec=0),
114
+ coverage_window_end=Time(sec=0, nanosec=0),
115
+ has_circle=False, circle_center=[0.0, 0.0, 0.0],
116
+ circle_radius_m=0.0)]
117
+ a = config.anchor
118
+ return [CoverageElement(
119
+ has_crs=True, crs="EPSG:4326", has_bbox=True,
120
+ # **[west, south, east, north]** — `BBox2D` is `double[4]`, not a
121
+ # struct with named members, and the order is stated only in a comment
122
+ # in the IDL (`discovery.idl:96`). Longitude first. Getting it wrong
123
+ # produces a well-formed box somewhere else entirely, which is why the
124
+ # order is written out here rather than inferred at the call site.
125
+ bbox=[a.lon_deg, a.lat_deg, a.lon_deg, a.lat_deg],
126
+ has_aabb=False,
127
+ aabb=Aabb3(min_xyz=[0.0, 0.0, 0.0], max_xyz=[0.0, 0.0, 0.0]),
128
+ _global=False,
129
+ has_frame_ref=True, frame_ref=robot_frame(config.frame_id),
130
+ has_coverage_window=False,
131
+ coverage_window_start=Time(sec=0, nanosec=0),
132
+ coverage_window_end=Time(sec=0, nanosec=0),
133
+ has_circle=False, circle_center=[0.0, 0.0, 0.0],
134
+ circle_radius_m=0.0)]
135
+
136
+
137
+ def _transforms(config: Config) -> list[Transform]:
138
+ """
139
+ The announce's own copy of the georeference, when there is one.
140
+
141
+ Empty without an anchor, for the reason the frames lane is silent: there is
142
+ nothing true to say. Note the spelling — `_from` is the *local* frame and
143
+ `to` is earth-fixed, which is the opposite of `FrameTransform`'s
144
+ parent/child roles while carrying identical numbers. Mapping by name
145
+ rather than by role inverts it, which is why both ends are written out
146
+ here rather than passed through a helper that could be read either way.
147
+ """
148
+ if config.anchor is None:
149
+ return []
150
+ t, q = enu_to_ecef_pose(config.anchor.lat_deg, config.anchor.lon_deg,
151
+ config.anchor.height_m)
152
+ return [Transform(
153
+ _from=robot_frame(config.frame_id), # the local frame
154
+ to=earth_fixed_frame(), # the global one
155
+ pose=PoseSE3(t=t, q=q),
156
+ stamp=now_time(),
157
+ has_validity=False,
158
+ # `_from` and `seconds`, not start/end — a window with a duration
159
+ # rather than two instants. Unread while `has_validity` is false; a
160
+ # georeference that expires would say the frame moved, which is not
161
+ # what an anchor from a launch file means.
162
+ validity=ValidityWindow(_from=Time(sec=0, nanosec=0), seconds=0))]
163
+
164
+
165
+ def announce_for(config: Config, kind: str = "OTHER",
166
+ ttl_sec: int = DEFAULT_TTL_SEC) -> Announce:
167
+ """This robot, as something another participant can discover."""
168
+ return Announce(
169
+ service_id=config.effective_source_id,
170
+ name=config.effective_label,
171
+ kind=getattr(ServiceKind, kind),
172
+ version="1.8",
173
+ org="",
174
+ hints=[],
175
+ caps=Capabilities(
176
+ supported_profiles=[
177
+ ProfileSupport(name=n, major=major, min_minor=lo,
178
+ max_minor=hi)
179
+ for n, major, lo, hi in PROFILES],
180
+ preferred_profiles=["spatial.owm/0.1"],
181
+ features=[]),
182
+ topics=_topics(config),
183
+ coverage=_coverage(config),
184
+ coverage_frame_ref=(earth_fixed_frame() if config.anchor
185
+ else robot_frame(config.frame_id)),
186
+ has_coverage_eval_time=False,
187
+ coverage_eval_time=Time(sec=0, nanosec=0),
188
+ transforms=_transforms(config),
189
+ manifest_uri="",
190
+ auth_hint="",
191
+ stamp=now_time(),
192
+ ttl_sec=ttl_sec,
193
+ coverage_source_ids=[],
194
+ )
195
+
196
+
197
+ #: The discovery lane is not scene-scoped — it is the one place a participant
198
+ #: looks before it knows what scenes exist.
199
+ ANNOUNCE_TOPIC = "spatialdds/discovery/announce/v1"
200
+
201
+
202
+ class Announcer:
203
+ """
204
+ Publishes the announce, and re-stamps it on a cadence.
205
+
206
+ Re-stamped rather than written once: `ttl_sec` is mapped onto DDS Lifespan,
207
+ so a sample that is never refreshed expires and the robot silently stops
208
+ being discoverable while it is still running. The prototype learned the
209
+ same thing — "Re-stamp the announce on refresh, not just the Lifespan".
210
+ """
211
+
212
+ def __init__(self, participant, config: Config, kind: str = "OTHER",
213
+ ttl_sec: int = DEFAULT_TTL_SEC) -> None:
214
+ from spatialdds_ros import transport as tt
215
+ self.config = config
216
+ self.kind = kind
217
+ self.ttl_sec = ttl_sec
218
+ self._writer = tt.make_writer(
219
+ participant, ANNOUNCE_TOPIC, Announce, MODEL_LATCHED.name,
220
+ lifespan_sec=float(ttl_sec))
221
+
222
+ def publish(self) -> str:
223
+ announce = announce_for(self.config, self.kind, self.ttl_sec)
224
+ self._writer.write(announce)
225
+ return (f"announce: {announce.service_id} as {announce.kind.name}, "
226
+ f"{len(announce.topics)} topics, ttl {announce.ttl_sec}s")
227
+
228
+ @property
229
+ def refresh_interval_s(self) -> float:
230
+ """Comfortably inside the TTL, so one missed refresh is not an outage."""
231
+ return max(1.0, self.ttl_sec / 3.0)
@@ -0,0 +1,293 @@
1
+ """
2
+ The parameter surface, and what each silence means.
3
+
4
+ Ruled 2026-10-19; `docs/extraction-census.md` §12 is the reasoning and this is
5
+ the implementation of it. The rule the surface is built on is the programme's:
6
+ **conventions fill plumbing and never claims.** So a parameter exists where an
7
+ operator could be wrong *about the world*, and a default exists only where
8
+ being wrong is impossible.
9
+
10
+ Three parameters have no honest default and therefore refuse to start:
11
+
12
+ * `scene` — which world this robot is in. A made-up default puts it somewhere
13
+ nobody is watching, and the failure is an empty bus rather than an error.
14
+ * `entity_id` — which robot it is. A default collides the moment two run, and
15
+ the one-writer-per-key guard would then stop the second with a reason that
16
+ points at the wrong thing.
17
+ * `frame_id` — which frame its pose is expressed in. A pose without its frame
18
+ is not a weak claim, it is a meaningless one.
19
+
20
+ One silence is a legitimate operating mode rather than a misconfiguration:
21
+
22
+ * `anchor` absent — **no `FrameTransform` is published**, said once plainly.
23
+ The adapter is still fully functional on the bus; the scene simply has no
24
+ georeference from it. The alternative, defaulting the anchor to the frame
25
+ origin, would have every unconfigured robot claiming to be in the Gulf of
26
+ Guinea with a pose that looks entirely plausible. A default is not neutral
27
+ when the thing being defaulted is a claim.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from dataclasses import dataclass
33
+ from typing import Optional, Sequence, Tuple
34
+
35
+ #: "mobile robot" — an automatic machine capable of movement in any given
36
+ #: environment, `subclass of` (P279) Q11012 (robot). Carried from the
37
+ #: prototype, where it was verified against the entity data rather than taken
38
+ #: from a search result: several US patents are also labelled "Mobile robot".
39
+ #: A default rather than a fixture — a drone or an AMR may want its own.
40
+ DEFAULT_TYPE_URI = "http://www.wikidata.org/entity/Q4810574"
41
+
42
+ #: nav2's own action name. A parameter because a robot may remap it; this is
43
+ #: what nav2 calls it unremapped.
44
+ DEFAULT_NAV2_ACTION = "navigate_to_pose"
45
+
46
+ #: How a map-frame pose is obtained. `tf` is the default for a reason the
47
+ #: census documents at length: the prototype read `nav_msgs/Odometry` on
48
+ #: `/odom`, which is map-frame *only* in the simulator, where `map -> odom` is
49
+ #: static and identity. On a real robot odom drifts against map, so publishing
50
+ #: it as a georeferenced OBSERVED pose emits a slowly-drifting claim about
51
+ #: where a robot sits on the earth with every number looking plausible.
52
+ POSE_SOURCES = ("tf", "amcl", "odom")
53
+
54
+ #: The IDL's bounds on the two reference sequences
55
+ #: (`sequence[str, 8]` and `sequence[KV, 8]` on `spatial.owm.Entity`).
56
+ #: Named here so a refusal can cite the limit rather than just report a
57
+ #: serialize failure.
58
+ MAX_CONTENT_REFS = 8
59
+ MAX_EXTERNAL_REFS = 8
60
+
61
+ #: One path segment of a SpatialDDS topic name (§3.3.1). Deliberately strict:
62
+ #: a scene name carrying a slash would silently produce a six-segment topic,
63
+ #: which is a name no other participant looks for.
64
+ _SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
65
+
66
+
67
+ class ConfigError(ValueError):
68
+ """
69
+ Raised for a configuration that cannot be started.
70
+
71
+ Carries *every* problem rather than the first, because a robot operator
72
+ editing a launch file should learn about three missing parameters in one
73
+ pass instead of three.
74
+ """
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Anchor:
79
+ """
80
+ Where a local frame's origin sits on the earth.
81
+
82
+ Validated rather than taken on trust: these three numbers are the library's
83
+ only statement about the planet, and a transposed pair is not detectable
84
+ downstream — an anchor at (-97.7, 30.3) instead of (30.3, -97.7) is a
85
+ perfectly well-formed point in the South Atlantic.
86
+ """
87
+
88
+ lat_deg: float
89
+ lon_deg: float
90
+ height_m: float
91
+
92
+ def __post_init__(self) -> None:
93
+ problems = []
94
+ if not -90.0 <= self.lat_deg <= 90.0:
95
+ problems.append(
96
+ f"lat_deg {self.lat_deg} is outside [-90, 90]; a latitude "
97
+ f"beyond the poles usually means lat and lon were swapped")
98
+ if not -180.0 <= self.lon_deg <= 180.0:
99
+ problems.append(
100
+ f"lon_deg {self.lon_deg} is outside [-180, 180]")
101
+ if problems:
102
+ raise ConfigError("; ".join(problems))
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Config:
107
+ """
108
+ Everything the adapter needs, validated at construction.
109
+
110
+ Frozen: the parameter surface is read at startup and a value that changed
111
+ underneath a running adapter would mean the entity it has been publishing
112
+ all along was a different claim than the one it is making now.
113
+ """
114
+
115
+ scene: str
116
+ entity_id: str
117
+ frame_id: str
118
+
119
+ anchor: Optional[Anchor] = None
120
+ source_id: Optional[str] = None
121
+ domain: int = 0
122
+ ground_z: float = 0.0
123
+ pose_source: str = "tf"
124
+ nav2_action: str = DEFAULT_NAV2_ACTION
125
+ type_uri: str = DEFAULT_TYPE_URI
126
+ label: Optional[str] = None
127
+
128
+ #: What this robot *is*, for a consumer that wants to draw it.
129
+ #:
130
+ #: Added 2026-10-19 because their absence blocked the first real adoption.
131
+ #: SpatialDDS-demo's robot carries `catalog:dcef7c31-…`, and a consumer
132
+ #: reading that reference is what makes it render a robot rather than a
133
+ #: pin: no reference, no asset, no model. An adapter that cannot say what
134
+ #: a robot looks like cannot be a consumer's only publisher of that robot,
135
+ #: and there was no way for a consumer to supply it from outside — the
136
+ #: record simply had nowhere to put it.
137
+ #:
138
+ #: **Empty by default, and empty means empty.** The adapter states what the
139
+ #: operator configured and nothing else; it invents no reference and
140
+ #: guesses no asset. An empty sequence is a published fact — "this robot
141
+ #: names no content" — not an accident of omission, and the gates assert
142
+ #: both directions.
143
+ content_refs: Tuple[str, ...] = ()
144
+
145
+ #: What this robot is *also known as*: wikidata, an asset-management id, a
146
+ #: fleet number. Same rule — only what was configured.
147
+ external_refs: Tuple[Tuple[str, str], ...] = ()
148
+
149
+ def __post_init__(self) -> None:
150
+ # A launch file or a dict will hand these over as lists. The dataclass
151
+ # is frozen, so they are normalised to tuples here rather than stored
152
+ # mutable — a shared mutable default on a frozen "value" is a bug
153
+ # waiting for a second robot.
154
+ object.__setattr__(self, "content_refs",
155
+ tuple(self.content_refs or ()))
156
+ object.__setattr__(self, "external_refs",
157
+ tuple(tuple(pair) for pair in
158
+ (self.external_refs or ())))
159
+ problems: list[str] = []
160
+
161
+ # The three with no honest default. Reported together, and each with
162
+ # what the silence would have meant rather than just "required".
163
+ if not self.scene:
164
+ problems.append(
165
+ "scene is required: it names the world this robot joins, and "
166
+ "there is no default that is not a guess about where it is. "
167
+ "A wrong scene is an empty bus, not an error")
168
+ elif not _SEGMENT.match(self.scene):
169
+ problems.append(
170
+ f"scene {self.scene!r} is not one topic segment (§3.3.1): "
171
+ f"expected letters, digits, dot, dash or underscore. A scene "
172
+ f"containing a slash silently produces a topic name no other "
173
+ f"participant looks for")
174
+
175
+ if not self.entity_id:
176
+ problems.append(
177
+ "entity_id is required: it names which robot this is. A "
178
+ "default collides as soon as a second robot runs, and the "
179
+ "one-writer-per-key guard would then refuse the second for a "
180
+ "reason that points at the wrong thing")
181
+
182
+ if not self.frame_id:
183
+ problems.append(
184
+ "frame_id is required: a pose without the frame it is "
185
+ "expressed in is not a weak claim, it is a meaningless one")
186
+
187
+ if self.pose_source not in POSE_SOURCES:
188
+ problems.append(
189
+ f"pose_source {self.pose_source!r} is not one of "
190
+ f"{', '.join(POSE_SOURCES)}")
191
+
192
+ if self.domain < 0 or self.domain > 232:
193
+ problems.append(
194
+ f"domain {self.domain} is outside the DDS range [0, 232]")
195
+
196
+ # Bounded sequences in the IDL: `content_refs` and `external_refs` are
197
+ # both `sequence[..., 8]`. Checked here rather than left to serialize,
198
+ # which fails late and says nothing about which field or why.
199
+ if len(self.content_refs) > MAX_CONTENT_REFS:
200
+ problems.append(
201
+ f"content_refs has {len(self.content_refs)} entries; the IDL "
202
+ f"bounds it at {MAX_CONTENT_REFS}")
203
+ for ref in self.content_refs:
204
+ if not ref or not str(ref).strip():
205
+ problems.append(
206
+ "a content_ref is empty; an empty reference resolves to "
207
+ "nothing and is not the same as publishing none")
208
+ if len(self.external_refs) > MAX_EXTERNAL_REFS:
209
+ problems.append(
210
+ f"external_refs has {len(self.external_refs)} entries; the IDL "
211
+ f"bounds it at {MAX_EXTERNAL_REFS}")
212
+ for pair in self.external_refs:
213
+ if len(tuple(pair)) != 2 or not all(str(v).strip() for v in pair):
214
+ problems.append(
215
+ f"external_refs entries are (key, value) pairs and both "
216
+ f"halves must be non-empty; got {pair!r}")
217
+
218
+ if problems:
219
+ raise ConfigError(
220
+ "this configuration cannot be started:\n - "
221
+ + "\n - ".join(problems))
222
+
223
+ # --- derived, never asked for -----------------------------------------
224
+
225
+ @property
226
+ def effective_source_id(self) -> str:
227
+ """
228
+ Who is publishing, which is not a claim about the world.
229
+
230
+ Derived from `entity_id` when unset: the publisher's identity follows
231
+ the thing it publishes, and asking an operator for both invites them
232
+ to disagree.
233
+ """
234
+ if self.source_id:
235
+ return self.source_id
236
+ tail = self.entity_id.split(":")[-1] or self.entity_id
237
+ return f"svc:robot:{tail}"
238
+
239
+ @property
240
+ def effective_label(self) -> str:
241
+ """A human-facing name. Cosmetic, so the id's tail will do."""
242
+ return self.label or (self.entity_id.split(":")[-1] or self.entity_id)
243
+
244
+ @property
245
+ def publishes_georeference(self) -> bool:
246
+ return self.anchor is not None
247
+
248
+ def startup_notes(self) -> list[str]:
249
+ """
250
+ What to say once, at startup, about the modes this configuration is in.
251
+
252
+ Returned rather than logged so the caller owns the logging and the
253
+ tests can read it. Each line states a *consequence*, not a setting:
254
+ "no anchor configured" leaves a reader to work out what follows, and
255
+ the whole point of the degraded mode being legitimate is that it is
256
+ legible.
257
+ """
258
+ notes = [
259
+ f"scene {self.scene}, entity {self.entity_id} in frame "
260
+ f"{self.frame_id}, DDS domain {self.domain}",
261
+ ]
262
+ if self.anchor is None:
263
+ # **Reworded 2026-10-19.** This used to say "nothing
264
+ # earth-references them", which assumed this adapter is the only
265
+ # thing that could — and in the first real deployment it was not:
266
+ # the venue publishes the scene's own FrameTransform, and running
267
+ # the robot anchorless is correct *because* somebody else owns
268
+ # that claim, which is the opposite of the absence the old wording
269
+ # described. So the note now says what it actually knows: this
270
+ # adapter is not publishing one. Whether anyone else is, it cannot
271
+ # see from here.
272
+ notes.append(
273
+ "no anchor configured, so this adapter publishes no "
274
+ "FrameTransform: its entity and poses are on the bus and "
275
+ "complete, and it makes no claim about where that frame sits "
276
+ "on the earth. Another participant may own that claim — a "
277
+ "venue publishing its own georeference is the ordinary case, "
278
+ "and then this is the correct configuration. If nobody "
279
+ "publishes one, a consumer cannot place this robot on a globe "
280
+ "and should say so rather than assume a position")
281
+ else:
282
+ notes.append(
283
+ f"anchor {self.anchor.lat_deg:.8f}, {self.anchor.lon_deg:.8f}, "
284
+ f"{self.anchor.height_m:.3f} m — publishing {self.frame_id} -> "
285
+ f"earth-fixed")
286
+ if self.pose_source == "odom":
287
+ notes.append(
288
+ "pose_source is odom: odom drifts against map, so this "
289
+ "publishes a pose that will slowly diverge from where the "
290
+ "robot actually is. Correct only where map -> odom is static "
291
+ "and identity, which is true of a simulator and false of a "
292
+ "localizing robot. Prefer pose_source=tf")
293
+ return notes