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,134 @@
1
+ """
2
+ Generated by Eclipse Cyclone DDS idlc Python Backend
3
+ Cyclone DDS IDL version: v11.0.1
4
+ Module: spatial.vio
5
+ IDL file: vio.idl
6
+
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from enum import auto
11
+ from typing import TYPE_CHECKING, Optional
12
+
13
+ import cyclonedds.idl as idl
14
+ import cyclonedds.idl.annotations as annotate
15
+ import cyclonedds.idl.types as types
16
+
17
+ # root module import for resolving types
18
+ # generate_types.py: bind the root name the dotted
19
+ # annotations below already use, so get_type_hints works
20
+ import spatialdds_idl18
21
+ from spatialdds_idl18 import spatial
22
+
23
+ if TYPE_CHECKING:
24
+ import builtin
25
+ import spatial.common
26
+ import spatial.core
27
+
28
+
29
+ MODULE_ID = "spatial.vio/1.8"
30
+ Time = types.typedef['spatialdds_idl18.spatial.vio.Time', 'spatialdds_idl18.builtin.Time']
31
+ FrameRef = types.typedef['spatialdds_idl18.spatial.vio.FrameRef', 'spatialdds_idl18.spatial.common.FrameRef']
32
+
33
+ @dataclass
34
+ @annotate.appendable
35
+ @annotate.autoid("sequential")
36
+ class ImuInfo(idl.IdlStruct, typename="spatial.vio.ImuInfo"):
37
+ imu_id: str
38
+ annotate.key("imu_id")
39
+ frame_ref: 'spatialdds_idl18.spatial.vio.FrameRef'
40
+ accel_noise_density: types.float64
41
+ gyro_noise_density: types.float64
42
+ accel_random_walk: types.float64
43
+ gyro_random_walk: types.float64
44
+ stamp: 'spatialdds_idl18.spatial.vio.Time'
45
+
46
+
47
+ @dataclass
48
+ @annotate.appendable
49
+ @annotate.autoid("sequential")
50
+ class ImuSample(idl.IdlStruct, typename="spatial.vio.ImuSample"):
51
+ imu_id: str
52
+ annotate.key("imu_id")
53
+ accel: 'spatialdds_idl18.spatial.common.Vec3'
54
+ gyro: 'spatialdds_idl18.spatial.common.Vec3'
55
+ stamp: 'spatialdds_idl18.spatial.vio.Time'
56
+ source_id: str
57
+ seq: types.uint64
58
+ has_accel_cov: bool
59
+ accel_cov: 'spatialdds_idl18.spatial.core.CovMatrix'
60
+ has_gyro_cov: bool
61
+ gyro_cov: 'spatialdds_idl18.spatial.core.CovMatrix'
62
+
63
+
64
+ @dataclass
65
+ @annotate.appendable
66
+ @annotate.autoid("sequential")
67
+ class MagnetometerSample(idl.IdlStruct, typename="spatial.vio.MagnetometerSample"):
68
+ mag_id: str
69
+ annotate.key("mag_id")
70
+ mag: 'spatialdds_idl18.spatial.common.Vec3'
71
+ stamp: 'spatialdds_idl18.spatial.vio.Time'
72
+ frame_ref: 'spatialdds_idl18.spatial.vio.FrameRef'
73
+ source_id: str
74
+ seq: types.uint64
75
+
76
+
77
+ @dataclass
78
+ @annotate.appendable
79
+ @annotate.autoid("sequential")
80
+ class SensorFusionSample(idl.IdlStruct, typename="spatial.vio.SensorFusionSample"):
81
+ fusion_id: str
82
+ annotate.key("fusion_id")
83
+ accel: 'spatialdds_idl18.spatial.common.Vec3'
84
+ gyro: 'spatialdds_idl18.spatial.common.Vec3'
85
+ mag: 'spatialdds_idl18.spatial.common.Vec3'
86
+ stamp: 'spatialdds_idl18.spatial.vio.Time'
87
+ frame_ref: 'spatialdds_idl18.spatial.vio.FrameRef'
88
+ source_id: str
89
+ seq: types.uint64
90
+
91
+ @annotate.final
92
+ class FusionMode(idl.IdlEnum, typename="spatial.vio.FusionMode", default="ORIENTATION_3DOF"):
93
+ ORIENTATION_3DOF = 0
94
+ ORIENTATION_6DOF = 1
95
+ POSE_6DOF = 2
96
+ @annotate.final
97
+ class FusionSourceKind(idl.IdlEnum, typename="spatial.vio.FusionSourceKind", default="EKF"):
98
+ EKF = 0
99
+ AHRS = 1
100
+ VIO_FUSED = 2
101
+ IMU_ONLY = 3
102
+ MAG_AIDED = 4
103
+ AR_PLATFORM = 5
104
+
105
+ @dataclass
106
+ @annotate.appendable
107
+ @annotate.autoid("sequential")
108
+ class FusedState(idl.IdlStruct, typename="spatial.vio.FusedState"):
109
+ fusion_id: str
110
+ annotate.key("fusion_id")
111
+ mode: 'spatialdds_idl18.spatial.vio.FusionMode'
112
+ source_kind: 'spatialdds_idl18.spatial.vio.FusionSourceKind'
113
+ q: 'spatialdds_idl18.spatial.common.QuaternionXYZW'
114
+ has_position: bool
115
+ t: 'spatialdds_idl18.spatial.common.Vec3'
116
+ has_gravity: bool
117
+ gravity: 'spatialdds_idl18.spatial.common.Vec3'
118
+ has_lin_accel: bool
119
+ lin_accel: 'spatialdds_idl18.spatial.common.Vec3'
120
+ has_gyro_bias: bool
121
+ gyro_bias: 'spatialdds_idl18.spatial.common.Vec3'
122
+ has_accel_bias: bool
123
+ accel_bias: 'spatialdds_idl18.spatial.common.Vec3'
124
+ has_cov_orient: bool
125
+ cov_orient: 'spatialdds_idl18.spatial.common.Mat3x3'
126
+ has_cov_pos: bool
127
+ cov_pos: 'spatialdds_idl18.spatial.common.Mat3x3'
128
+ stamp: 'spatialdds_idl18.spatial.vio.Time'
129
+ frame_ref: 'spatialdds_idl18.spatial.vio.FrameRef'
130
+ source_id: str
131
+ seq: types.uint64
132
+ quality: types.float64
133
+
134
+
@@ -0,0 +1,6 @@
1
+ """A ROS 2 robot as a SpatialDDS participant.
2
+
3
+ See docs/extraction-census.md for what is configurable and why.
4
+ """
5
+
6
+ __version__ = "0.3.0"
@@ -0,0 +1,336 @@
1
+ """
2
+ The adapter: one robot, published as a `spatial.owm` entity.
3
+
4
+ **No ROS in here.** It is handed poses and handed a navigator. On a robot the
5
+ caller feeds it the map-frame pose and gives it a nav2 action client; in the
6
+ no-hardware mode the caller feeds it a kinematic sim and gives it that sim's
7
+ goal setter. Both call the same methods, which keeps the interesting behaviour
8
+ — cadence, latching, going quiet, declining, refusing — testable with no ROS
9
+ installation and no robot. That is this library's CI story, and the prototype's
10
+ `RobotBridge` is the shape it follows.
11
+
12
+ It owns exactly one key and writes nothing else. The pose-lane rule is **one
13
+ writer per key**, and `already_published` is the one place two processes could
14
+ plausibly break it.
15
+
16
+ Three things it will not do, each because the alternative is a false
17
+ statement:
18
+
19
+ * **It does not dispose its entity when the feed goes quiet.** Disposal says
20
+ the robot is gone; silence only licenses "this is where it was last seen,
21
+ not where it is". So the record is republished `UNOBSERVED` with a reason.
22
+ * **It does not accept a goal it knows nothing will act on.** A `goto` with no
23
+ pose publishing, or with the navigator refusing goals, is declined with the
24
+ reason — because a requester watching a motionless robot cannot tell a
25
+ declined goal from an ignored one.
26
+ * **It does not let an accepted goal fail silently.** Whether a goal is
27
+ drivable can depend on where the robot is when it meets a boundary, so the
28
+ refusal arrives after the acceptance — on the latched record, where whoever
29
+ asked will see it.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import time
34
+ from typing import List, Optional, Protocol, Tuple
35
+
36
+ from cyclonedds.domain import DomainParticipant
37
+
38
+ from spatialdds_idl18.spatial.common import KV
39
+ from spatialdds_idl18.spatial.core import Aabb3, PoseSE3
40
+ from spatialdds_idl18.spatial.owm import (
41
+ Basis, Entity, LifecycleState, ModelCommand, ModelLayer, ModelPose)
42
+ from spatialdds_idl18.spatial.core import FrameTransform
43
+
44
+ from spatialdds_ros import transport as tt
45
+ from spatialdds_ros.config import Config
46
+ from spatialdds_ros.frames import georeference, now_time, robot_frame
47
+ from spatialdds_ros.qos import MODEL_COMMAND, MODEL_FAST, MODEL_LATCHED
48
+ from spatialdds_ros.tempo import Tempo
49
+ from spatialdds_ros.topics import Topics
50
+
51
+ #: The property key a late refusal travels on. `demo.nav_status` in the
52
+ #: prototype; `nav.status` here, because this is no longer a demo's private
53
+ #: note but the library's own documented surface.
54
+ NAV_STATUS_KEY = "nav.status"
55
+
56
+
57
+ class Navigator(Protocol):
58
+ """Whatever actually drives the robot."""
59
+
60
+ def available(self) -> Optional[str]:
61
+ """`None` when it will accept goals; otherwise one plain reason."""
62
+
63
+ def send(self, x: float, y: float) -> str:
64
+ """Ask for a goal. Returns what was *accepted*, for the log."""
65
+
66
+
67
+ class RobotAdapter:
68
+ """Owns one entity on the scene's lanes."""
69
+
70
+ def __init__(self, participant: DomainParticipant, config: Config,
71
+ navigator: Optional[Navigator] = None) -> None:
72
+ self.config = config
73
+ self.topics = Topics(config.scene)
74
+ self._entities = tt.make_writer(
75
+ participant, self.topics.entity, Entity, MODEL_LATCHED.name)
76
+ self._poses = tt.make_writer(
77
+ participant, self.topics.pose, ModelPose, MODEL_FAST.name)
78
+ self._commands = tt.make_reader(
79
+ participant, self.topics.command, ModelCommand, MODEL_COMMAND.name)
80
+ # Created only when there is something to say. A writer on the frames
81
+ # lane that never writes is indistinguishable, to a reader, from one
82
+ # about to — so with no anchor there is no writer at all.
83
+ self._frames = (
84
+ tt.make_writer(participant, self.topics.frame_transform,
85
+ FrameTransform, MODEL_LATCHED.name)
86
+ if config.publishes_georeference else None)
87
+
88
+ self._tempo = Tempo()
89
+ self._entity: Optional[Entity] = None
90
+ self._goal: Optional[Tuple[float, float]] = None
91
+ self._silent = False
92
+ self._last_seen = 0.0
93
+ self._navigator = navigator
94
+ # **The last thing that happened to a goal, held until something
95
+ # changes it.** A refusal published once and then superseded by the
96
+ # next pose is an *event*, and an event can be missed: a client that
97
+ # polls the latched record a moment later sees a robot sitting still
98
+ # with nothing to explain it, which is the exact silence this whole
99
+ # path exists to end. "What became of the goal I sent" is state, and a
100
+ # latched record is where state belongs. Cleared when a new goal is
101
+ # accepted, because that supersedes the old outcome.
102
+ self._nav_status: Optional[str] = None
103
+
104
+ # --- what it publishes ------------------------------------------------
105
+
106
+ def _record(self, pose: PoseSE3, state: LifecycleState,
107
+ reason: str = "") -> Entity:
108
+ properties = [KV(key="label", value=self.config.effective_label)]
109
+ if self._nav_status:
110
+ properties.append(KV(key=NAV_STATUS_KEY, value=self._nav_status))
111
+ return Entity(
112
+ entity_id=self.config.entity_id,
113
+ # OBSERVED, and not configurable. A self-localizing robot is
114
+ # reporting where it has worked out that it is, from sensors —
115
+ # a sighting rather than an assertion, and not DERIVED because
116
+ # nothing here fuses anyone else's claims.
117
+ basis=Basis.OBSERVED,
118
+ type_uris=[self.config.type_uri],
119
+ layer=ModelLayer.FAST,
120
+ frame_ref=robot_frame(self.config.frame_id),
121
+ has_pose=True,
122
+ pose=pose,
123
+ # A robot locates itself; it does not legislate a region. An
124
+ # extent here would be read as keep-out by anything applying the
125
+ # DECLARED rule, which is not what a robot's footprint means.
126
+ has_extent=False,
127
+ extent=Aabb3(min_xyz=[0.0, 0.0, 0.0], max_xyz=[0.0, 0.0, 0.0]),
128
+ properties=properties,
129
+ # Only what the operator configured. An adapter that invented a
130
+ # reference would be asserting something about the world on
131
+ # nobody's authority; an adapter with nowhere to put one cannot be
132
+ # a consumer's only publisher of its robot, which is what blocked
133
+ # the first adoption. Empty is a published fact, not an omission.
134
+ external_refs=[KV(key=k, value=v)
135
+ for k, v in self.config.external_refs],
136
+ content_refs=list(self.config.content_refs),
137
+ state=state,
138
+ state_reason=reason,
139
+ source_id=self.config.effective_source_id,
140
+ stamp=now_time(),
141
+ )
142
+
143
+ def publish_georeference(self) -> Optional[str]:
144
+ """
145
+ Where this robot's frame sits on the earth, if an anchor was given.
146
+
147
+ Returns the line to log, or `None` when there is no anchor — which is
148
+ a documented mode and not a failure. `Config.startup_notes()` is where
149
+ that absence is explained to an operator; this just does not write.
150
+ """
151
+ if self._frames is None or self.config.anchor is None:
152
+ return None
153
+ transform = georeference(self.config.frame_id, self.config.anchor)
154
+ self._frames.write(transform)
155
+ return (f"georeference: {self.config.frame_id} -> "
156
+ f"{transform.parent_ref.fqn}")
157
+
158
+ def observed(self, x: float, y: float, yaw: float,
159
+ now: Optional[float] = None) -> None:
160
+ """
161
+ A new map-frame pose from upstream. Fast lane always; latch on cadence.
162
+
163
+ `z` comes from the configured ground height rather than from the
164
+ caller: a planar robot's upstream pose often carries a z of zero in its
165
+ own frame, and the scene's floor is a property of the scene.
166
+ """
167
+ now = time.time() if now is None else now
168
+ pose = PoseSE3(t=[x, y, self.config.ground_z],
169
+ q=_yaw_quaternion(yaw))
170
+ first = self._entity is None
171
+ was_silent = self._silent
172
+ self._entity = self._record(pose, LifecycleState.ACTIVE)
173
+ self._last_seen = now
174
+ self._silent = False
175
+
176
+ self._poses.write(ModelPose(
177
+ entity_id=self.config.entity_id, pose=pose,
178
+ source_id=self.config.effective_source_id,
179
+ stamp=self._entity.stamp))
180
+
181
+ # The expensive record goes on the cadence — and unconditionally when
182
+ # the robot first appears or comes back, because those are changes of
183
+ # *state* rather than of position, and a client waiting four more poses
184
+ # to learn the robot is alive again would be told late.
185
+ if (self._tempo.observed(self.config.entity_id, now)
186
+ or first or was_silent):
187
+ self._entities.write(self._entity)
188
+
189
+ def refused(self, why: str) -> None:
190
+ """
191
+ Republish the latched record carrying a refusal, now.
192
+
193
+ Off the cadence deliberately: this is a change of state, and the point
194
+ is that the client which asked learns promptly rather than up to four
195
+ poses later. It then *stays* on the record until a new goal is
196
+ accepted — see `_nav_status`. A no-op before the first pose: there is
197
+ no record to amend, and announcing a robot by complaining about it is
198
+ not an introduction.
199
+ """
200
+ if self._entity is None:
201
+ return
202
+ self._nav_status = why
203
+ self._entity = self._record(self._entity.pose, LifecycleState.ACTIVE)
204
+ self._entities.write(self._entity)
205
+
206
+ # --- what it is asked to do -------------------------------------------
207
+
208
+ def handle_command(self, command: ModelCommand) -> Optional[str]:
209
+ """
210
+ `goto`, and nothing else. A line for the log, or `None` when the
211
+ command was not ours to answer.
212
+
213
+ Silence for somebody else's subject: the lane has several owners and a
214
+ refusal from the wrong one reads as the system disagreeing with itself.
215
+ A malformed command *about this robot* is a different matter and gets
216
+ a reason.
217
+ """
218
+ if command.subject_id != self.config.entity_id:
219
+ return None
220
+ if command.verb != "goto":
221
+ return None
222
+ if not command.has_pose:
223
+ # A zeroed pose is a well-formed request to drive to the frame
224
+ # origin, so declining is the only reading that cannot be mistaken
225
+ # for obedience.
226
+ return (f"declined: goto for {self.config.entity_id} carried no "
227
+ f"pose")
228
+ if self._entity is None or self._silent:
229
+ return (f"declined: goto for {self.config.entity_id} — no pose "
230
+ f"from {self.config.effective_source_id}, so there is "
231
+ f"nothing to navigate from")
232
+ if self._navigator is not None:
233
+ unavailable = self._navigator.available()
234
+ if unavailable:
235
+ return (f"declined: goto for {self.config.entity_id} — "
236
+ f"{unavailable}")
237
+
238
+ x, y = command.pose.t[0], command.pose.t[1]
239
+ self._goal = (x, y)
240
+ # A new goal supersedes whatever became of the last one, so the record
241
+ # should stop reporting it. Cleared here rather than on arrival, so a
242
+ # *declined* command leaves the previous outcome standing — the
243
+ # requester still needs to see it.
244
+ self._nav_status = None
245
+ if self._navigator is None:
246
+ return (f"accepted goto ({x:.2f}, {y:.2f}) "
247
+ f"(asked by {command.requester_id})")
248
+ # What the navigator accepted, not what was sent. If it rounds, clamps
249
+ # or refuses, that is what belongs in the log.
250
+ outcome = self._navigator.send(x, y)
251
+ return f"{outcome} (asked by {command.requester_id})"
252
+
253
+ def poll_commands(self) -> List[str]:
254
+ """Drain the command lane. The goal is already set; the caller decides
255
+ what to do with the notes."""
256
+ notes = []
257
+ try:
258
+ batch = tt.take_samples(self._commands)
259
+ except Exception as error: # noqa: BLE001
260
+ # Untrusted traffic, like every other reader here.
261
+ return [f"command lane read failed: {error!r}"]
262
+ for command in batch:
263
+ note = self.handle_command(command)
264
+ if note:
265
+ notes.append(note)
266
+ return notes
267
+
268
+ def tick(self, now: Optional[float] = None) -> Optional[str]:
269
+ """
270
+ Housekeeping between poses: flush the latch when the robot stops
271
+ moving, and declare it UNOBSERVED when we stop hearing from it at all.
272
+ """
273
+ now = time.time() if now is None else now
274
+ if self._entity is None:
275
+ return None
276
+
277
+ if not self._silent and now - self._last_seen > self.silence_threshold():
278
+ self._silent = True
279
+ quiet_for = now - self._last_seen
280
+ self._entity = self._record(
281
+ self._entity.pose, LifecycleState.UNOBSERVED,
282
+ f"no pose from {self.config.effective_source_id} for "
283
+ f"{quiet_for:.1f}s; this is where it was last seen, not where "
284
+ f"it is")
285
+ self._entities.write(self._entity)
286
+ self._tempo.forget(self.config.entity_id)
287
+ return f"unobserved after {quiet_for:.1f}s of silence"
288
+
289
+ if not self._silent and self._tempo.idle(now):
290
+ self._entities.write(self._entity)
291
+ return "latch flushed — it stopped moving"
292
+ return None
293
+
294
+ def silence_threshold(self) -> float:
295
+ return self._tempo.threshold(self.config.entity_id)
296
+
297
+ @property
298
+ def goal(self) -> Optional[Tuple[float, float]]:
299
+ return self._goal
300
+
301
+ def clear_goal(self) -> None:
302
+ self._goal = None
303
+
304
+ @property
305
+ def state(self) -> str:
306
+ return "UNOBSERVED" if self._silent else "ACTIVE"
307
+
308
+
309
+ def _yaw_quaternion(yaw: float) -> list:
310
+ from spatialdds_ros.frames import yaw_quaternion
311
+ return yaw_quaternion(yaw)
312
+
313
+
314
+ def already_published(participant: DomainParticipant, config: Config,
315
+ settle: float = 2.5) -> Optional[str]:
316
+ """
317
+ Is somebody else already publishing this entity? Returns their source id.
318
+
319
+ One writer per key is the rule the whole model rests on, and two adapters
320
+ on one robot would put it in two places with nothing anywhere reporting a
321
+ conflict — the readers would simply see a robot that teleports. The caller
322
+ is expected to refuse to start.
323
+
324
+ `settle` is how long to wait for a latched record to arrive. TRANSIENT_LOCAL
325
+ means an existing publisher's record is delivered on connect, so this is
326
+ bounded discovery rather than a poll of an unknown duration.
327
+ """
328
+ reader = tt.make_reader(participant, Topics(config.scene).entity, Entity,
329
+ MODEL_LATCHED.name)
330
+ deadline = time.time() + settle
331
+ while time.time() < deadline:
332
+ for sample in tt.take_samples(reader):
333
+ if sample.entity_id == config.entity_id:
334
+ return sample.source_id
335
+ time.sleep(0.05)
336
+ return None