nros_rmw/traits.rs
1//! Transport abstraction traits.
2//!
3//! Defines the backend-agnostic interface that transport implementations
4//! (zenoh-pico, XRCE-DDS) must satisfy. The core trait hierarchy is:
5//!
6//! - [`Session`] — connection lifecycle and handle creation
7//! - [`Publisher`] / [`Subscription`] — pub/sub data transport
8//! - [`ServiceTrait`] / [`ClientTrait`] — request/reply
9//! - [`Rmw`] — top-level factory that creates sessions
10
11use nros_core::{Deserialize, RosMessage, RosService, Serialize};
12
13/// Topic information for pub/sub
14#[derive(Debug, Clone)]
15pub struct TopicInfo<'a> {
16 /// Topic name (e.g., "/chatter")
17 pub name: &'a str,
18 /// ROS type name (e.g., "std_msgs::msg::dds_::String_")
19 pub type_name: &'a str,
20 /// Type hash for compatibility checking
21 pub type_hash: &'a str,
22 /// Domain ID (default: 0)
23 pub domain_id: u32,
24 /// Node name for liveliness token generation.
25 /// `None` means no node association — no liveliness token will be declared.
26 pub node_name: Option<&'a str>,
27 /// Node namespace for liveliness token generation (default: "/").
28 /// In ROS 2, "/" is the root namespace and the standard default.
29 pub namespace: &'a str,
30 /// Phase 231 (RFC-0038) — receive-buffer size hint, bytes. The executor sets
31 /// this from the subscription's `RX_BUF`; a backend may use it to route the
32 /// subscription to a size-class receive buffer (zenoh-pico: small vs large).
33 /// `0` = unset (backend picks its default). Ignored by backends that don't
34 /// size-class their receive storage.
35 pub rx_buffer_hint: usize,
36 /// phase-279 (#145) — publisher-side "express" hint: this topic's samples
37 /// bypass transport tx batching (zenoh: the wire EXPRESS flag; a batching
38 /// zenoh-pico session sends them immediately instead of queueing them for
39 /// the next flush). For control-tier / latency-sensitive topics. Ignored by
40 /// subscriptions and by backends without a batching concept.
41 pub tx_express: bool,
42}
43
44impl<'a> TopicInfo<'a> {
45 /// Create new topic info
46 pub const fn new(name: &'a str, type_name: &'a str, type_hash: &'a str) -> Self {
47 Self {
48 name,
49 type_name,
50 type_hash,
51 domain_id: 0,
52 node_name: None,
53 namespace: "/",
54 rx_buffer_hint: 0,
55 tx_express: false,
56 }
57 }
58
59 /// Create topic info with custom domain ID
60 pub const fn with_domain(mut self, domain_id: u32) -> Self {
61 self.domain_id = domain_id;
62 self
63 }
64
65 /// Phase 231 (RFC-0038) — set the receive-buffer size hint (bytes) used by
66 /// size-classing backends (zenoh-pico) to route the subscription's receive
67 /// buffer to the small or large class.
68 pub const fn with_rx_buffer_hint(mut self, hint: usize) -> Self {
69 self.rx_buffer_hint = hint;
70 self
71 }
72
73 /// phase-279 (#145) — mark this publisher's samples express: they bypass
74 /// transport tx batching (sent immediately even when `ZPICO_TX_BATCH` is
75 /// on). For control-tier / latency-sensitive topics.
76 pub const fn with_tx_express(mut self, express: bool) -> Self {
77 self.tx_express = express;
78 self
79 }
80
81 /// Set the node name for liveliness token generation
82 pub const fn with_node_name(mut self, node_name: &'a str) -> Self {
83 self.node_name = Some(node_name);
84 self
85 }
86
87 /// Set the node namespace for liveliness token generation
88 pub const fn with_namespace(mut self, namespace: &'a str) -> Self {
89 self.namespace = namespace;
90 self
91 }
92}
93
94/// Service information for service client/server
95#[derive(Debug, Clone)]
96pub struct ServiceInfo<'a> {
97 /// Service name (e.g., "/add_two_ints")
98 pub name: &'a str,
99 /// ROS service type name (e.g., "example_interfaces::srv::dds_::AddTwoInts_")
100 pub type_name: &'a str,
101 /// Type hash for compatibility checking
102 pub type_hash: &'a str,
103 /// Domain ID (default: 0)
104 pub domain_id: u32,
105 /// Node name for liveliness token generation.
106 /// `None` means no node association — no liveliness token will be declared.
107 pub node_name: Option<&'a str>,
108 /// Node namespace for liveliness token generation (default: "/").
109 /// In ROS 2, "/" is the root namespace and the standard default.
110 pub namespace: &'a str,
111}
112
113/// Action information for action client/server
114///
115/// Actions in ROS 2 use 5 communication channels:
116/// - `send_goal` service: `<action_name>/_action/send_goal`
117/// - `cancel_goal` service: `<action_name>/_action/cancel_goal`
118/// - `get_result` service: `<action_name>/_action/get_result`
119/// - `feedback` topic: `<action_name>/_action/feedback`
120/// - `status` topic: `<action_name>/_action/status`
121#[derive(Debug, Clone)]
122pub struct ActionInfo<'a> {
123 /// Action name (e.g., "/fibonacci")
124 pub name: &'a str,
125 /// ROS action type name (e.g., "example_interfaces::action::dds_::Fibonacci_")
126 pub type_name: &'a str,
127 /// Type hash for compatibility checking
128 pub type_hash: &'a str,
129 /// Domain ID (default: 0)
130 pub domain_id: u32,
131}
132
133impl<'a> ActionInfo<'a> {
134 /// Create new action info
135 pub const fn new(name: &'a str, type_name: &'a str, type_hash: &'a str) -> Self {
136 Self {
137 name,
138 type_name,
139 type_hash,
140 domain_id: 0,
141 }
142 }
143
144 /// Create action info with custom domain ID
145 pub const fn with_domain(mut self, domain_id: u32) -> Self {
146 self.domain_id = domain_id;
147 self
148 }
149
150 /// Generate the send_goal service name
151 /// Returns: `<action>/_action/send_goal`
152 pub fn send_goal_key<const N: usize>(&self) -> heapless::String<N> {
153 self.sub_name::<N>("send_goal")
154 }
155
156 /// Generate the cancel_goal service name
157 /// Returns: `<action>/_action/cancel_goal`
158 pub fn cancel_goal_key<const N: usize>(&self) -> heapless::String<N> {
159 self.sub_name::<N>("cancel_goal")
160 }
161
162 /// Generate the get_result service name
163 /// Returns: `<action>/_action/get_result`
164 pub fn get_result_key<const N: usize>(&self) -> heapless::String<N> {
165 self.sub_name::<N>("get_result")
166 }
167
168 /// Generate the feedback topic name
169 /// Returns: `<action>/_action/feedback`
170 pub fn feedback_key<const N: usize>(&self) -> heapless::String<N> {
171 self.sub_name::<N>("feedback")
172 }
173
174 /// Generate the status topic name
175 /// Returns: `<action>/_action/status`
176 pub fn status_key<const N: usize>(&self) -> heapless::String<N> {
177 self.sub_name::<N>("status")
178 }
179
180 /// Generate a sub-entity name for an action component
181 /// Returns: `<action>/_action/<suffix>` (e.g., `fibonacci/_action/send_goal`)
182 ///
183 /// The caller is responsible for constructing the full key expression
184 /// by wrapping this name in a `ServiceInfo` or `TopicInfo` with the
185 /// correct sub-service/sub-topic type name.
186 fn sub_name<const N: usize>(&self, suffix: &str) -> heapless::String<N> {
187 let mut name = heapless::String::new();
188 let action_stripped = self.name.trim_matches('/');
189 let _ = core::fmt::write(
190 &mut name,
191 format_args!("/{}/_action/{}", action_stripped, suffix),
192 );
193 name
194 }
195}
196
197impl<'a> ServiceInfo<'a> {
198 /// Create new service info
199 pub const fn new(name: &'a str, type_name: &'a str, type_hash: &'a str) -> Self {
200 Self {
201 name,
202 type_name,
203 type_hash,
204 domain_id: 0,
205 node_name: None,
206 namespace: "/",
207 }
208 }
209
210 /// Create service info with custom domain ID
211 pub const fn with_domain(mut self, domain_id: u32) -> Self {
212 self.domain_id = domain_id;
213 self
214 }
215
216 /// Set the node name for liveliness token generation
217 pub const fn with_node_name(mut self, node_name: &'a str) -> Self {
218 self.node_name = Some(node_name);
219 self
220 }
221
222 /// Set the node namespace for liveliness token generation
223 pub const fn with_namespace(mut self, namespace: &'a str) -> Self {
224 self.namespace = namespace;
225 self
226 }
227}
228
229/// Transport error types.
230///
231/// No longer `Copy` — the `Backend` / `BackendDynamic` variants carry a
232/// string diagnostic, which can't be `Copy`. Rust callers that used to
233/// copy a `TransportError` value repeatedly now need `.clone()` or
234/// `ref` in match arms. C/C++ callers are unaffected — both map
235/// `TransportError` to integer codes (`nros_ret_t` / `ErrorCode`)
236/// before crossing the FFI boundary.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum TransportError {
239 /// Failed to connect to transport
240 ConnectionFailed,
241 /// Connection was closed
242 Disconnected,
243 /// Failed to create publisher
244 PublisherCreationFailed,
245 /// Failed to create subscriber
246 SubscriberCreationFailed,
247 /// Failed to create service server
248 ServiceServerCreationFailed,
249 /// Failed to create service client
250 ServiceClientCreationFailed,
251 /// Failed to publish message
252 PublishFailed,
253 /// Failed to send service request
254 ServiceRequestFailed,
255 /// Failed to send service reply
256 ServiceReplyFailed,
257 /// Serialization error
258 SerializationError,
259 /// Deserialization error
260 DeserializationError,
261 /// Buffer too small
262 BufferTooSmall,
263 /// Incoming message exceeded the static buffer capacity
264 MessageTooLarge,
265 /// Timeout waiting for message
266 Timeout,
267 /// Invalid configuration
268 InvalidConfig,
269 /// Resource (slot, buffer, queue) momentarily unavailable. Retry.
270 /// Phase 99: returned by `try_loan` when arena slots are full and
271 /// by `try_borrow` when no message is ready (alternative to
272 /// `Ok(None)` for backends that prefer the error variant).
273 WouldBlock,
274 /// Requested allocation exceeds backend capacity. Phase 99:
275 /// `try_loan(len)` returns this when `len` > arena slot size.
276 TooLarge,
277 /// Failed to start background tasks
278 TaskStartFailed,
279 /// Failed to poll for incoming messages
280 PollFailed,
281 /// Failed to send keepalive
282 KeepaliveFailed,
283 /// Failed to send join message
284 JoinFailed,
285 /// Caller supplied a NULL pointer, an out-of-range value, or an
286 /// inconsistent argument combination. Phase 102.1.
287 InvalidArgument,
288 /// The backend does not implement this operation. Optional
289 /// callbacks return this; the runtime then falls back to a
290 /// default path. Phase 102.1.
291 Unsupported,
292 /// Memory allocation failed. Returned by backends on `std` /
293 /// `alloc`-equipped platforms when heap allocation fails.
294 /// Bare-metal backends generally do not produce this — they
295 /// preallocate at session-open time. Phase 102.1.
296 BadAlloc,
297 /// Publisher and subscriber QoS profiles do not match in a way
298 /// the backend cannot reconcile. Phase 102.1.
299 IncompatibleQos,
300 /// Topic, service, or action name failed validation. Phase 102.1.
301 TopicNameInvalid,
302 /// A request referenced a node that does not exist in this
303 /// session. Phase 102.1.
304 NodeNameNonExistent,
305 /// The backend does not support loaned messages on this entity,
306 /// or the loan slot is currently in use. Phase 102.1.
307 LoanNotSupported,
308 /// No data was available on a non-blocking receive. Distinct
309 /// from `Timeout`: fires immediately, not after a bounded wait.
310 /// Phase 102.1.
311 NoData,
312 /// Phase 115.A.2 — caller passed a versioned vtable struct
313 /// (e.g. `NrosTransportOps`) with an `abi_version` the runtime
314 /// doesn't know. Maps to `NROS_RMW_RET_INCOMPATIBLE_ABI` at
315 /// the C boundary.
316 IncompatibleAbi,
317 /// Backend-specific error with a `'static` diagnostic string.
318 ///
319 /// Useful for zenoh-pico / XRCE-DDS return codes that map to a
320 /// fixed set of known messages. `no_std`-compatible.
321 Backend(&'static str),
322 /// Backend-specific error with an owned diagnostic string.
323 ///
324 /// Available only with the `alloc` feature. Use this when the
325 /// diagnostic is formatted at runtime (e.g. from a C error code
326 /// plus a socket address).
327 #[cfg(feature = "alloc")]
328 BackendDynamic(alloc::string::String),
329}
330
331/// QoS history policy
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
333pub enum QoSHistoryPolicy {
334 /// **The caller did not state a history policy** — the backend picks.
335 /// Upstream's `RMW_QOS_POLICY_HISTORY_SYSTEM_DEFAULT`; lowers to
336 /// `NROS_RMW_HISTORY_SYSTEM_DEFAULT` (0) across the C ABI.
337 ///
338 /// See [`QoSSystemDefaults`] for how a backend resolves it. Listed
339 /// first to match rclrs's enumerator order.
340 SystemDefault,
341 /// Keep last N messages (where N is defined in QoSProfile)
342 #[default]
343 KeepLast,
344 /// Keep all messages (up to resource limits)
345 KeepAll,
346}
347
348/// QoS reliability policy
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
350pub enum QoSReliabilityPolicy {
351 /// **The caller did not state a reliability policy** — the backend picks.
352 /// Upstream's `RMW_QOS_POLICY_RELIABILITY_SYSTEM_DEFAULT`; lowers to
353 /// `NROS_RMW_RELIABILITY_SYSTEM_DEFAULT` (0) across the C ABI.
354 ///
355 /// See [`QoSSystemDefaults`].
356 SystemDefault,
357 /// Reliable delivery (retransmit if needed).
358 ///
359 /// Default — matches ROS 2 `rmw_qos_profile_default` and the
360 /// `QoSProfile::default()` / `QOS_PROFILE_DEFAULT` aggregates.
361 #[default]
362 Reliable,
363 /// Best-effort delivery (no retransmits)
364 BestEffort,
365}
366
367/// QoS durability policy
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
369pub enum QoSDurabilityPolicy {
370 /// **The caller did not state a durability policy** — the backend picks.
371 /// Upstream's `RMW_QOS_POLICY_DURABILITY_SYSTEM_DEFAULT`; lowers to
372 /// `NROS_RMW_DURABILITY_SYSTEM_DEFAULT` (0) across the C ABI.
373 ///
374 /// See [`QoSSystemDefaults`].
375 SystemDefault,
376 /// Messages are discarded when subscriber disconnects
377 #[default]
378 Volatile,
379 /// Messages are persisted for late-joining subscribers
380 TransientLocal,
381}
382
383/// QoS liveliness policy. Matches DDS `LIVELINESS` semantics.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
385#[repr(u8)]
386pub enum QoSLivelinessPolicy {
387 /// No liveliness assertion or tracking. Default for entities
388 /// that don't care about liveliness.
389 #[default]
390 None = 0,
391 /// Backend's keepalive task asserts liveliness automatically.
392 Automatic = 1,
393 /// Application calls `assert_liveliness()` at the node level.
394 ///
395 /// Phase 376 W5/B2 — this was 3 and `ManualByTopic` was 2, the opposite of
396 /// upstream. The discriminant IS the C ABI value (`liveliness_kind` is
397 /// written with `as u8`), and the cyclonedds backend turns it into a real
398 /// DDS liveliness kind that a ROS peer matches on, so the swap was visible
399 /// on the wire and nowhere else.
400 ManualByNode = 2,
401 /// Application calls `assert_liveliness()` per topic explicitly.
402 ManualByTopic = 3,
403}
404
405/// Phase 211.H — which side of a topic a [`QoSOverride`] targets.
406/// Mirrors the `<role>` segment of a ROS 2
407/// `qos_overrides.<topic>.<role>.<policy>` launch parameter.
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409pub enum QoSOverrideRole {
410 /// `qos_overrides.<topic>.publisher.*`
411 Publisher,
412 /// `qos_overrides.<topic>.subscription.*`
413 Subscription,
414}
415
416/// Phase 211.H — a single policy value a [`QoSOverride`] sets. A typed enum
417/// (not a string) so the codegen that bakes these from the plan catches an
418/// unknown policy / mistyped value at generation time rather than silently
419/// no-op-ing at runtime.
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421pub enum QoSOverrideValue {
422 /// `.reliability` → Reliable / BestEffort.
423 Reliability(QoSReliabilityPolicy),
424 /// `.durability` → Volatile / TransientLocal.
425 Durability(QoSDurabilityPolicy),
426 /// `.history` → KeepLast / KeepAll.
427 History(QoSHistoryPolicy),
428 /// `.depth` → KeepLast depth.
429 Depth(u32),
430 /// Issue 0303 — `.deadline` → [`QoSProfile::deadline_ms`]. `0` /
431 /// [`DURATION_INFINITE_MS`] = no deadline check.
432 Deadline(u32),
433 /// Issue 0303 — `.lifespan` → [`QoSProfile::lifespan_ms`].
434 Lifespan(u32),
435 /// Issue 0303 — `.liveliness` → [`QoSProfile::liveliness_kind`].
436 Liveliness(QoSLivelinessPolicy),
437 /// Issue 0303 — `.liveliness_lease_duration` →
438 /// [`QoSProfile::liveliness_lease_ms`].
439 LivelinessLease(u32),
440}
441
442/// Issue 0303 — the wire form a baked QoS override travels in:
443/// `(topic, role, policy, value)`, all primitives.
444///
445/// One numbering for every language: `nros_qos_override_t` (C),
446/// `nros_cpp_qos_override_t` (C++), `RuntimeCtx::qos_overrides` (the Rust
447/// entry bake) and `NodeRecord::qos_overrides` are all THIS tuple. Codes rather
448/// than [`QoSOverride`] because the table crosses the C ABI and rides
449/// `nros-platform`, which sits below this crate in the layer graph.
450///
451/// `topic` is `&'static` because every producer bakes a literal.
452pub type QoSOverrideCode = (&'static str, u8, u8, u32);
453
454/// `role` codes for [`QoSOverrideCode`].
455pub mod qos_override_role {
456 /// The override targets publishers on the topic.
457 pub const PUBLISHER: u8 = 0;
458 /// The override targets subscriptions on the topic.
459 pub const SUBSCRIPTION: u8 = 1;
460}
461
462/// `policy` codes for [`QoSOverrideCode`].
463///
464/// Append-only: these numbers are baked into shipped images and mirrored in
465/// two C headers. Never renumber — add.
466pub mod qos_override_policy {
467 /// value `0` = best_effort, `1` = reliable.
468 pub const RELIABILITY: u8 = 0;
469 /// value `0` = volatile, `1` = transient_local.
470 pub const DURABILITY: u8 = 1;
471 /// value `0` = keep_last, `1` = keep_all.
472 pub const HISTORY: u8 = 2;
473 /// value = the KeepLast depth.
474 pub const DEPTH: u8 = 3;
475 /// value = milliseconds (issue 0303).
476 pub const DEADLINE: u8 = 4;
477 /// value = milliseconds (issue 0303).
478 pub const LIFESPAN: u8 = 5;
479 /// value = [`super::QoSLivelinessPolicy`] discriminant (issue 0303).
480 pub const LIVELINESS: u8 = 6;
481 /// value = milliseconds (issue 0303).
482 pub const LIVELINESS_LEASE: u8 = 7;
483}
484
485/// Decode one [`QoSOverrideCode`] into a typed [`QoSOverride`].
486///
487/// `None` for an unrecognised role or policy code. THE one decoder: before
488/// issue 0303 this match existed four times (nros-node, nros-c, nros-cpp, and
489/// the executor's node record), each with a silent catch-all, so adding a
490/// policy meant finding all four — and the two FFI copies had already been
491/// forgotten once.
492pub fn decode_qos_override(code: &QoSOverrideCode) -> Option<QoSOverride> {
493 let (topic, role, policy, value) = *code;
494 decode_qos_override_parts(topic, role, policy, value)
495}
496
497/// [`decode_qos_override`] over loose parts — for the FFI paths, which read the
498/// fields out of a `#[repr(C)]` struct rather than a tuple.
499pub fn decode_qos_override_parts(
500 topic: &'static str,
501 role: u8,
502 policy: u8,
503 value: u32,
504) -> Option<QoSOverride> {
505 Some(QoSOverride {
506 topic,
507 role: decode_qos_override_role(role)?,
508 value: decode_qos_override_value(policy, value)?,
509 })
510}
511
512/// Decode a `role` code. `None` for an unrecognised one.
513pub fn decode_qos_override_role(role: u8) -> Option<QoSOverrideRole> {
514 match role {
515 qos_override_role::PUBLISHER => Some(QoSOverrideRole::Publisher),
516 qos_override_role::SUBSCRIPTION => Some(QoSOverrideRole::Subscription),
517 _ => None,
518 }
519}
520
521/// Decode a `(policy, value)` code pair. `None` for an unrecognised policy or
522/// an out-of-range enum value.
523///
524/// Split out from [`decode_qos_override`] for the FFI paths: they have already
525/// matched the topic against a `*const c_char`, so they need the VALUE without
526/// a `&'static str` to build a whole [`QoSOverride`] around.
527pub fn decode_qos_override_value(policy: u8, value: u32) -> Option<QoSOverrideValue> {
528 let out = match policy {
529 qos_override_policy::RELIABILITY => QoSOverrideValue::Reliability(if value == 0 {
530 QoSReliabilityPolicy::BestEffort
531 } else {
532 QoSReliabilityPolicy::Reliable
533 }),
534 qos_override_policy::DURABILITY => QoSOverrideValue::Durability(if value == 1 {
535 QoSDurabilityPolicy::TransientLocal
536 } else {
537 QoSDurabilityPolicy::Volatile
538 }),
539 qos_override_policy::HISTORY => QoSOverrideValue::History(if value == 1 {
540 QoSHistoryPolicy::KeepAll
541 } else {
542 QoSHistoryPolicy::KeepLast
543 }),
544 qos_override_policy::DEPTH => QoSOverrideValue::Depth(value),
545 qos_override_policy::DEADLINE => QoSOverrideValue::Deadline(value),
546 qos_override_policy::LIFESPAN => QoSOverrideValue::Lifespan(value),
547 // The encoder is `nros_orchestration_ir::qos_override`; both ends name
548 // the variant rather than its number, so W5/B2's renumbering moved them
549 // together instead of silently swapping two policies.
550 qos_override_policy::LIVELINESS => QoSOverrideValue::Liveliness(match value {
551 v if v == QoSLivelinessPolicy::None as u32 => QoSLivelinessPolicy::None,
552 v if v == QoSLivelinessPolicy::Automatic as u32 => QoSLivelinessPolicy::Automatic,
553 v if v == QoSLivelinessPolicy::ManualByNode as u32 => QoSLivelinessPolicy::ManualByNode,
554 v if v == QoSLivelinessPolicy::ManualByTopic as u32 => {
555 QoSLivelinessPolicy::ManualByTopic
556 }
557 _ => return None,
558 }),
559 qos_override_policy::LIVELINESS_LEASE => QoSOverrideValue::LivelinessLease(value),
560 _ => return None,
561 };
562 Some(out)
563}
564
565/// Phase 211.H — one per-topic QoS override, lowered from a ROS 2
566/// `qos_overrides.<topic>.<role>.<policy>` launch parameter by the planner and
567/// baked into a `&'static [QoSOverride]` table by the entry codegen. The node
568/// folds the matching entries into the entity's [`QoSProfile`] at
569/// `create_publisher` / `create_subscription` time (setup-time, single
570/// linear scan, no alloc), *before* the backend-compat `validate_against` —
571/// so an override the active RMW can't honour still errors loudly, never a
572/// silent downgrade.
573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
574pub struct QoSOverride {
575 /// The resolved (remapped) topic name the override targets, e.g.
576 /// `"/chatter"`. Matched exactly against the entity's topic.
577 pub topic: &'static str,
578 /// Publisher or subscription side.
579 pub role: QoSOverrideRole,
580 /// The policy + value to set.
581 pub value: QoSOverrideValue,
582}
583
584/// Phase-301 (issue 0241) — explicit "infinite" spelling for the u32
585/// millisecond QoS duration fields (`deadline_ms`, `lifespan_ms`,
586/// `liveliness_lease_ms`). Semantically identical to `0` (unset /
587/// no-check) at every check site; exists so a caller can distinguish
588/// "default" from "deliberately infinite". Mirrors the C header's
589/// `NROS_RMW_DURATION_INFINITE_MS`.
590pub const DURATION_INFINITE_MS: u32 = u32::MAX;
591
592/// Phase-301 (issue 0241) — lower a [`core::time::Duration`] into a u32
593/// millisecond QoS field. Boundary contract:
594///
595/// - zero stays `0` (unset / no-check);
596/// - sub-millisecond remainders CEIL to the next ms (rounding down
597/// would silently turn a real deadline into "no deadline");
598/// - values at or past [`DURATION_INFINITE_MS`] ms are a create-time
599/// error, never a clamp (infinite is requested via the sentinel or
600/// `0`, not by a huge finite duration).
601pub fn duration_to_qos_ms(d: core::time::Duration) -> Result<u32, TransportError> {
602 if d.is_zero() {
603 return Ok(0);
604 }
605 let mut ms = d.as_millis();
606 if !d.subsec_nanos().is_multiple_of(1_000_000) {
607 ms += 1;
608 }
609 if ms >= DURATION_INFINITE_MS as u128 {
610 return Err(TransportError::InvalidArgument);
611 }
612 Ok(ms as u32)
613}
614
615/// Full DDS-shaped QoS profile. Matches the field set of upstream
616/// `rmw_qos_profile_t`.
617///
618/// Backends advertise per-policy support via
619/// [`Session::supported_qos_policies`]; entities created with a
620/// profile the active backend can't honour return
621/// [`TransportError::IncompatibleQos`] synchronously at create time
622/// — no silent downgrade.
623///
624/// Zero-valued time-window fields ("off") mean infinite — the policy
625/// is effectively disabled for the entity.
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub struct QoSProfile {
628 /// History policy
629 pub history: QoSHistoryPolicy,
630 /// Reliability policy
631 pub reliability: QoSReliabilityPolicy,
632 /// Durability policy
633 pub durability: QoSDurabilityPolicy,
634 /// Liveliness policy
635 pub liveliness_kind: QoSLivelinessPolicy,
636 /// History depth (only used if history is KeepLast)
637 pub depth: u32,
638 /// Subscription max-inter-arrival / publisher offered-rate, ms.
639 /// `0` = infinite (no deadline check).
640 pub deadline_ms: u32,
641 /// Sample expiry, ms. Subscribers filter samples older than this.
642 /// `0` = infinite (no expiry).
643 pub lifespan_ms: u32,
644 /// Liveliness lease, ms. `0` = infinite.
645 pub liveliness_lease_ms: u32,
646 /// If `true`, topic-name encoding skips the `/rt/` ROS prefix.
647 pub avoid_ros_namespace_conventions: bool,
648 /// Phase 282 (#145) — publisher-side "express" hint: this publisher's
649 /// samples bypass transport tx batching (zenoh: the wire EXPRESS flag; a
650 /// batching zenoh-pico session sends them immediately instead of queueing
651 /// them for the next flush). A transport hint, not a DDS policy — no RxO
652 /// matching, no backend-compat validation; ignored by subscriptions and by
653 /// backends without a batching concept.
654 pub tx_express: bool,
655}
656
657impl Default for QoSProfile {
658 fn default() -> Self {
659 Self::QOS_PROFILE_DEFAULT
660 }
661}
662
663/// The depth sentinel — "the caller did not state a queue depth".
664///
665/// issue 0829. Upstream spells it `RMW_QOS_POLICY_DEPTH_SYSTEM_DEFAULT = 0`
666/// (`rmw/include/rmw/types.h`); depth is `size_t` there and `uint16_t` in our
667/// C ABI, but 0 is 0 in both. The value was already free on every backend:
668/// Cyclone REJECTS `KEEP_LAST` with depth 0 outright
669/// (`validate_history_qospolicy`, `ddsi_plist.c:2603-2604`), the XRCE client
670/// already reads it as "unstated" and drops the field from the wire
671/// (`create_entities_bin.c:148`), and both `read_entity_qos`
672/// (`nros-rmw-cyclonedds/src/qos.cpp:138`) and `report_qos_downgrade`
673/// (`nros-rmw-cffi/src/lib.rs:1903`) already treat a 0 read-back as "no
674/// answer" rather than as an answer.
675pub const DEPTH_SYSTEM_DEFAULT: u32 = 0;
676
677/// What ONE backend resolves the `SYSTEM_DEFAULT` sentinel to.
678///
679/// issue 0829. `rmw_qos_profile_system_default` is an absence, and the RMW
680/// fills it — which means the answer is per backend and there is no constant
681/// this crate could bake. Each backend declares its own `QoSSystemDefaults`
682/// and applies it with [`QoSProfile::resolve_system_default`] at its create
683/// entry, **before anything is derived from the QoS**.
684///
685/// That ordering is load-bearing on the zenoh path: the profile is serialised
686/// into the liveliness-token keyexpr that a ROS `rmw_zenoh_cpp` peer parses
687/// out of the graph (`nros-rmw-zenoh/src/keyexpr.rs`). Upstream resolves in
688/// `best_available_qos` before the entity and its token exist, so its tokens
689/// never carry a sentinel; ours must not either, or we advertise `0:0:0,0` to
690/// peers as though it were a policy.
691///
692/// The values a backend picks should mirror **the corresponding upstream
693/// RMW**, not the raw middleware default — interop with a ROS peer is the
694/// requirement, and the two differ. Leaving `dds_qset_reliability` unset gives
695/// Cyclone's own reader default of BEST_EFFORT (`ddsi_plist.c:3470`), where
696/// `rmw_cyclonedds_cpp` deliberately picks RELIABLE.
697#[derive(Debug, Clone, Copy, PartialEq, Eq)]
698pub struct QoSSystemDefaults {
699 /// Concrete reliability. Must not itself be `SystemDefault`.
700 pub reliability: QoSReliabilityPolicy,
701 /// Concrete durability. Must not itself be `SystemDefault`.
702 pub durability: QoSDurabilityPolicy,
703 /// Concrete history. Must not itself be `SystemDefault`.
704 pub history: QoSHistoryPolicy,
705 /// Concrete queue depth. `0` means the backend genuinely has no answer and
706 /// defers further down the stack — the XRCE case, where the client encodes
707 /// exactly that (`optional_history_depth = false`) and the Agent's DDS
708 /// layer resolves it.
709 pub depth: u32,
710}
711
712impl QoSProfile {
713 /// Phase 211.H — fold the plan's `qos_overrides` matching `topic` + `role`
714 /// into this profile, returning the overridden profile. Setup-time only
715 /// (called from `create_publisher`/`create_subscription`): a single linear
716 /// scan over the baked `&'static` table, no alloc, RT-safe. Later entries
717 /// win on a duplicate `(topic, role, policy)` (last-write), matching the
718 /// planner's sorted, de-conflicted emit. Non-matching entries are ignored,
719 /// so passing the whole node table to every entity is cheap + correct.
720 #[must_use]
721 pub fn apply_overrides(
722 mut self,
723 topic: &str,
724 role: QoSOverrideRole,
725 overrides: &[QoSOverride],
726 ) -> Self {
727 for ovr in overrides {
728 if ovr.topic == topic && ovr.role == role {
729 self.apply_override_value(ovr.value);
730 }
731 }
732 self
733 }
734
735 /// Issue 0303 — apply ONE decoded override value. The single place a
736 /// policy maps to the field it sets; `apply_overrides` and the FFI folds
737 /// both go through it, so a new policy cannot reach some paths only.
738 pub fn apply_override_value(&mut self, value: QoSOverrideValue) {
739 match value {
740 QoSOverrideValue::Reliability(r) => self.reliability = r,
741 QoSOverrideValue::Durability(d) => self.durability = d,
742 QoSOverrideValue::History(h) => self.history = h,
743 QoSOverrideValue::Depth(d) => self.depth = d,
744 QoSOverrideValue::Deadline(ms) => self.deadline_ms = ms,
745 QoSOverrideValue::Lifespan(ms) => self.lifespan_ms = ms,
746 QoSOverrideValue::Liveliness(k) => self.liveliness_kind = k,
747 QoSOverrideValue::LivelinessLease(ms) => self.liveliness_lease_ms = ms,
748 }
749 }
750
751 /// Issue 0303 — fold baked [`QoSOverrideCode`]s for one `(topic, role)`.
752 /// Unrecognised codes are skipped; the producers reject them at BAKE time
753 /// (`nros_orchestration_ir::qos_override`), so a code reaching here that
754 /// this build does not know is an older image running newer data, not a
755 /// user error to diagnose at runtime.
756 pub fn apply_override_codes(
757 self,
758 topic: &str,
759 role: QoSOverrideRole,
760 codes: &[QoSOverrideCode],
761 ) -> Self {
762 let mut qos = self;
763 for code in codes {
764 if let Some(ovr) = decode_qos_override(code) {
765 qos = qos.apply_overrides(topic, role, core::slice::from_ref(&ovr));
766 }
767 }
768 qos
769 }
770}
771
772impl QoSProfile {
773 /// Internal const builder. Extended-policy fields default to
774 /// "off" (zero) and `liveliness_kind = Automatic` (the upstream
775 /// `rmw_qos_profile_default` choice).
776 const fn build(
777 reliability: QoSReliabilityPolicy,
778 durability: QoSDurabilityPolicy,
779 history: QoSHistoryPolicy,
780 depth: u32,
781 ) -> Self {
782 Self {
783 history,
784 reliability,
785 durability,
786 liveliness_kind: QoSLivelinessPolicy::Automatic,
787 depth,
788 deadline_ms: 0,
789 lifespan_ms: 0,
790 liveliness_lease_ms: 0,
791 avoid_ros_namespace_conventions: false,
792 tx_express: false,
793 }
794 }
795
796 /// Create new QoS settings with defaults (matches `QOS_PROFILE_DEFAULT`:
797 /// Reliable, Volatile, KeepLast(10)).
798 pub const fn new() -> Self {
799 Self::QOS_PROFILE_DEFAULT
800 }
801
802 /// Best-effort QoS (for real-time)
803 pub const BEST_EFFORT: Self = Self::build(
804 QoSReliabilityPolicy::BestEffort,
805 QoSDurabilityPolicy::Volatile,
806 QoSHistoryPolicy::KeepLast,
807 1,
808 );
809
810 /// Reliable QoS
811 pub const RELIABLE: Self = Self::build(
812 QoSReliabilityPolicy::Reliable,
813 QoSDurabilityPolicy::Volatile,
814 QoSHistoryPolicy::KeepLast,
815 10,
816 );
817
818 /// `rmw_qos_profile_system_default` — **an absence, not a profile.**
819 ///
820 /// issue 0829. Every field is the sentinel: upstream's constant names no
821 /// concrete policy at all, and the two reference RMWs fill the absence with
822 /// different numbers — `rmw_cyclonedds_cpp`'s `create_readwrite_qos` folds
823 /// `RMW_QOS_POLICY_DEPTH_SYSTEM_DEFAULT` to `KEEP_LAST(1)`, while
824 /// `rmw_zenoh_cpp`'s `QoS::QoS()` fills it from
825 /// `RMW_ZENOH_DEFAULT_HISTORY_DEPTH`, which is 42, over a comment stating
826 /// the contract outright: *"If the depth field in the qos profile is set to
827 /// 0, the RMW implementation has the liberty to assign a default depth."*
828 ///
829 /// So no baked number can be right. This carried a concrete
830 /// `Reliable / Volatile / KeepLast(1)` until 2026-09-03, and
831 /// `nros::qos::SYSTEM_DEFAULT` carried a concrete depth **10**, which is how
832 /// one name shipped two queue depths. Both are gone; the backend resolves
833 /// this at its create entry via [`QoSProfile::resolve_system_default`].
834 ///
835 /// `liveliness_kind` is [`QoSLivelinessPolicy::None`], which IS the
836 /// sentinel on that policy — it lowers to
837 /// `NROS_RMW_LIVELINESS_SYSTEM_DEFAULT` (0), the two having collapsed onto
838 /// one value in phase-376 W5/B2.
839 pub const QOS_PROFILE_SYSTEM_DEFAULT: Self = Self {
840 history: QoSHistoryPolicy::SystemDefault,
841 reliability: QoSReliabilityPolicy::SystemDefault,
842 durability: QoSDurabilityPolicy::SystemDefault,
843 liveliness_kind: QoSLivelinessPolicy::None,
844 depth: DEPTH_SYSTEM_DEFAULT,
845 deadline_ms: 0,
846 lifespan_ms: 0,
847 liveliness_lease_ms: 0,
848 avoid_ros_namespace_conventions: false,
849 tx_express: false,
850 };
851
852 /// Default QoS profile (matches rmw_qos_profile_default)
853 pub const QOS_PROFILE_DEFAULT: Self = Self::build(
854 QoSReliabilityPolicy::Reliable,
855 QoSDurabilityPolicy::Volatile,
856 QoSHistoryPolicy::KeepLast,
857 10,
858 );
859
860 /// Sensor data QoS profile (matches rmw_qos_profile_sensor_data)
861 pub const QOS_PROFILE_SENSOR_DATA: Self = Self::build(
862 QoSReliabilityPolicy::BestEffort,
863 QoSDurabilityPolicy::Volatile,
864 QoSHistoryPolicy::KeepLast,
865 5,
866 );
867
868 /// Services default QoS profile (matches rmw_qos_profile_services_default)
869 pub const QOS_PROFILE_SERVICES_DEFAULT: Self = Self::build(
870 QoSReliabilityPolicy::Reliable,
871 QoSDurabilityPolicy::Volatile,
872 QoSHistoryPolicy::KeepLast,
873 10,
874 );
875
876 /// Parameters QoS profile (matches rmw_qos_profile_parameters)
877 /// Mirrors `rmw_qos_profile_parameters`: KEEP_LAST(1000), RELIABLE,
878 /// **VOLATILE**.
879 ///
880 /// issue 0793 — this said `TransientLocal` until 2026-08-25, disagreeing
881 /// both with upstream (`/opt/ros/<distro>/include/rmw/rmw/qos_profiles.h`)
882 /// and with our own second copy of the same profile, `nros::qos::PARAMETERS`,
883 /// which was already correct. Two copies of one profile that disagree is the
884 /// defect; the wrong one being the one named after the upstream constant is
885 /// what made it hard to see.
886 pub const QOS_PROFILE_PARAMETERS: Self = Self::build(
887 QoSReliabilityPolicy::Reliable,
888 QoSDurabilityPolicy::Volatile,
889 QoSHistoryPolicy::KeepLast,
890 1000,
891 );
892
893 /// Clock QoS profile - same as sensor data but with depth 1
894 pub const QOS_PROFILE_CLOCK: Self = Self::build(
895 QoSReliabilityPolicy::BestEffort,
896 QoSDurabilityPolicy::Volatile,
897 QoSHistoryPolicy::KeepLast,
898 1,
899 );
900
901 /// Parameter events QoS profile (matches rmw_qos_profile_parameter_events)
902 pub const QOS_PROFILE_PARAMETER_EVENTS: Self = Self::build(
903 QoSReliabilityPolicy::Reliable,
904 QoSDurabilityPolicy::Volatile,
905 QoSHistoryPolicy::KeepAll,
906 0, // Not used with KeepAll
907 );
908
909 /// Action status default QoS profile (matches rcl_action_qos_profile_status_default)
910 pub const QOS_PROFILE_ACTION_STATUS_DEFAULT: Self = Self::build(
911 QoSReliabilityPolicy::Reliable,
912 QoSDurabilityPolicy::TransientLocal,
913 QoSHistoryPolicy::KeepLast,
914 1,
915 );
916
917 /// PX4 companion QoS profile (Phase 233 / RFC-0039 Track B). Matches the
918 /// QoS PX4's `uxrce_dds_client` uses on `/fmu/out/*` and `/fmu/in/*` —
919 /// `BEST_EFFORT` + `VOLATILE` + `KEEP_LAST(1)`. A nano-ros node talking to
920 /// the same `MicroXRCEAgent` must use this (a reliable or
921 /// `TRANSIENT_LOCAL` reader will not match PX4's volatile best-effort
922 /// writers). Verified against real PX4 SITL (`nros-px4-sitl-test`):
923 /// `TRANSIENT_LOCAL` durability silently fails to match `/fmu/out/*`.
924 /// Adjust depth via `.keep_last(n)` for higher-rate streams.
925 pub const QOS_PROFILE_PX4: Self = Self::build(
926 QoSReliabilityPolicy::BestEffort,
927 QoSDurabilityPolicy::Volatile,
928 QoSHistoryPolicy::KeepLast,
929 1,
930 );
931
932 // --- Static constructor methods (matching rclrs API) ---
933
934 /// Get the default QoS profile for ordinary topics
935 pub const fn topics_default() -> Self {
936 Self::QOS_PROFILE_DEFAULT
937 }
938
939 /// The PX4 companion QoS profile ([`QOS_PROFILE_PX4`](Self::QOS_PROFILE_PX4))
940 /// — use for `/fmu/out/*` subscriptions and `/fmu/in/*` publications against
941 /// a `MicroXRCEAgent`.
942 pub const fn px4() -> Self {
943 Self::QOS_PROFILE_PX4
944 }
945
946 /// Get the default QoS profile for sensor data topics
947 pub const fn sensor_data_default() -> Self {
948 Self::QOS_PROFILE_SENSOR_DATA
949 }
950
951 /// Get the default QoS profile for services
952 pub const fn services_default() -> Self {
953 Self::QOS_PROFILE_SERVICES_DEFAULT
954 }
955
956 /// Get the default QoS profile for parameter services
957 pub const fn parameters_default() -> Self {
958 Self::QOS_PROFILE_PARAMETERS
959 }
960
961 /// Get the default QoS profile for parameter events
962 pub const fn parameter_events_default() -> Self {
963 Self::QOS_PROFILE_PARAMETER_EVENTS
964 }
965
966 /// Get the system default QoS profile
967 pub const fn system_default() -> Self {
968 Self::QOS_PROFILE_SYSTEM_DEFAULT
969 }
970
971 /// Get the default QoS profile for action status topics
972 pub const fn action_status_default() -> Self {
973 Self::QOS_PROFILE_ACTION_STATUS_DEFAULT
974 }
975
976 /// Get the default QoS profile for clock topics
977 pub const fn clock_default() -> Self {
978 Self::QOS_PROFILE_CLOCK
979 }
980
981 // --- Builder methods ---
982
983 /// Set history to keep last N messages
984 pub const fn keep_last(mut self, depth: u32) -> Self {
985 self.history = QoSHistoryPolicy::KeepLast;
986 self.depth = depth;
987 self
988 }
989
990 /// Set history to keep all messages
991 pub const fn keep_all(mut self) -> Self {
992 self.history = QoSHistoryPolicy::KeepAll;
993 self
994 }
995
996 /// Set reliability to reliable
997 pub const fn reliable(mut self) -> Self {
998 self.reliability = QoSReliabilityPolicy::Reliable;
999 self
1000 }
1001
1002 /// Set reliability to best-effort
1003 pub const fn best_effort(mut self) -> Self {
1004 self.reliability = QoSReliabilityPolicy::BestEffort;
1005 self
1006 }
1007
1008 /// Set durability to volatile
1009 pub const fn volatile(mut self) -> Self {
1010 self.durability = QoSDurabilityPolicy::Volatile;
1011 self
1012 }
1013
1014 /// Set durability to transient local
1015 pub const fn transient_local(mut self) -> Self {
1016 self.durability = QoSDurabilityPolicy::TransientLocal;
1017 self
1018 }
1019
1020 /// Set reliability policy explicitly
1021 pub const fn reliability(mut self, policy: QoSReliabilityPolicy) -> Self {
1022 self.reliability = policy;
1023 self
1024 }
1025
1026 /// Set durability policy explicitly
1027 pub const fn durability(mut self, policy: QoSDurabilityPolicy) -> Self {
1028 self.durability = policy;
1029 self
1030 }
1031
1032 /// Set history policy explicitly
1033 pub const fn history(mut self, policy: QoSHistoryPolicy) -> Self {
1034 self.history = policy;
1035 self
1036 }
1037
1038 /// Set history depth explicitly
1039 pub const fn depth(mut self, depth: u32) -> Self {
1040 self.depth = depth;
1041 self
1042 }
1043
1044 /// Phase 282 (#145) — mark this publisher's samples "express": they
1045 /// bypass transport tx batching (sent immediately even when the batching
1046 /// knob is on). A transport hint for control-tier / latency-sensitive
1047 /// topics; ignored on subscriptions and by backends without batching.
1048 pub const fn tx_express(mut self, express: bool) -> Self {
1049 self.tx_express = express;
1050 self
1051 }
1052
1053 /// Get history depth (for backwards compatibility)
1054 pub const fn history_depth(&self) -> u8 {
1055 if self.depth > 255 {
1056 255
1057 } else {
1058 self.depth as u8
1059 }
1060 }
1061}
1062
1063/// Transport session configuration
1064#[derive(Debug, Clone)]
1065pub struct TransportConfig<'a> {
1066 /// Peer locator (e.g., "tcp/192.168.1.1:7447" or "serial//dev/ttyUSB0#baudrate=115200")
1067 pub locator: Option<&'a str>,
1068 /// Session mode: client, peer, or router
1069 pub mode: SessionMode,
1070 /// Additional transport properties (key-value pairs)
1071 ///
1072 /// These are passed through to the underlying transport backend.
1073 /// For zenoh-pico, recognized keys include:
1074 /// - `"multicast_scouting"` - Enable/disable multicast scouting (`"true"` or `"false"`)
1075 /// - `"scouting_timeout_ms"` - Scouting timeout in milliseconds
1076 /// - `"multicast_locator"` - Multicast group address
1077 /// - `"listen"` - Listen endpoint (e.g., `"tcp/0.0.0.0:0"`)
1078 /// - `"add_timestamp"` - Add timestamps to messages (`"true"` or `"false"`)
1079 pub properties: &'a [(&'a str, &'a str)],
1080 /// Node name for ROS 2 graph discovery liveliness token.
1081 ///
1082 /// Empty string (`""`) means no node-liveliness token is declared (preserves
1083 /// the pre-#104 behaviour). Non-empty causes the session to declare a
1084 /// `@ros2_lv/<domain>/<zid>/0/0/NN/%/<ns>/<node>` token on open.
1085 pub node_name: &'a str,
1086 /// Node namespace for the liveliness token (e.g., `""` or `"/ns1"`).
1087 ///
1088 /// Empty string is treated as root `"/"` by the keyexpr builder.
1089 pub namespace: &'a str,
1090 /// ROS 2 domain ID used in the liveliness token key expression.
1091 pub domain_id: u32,
1092}
1093
1094impl Default for TransportConfig<'_> {
1095 fn default() -> Self {
1096 Self {
1097 locator: None,
1098 mode: SessionMode::Client,
1099 properties: &[],
1100 node_name: "",
1101 namespace: "",
1102 domain_id: 0,
1103 }
1104 }
1105}
1106
1107/// Middleware-agnostic session configuration.
1108///
1109/// `RmwConfig` provides a uniform interface that any RMW backend can
1110/// interpret. Backends map the universal fields to their own connection
1111/// parameters and interpret `properties` for anything backend-specific.
1112///
1113/// # Examples
1114///
1115/// ```
1116/// use nros_rmw::{RmwConfig, SessionMode};
1117///
1118/// let config = RmwConfig {
1119/// locator: "tcp/192.168.1.1:7447",
1120/// mode: SessionMode::Client,
1121/// domain_id: 0,
1122/// node_name: "talker",
1123/// namespace: "",
1124/// properties: &[],
1125/// };
1126/// ```
1127#[derive(Debug, Clone, Copy)]
1128pub struct RmwConfig<'a> {
1129 /// Middleware-specific connection string.
1130 ///
1131 /// - zenoh: `"tcp/192.168.1.1:7447"` or `"udp/224.0.0.224:7447"`
1132 /// - XRCE-DDS: `"udp/192.168.1.1:2019"`
1133 pub locator: &'a str,
1134 /// Session mode (zenoh: client/peer; XRCE-DDS: always client)
1135 pub mode: SessionMode,
1136 /// ROS 2 domain ID (maps to DDS domain or zenoh key prefix)
1137 pub domain_id: u32,
1138 /// Node name (e.g., `"talker"`)
1139 pub node_name: &'a str,
1140 /// Node namespace (e.g., `""` or `"/ns1"`)
1141 pub namespace: &'a str,
1142 /// Backend-specific key/value properties.
1143 ///
1144 /// Uniform escape hatch for backend-specific tuning that doesn't fit
1145 /// the universal fields above. Each backend documents the keys it
1146 /// understands; unknown keys are ignored. Passing `&[]` is always
1147 /// valid.
1148 ///
1149 /// Examples:
1150 /// - zenoh: `"tls.root_ca"`, `"scouting.multicast.enabled"`
1151 /// - XRCE-DDS: `"agent_port"`, `"client_key"`
1152 pub properties: &'a [(&'a str, &'a str)],
1153}
1154
1155impl Default for RmwConfig<'_> {
1156 fn default() -> Self {
1157 Self {
1158 locator: "tcp/127.0.0.1:7447",
1159 mode: SessionMode::Client,
1160 domain_id: 0,
1161 node_name: "node",
1162 namespace: "",
1163 properties: &[],
1164 }
1165 }
1166}
1167
1168/// Locator transport protocol
1169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1170pub enum LocatorProtocol {
1171 /// TCP transport (e.g., "tcp/127.0.0.1:7447")
1172 Tcp,
1173 /// UDP transport (e.g., "udp/192.168.1.50:2019" — common for XRCE-DDS)
1174 Udp,
1175 /// Serial/UART transport (e.g., "serial//dev/ttyUSB0#baudrate=115200")
1176 Serial,
1177 /// Unknown protocol
1178 Unknown,
1179}
1180
1181/// Parse the protocol from a locator string
1182pub fn locator_protocol(locator: &str) -> LocatorProtocol {
1183 if locator.starts_with("tcp/") {
1184 LocatorProtocol::Tcp
1185 } else if locator.starts_with("udp/") {
1186 LocatorProtocol::Udp
1187 } else if locator.starts_with("serial/") {
1188 LocatorProtocol::Serial
1189 } else {
1190 LocatorProtocol::Unknown
1191 }
1192}
1193
1194/// Validate a locator string format.
1195///
1196/// Returns `Ok(())` if the locator is well-formed, or an error message describing
1197/// the problem. This provides early feedback before zenoh-pico or XRCE-DDS rejects
1198/// a bad locator.
1199///
1200/// Supported formats:
1201/// - TCP: `tcp/<host>:<port>` (e.g., `tcp/127.0.0.1:7447`)
1202/// - UDP: `udp/<host>:<port>` (e.g., `udp/192.168.1.50:2019`)
1203/// - Serial: `serial/<device>#baudrate=<rate>` (e.g., `serial//dev/ttyUSB0#baudrate=115200`)
1204pub fn validate_locator(locator: &str) -> Result<(), &'static str> {
1205 match locator_protocol(locator) {
1206 LocatorProtocol::Tcp => {
1207 let rest = &locator[4..]; // skip "tcp/"
1208 if !rest.contains(':') {
1209 return Err("TCP locator must contain host:port (e.g., tcp/127.0.0.1:7447)");
1210 }
1211 Ok(())
1212 }
1213 LocatorProtocol::Udp => {
1214 let rest = &locator[4..]; // skip "udp/"
1215 if !rest.contains(':') {
1216 return Err("UDP locator must contain host:port (e.g., udp/192.168.1.50:2019)");
1217 }
1218 Ok(())
1219 }
1220 LocatorProtocol::Serial => {
1221 let rest = &locator[7..]; // skip "serial/"
1222 if rest.is_empty() {
1223 return Err(
1224 "serial locator must specify device (e.g., serial//dev/ttyUSB0#baudrate=115200)",
1225 );
1226 }
1227 if !rest.contains("#baudrate=") {
1228 return Err(
1229 "serial locator must include #baudrate=RATE (e.g., serial//dev/ttyUSB0#baudrate=115200)",
1230 );
1231 }
1232 // Validate baudrate is numeric
1233 if let Some(baud_str) = rest.split("#baudrate=").nth(1) {
1234 let baud_str = baud_str.split('#').next().unwrap_or(baud_str);
1235 if baud_str.parse::<u32>().is_err() {
1236 return Err("serial baudrate must be a number");
1237 }
1238 }
1239 Ok(())
1240 }
1241 LocatorProtocol::Unknown => {
1242 Err("unknown locator protocol (expected tcp/, udp/, or serial/)")
1243 }
1244 }
1245}
1246
1247/// Session mode
1248#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1249pub enum SessionMode {
1250 /// Connect as client to a router
1251 #[default]
1252 Client,
1253 /// Connect as peer for peer-to-peer communication
1254 Peer,
1255}
1256
1257/// Transport session trait — the per-process anchor an RMW backend
1258/// gives to the executor.
1259///
1260/// # Threading
1261///
1262/// `&mut self` on every method means the executor serialises all
1263/// session calls onto a single thread. A backend may rely on this
1264/// — no internal locking is required for `create_*` / `close` /
1265/// `drive_io`. **Publisher / subscriber / service handles created
1266/// from the session, however, are typically used from worker
1267/// threads** and must carry their own synchronisation (see the
1268/// [`Publisher`] / [`Subscription`] trait docs).
1269///
1270/// # Calling pattern
1271///
1272/// 1. Open the session (backend-specific factory; not on this trait).
1273/// 2. `create_*` for every entity at startup. Creating entities mid-
1274/// flight after `drive_io` has run is allowed but not common.
1275/// 3. The executor calls `drive_io` periodically. Worker threads
1276/// publish / receive in parallel.
1277/// 4. `close` once at shutdown. Entities must be dropped first.
1278pub trait Session {
1279 /// RFC-0088 — the serialization format this backend speaks, as ROS 2's
1280 /// `rmw_get_serialization_format()` reports it ("One middleware can only
1281 /// have one encoding").
1282 ///
1283 /// Defaulted to CDR because every backend in tree except uORB speaks it,
1284 /// and a backend that speaks something else says so by overriding these
1285 /// two. They travel together: `SERIALIZATION_FORMAT` is the identity that
1286 /// crosses images (bridge config, tooling, the vtable slot) and
1287 /// `SERIALIZATION_FORMAT_ID` is the image-local discriminant used for the
1288 /// one-byte comparison a bridge makes at construction.
1289 const SERIALIZATION_FORMAT: &'static str =
1290 nros_serdes::format::SerializationFormatId::Cdr.as_str();
1291
1292 /// Image-local discriminant for [`Self::SERIALIZATION_FORMAT`]. Never
1293 /// persisted, never compared across images — see `nros_serdes::format`.
1294 const SERIALIZATION_FORMAT_ID: nros_serdes::format::SerializationFormatId =
1295 nros_serdes::format::SerializationFormatId::Cdr;
1296
1297 /// RFC-0088 — this session's serialization format, as ROS 2's
1298 /// `rmw_get_serialization_format()` reports it.
1299 ///
1300 /// **Per session, not per process.** ROS 2's function takes no handle
1301 /// because one process links one middleware; an `Executor::open_multi`
1302 /// image links two, so the answer must be asked of the session.
1303 ///
1304 /// The default answers from [`Self::SERIALIZATION_FORMAT`], which is right
1305 /// for any backend whose format is a compile-time fact. A session that
1306 /// dispatches to a backend chosen at run time — the C-ABI adapter, whose
1307 /// vtable carries the answer — overrides this to ask the backend.
1308 fn serialization_format(&self) -> &'static str {
1309 Self::SERIALIZATION_FORMAT
1310 }
1311
1312 /// Error type for this session
1313 type Error;
1314 /// Publisher handle type
1315 type PublisherHandle;
1316 /// Subscription handle type
1317 type SubscriptionHandle;
1318 /// Service server handle type
1319 type ServiceHandle;
1320 /// Service client handle type
1321 type ClientHandle;
1322
1323 /// Create a publisher bound to this session.
1324 ///
1325 /// May allocate transport resources (zenoh declarations, DDS
1326 /// writers). Returns a handle that outlives the call but not the
1327 /// session — drop the handle before `close()`.
1328 fn create_publisher(
1329 &mut self,
1330 topic: &TopicInfo,
1331 qos: QoSProfile,
1332 ) -> Result<Self::PublisherHandle, Self::Error>;
1333
1334 /// Create a subscriber bound to this session.
1335 ///
1336 /// Subscribers may start receiving immediately after creation if
1337 /// the transport supports late-joining publishers. Late messages
1338 /// are buffered up to the QoS depth.
1339 fn create_subscription(
1340 &mut self,
1341 topic: &TopicInfo,
1342 qos: QoSProfile,
1343 ) -> Result<Self::SubscriptionHandle, Self::Error>;
1344
1345 /// Create a service server bound to this session. Replies are
1346 /// matched to requests by the sequence number returned from
1347 /// [`ServiceTrait::take_request`].
1348 ///
1349 /// `qos` is applied to both the request and reply endpoints (a
1350 /// service is two DDS topics; rmw uses one profile for both). The
1351 /// default is [`QoSProfile::services_default`]
1352 /// (RELIABLE+VOLATILE+KEEP_LAST(10)).
1353 fn create_service(
1354 &mut self,
1355 service: &ServiceInfo,
1356 qos: QoSProfile,
1357 ) -> Result<Self::ServiceHandle, Self::Error>;
1358
1359 /// Create a service client bound to this session.
1360 ///
1361 /// `qos` is applied to both the request and reply endpoints (a
1362 /// service is two DDS topics; rmw uses one profile for both). The
1363 /// default is [`QoSProfile::services_default`]
1364 /// (RELIABLE+VOLATILE+KEEP_LAST(10)).
1365 fn create_client(
1366 &mut self,
1367 service: &ServiceInfo,
1368 qos: QoSProfile,
1369 ) -> Result<Self::ClientHandle, Self::Error>;
1370
1371 /// Close the session, releasing transport resources. All entity
1372 /// handles created from this session must already be dropped.
1373 fn close(&mut self) -> Result<(), Self::Error>;
1374
1375 /// Drive transport I/O (poll network, dispatch callbacks).
1376 ///
1377 /// Both zenoh-pico and XRCE-DDS are pull-based: they require the
1378 /// application to periodically call this method to read from the
1379 /// network socket and dispatch incoming messages to subscriber
1380 /// buffers.
1381 ///
1382 /// `timeout_ms` is the maximum time to wait for data (0 = non-blocking;
1383 /// negative values mean "block indefinitely" — see Phase 84.D7 for the
1384 /// planned migration to `core::time::Duration`).
1385 ///
1386 /// **Required**. There is no default body — both shipped backends
1387 /// (zenoh and XRCE) must drive I/O, and a silent no-op default was a
1388 /// trap for third-party implementers. If your backend genuinely
1389 /// receives data via OS callbacks (push-based) and has nothing to do
1390 /// here, return `Ok(())` explicitly.
1391 fn drive_io(&mut self, timeout_ms: i32) -> Result<(), Self::Error>;
1392
1393 /// Phase 109 — report which QoS policies the active backend
1394 /// honours. The runtime validates requested QoS against this mask
1395 /// at entity-create time and returns
1396 /// [`TransportError::IncompatibleQos`] if the requested profile
1397 /// includes a policy the backend can't enforce. **No silent
1398 /// downgrade.**
1399 ///
1400 /// Default returns [`QoSPolicyMask::CORE`] — reliability +
1401 /// durability VOLATILE + history + depth. Backends override per
1402 /// supported policy.
1403 fn supported_qos_policies(&self) -> QoSPolicyMask {
1404 QoSPolicyMask::CORE
1405 }
1406
1407 /// Phase 110.0 — backend's next internal-event deadline in
1408 /// milliseconds from now (lease keepalive, heartbeat, reader
1409 /// ACK-NACK timeout, etc.).
1410 ///
1411 /// The executor caps its `drive_io` timeout against
1412 /// `min(user_timeout, timer_deadline, this)` so quiet links don't
1413 /// wake early, see no user-visible work, and round-trip back into
1414 /// `drive_io`. Returns `None` when the backend has no internal
1415 /// deadlines or chooses not to expose them.
1416 ///
1417 /// Default `None` keeps existing backends working unchanged; opt-in
1418 /// per backend.
1419 fn next_deadline_ms(&self) -> Option<u32> {
1420 None
1421 }
1422
1423 /// Phase 124.B.1 — install (or clear, when `cb.is_none()`) the
1424 /// executor wake callback. The runtime calls this once per
1425 /// session after `open` with `cb` pointing at a runtime-owned
1426 /// function and `ctx` pointing at the executor's wake state.
1427 /// The backend stores `(cb, ctx)` in its per-session state and
1428 /// calls `cb(ctx)` whenever its transport notification path
1429 /// fires (datagram arrival, condvar wake, etc.) — the runtime
1430 /// cb does flag-write + condvar-signal atomically, so a
1431 /// `spin_once` blocked on the wake condvar resumes immediately
1432 /// instead of waiting for the next poll iteration.
1433 ///
1434 /// # Safety
1435 ///
1436 /// When `cb` is `Some`, `ctx` must remain valid until the callback is
1437 /// cleared or the session is closed. The backend may invoke `cb(ctx)` from
1438 /// its transport notification path.
1439 ///
1440 /// Default body: ignore the call. Poll-only backends (XRCE,
1441 /// bare-metal) leave the default in place; the executor still
1442 /// drains them on its deadline-bound cv-wait boundary.
1443 unsafe fn set_wake_callback(
1444 &mut self,
1445 cb: Option<unsafe extern "C" fn(ctx: *mut core::ffi::c_void)>,
1446 ctx: *mut core::ffi::c_void,
1447 ) {
1448 let _ = (cb, ctx);
1449 }
1450
1451 /// Phase 130.4 — does this backend actually honour
1452 /// [`set_wake_callback`]?
1453 ///
1454 /// `true` means the backend installs the callback and will
1455 /// fire it from its async notify path (worker thread, ISR,
1456 /// signalfd, …). `false` (the default) means
1457 /// `set_wake_callback` was a no-op — the executor must drive
1458 /// I/O for the caller's full timeout because no async wake
1459 /// will pre-empt it.
1460 ///
1461 /// The executor uses this to choose between the wake-primitive
1462 /// wait (`NodeWake::wait_ms` / `std::Condvar::wait_timeout_while`)
1463 /// and a direct `drive_io(timeout_ms)`. Poll-only backends
1464 /// (XRCE-DDS-Client, bare-metal smoltcp) return `false`;
1465 /// event-driven backends (zenoh-pico with an RX task that
1466 /// invokes the callback on packet arrival) return `true`.
1467 ///
1468 /// [`set_wake_callback`]: Self::set_wake_callback
1469 fn supports_wake_callback(&self) -> bool {
1470 false
1471 }
1472
1473 /// Phase 124.F.1 — session-level connectivity probe.
1474 ///
1475 /// Sends a wire-level round-trip probe and waits up to
1476 /// `timeout_ms`. `Ok(())` on reply, `Err(TransportError::Timeout)`
1477 /// on no reply, `Err(TransportError::Unsupported)` when the
1478 /// backend can't probe (DDS without participant introspection).
1479 /// Lesson from micro-ROS's `rmw_uros_ping_agent`.
1480 ///
1481 /// Default body: `Err(Unsupported)`. Backends with a native
1482 /// ping API (zenoh: `z_send_ping`; XRCE:
1483 /// `uxr_ping_agent_session_until_timeout`) opt in by overriding.
1484 fn ping_session(&mut self, timeout_ms: i32) -> Result<(), Self::Error>
1485 where
1486 Self::Error: From<TransportError>,
1487 {
1488 let _ = timeout_ms;
1489 Err(TransportError::Unsupported.into())
1490 }
1491
1492 /// phase-381 W3 — enumerate the nodes this session can see.
1493 ///
1494 /// A VISITOR, not a returned collection, because upstream's
1495 /// `rcutils_string_array_t` allocates two levels deep and there is no
1496 /// allocator at this seam. A caller-provided buffer is worse than it looks:
1497 /// the graph has no bound the CALLER can know. So the backend streams from
1498 /// state it already holds, peak extra RAM is one entry, and a caller with a
1499 /// bound stops early by returning `false`.
1500 ///
1501 /// `namespace` and `name` are ROS names. `enclave` is `None` where the
1502 /// backend does not track one — which is what lets this one method answer
1503 /// both `rmw_get_node_names` and `rmw_get_node_names_with_enclaves`.
1504 ///
1505 /// Every string is BORROWED for the duration of the call.
1506 ///
1507 /// **Must not block on the wire, and takes no timeout.** It reports what has
1508 /// ALREADY arrived, so the first call after startup legitimately returns a
1509 /// partial graph — a backend feeds its view from `drive_io`. Letting this
1510 /// block was considered and rejected: it would stall the executor's only
1511 /// thread inside an introspection call, on a runtime whose premise is that
1512 /// there is no other thread to do the work.
1513 ///
1514 /// Default body: `Err(Unsupported)` — a backend with no graph (XRCE) says
1515 /// so, and the runtime can tell that from an empty graph.
1516 fn get_node_names(
1517 &mut self,
1518 visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
1519 ) -> Result<(), Self::Error>
1520 where
1521 Self::Error: From<TransportError>,
1522 {
1523 let _ = visit;
1524 Err(TransportError::Unsupported.into())
1525 }
1526
1527 /// phase-381 W3 — how many publishers this session can see on `topic_name`.
1528 ///
1529 /// `topic_name` is a ROS name (`/chatter`); the backend mangles as needed.
1530 /// Same warm-up caveat as [`Self::get_node_names`]: a count reflects what
1531 /// has already been discovered, so it can be low right after startup and is
1532 /// never a proof of absence.
1533 ///
1534 /// Default body: `Err(Unsupported)` — distinct from `Ok(0)`, which claims
1535 /// there are none.
1536 fn count_publishers(&mut self, topic_name: &str) -> Result<usize, Self::Error>
1537 where
1538 Self::Error: From<TransportError>,
1539 {
1540 let _ = topic_name;
1541 Err(TransportError::Unsupported.into())
1542 }
1543
1544 /// phase-381 W3 — how many subscribers this session can see on `topic_name`.
1545 /// See [`Self::count_publishers`] for the caveats.
1546 fn count_subscribers(&mut self, topic_name: &str) -> Result<usize, Self::Error>
1547 where
1548 Self::Error: From<TransportError>,
1549 {
1550 let _ = topic_name;
1551 Err(TransportError::Unsupported.into())
1552 }
1553
1554 /// phase-381 W3 — every topic, with the types published or subscribed on it.
1555 ///
1556 /// A visitor for the same reason as [`Self::get_node_names`], and one call
1557 /// per distinct NAME: the contract hands over a name and the types on it,
1558 /// so a topic carrying two types is one visit with two entries, not two
1559 /// visits.
1560 ///
1561 /// `types_count` may legitimately be 0 on a partially discovered graph —
1562 /// reporting the name without a type beats dropping it.
1563 ///
1564 /// Default body: `Err(Unsupported)`.
1565 fn get_topic_names_and_types(
1566 &mut self,
1567 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
1568 ) -> Result<(), Self::Error>
1569 where
1570 Self::Error: From<TransportError>,
1571 {
1572 let _ = visit;
1573 Err(TransportError::Unsupported.into())
1574 }
1575
1576 /// phase-381 W3 — every service, with its types. As
1577 /// [`Self::get_topic_names_and_types`], over servers and clients.
1578 fn get_service_names_and_types(
1579 &mut self,
1580 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
1581 ) -> Result<(), Self::Error>
1582 where
1583 Self::Error: From<TransportError>,
1584 {
1585 let _ = visit;
1586 Err(TransportError::Unsupported.into())
1587 }
1588
1589 /// phase-381 W3 — what ONE named node publishes / subscribes / serves /
1590 /// calls.
1591 ///
1592 /// `node_name` and `node_namespace` are ROS names; a node the graph has not
1593 /// discovered yields no visits, which is NOT an error — see
1594 /// [`Self::get_node_names`] for why an empty answer is "not seen yet".
1595 ///
1596 /// `kind` selects which of the four upstream `*_by_node` questions this
1597 /// answers. One method rather than four because the four differ ONLY by
1598 /// which entity kind they keep, and four trait methods would be four copies
1599 /// of one filter.
1600 ///
1601 /// The trait keeps the ABI's `subscriber` vocabulary because
1602 /// `rmw_get_subscriber_names_and_types_by_node` is what upstream rmw calls
1603 /// it and RFC-0054 makes the C headers the SSoT. The USER-facing spelling
1604 /// is per language and settled at that layer: rcl says `subscriber`, rclcpp
1605 /// and rclrs say `subscription`.
1606 ///
1607 /// Default body: `Err(Unsupported)`.
1608 fn get_names_and_types_by_node(
1609 &mut self,
1610 kind: GraphEntityKind,
1611 node_name: &str,
1612 node_namespace: &str,
1613 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
1614 ) -> Result<(), Self::Error>
1615 where
1616 Self::Error: From<TransportError>,
1617 {
1618 let _ = (kind, node_name, node_namespace, visit);
1619 Err(TransportError::Unsupported.into())
1620 }
1621
1622 /// phase-381 W3 — the endpoints on one topic, with the QoS each GRANTED.
1623 ///
1624 /// `publishers` selects `rmw_get_publishers_info_by_topic` (`true`) or
1625 /// `rmw_get_subscriptions_info_by_topic` (`false`).
1626 ///
1627 /// The granted profile is the whole reason a consumer asks: "why is nothing
1628 /// arriving" is usually a QoS incompatibility, and the REQUESTED profile
1629 /// cannot answer it. A backend that cannot read a remote's granted QoS says
1630 /// so per field rather than echoing the request back — see
1631 /// `rmw_topic_endpoint_info_t`.
1632 ///
1633 /// Default body: `Err(Unsupported)`.
1634 fn get_endpoint_info_by_topic(
1635 &mut self,
1636 publishers: bool,
1637 topic_name: &str,
1638 visit: &mut dyn FnMut(&GraphEndpointInfo<'_>) -> bool,
1639 ) -> Result<(), Self::Error>
1640 where
1641 Self::Error: From<TransportError>,
1642 {
1643 let _ = (publishers, topic_name, visit);
1644 Err(TransportError::Unsupported.into())
1645 }
1646}
1647
1648/// Which entity kind a `*_by_node` graph query keeps — phase-381 W3.
1649///
1650/// Named for upstream rmw's four `*_by_node` slots. `Subscriber` carries rmw's
1651/// word, not rclcpp's `subscription`; the user-facing spelling is chosen per
1652/// language one layer up.
1653#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1654pub enum GraphEntityKind {
1655 Publisher,
1656 Subscriber,
1657 Service,
1658 Client,
1659}
1660
1661/// One discovered endpoint on a topic — phase-381 W3, the Rust view of
1662/// `rmw_topic_endpoint_info_t`.
1663///
1664/// Every string BORROWS from the backend's own state for the duration of the
1665/// visit. A caller that needs one afterwards copies it; that is what lets the
1666/// graph stream without an allocator.
1667#[derive(Debug, Clone)]
1668pub struct GraphEndpointInfo<'a> {
1669 /// Node that owns the endpoint.
1670 pub node_name: &'a str,
1671 /// That node's namespace.
1672 pub node_namespace: &'a str,
1673 /// Fully-qualified type on the wire, e.g. `"std_msgs/msg/Int32"`.
1674 pub topic_type: &'a str,
1675 /// `true` for a publisher, `false` for a subscription.
1676 pub is_publisher: bool,
1677 /// The endpoint's 24-byte identity; all-zero when the backend has none.
1678 pub endpoint_gid: [u8; 24],
1679}
1680
1681// No `qos` field, deliberately. The C seam carries one and fills it with the
1682// ABI's `*_UNKNOWN` sentinels, which is the contract for a policy a backend
1683// cannot determine (see `publisher_get_actual_qos`: write UNKNOWN and return
1684// OK; `UNSUPPORTED` means no read-back AT ALL).
1685//
1686// No backend can fill it today. zenoh's liveliness token carries the DECLARING
1687// side's own profile, not a negotiated grant, and this seam promises the
1688// granted one — reporting the declaration would be the plausible wrong answer
1689// the slot exists to avoid, since "why is nothing arriving" is usually a QoS
1690// mismatch and the requested profile cannot show it. Cyclone can read a real
1691// grant and will need this; adding a field to a Rust struct then is additive
1692// and not an ABI change, which is why carrying an always-`None` field now
1693// buys nothing.
1694
1695/// Bitmask of QoS policies a backend can honour. See
1696/// [`Session::supported_qos_policies`].
1697///
1698/// `CORE` covers the policies every nano-ros backend implements:
1699/// reliability, durability=VOLATILE, history, depth. Backends opt
1700/// into additional policies by OR-ing the relevant flags.
1701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1702pub struct QoSPolicyMask(pub u32);
1703
1704impl QoSPolicyMask {
1705 pub const RELIABILITY: Self = Self(1 << 0);
1706 pub const DURABILITY_VOLATILE: Self = Self(1 << 1);
1707 pub const DURABILITY_TRANSIENT_LOCAL: Self = Self(1 << 2);
1708 pub const HISTORY: Self = Self(1 << 3);
1709 pub const DEPTH: Self = Self(1 << 4);
1710 pub const DEADLINE: Self = Self(1 << 5);
1711 pub const LIFESPAN: Self = Self(1 << 6);
1712 pub const LIVELINESS_AUTOMATIC: Self = Self(1 << 7);
1713 pub const LIVELINESS_MANUAL_BY_TOPIC: Self = Self(1 << 8);
1714 pub const LIVELINESS_MANUAL_BY_NODE: Self = Self(1 << 9);
1715 pub const LIVELINESS_LEASE: Self = Self(1 << 10);
1716 pub const AVOID_ROS_NAMESPACE_CONVENTIONS: Self = Self(1 << 11);
1717
1718 /// Policies every nano-ros backend implements.
1719 pub const CORE: Self =
1720 Self(Self::RELIABILITY.0 | Self::DURABILITY_VOLATILE.0 | Self::HISTORY.0 | Self::DEPTH.0);
1721
1722 /// `true` if `self` contains every policy in `other`.
1723 pub const fn contains(self, other: Self) -> bool {
1724 self.0 & other.0 == other.0
1725 }
1726
1727 /// Bitwise OR of two masks.
1728 pub const fn union(self, other: Self) -> Self {
1729 Self(self.0 | other.0)
1730 }
1731}
1732
1733impl core::ops::BitOr for QoSPolicyMask {
1734 type Output = Self;
1735 fn bitor(self, rhs: Self) -> Self {
1736 self.union(rhs)
1737 }
1738}
1739
1740impl core::ops::BitOrAssign for QoSPolicyMask {
1741 fn bitor_assign(&mut self, rhs: Self) {
1742 self.0 |= rhs.0;
1743 }
1744}
1745
1746impl QoSProfile {
1747 /// Compute the set of QoS policies actually requested by this profile.
1748 ///
1749 /// Zero-valued time fields and `LivelinessKind::None` count as "not
1750 /// requesting" the corresponding policy — the cheap default.
1751 ///
1752 /// issue 0829 — the four `CORE` bits (reliability, durability, history,
1753 /// depth) used to be added UNCONDITIONALLY, on the reasoning that every
1754 /// nano-ros backend honours them. That is true and still not the right
1755 /// test: this function answers *what did the caller ASK FOR*, and a
1756 /// `SYSTEM_DEFAULT` policy asks for nothing. Starting from `CORE` made
1757 /// `QOS_PROFILE_SYSTEM_DEFAULT` demand all four, so a backend that could
1758 /// not honour one would reject the profile with `IncompatibleQos` — for
1759 /// requesting nothing. The pattern was already here for the extended
1760 /// policies three lines down (a zero `deadline_ms` declines its bit); it
1761 /// just never reached the four CORE ones.
1762 ///
1763 /// This RELAXES the mask, so it can only turn a rejection into an
1764 /// acceptance, never the reverse — and it flips no verdict today: every
1765 /// `supported_qos_policies` impl in the tree returns at least `CORE`
1766 /// (`traits.rs` default impl, `nros-node/src/mock.rs:259`,
1767 /// `nros-rmw-cffi/src/lib.rs:2580`, `nros-rmw-zenoh/src/shim/session.rs:1245`),
1768 /// so a CORE bit has never been the reason for a failure.
1769 pub fn required_policies(&self) -> QoSPolicyMask {
1770 let mut mask = QoSPolicyMask(0);
1771 if self.reliability != QoSReliabilityPolicy::SystemDefault {
1772 mask |= QoSPolicyMask::RELIABILITY;
1773 }
1774 if self.history != QoSHistoryPolicy::SystemDefault {
1775 mask |= QoSPolicyMask::HISTORY;
1776 }
1777 // Depth's sentinel is a VALUE, not a variant: `DEPTH_SYSTEM_DEFAULT`
1778 // is 0, the same 0 that `KEEP_ALL` profiles carry to mean "depth is
1779 // not used here" (`QOS_PROFILE_PARAMETER_EVENTS`). Both mean the
1780 // caller did not ask for a depth, so both decline the bit.
1781 if self.depth != DEPTH_SYSTEM_DEFAULT {
1782 mask |= QoSPolicyMask::DEPTH;
1783 }
1784 match self.durability {
1785 QoSDurabilityPolicy::SystemDefault => {}
1786 QoSDurabilityPolicy::Volatile => mask |= QoSPolicyMask::DURABILITY_VOLATILE,
1787 QoSDurabilityPolicy::TransientLocal => {
1788 mask |= QoSPolicyMask::DURABILITY_TRANSIENT_LOCAL
1789 }
1790 }
1791 // Phase-301 (issue 0241): DURATION_INFINITE_MS reads the same as 0
1792 // (infinite = no check) at every duration check site.
1793 if self.deadline_ms != 0 && self.deadline_ms != DURATION_INFINITE_MS {
1794 mask |= QoSPolicyMask::DEADLINE;
1795 }
1796 if self.lifespan_ms != 0 && self.lifespan_ms != DURATION_INFINITE_MS {
1797 mask |= QoSPolicyMask::LIFESPAN;
1798 }
1799 match self.liveliness_kind {
1800 QoSLivelinessPolicy::None => {}
1801 QoSLivelinessPolicy::Automatic => mask |= QoSPolicyMask::LIVELINESS_AUTOMATIC,
1802 QoSLivelinessPolicy::ManualByTopic => mask |= QoSPolicyMask::LIVELINESS_MANUAL_BY_TOPIC,
1803 QoSLivelinessPolicy::ManualByNode => mask |= QoSPolicyMask::LIVELINESS_MANUAL_BY_NODE,
1804 }
1805 if self.liveliness_lease_ms != 0 && self.liveliness_lease_ms != DURATION_INFINITE_MS {
1806 mask |= QoSPolicyMask::LIVELINESS_LEASE;
1807 }
1808 if self.avoid_ros_namespace_conventions {
1809 mask |= QoSPolicyMask::AVOID_ROS_NAMESPACE_CONVENTIONS;
1810 }
1811 mask
1812 }
1813
1814 /// Returns `Err(TransportError::IncompatibleQos)` if any policy this
1815 /// profile requires is missing from the backend's `supported` mask.
1816 /// Used at entity-create time to enforce the **no silent
1817 /// degradation** contract.
1818 pub fn validate_against(&self, supported: QoSPolicyMask) -> Result<(), TransportError> {
1819 if supported.contains(self.required_policies()) {
1820 Ok(())
1821 } else {
1822 Err(TransportError::IncompatibleQos)
1823 }
1824 }
1825
1826 /// Replace every `SYSTEM_DEFAULT` field with the backend's own answer.
1827 ///
1828 /// issue 0829 — a backend calls this at its create entry, **before**
1829 /// anything is derived from the profile (see [`QoSSystemDefaults`] for why
1830 /// the ordering matters on the zenoh path). Fields the caller DID state
1831 /// are left exactly as they are: this resolves an absence, it never
1832 /// overrides a request.
1833 ///
1834 /// Idempotent, and safe to call on a fully concrete profile — a profile
1835 /// with no sentinel in it is returned unchanged.
1836 #[must_use]
1837 pub const fn resolve_system_default(mut self, defaults: &QoSSystemDefaults) -> Self {
1838 if matches!(self.reliability, QoSReliabilityPolicy::SystemDefault) {
1839 self.reliability = defaults.reliability;
1840 }
1841 if matches!(self.durability, QoSDurabilityPolicy::SystemDefault) {
1842 self.durability = defaults.durability;
1843 }
1844 if matches!(self.history, QoSHistoryPolicy::SystemDefault) {
1845 self.history = defaults.history;
1846 }
1847 if self.depth == DEPTH_SYSTEM_DEFAULT {
1848 self.depth = defaults.depth;
1849 }
1850 self
1851 }
1852
1853 /// `true` if any field is still the `SYSTEM_DEFAULT` sentinel.
1854 ///
1855 /// Depth is deliberately NOT part of this test: a `KEEP_ALL` profile
1856 /// legitimately carries depth 0 forever (`QOS_PROFILE_PARAMETER_EVENTS`),
1857 /// and a backend that resolves the sentinel depth to 0 — XRCE does, on
1858 /// purpose — leaves a resolved profile reading 0 here. This asks about the
1859 /// three POLICY fields, where the sentinel is a distinct variant and so
1860 /// cannot be confused with a stated value.
1861 #[must_use]
1862 pub const fn has_unresolved_system_default(&self) -> bool {
1863 matches!(self.reliability, QoSReliabilityPolicy::SystemDefault)
1864 || matches!(self.durability, QoSDurabilityPolicy::SystemDefault)
1865 || matches!(self.history, QoSHistoryPolicy::SystemDefault)
1866 }
1867}
1868
1869/// Publisher trait for sending messages.
1870///
1871/// # Threading
1872///
1873/// `&self` on `publish_raw` — implementors must allow concurrent
1874/// publishes from multiple threads. Internal locking (or lock-free
1875/// queues) is the backend's responsibility.
1876///
1877/// # Buffer ownership
1878///
1879/// `data` in `publish_raw` is borrowed for the duration of the call.
1880/// The backend must either send it inline or copy into its own
1881/// buffer before returning — the slice is invalid after the call.
1882///
1883/// # Blocking
1884///
1885/// `publish_raw` is expected to be non-blocking on best-effort QoS
1886/// and bounded-blocking on reliable QoS (waiting for outbound queue
1887/// space). Backends should *not* block waiting for ack from a
1888/// matched subscriber.
1889pub trait Publisher {
1890 /// Error type for publish operations
1891 type Error;
1892
1893 /// Publish a CDR-serialised message.
1894 ///
1895 /// Returns once the message has been handed to the transport
1896 /// (queued or fired-and-forgotten depending on QoS). Does **not**
1897 /// wait for delivery.
1898 fn publish_raw(&self, data: &[u8]) -> Result<(), Self::Error>;
1899
1900 /// Phase 128.F.4 — publish with an opaque attachment block.
1901 ///
1902 /// `attachment` rides alongside the payload at the wire layer.
1903 /// Receivers can read it back via
1904 /// [`Subscription::take_serialized_with_attachment`].
1905 ///
1906 /// Primary use case: cross-RMW bridges stamp a `bridge_origin`
1907 /// tag (the source backend's RMW name) so a paired return
1908 /// bridge can deterministically drop echoed frames.
1909 ///
1910 /// Default body delegates to [`publish_raw`](Self::publish_raw)
1911 /// and discards the attachment — backends that do not natively
1912 /// carry attachments (XRCE today, DDS without a user-data hook)
1913 /// see no change. Backends with native attachment support
1914 /// (zenoh-pico's `z_publisher_put_options::attachment`,
1915 /// Cyclone DDS user-data) override to write the bytes onto the
1916 /// wire.
1917 fn publish_raw_with_attachment(
1918 &self,
1919 data: &[u8],
1920 _attachment: &[u8],
1921 ) -> Result<(), Self::Error> {
1922 self.publish_raw(data)
1923 }
1924
1925 /// Phase 124.E.1 — streamed publish.
1926 ///
1927 /// `size_cb` reports the total payload length once; `chunk_cb`
1928 /// fills the slot in chunks. Saves the per-publisher staging
1929 /// buffer when the message is large enough to dominate the
1930 /// device's `.bss`.
1931 ///
1932 /// Default body — the **staging-buffer fallback** (124.E.2).
1933 /// Asks `size_cb` for the total length, fills a stack-allocated
1934 /// `[u8; NROS_MAX_STREAM_CHUNK]` via `chunk_cb`, then forwards
1935 /// to `publish_raw`. Returns `Err(BufferTooSmall)` if the total
1936 /// exceeds the stack cap so the caller can drop back to a
1937 /// regular `publish_raw` with a heap-sized buffer.
1938 ///
1939 /// Concrete backends opt in by overriding to stream straight
1940 /// into the network buffer (zenoh: write into the zenoh-pico
1941 /// outbound buffer; XRCE: micro-CDR streaming APIs).
1942 ///
1943 /// # Safety
1944 ///
1945 /// The caller must ensure `user_ctx` is valid for every invocation of
1946 /// `size_cb` and `chunk_cb` during this call, and that both callbacks obey
1947 /// their out-pointer contracts.
1948 ///
1949 /// `size_cb` and `chunk_cb` may be called from the same thread
1950 /// that called `publish_streamed`. Backends MUST NOT defer the
1951 /// calls past the function return; the caller's `user_ctx`
1952 /// pointer is only guaranteed valid for the duration of the
1953 /// call.
1954 unsafe fn publish_streamed(
1955 &self,
1956 size_cb: unsafe extern "C" fn(out_total_len: *mut usize, user_ctx: *mut core::ffi::c_void),
1957 chunk_cb: unsafe extern "C" fn(
1958 out_buf: *mut u8,
1959 cap: usize,
1960 out_written: *mut usize,
1961 user_ctx: *mut core::ffi::c_void,
1962 ),
1963 user_ctx: *mut core::ffi::c_void,
1964 ) -> Result<(), Self::Error>
1965 where
1966 Self::Error: From<TransportError>,
1967 {
1968 /// Default staging-buffer cap. Stack-allocated, so embedded
1969 /// callers don't pay for it unless they actually invoke this
1970 /// fallback. 4 KiB matches typical RTPS frag-size +
1971 /// micro-XRCE message ceilings.
1972 const STAGE_CAP: usize = 4096;
1973
1974 let mut total = 0usize;
1975 // SAFETY: caller's contract on `size_cb` matches our trait
1976 // doc — fire once with a writable `*mut usize` slot.
1977 unsafe { size_cb(&mut total as *mut usize, user_ctx) };
1978 if total > STAGE_CAP {
1979 return Err(TransportError::BufferTooSmall.into());
1980 }
1981 let mut stage = [0u8; STAGE_CAP];
1982 let mut written_so_far = 0usize;
1983 while written_so_far < total {
1984 let mut chunk_written = 0usize;
1985 let remaining = total - written_so_far;
1986 // SAFETY: `chunk_cb` writes ≤ `cap` bytes to
1987 // `out_buf` and reports the count via `out_written`.
1988 unsafe {
1989 chunk_cb(
1990 stage.as_mut_ptr().add(written_so_far),
1991 remaining,
1992 &mut chunk_written as *mut usize,
1993 user_ctx,
1994 );
1995 }
1996 if chunk_written == 0 {
1997 // Caller signalled EOF early — treat the partial
1998 // write as a malformed sequence; reporting it as
1999 // BufferTooSmall keeps the surface tight without
2000 // adding a new variant.
2001 return Err(TransportError::BufferTooSmall.into());
2002 }
2003 written_so_far += chunk_written;
2004 }
2005 self.publish_raw(&stage[..total])
2006 }
2007
2008 /// Publish a typed message (serializes automatically)
2009 fn publish<M: RosMessage>(&self, msg: &M, buf: &mut [u8]) -> Result<(), Self::Error> {
2010 use nros_core::CdrWriter;
2011
2012 let mut writer = CdrWriter::new_with_header(buf).map_err(|_| self.buffer_error())?;
2013 msg.serialize(&mut writer)
2014 .map_err(|_| self.serialization_error())?;
2015 let len = writer.position();
2016 self.publish_raw(&buf[..len])
2017 }
2018
2019 /// Return a buffer-too-small error (implementation specific)
2020 fn buffer_error(&self) -> Self::Error;
2021
2022 /// Return a serialization error (implementation specific)
2023 fn serialization_error(&self) -> Self::Error;
2024
2025 /// Phase 108 — `true` if the backend can generate this event for
2026 /// this publisher. Default returns `false`; backends override per
2027 /// supported event kind.
2028 ///
2029 /// Only [`EventKind::LivelinessLost`](crate::event::EventKind::LivelinessLost) and
2030 /// [`EventKind::OfferedDeadlineMissed`](crate::event::EventKind::OfferedDeadlineMissed) are publisher-side events;
2031 /// other kinds always return `false` here.
2032 fn supports_event(&self, _kind: crate::event::EventKind) -> bool {
2033 false
2034 }
2035
2036 /// Phase 108 — register a callback fired when the named status
2037 /// event occurs. `deadline_ms` applies to
2038 /// [`EventKind::OfferedDeadlineMissed`](crate::event::EventKind::OfferedDeadlineMissed) only; ignored otherwise.
2039 /// Default impl returns the backend's "unsupported"-shaped error.
2040 ///
2041 /// # Safety
2042 ///
2043 /// `cb` and `user_ctx` must remain valid for the entity's
2044 /// lifetime. Caller (typically `nros-node`'s typed wrapper) is
2045 /// responsible for keeping the closure / context arena alive.
2046 unsafe fn register_event_callback(
2047 &mut self,
2048 _kind: crate::event::EventKind,
2049 _deadline_ms: u32,
2050 _cb: crate::event::EventCallback,
2051 _user_ctx: *mut core::ffi::c_void,
2052 ) -> Result<(), Self::Error> {
2053 Err(self.unsupported_event_error())
2054 }
2055
2056 /// Phase 108 — backend's error variant for "this event kind is
2057 /// not supported." Default impl reuses `serialization_error()`
2058 /// since most backends share an `Unsupported` variant; backends
2059 /// override if they have a distinct `Unsupported` mapping.
2060 fn unsupported_event_error(&self) -> Self::Error {
2061 self.serialization_error()
2062 }
2063
2064 /// Phase 109 — assert this publisher's liveliness manually.
2065 /// Required for publishers configured with
2066 /// `QoSLivelinessPolicy::ManualByTopic`. No-op for other
2067 /// liveliness kinds. Default impl returns `Ok(())` (no-op);
2068 /// backends override when they implement manual liveliness.
2069 fn assert_liveliness(&self) -> Result<(), Self::Error> {
2070 Ok(())
2071 }
2072}
2073
2074/// Subscription trait for receiving messages.
2075///
2076/// # Threading
2077///
2078/// `&mut self` on `take_serialized` — the executor takes exclusive
2079/// ownership of the subscriber for the duration of a receive. A
2080/// backend that wants to allow concurrent receives must split into
2081/// per-thread sub-handles internally.
2082///
2083/// # Buffer ownership
2084///
2085/// `buf` is caller-owned. The implementation copies the next ready
2086/// message into `buf` and returns the byte count. The caller may
2087/// re-use or drop `buf` immediately after the call.
2088///
2089/// # Blocking
2090///
2091/// `take_serialized` is **non-blocking**: returns `Ok(None)` (or
2092/// equivalent for backends that map empty into a zero-length read)
2093/// when no message is ready. Use [`Session::drive_io`] to wait for
2094/// data; never sleep inside `take_serialized`.
2095pub trait Subscription {
2096 /// Error type for receive operations
2097 type Error;
2098
2099 /// Check if data is available without consuming it.
2100 ///
2101 /// Non-destructive — does not advance the receive cursor.
2102 /// Conservative default returns `true` (always assume data may
2103 /// be available); backends should override with a real check
2104 /// to avoid spurious receive attempts.
2105 fn has_data(&self) -> bool {
2106 true
2107 }
2108
2109 /// Try to receive one message into `buf`.
2110 ///
2111 /// Non-blocking. On success returns `Ok(Some(len))` where `len`
2112 /// is the byte count written into `buf[..len]`. Returns
2113 /// `Ok(None)` if no message is ready. If `buf` is too small the
2114 /// backend may either truncate (and document it) or return an
2115 /// error (preferred).
2116 fn take_serialized(&mut self, buf: &mut [u8]) -> Result<Option<usize>, Self::Error>;
2117
2118 /// Phase 128.F.4 — receive with attachment bytes alongside the
2119 /// payload.
2120 ///
2121 /// On success returns `Ok(Some((payload_len, attachment_len)))`
2122 /// with the payload written into `buf[..payload_len]` and the
2123 /// attachment (if any) written into
2124 /// `att_buf[..attachment_len]`. `attachment_len == 0` means the
2125 /// incoming sample carried no attachment.
2126 ///
2127 /// Default body falls back to [`take_serialized`](Self::take_serialized)
2128 /// and reports a 0-length attachment. Backends with native
2129 /// attachment support override to populate `att_buf`. Cross-RMW
2130 /// bridges use the attachment to read the `bridge_origin` tag
2131 /// stamped by the sending side.
2132 fn take_serialized_with_attachment(
2133 &mut self,
2134 buf: &mut [u8],
2135 _att_buf: &mut [u8],
2136 ) -> Result<Option<(usize, usize)>, Self::Error> {
2137 match self.take_serialized(buf)? {
2138 Some(len) => Ok(Some((len, 0))),
2139 None => Ok(None),
2140 }
2141 }
2142
2143 /// Phase 124.D.1 — burst-take.
2144 ///
2145 /// Drain up to `max_msgs` queued samples into the contiguous
2146 /// `buf` block in one call, with the i-th sample at
2147 /// `buf[i * per_msg_cap .. i * per_msg_cap + out_lens[i]]`.
2148 /// Returns the number of messages actually delivered. Partial
2149 /// drains MUST report the count, not error out.
2150 ///
2151 /// Default body loop-drives `take_serialized` so callers can
2152 /// commit to the batched API regardless of backend support.
2153 /// Concrete backends opt in by overriding with a native batch
2154 /// take (zenoh queue drain, `dds_take(max_samples)`).
2155 fn take_sequence(
2156 &mut self,
2157 buf: &mut [u8],
2158 per_msg_cap: usize,
2159 max_msgs: usize,
2160 out_lens: &mut [usize],
2161 ) -> Result<usize, Self::Error> {
2162 if per_msg_cap == 0 || max_msgs == 0 {
2163 return Ok(0);
2164 }
2165 let limit = max_msgs.min(out_lens.len());
2166 let mut count = 0;
2167 for i in 0..limit {
2168 let slot = &mut buf[i * per_msg_cap..(i + 1) * per_msg_cap];
2169 // Issue 0971 — NOT `take_serialized(slot)?`. The `?` propagates the
2170 // error and DISCARDS `count`, which is precisely what the doc
2171 // comment above forbids ("Partial drains MUST report the count, not
2172 // error out") and what `c3af8c1d1` removed from the two concrete
2173 // implementations. A backend that does not override this body got
2174 // the original defect back through the default.
2175 //
2176 // The shape is the one that fix established: a drain that has
2177 // already taken messages reports the COUNT, and the error is
2178 // delivered by the NEXT call — the caller sees every message it was
2179 // handed, then the reason the drain stopped. With nothing taken
2180 // there is no count to protect, so the error goes out immediately.
2181 //
2182 // Parking is per-implementation state, which a default body has no
2183 // place to keep. So it does the half it can do correctly: it
2184 // returns the partial count and lets the error surface on the
2185 // caller's next `take_serialized`, which is where an unconsumed
2186 // backend error still sits.
2187 match self.take_serialized(slot) {
2188 Ok(Some(len)) => {
2189 out_lens[i] = len;
2190 count += 1;
2191 }
2192 Ok(None) => break,
2193 Err(e) => {
2194 if count == 0 {
2195 return Err(e);
2196 }
2197 break;
2198 }
2199 }
2200 }
2201 Ok(count)
2202 }
2203
2204 /// Try to receive a typed message (non-blocking)
2205 fn take<M: RosMessage>(&mut self, buf: &mut [u8]) -> Result<Option<M>, Self::Error> {
2206 use nros_core::CdrReader;
2207
2208 match self.take_serialized(buf)? {
2209 Some(len) => {
2210 let mut reader = CdrReader::new_with_header(&buf[..len])
2211 .map_err(|_| self.deserialization_error())?;
2212 let msg = M::deserialize(&mut reader).map_err(|_| self.deserialization_error())?;
2213 Ok(Some(msg))
2214 }
2215 None => Ok(None),
2216 }
2217 }
2218
2219 /// Process the received message in-place without copying.
2220 ///
2221 /// Calls `f` with a reference to the raw CDR bytes in the subscriber's
2222 /// internal receive buffer, avoiding a copy into a caller-provided buffer.
2223 /// While `f` executes the buffer is exclusively borrowed — any messages
2224 /// arriving from the transport during that time are dropped to prevent
2225 /// data races.
2226 ///
2227 /// Returns `Ok(true)` if a message was available and `f` was called,
2228 /// `Ok(false)` if no message was available.
2229 ///
2230 /// **Default body**: returns `Err(MessageTooLarge)` — the old default
2231 /// silently truncated anything larger than 1 KB into a stack buffer,
2232 /// which broke large messages with no diagnostic. Backends must
2233 /// override this with a real zero-copy path if they advertise support
2234 /// for `process_raw_in_place`; callers that hit the default should
2235 /// use `take_serialized` with a caller-sized buffer instead.
2236 fn process_raw_in_place(&mut self, f: impl FnOnce(&[u8])) -> Result<bool, Self::Error>
2237 where
2238 Self::Error: From<TransportError>,
2239 {
2240 let _ = f;
2241 Err(TransportError::MessageTooLarge.into())
2242 }
2243
2244 /// Whether this backend implements the in-place dispatch methods
2245 /// ([`process_raw_in_place`](Subscription::process_raw_in_place) /
2246 /// [`process_raw_in_place_with_info`](Subscription::process_raw_in_place_with_info))
2247 /// with a real zero-copy borrow.
2248 ///
2249 /// The executor consults this at subscription registration to choose the
2250 /// **in-place** arena dispatch (borrow + deserialize from the backend slot, no
2251 /// arena buffer) over the **buffered** dispatch (copy into an arena buffer
2252 /// first). Backends that leave the in-place methods at their unsupported
2253 /// default return `false` (the default) and keep the buffered path. (RFC-0038,
2254 /// Phase 231 Wave 0.2.)
2255 fn supports_process_in_place(&self) -> bool {
2256 false
2257 }
2258
2259 /// In-place processing variant that also surfaces publisher metadata.
2260 ///
2261 /// Same borrow contract as
2262 /// [`process_raw_in_place`](Subscription::process_raw_in_place): `f` receives
2263 /// the raw CDR bytes plus the parsed [`MessageInfo`](nros_core::MessageInfo)
2264 /// — the co-located attachment (publisher GID / sequence / source timestamp),
2265 /// or `None` when no attachment was present — for the duration of the call;
2266 /// the slot is released after `f` returns. `Ok(true)` = a message was
2267 /// available and `f` was called; `Ok(false)` = none ready.
2268 ///
2269 /// **Default body**: returns the unsupported error (mirrors
2270 /// `process_raw_in_place`). Backends that advertise in-place support override
2271 /// this with a real zero-copy path; callers that hit the default should use
2272 /// the buffered [`take_serialized_with_info`](Subscription::take_serialized_with_info)
2273 /// path instead. (RFC-0038, Phase 231 Wave 0.1.)
2274 fn process_raw_in_place_with_info(
2275 &mut self,
2276 f: impl FnOnce(&[u8], Option<nros_core::MessageInfo>),
2277 ) -> Result<bool, Self::Error>
2278 where
2279 Self::Error: From<TransportError>,
2280 {
2281 let _ = f;
2282 Err(TransportError::MessageTooLarge.into())
2283 }
2284
2285 /// Try to receive raw data along with publisher metadata.
2286 ///
2287 /// When available, [`MessageInfo`](nros_core::MessageInfo) contains
2288 /// the publisher's GID (Global Identifier) and source timestamp,
2289 /// extracted from a transport-level attachment on the incoming message.
2290 ///
2291 /// Returns `Ok(Some((len, info)))` if data is available, where:
2292 /// - `len` is the number of bytes written to the buffer
2293 /// - `info` is the parsed publisher metadata (if attachment was present)
2294 ///
2295 /// Default: delegates to [`take_serialized`](Subscription::take_serialized) with no info.
2296 fn take_serialized_with_info(
2297 &mut self,
2298 buf: &mut [u8],
2299 ) -> Result<Option<(usize, Option<nros_core::MessageInfo>)>, Self::Error> {
2300 self.take_serialized(buf)
2301 .map(|opt| opt.map(|len| (len, None)))
2302 }
2303
2304 /// Try to receive raw data with E2E safety validation (CRC + sequence tracking).
2305 ///
2306 /// Returns `Ok(Some((len, status)))` if data is available, where:
2307 /// - `len` is the number of bytes written to the buffer
2308 /// - `status` is the integrity validation result
2309 ///
2310 /// Default: delegates to `take_serialized` with no CRC info.
2311 #[cfg(feature = "safety-e2e")]
2312 fn take_validated(
2313 &mut self,
2314 buf: &mut [u8],
2315 ) -> Result<Option<(usize, crate::IntegrityStatus)>, Self::Error> {
2316 self.take_serialized(buf).map(|opt| {
2317 opt.map(|len| {
2318 (
2319 len,
2320 crate::IntegrityStatus {
2321 gap: 0,
2322 duplicate: false,
2323 crc_valid: None,
2324 },
2325 )
2326 })
2327 })
2328 }
2329
2330 /// Register an async waker to be notified when data arrives.
2331 ///
2332 /// Called from `Future::poll()` implementations to store the waker.
2333 /// The transport backend calls `waker.wake()` from its receive callback
2334 /// when new data is available, enabling event-driven async without
2335 /// busy-polling.
2336 ///
2337 /// Default: no-op (backends that don't support waking simply ignore this).
2338 fn register_waker(&self, _waker: &core::task::Waker) {}
2339
2340 /// Return a deserialization error (implementation specific)
2341 fn deserialization_error(&self) -> Self::Error;
2342
2343 /// Phase 108 — `true` if the backend can generate this event for
2344 /// this subscriber. Default returns `false`; backends override per
2345 /// supported event kind.
2346 ///
2347 /// Subscription-side event kinds:
2348 /// [`EventKind::LivelinessChanged`](crate::event::EventKind::LivelinessChanged),
2349 /// [`EventKind::RequestedDeadlineMissed`](crate::event::EventKind::RequestedDeadlineMissed),
2350 /// [`EventKind::MessageLost`](crate::event::EventKind::MessageLost).
2351 /// Publisher kinds always return `false` here.
2352 fn supports_event(&self, _kind: crate::event::EventKind) -> bool {
2353 false
2354 }
2355
2356 /// Phase 108 — register a callback fired when the named status
2357 /// event occurs. `deadline_ms` applies to
2358 /// [`EventKind::RequestedDeadlineMissed`](crate::event::EventKind::RequestedDeadlineMissed) only; ignored otherwise.
2359 /// Default impl returns the backend's "unsupported"-shaped error.
2360 ///
2361 /// # Safety
2362 ///
2363 /// `cb` and `user_ctx` must remain valid for the entity's
2364 /// lifetime. Caller (typically `nros-node`'s typed wrapper) is
2365 /// responsible for keeping the closure / context arena alive.
2366 unsafe fn register_event_callback(
2367 &mut self,
2368 _kind: crate::event::EventKind,
2369 _deadline_ms: u32,
2370 _cb: crate::event::EventCallback,
2371 _user_ctx: *mut core::ffi::c_void,
2372 ) -> Result<(), Self::Error> {
2373 Err(self.unsupported_event_error())
2374 }
2375
2376 /// Phase 108 — backend's error variant for "this event kind is
2377 /// not supported." Default reuses `deserialization_error()` for
2378 /// backends that don't have a distinct `Unsupported` mapping.
2379 fn unsupported_event_error(&self) -> Self::Error {
2380 self.deserialization_error()
2381 }
2382}
2383
2384/// Service request from a client
2385pub struct ServiceRequest<'a> {
2386 /// Raw request data (CDR encoded)
2387 pub data: &'a [u8],
2388 /// Sequence number for request/response matching
2389 pub sequence_number: i64,
2390}
2391
2392// ============================================================================
2393// Phase 99 — zero-copy raw API: SlotLending / SlotBorrowing
2394// ============================================================================
2395//
2396// Backends that can lend a slot directly into their outbound buffer
2397// (zenoh-pico w/ unstable-zenoh-api, XRCE-DDS via uxr_prepare_output_stream,
2398// full DDS w/ SHM transport) implement these traits. Backends that cannot
2399// (uORB, default zenoh-pico) do NOT impl them — `EmbeddedRawPublisher` then
2400// falls back to its per-publisher arena and memcpys at commit time. Both
2401// paths land at `Publisher::publish_raw` for the actual wire write; only
2402// the user-side copy is eliminated when lending is available.
2403//
2404// Selection is **compile-time** via the `lending` Cargo feature. Each
2405// backend crate forwards its own `lending` feature to `nros-rmw/lending`
2406// when it can satisfy the trait. nros-node's `rmw-lending` aggregates.
2407// User opting `nros/rmw-lending` w/ a non-lending backend (e.g. uORB)
2408// gets a clear compile error from the unsatisfied trait bound on the
2409// concrete `RmwPublisher`.
2410
2411/// Backend can lend a writable slot into its outbound buffer.
2412///
2413/// The returned slot's lifetime is tied to `&self`; user fills it in
2414/// place, then calls [`commit_slot`](Self::commit_slot) to publish.
2415/// Dropping the slot without commit is a no-op (slot returned to free
2416/// pool); concurrent loan attempts that would exceed backend capacity
2417/// return [`TransportError::WouldBlock`] — never block.
2418#[cfg(feature = "lending")]
2419pub trait SlotLending: Publisher {
2420 /// Backend-owned writable slot. Holds a `&'a mut [u8]` and any
2421 /// state needed for commit_slot.
2422 type Slot<'a>: AsMut<[u8]> + 'a
2423 where
2424 Self: 'a;
2425
2426 /// Reserve a writable slot of `len` bytes from the backend's
2427 /// outbound buffer. Returns `Ok(None)` if the backend has no slot
2428 /// available (full); never blocks.
2429 fn try_lend_slot(&self, len: usize) -> Result<Option<Self::Slot<'_>>, Self::Error>;
2430
2431 /// Commit a previously-lent slot. Consumes the slot and triggers
2432 /// the actual wire write. Returns `Err` on backend send failure;
2433 /// the slot's bytes are lost in that case (caller must re-lend +
2434 /// re-fill to retry).
2435 fn commit_slot(&self, slot: Self::Slot<'_>) -> Result<(), Self::Error>;
2436}
2437
2438/// Backend can lend a read-only view into its receive buffer.
2439///
2440/// The returned view's lifetime is tied to `&mut self` (subscriber-
2441/// exclusive); dropping the view releases any backend lock and lets
2442/// the next message advance into the buffer.
2443#[cfg(feature = "lending")]
2444pub trait SlotBorrowing: Subscription {
2445 /// Backend-owned read-only view. Holds a `&'a [u8]` and any state
2446 /// needed to release the borrow on Drop.
2447 type View<'a>: AsRef<[u8]> + 'a
2448 where
2449 Self: 'a;
2450
2451 /// Try to borrow the next available message in place. Returns
2452 /// `Ok(None)` if no message is ready; never blocks.
2453 fn try_borrow(&mut self) -> Result<Option<Self::View<'_>>, Self::Error>;
2454}
2455
2456/// Service server trait for handling requests.
2457///
2458/// # Threading
2459///
2460/// `&mut self` on `take_request` and `send_response` — the executor
2461/// owns the server while a request is being handled. Handler bodies
2462/// run synchronously on the executor thread; long handlers should
2463/// dispatch work to a worker queue and reply later via the recorded
2464/// `sequence_number`.
2465///
2466/// # Calling pattern
2467///
2468/// 1. Executor calls `take_request(buf)`.
2469/// 2. If `Some(req)` returned, decode, run handler, encode reply.
2470/// 3. `send_response(req.sequence_number, &reply_buf)`.
2471///
2472/// `sequence_number` is the canonical request → reply correlation
2473/// token; backends derive it from the wire-level metadata (zenoh
2474/// query id, DDS sample identity).
2475pub trait ServiceTrait {
2476 /// Error type for service operations
2477 type Error;
2478
2479 /// Check if a request is available without consuming it.
2480 ///
2481 /// Non-destructive. Default returns `true` (always assume one
2482 /// may be available); backends should override with a real
2483 /// check.
2484 fn has_request(&self) -> bool {
2485 true
2486 }
2487
2488 /// Phase 122.3.c.6.e — register a `Waker` for event-driven
2489 /// service servers. Mirrors the matching method on
2490 /// `SubscriberTrait` / `ClientTrait`. Backends that
2491 /// surface incoming-request notifications wake `waker` when
2492 /// `has_request()` flips true. Default: no-op (backends without
2493 /// wake support ignore — caller falls back to polling).
2494 fn register_waker(&self, _waker: &core::task::Waker) {}
2495
2496 /// Try to receive a service request into `buf` (non-blocking).
2497 ///
2498 /// On success returns a `ServiceRequest` that borrows from
2499 /// `buf`. The borrow is released when the returned struct is
2500 /// dropped — typically before `send_response` is called, since
2501 /// `send_response` takes `&mut self`.
2502 fn take_request<'a>(
2503 &mut self,
2504 buf: &'a mut [u8],
2505 ) -> Result<Option<ServiceRequest<'a>>, Self::Error>;
2506
2507 /// Send a reply for the given sequence number. Non-blocking
2508 /// from the application's perspective; the backend may queue
2509 /// the reply for transport-level transmission.
2510 fn send_response(&mut self, sequence_number: i64, data: &[u8]) -> Result<(), Self::Error>;
2511
2512 /// Handle a service request with typed messages
2513 fn handle_request<S: RosService>(
2514 &mut self,
2515 req_buf: &mut [u8],
2516 reply_buf: &mut [u8],
2517 handler: impl FnOnce(&S::Request) -> S::Reply,
2518 ) -> Result<bool, Self::Error>
2519 where
2520 Self::Error: From<TransportError>,
2521 {
2522 use nros_core::{CdrReader, CdrWriter};
2523
2524 // First, try to receive a request and extract necessary data.
2525 // Capture the data slice's offset within `req_buf` so we can
2526 // re-borrow it after the `ServiceRequest` (which holds a
2527 // borrow into `req_buf`) is dropped. Some backends prepend a
2528 // header (DDS: 8-byte sequence number) and place the CDR
2529 // payload at a non-zero offset in the buffer; others (zenoh)
2530 // put it at offset 0. Reading from offset 0 unconditionally
2531 // would feed the prefix bytes to the CDR deserializer and
2532 // silently corrupt the request.
2533 let buf_start = req_buf.as_ptr() as usize;
2534 let (data_offset, data_len, sequence_number) = match self.take_request(req_buf)? {
2535 Some(request) => {
2536 let offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
2537 (offset, request.data.len(), request.sequence_number)
2538 }
2539 None => return Ok(false),
2540 };
2541
2542 // Deserialize request from the captured offset.
2543 let mut reader = CdrReader::new_with_header(&req_buf[data_offset..data_offset + data_len])
2544 .map_err(|_| TransportError::DeserializationError)?;
2545 let req = S::Request::deserialize(&mut reader)
2546 .map_err(|_| TransportError::DeserializationError)?;
2547
2548 // Call handler
2549 let reply = handler(&req);
2550
2551 // Serialize reply
2552 let mut writer =
2553 CdrWriter::new_with_header(reply_buf).map_err(|_| TransportError::BufferTooSmall)?;
2554 reply
2555 .serialize(&mut writer)
2556 .map_err(|_| TransportError::SerializationError)?;
2557 let len = writer.position();
2558
2559 // Send reply (now we can borrow self mutably again)
2560 self.send_response(sequence_number, &reply_buf[..len])?;
2561 Ok(true)
2562 }
2563
2564 /// Handle a service request where the handler returns `Box<S::Reply>`
2565 ///
2566 /// Identical to `handle_request` but the handler returns a heap-allocated reply.
2567 /// This is needed for services with large response types (e.g., parameter services
2568 /// where `Vec<ParameterValue, 64>` is ~1MB+) that would overflow the stack.
2569 #[cfg(feature = "alloc")]
2570 fn handle_request_boxed<S: RosService>(
2571 &mut self,
2572 req_buf: &mut [u8],
2573 reply_buf: &mut [u8],
2574 handler: impl FnOnce(&S::Request) -> alloc::boxed::Box<S::Reply>,
2575 ) -> Result<bool, Self::Error>
2576 where
2577 Self::Error: From<TransportError>,
2578 {
2579 use nros_core::{CdrReader, CdrWriter};
2580
2581 let buf_start = req_buf.as_ptr() as usize;
2582 let (data_offset, data_len, sequence_number) = match self.take_request(req_buf)? {
2583 Some(request) => {
2584 let offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
2585 (offset, request.data.len(), request.sequence_number)
2586 }
2587 None => return Ok(false),
2588 };
2589
2590 let mut reader = CdrReader::new_with_header(&req_buf[data_offset..data_offset + data_len])
2591 .map_err(|_| TransportError::DeserializationError)?;
2592 let req = S::Request::deserialize(&mut reader)
2593 .map_err(|_| TransportError::DeserializationError)?;
2594
2595 let reply = handler(&req);
2596
2597 let mut writer =
2598 CdrWriter::new_with_header(reply_buf).map_err(|_| TransportError::BufferTooSmall)?;
2599 reply
2600 .serialize(&mut writer)
2601 .map_err(|_| TransportError::SerializationError)?;
2602 let len = writer.position();
2603
2604 self.send_response(sequence_number, &reply_buf[..len])?;
2605 Ok(true)
2606 }
2607
2608 /// Handle one request by STREAMING: the handler reads fields off the wire
2609 /// and writes the reply's fields straight back, so neither the request nor
2610 /// the reply is ever materialised as a value.
2611 ///
2612 /// phase-382 W1'. `handle_request_boxed` above boxes the REPLY because the
2613 /// parameter responses are enormous — `GetParametersResponse` measures
2614 /// 1,176,072 bytes. It does not box the REQUEST, which is deserialized by
2615 /// value into a stack local one line above the handler, and
2616 /// `SetParametersRequest` measures **1,192,968 bytes**. So every
2617 /// `ros2 param set` against a node put a 1.19 MB local on the calling
2618 /// task's stack — larger than the reply the boxing exists for, on every
2619 /// platform, with `param-services` live on Zephyr.
2620 ///
2621 /// Streaming removes both, and removes the `alloc` requirement with them:
2622 /// the whole value is never needed, because serialisation happens on the
2623 /// line after construction. Three things make it safe rather than clever:
2624 ///
2625 /// * **No `rcl_interfaces` message uses a DHEADER** — every `Serialize` impl
2626 /// is plain sequential CDR, so hand-written field writes are byte-identical
2627 /// to the generated ones. (If a future message gains XCDR2 extensibility
2628 /// this stops being true for THAT message; see RFC-0055.)
2629 /// * `req_buf` and `reply_buf` are disjoint, so a handler can hold the
2630 /// reader and the writer at once.
2631 /// * `CdrReader::read_string` borrows out of `req_buf` rather than copying,
2632 /// so a handler can look a name up without a buffer of its own.
2633 ///
2634 /// The cost is that the hand-written writes can drift from the generated
2635 /// `Serialize`. Guard it with a round-trip test that deserialises the
2636 /// streamed bytes back into the generated type — the by-value handler makes
2637 /// a good test-only oracle.
2638 ///
2639 /// No `alloc`, deliberately: this is the seam that lets `param-services` and
2640 /// `lifecycle-services` build without it.
2641 fn handle_request_raw(
2642 &mut self,
2643 req_buf: &mut [u8],
2644 reply_buf: &mut [u8],
2645 handler: impl FnOnce(
2646 &mut nros_core::CdrReader<'_>,
2647 &mut nros_core::CdrWriter<'_>,
2648 ) -> Result<(), TransportError>,
2649 ) -> Result<bool, Self::Error>
2650 where
2651 Self::Error: From<TransportError>,
2652 {
2653 use nros_core::{CdrReader, CdrWriter};
2654
2655 let buf_start = req_buf.as_ptr() as usize;
2656 let (data_offset, data_len, sequence_number) = match self.take_request(req_buf)? {
2657 Some(request) => {
2658 let offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
2659 (offset, request.data.len(), request.sequence_number)
2660 }
2661 None => return Ok(false),
2662 };
2663
2664 // Split the borrow so the reader (over `req_buf`) and the writer (over
2665 // `reply_buf`) can be live at the same time. They are separate
2666 // parameters, so this is disjoint by construction.
2667 let mut reader = CdrReader::new_with_header(&req_buf[data_offset..data_offset + data_len])
2668 .map_err(|_| TransportError::DeserializationError)?;
2669 let mut writer =
2670 CdrWriter::new_with_header(reply_buf).map_err(|_| TransportError::BufferTooSmall)?;
2671
2672 handler(&mut reader, &mut writer)?;
2673 let len = writer.position();
2674
2675 self.send_response(sequence_number, &reply_buf[..len])?;
2676 Ok(true)
2677 }
2678}
2679
2680/// Service client trait for sending requests.
2681///
2682/// # Threading
2683///
2684/// `&mut self` on every method — the client is single-owner. For
2685/// fan-out request patterns, create one client per worker thread.
2686///
2687/// # Calling pattern
2688///
2689/// All in-tree backends route blocking waits through the executor:
2690///
2691/// 1. `send_request_raw(buf)` — non-blocking; returns once the
2692/// request is queued for transmission.
2693/// 2. The executor's `drive_io` runs.
2694/// 3. `take_response_raw(buf)` — non-blocking; returns
2695/// `Ok(Some(len))` when the reply is back.
2696///
2697/// Phase-301 (issue 0240): the deprecated blocking `call_raw` path is
2698/// DELETED — `send_request_raw` + `take_response_raw` is the one
2699/// request/reply path, and both are required for service-capable
2700/// backends.
2701pub trait ClientTrait {
2702 /// Error type for service operations
2703 type Error;
2704
2705 /// Send a service request without waiting for a reply (non-blocking).
2706 ///
2707 /// Returns the SEQUENCE ID the backend assigned. The caller must
2708 /// subsequently poll [`take_response_raw`](Self::take_response_raw) and
2709 /// match that id against the one the reply carries.
2710 ///
2711 /// Issue 0778 — this returned `()` until 2026-08-25, and every backend
2712 /// computed an id and discarded it. With nothing to correlate by, a client
2713 /// with two calls outstanding could not tell the replies apart, so each
2714 /// backend picked a policy: cyclonedds abandoned the older request, zenoh
2715 /// took the first reply. Both are wrong for `send_goal` and
2716 /// `SetParameters`, which travel this path and are not idempotent.
2717 fn send_request_raw(&mut self, request: &[u8]) -> Result<i64, Self::Error>;
2718
2719 /// Poll for a reply (non-blocking).
2720 ///
2721 /// Returns `Ok(Some((len, sequence_id)))` when a reply has arrived,
2722 /// `Ok(None)` if not yet available, or `Err` on failure. The
2723 /// `sequence_id` is the one [`send_request_raw`](Self::send_request_raw)
2724 /// returned for the request this answers.
2725 ///
2726 /// It used to say "a reply to the MOST RECENTLY sent request", which was
2727 /// the single-outstanding-call assumption written into the contract.
2728 fn take_response_raw(
2729 &mut self,
2730 reply_buf: &mut [u8],
2731 ) -> Result<Option<(usize, i64)>, Self::Error>;
2732
2733 /// Send a typed service request without waiting for a reply (non-blocking).
2734 ///
2735 /// Serializes the request into `req_buf` and calls [`send_request_raw`](Self::send_request_raw).
2736 fn send_request<S: RosService>(
2737 &mut self,
2738 request: &S::Request,
2739 req_buf: &mut [u8],
2740 ) -> Result<(), Self::Error>
2741 where
2742 Self::Error: From<TransportError>,
2743 {
2744 use nros_core::CdrWriter;
2745
2746 let mut writer =
2747 CdrWriter::new_with_header(req_buf).map_err(|_| TransportError::BufferTooSmall)?;
2748 request
2749 .serialize(&mut writer)
2750 .map_err(|_| TransportError::SerializationError)?;
2751 let req_len = writer.position();
2752
2753 self.send_request_raw(&req_buf[..req_len]).map(|_seq| ())
2754 }
2755
2756 /// Poll for a typed reply to the most recently sent request (non-blocking).
2757 ///
2758 /// Calls [`take_response_raw`](Self::take_response_raw) and deserializes if available.
2759 fn take_response<S: RosService>(
2760 &mut self,
2761 reply_buf: &mut [u8],
2762 ) -> Result<Option<S::Reply>, Self::Error>
2763 where
2764 Self::Error: From<TransportError>,
2765 {
2766 use nros_core::CdrReader;
2767
2768 match self.take_response_raw(reply_buf)? {
2769 Some((len, _seq)) => {
2770 let mut reader = CdrReader::new_with_header(&reply_buf[..len])
2771 .map_err(|_| TransportError::DeserializationError)?;
2772 let reply = S::Reply::deserialize(&mut reader)
2773 .map_err(|_| TransportError::DeserializationError)?;
2774 Ok(Some(reply))
2775 }
2776 None => Ok(None),
2777 }
2778 }
2779
2780 /// Register an async waker to be notified when a reply arrives.
2781 ///
2782 /// Called from `Future::poll()` implementations to store the waker.
2783 /// The transport backend calls `waker.wake()` from its reply callback
2784 /// when a response is available, enabling event-driven async without
2785 /// busy-polling.
2786 ///
2787 /// Default: no-op (backends that don't support waking simply ignore this).
2788 fn register_waker(&self, _waker: &core::task::Waker) {}
2789
2790 /// Begin a server-discovery query on this client (non-blocking).
2791 ///
2792 /// Models `rclcpp::ClientBase::wait_for_service` machinery: the backend
2793 /// fires off a discovery probe (typically a Zenoh liveliness query
2794 /// against the matching server's wildcarded liveliness keyexpr) and
2795 /// the caller polls [`poll_server_discovery`](Self::poll_server_discovery)
2796 /// to collect the result.
2797 ///
2798 /// Default impl: no-op success. Backends without a discovery channel
2799 /// (or those that always assume the server is reachable) can leave
2800 /// this default and have `poll_server_discovery` return
2801 /// `Ok(Some(true))` immediately.
2802 fn start_server_discovery(&mut self, _timeout_ms: u32) -> Result<(), Self::Error> {
2803 Ok(())
2804 }
2805
2806 /// Poll an in-flight server-discovery query.
2807 ///
2808 /// - `Ok(Some(true))` — at least one matching server has reported
2809 /// back; safe to send the first request.
2810 /// - `Ok(Some(false))` — discovery query finished without finding
2811 /// any matching server (timeout / no-replies).
2812 /// - `Ok(None)` — query still in flight.
2813 /// - `Err(_)` — transport-level failure unrelated to server presence.
2814 ///
2815 /// Default impl: returns `Ok(Some(true))` (i.e., "server is always
2816 /// assumed reachable"). The Zenoh backend overrides this with a
2817 /// liveliness-token check.
2818 fn poll_server_discovery(&mut self) -> Result<Option<bool>, Self::Error> {
2819 Ok(Some(true))
2820 }
2821
2822 /// Whether a matching service server is currently discoverable.
2823 ///
2824 /// Mirrors `rclcpp::ClientBase::service_is_ready` — the NAME is upstream's.
2825 /// The SHAPE is `rcl`'s: `rcl_service_server_is_available(node, client,
2826 /// bool *is_available)` returns `RCL_RET_OK` "if the check was made
2827 /// successfully (regardless of the service readiness)", i.e. the return
2828 /// code says whether the CHECK worked and the out-param carries the ANSWER.
2829 /// rclcpp collapses that to a bare `bool` and moves the error to
2830 /// exceptions; RFC-0018 forbids exceptions, so `Result<bool, _>` is how the
2831 /// same contract is expressed here (phase-379 W6, RFC-0036).
2832 ///
2833 /// Returns `Ok(true)` if at least one matching server has been
2834 /// discovered, `Ok(false)` if none yet, or `Err(_)` if the
2835 /// backend cannot answer (e.g. XRCE — micro-XRCE-DDS-Client has
2836 /// no participant enumeration). Distinct from
2837 /// [`is_server_ready`](Self::is_server_ready), which collapses
2838 /// "don't know" and "no server" into the same `false` answer.
2839 ///
2840 /// User-facing surface: `Client<S>::server_available()` in Rust,
2841 /// `nros_client_server_available()` in C/C++. Clients use this
2842 /// to gate the first request so a startup-ordering race
2843 /// (client opens before server's discovery announcement lands)
2844 /// doesn't surface as a request-side timeout.
2845 ///
2846 /// Default impl: `Err(TransportError::Unsupported)` — backends
2847 /// that support graph introspection (zenoh queryable interest,
2848 /// DDS built-in topic readers) opt in by overriding.
2849 fn service_is_ready(&self) -> Result<bool, Self::Error>
2850 where
2851 Self::Error: From<TransportError>,
2852 {
2853 Err(TransportError::Unsupported.into())
2854 }
2855}
2856
2857/// Transport backend trait (legacy).
2858///
2859/// Use [`Rmw`] for new code. This trait is retained for backward compatibility
2860/// with existing code that uses [`TransportConfig`] directly.
2861pub trait Transport {
2862 /// Error type for this transport
2863 type Error;
2864 /// Session type for this transport
2865 type Session: Session;
2866
2867 /// Open a new session with the given configuration
2868 fn open(config: &TransportConfig) -> Result<Self::Session, Self::Error>;
2869}
2870
2871/// Factory trait for compile-time middleware selection.
2872///
2873/// Embedded crates select a backend via feature flag:
2874/// ```rust,ignore
2875/// #[cfg(feature = "rmw-cffi")]
2876/// type DefaultRmw = nros_rmw_cffi::CffiRmw;
2877/// ```
2878///
2879/// Each backend provides its own `Rmw` implementation that bridges
2880/// from the middleware-agnostic [`RmwConfig`] to backend-specific
2881/// initialization.
2882///
2883/// Phase 84.E2: `open` consumes `self`. Backends carry their own
2884/// configuration (agent addresses, serial ports, TLS CA slots)
2885/// inside the factory value and hand that over to the session at
2886/// `open` time. All in-repo backends also implement
2887/// [`Default`]; most callers spell this as
2888/// `BackendRmw::default().open(&config)`.
2889pub trait Rmw {
2890 /// Session type returned by [`open`](Rmw::open)
2891 type Session: Session;
2892 /// Error type for session creation
2893 type Error: core::fmt::Debug;
2894
2895 /// Open a new middleware session with the given configuration.
2896 ///
2897 /// The backend maps [`RmwConfig`] fields to its own connection
2898 /// parameters (e.g., zenoh locator and session mode, XRCE-DDS
2899 /// agent address). Any backend-specific pre-open state stored
2900 /// on `self` (e.g. configured agent IP / port) is moved into the
2901 /// returned `Session`.
2902 fn open(self, config: &RmwConfig) -> Result<Self::Session, Self::Error>;
2903}
2904
2905#[cfg(test)]
2906mod tests {
2907 use super::*;
2908
2909 /// Issue 0971 — the DEFAULT `take_sequence` body must report a partial
2910 /// count rather than discard it, which is what its own doc comment says and
2911 /// what `?` did not do.
2912 ///
2913 /// This is the default's negative control: a backend that takes two
2914 /// messages and then errors must hand the caller those two. Before the fix
2915 /// the `?` threw them away and the caller could not tell two messages had
2916 /// been consumed — they were gone from the queue and absent from the
2917 /// result.
2918 mod take_sequence_default {
2919 use super::*;
2920
2921 #[derive(Debug, PartialEq)]
2922 struct Boom;
2923
2924 /// Yields `n` one-byte messages, then errors forever.
2925 struct ThenErrors {
2926 left: usize,
2927 }
2928
2929 impl Subscription for ThenErrors {
2930 type Error = Boom;
2931
2932 fn take_serialized(&mut self, buf: &mut [u8]) -> Result<Option<usize>, Self::Error> {
2933 if self.left == 0 {
2934 return Err(Boom);
2935 }
2936 self.left -= 1;
2937 buf[0] = 0xAB;
2938 Ok(Some(1))
2939 }
2940
2941 fn deserialization_error(&self) -> Self::Error {
2942 Boom
2943 }
2944 }
2945
2946 #[test]
2947 fn a_partial_drain_reports_its_count_instead_of_the_error() {
2948 let mut sub = ThenErrors { left: 2 };
2949 let mut buf = [0u8; 4 * 8];
2950 let mut lens = [0usize; 4];
2951
2952 // Two messages are available, the third call errors.
2953 let got = sub.take_sequence(&mut buf, 8, 4, &mut lens);
2954
2955 assert_eq!(
2956 got,
2957 Ok(2),
2958 "a drain that took messages must report them; `?` discarded the \
2959 count and the caller lost two consumed messages"
2960 );
2961 assert_eq!(&lens[..2], &[1, 1]);
2962 }
2963
2964 #[test]
2965 fn an_error_with_nothing_taken_is_returned_immediately() {
2966 let mut sub = ThenErrors { left: 0 };
2967 let mut buf = [0u8; 4 * 8];
2968 let mut lens = [0usize; 4];
2969
2970 // No count to protect, so the caller should see the error itself
2971 // rather than an ambiguous `Ok(0)`.
2972 assert_eq!(sub.take_sequence(&mut buf, 8, 4, &mut lens), Err(Boom));
2973 }
2974 }
2975
2976 #[test]
2977 fn test_topic_info() {
2978 let topic = TopicInfo::new("/chatter", "std_msgs::msg::dds_::String_", "abc123");
2979 assert_eq!(topic.name, "/chatter");
2980 assert_eq!(topic.domain_id, 0);
2981 }
2982
2983 #[test]
2984 fn qos_apply_overrides_matches_topic_and_role() {
2985 // Default is Reliable / Volatile / KeepLast(10).
2986 static OVERRIDES: &[QoSOverride] = &[
2987 QoSOverride {
2988 topic: "/chatter",
2989 role: QoSOverrideRole::Publisher,
2990 value: QoSOverrideValue::Reliability(QoSReliabilityPolicy::BestEffort),
2991 },
2992 QoSOverride {
2993 topic: "/chatter",
2994 role: QoSOverrideRole::Publisher,
2995 value: QoSOverrideValue::Depth(5),
2996 },
2997 QoSOverride {
2998 topic: "/scan",
2999 role: QoSOverrideRole::Subscription,
3000 value: QoSOverrideValue::Durability(QoSDurabilityPolicy::TransientLocal),
3001 },
3002 ];
3003
3004 // Matching topic + publisher role → reliability + depth applied.
3005 let pub_qos = QoSProfile::default().apply_overrides(
3006 "/chatter",
3007 QoSOverrideRole::Publisher,
3008 OVERRIDES,
3009 );
3010 assert_eq!(pub_qos.reliability, QoSReliabilityPolicy::BestEffort);
3011 assert_eq!(pub_qos.depth, 5);
3012 assert_eq!(pub_qos.durability, QoSDurabilityPolicy::Volatile); // untouched
3013
3014 // Same topic but subscription role → publisher overrides DON'T apply;
3015 // the /scan override is for a different topic → also no change.
3016 let sub_qos = QoSProfile::default().apply_overrides(
3017 "/chatter",
3018 QoSOverrideRole::Subscription,
3019 OVERRIDES,
3020 );
3021 assert_eq!(sub_qos, QoSProfile::default());
3022
3023 // The /scan subscription override applies only to /scan+subscription.
3024 let scan_qos = QoSProfile::default().apply_overrides(
3025 "/scan",
3026 QoSOverrideRole::Subscription,
3027 OVERRIDES,
3028 );
3029 assert_eq!(scan_qos.durability, QoSDurabilityPolicy::TransientLocal);
3030
3031 // Empty table → identity (the zero-override fast path).
3032 assert_eq!(
3033 QoSProfile::default().apply_overrides("/x", QoSOverrideRole::Publisher, &[]),
3034 QoSProfile::default()
3035 );
3036 }
3037
3038 #[test]
3039 fn test_qos_defaults() {
3040 let qos = QoSProfile::default();
3041 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
3042 }
3043
3044 #[test]
3045 fn test_action_info() {
3046 let action = ActionInfo::new(
3047 "/fibonacci",
3048 "example_interfaces::action::dds_::Fibonacci_",
3049 "abc123",
3050 );
3051 assert_eq!(action.name, "/fibonacci");
3052 assert_eq!(action.domain_id, 0);
3053 }
3054
3055 #[test]
3056 fn test_action_info_with_domain() {
3057 let action = ActionInfo::new(
3058 "/fibonacci",
3059 "example_interfaces::action::dds_::Fibonacci_",
3060 "abc123",
3061 )
3062 .with_domain(42);
3063 assert_eq!(action.domain_id, 42);
3064 }
3065
3066 #[test]
3067 fn test_action_send_goal_key() {
3068 let action = ActionInfo::new(
3069 "/fibonacci",
3070 "example_interfaces::action::dds_::Fibonacci_",
3071 "abc123",
3072 )
3073 .with_domain(0);
3074
3075 let key: heapless::String<256> = action.send_goal_key();
3076 // ActionInfo returns the sub-entity name with leading slash for ROS 2 compatibility
3077 assert_eq!(key.as_str(), "/fibonacci/_action/send_goal");
3078 }
3079
3080 #[test]
3081 fn test_action_feedback_key() {
3082 let action = ActionInfo::new(
3083 "/fibonacci",
3084 "example_interfaces::action::dds_::Fibonacci_",
3085 "abc123",
3086 )
3087 .with_domain(0);
3088
3089 let key: heapless::String<256> = action.feedback_key();
3090 assert_eq!(key.as_str(), "/fibonacci/_action/feedback");
3091 }
3092
3093 #[test]
3094 fn test_action_all_sub_names() {
3095 let action = ActionInfo::new(
3096 "/fibonacci",
3097 "example_interfaces::action::dds_::Fibonacci_",
3098 "abc123",
3099 )
3100 .with_domain(0);
3101
3102 let cancel: heapless::String<256> = action.cancel_goal_key();
3103 assert_eq!(cancel.as_str(), "/fibonacci/_action/cancel_goal");
3104
3105 let result: heapless::String<256> = action.get_result_key();
3106 assert_eq!(result.as_str(), "/fibonacci/_action/get_result");
3107
3108 let status: heapless::String<256> = action.status_key();
3109 assert_eq!(status.as_str(), "/fibonacci/_action/status");
3110 }
3111
3112 // --- QoS Profile Tests ---
3113
3114 #[test]
3115 fn test_qos_profile_sensor_data() {
3116 let qos = QoSProfile::QOS_PROFILE_SENSOR_DATA;
3117 assert_eq!(qos.reliability, QoSReliabilityPolicy::BestEffort);
3118 assert_eq!(qos.durability, QoSDurabilityPolicy::Volatile);
3119 assert_eq!(qos.history, QoSHistoryPolicy::KeepLast);
3120 assert_eq!(qos.depth, 5);
3121 }
3122
3123 #[test]
3124 fn test_qos_profile_default() {
3125 let qos = QoSProfile::QOS_PROFILE_DEFAULT;
3126 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
3127 assert_eq!(qos.durability, QoSDurabilityPolicy::Volatile);
3128 assert_eq!(qos.depth, 10);
3129 }
3130
3131 #[test]
3132 fn test_qos_profile_services_default() {
3133 let qos = QoSProfile::QOS_PROFILE_SERVICES_DEFAULT;
3134 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
3135 assert_eq!(qos.durability, QoSDurabilityPolicy::Volatile);
3136 }
3137
3138 /// issue 0829 — `SYSTEM_DEFAULT` asks for NOTHING, so it demands no policy.
3139 ///
3140 /// `required_policies` started from `QoSPolicyMask::CORE` unconditionally,
3141 /// which made the sentinel profile demand reliability, durability, history
3142 /// AND depth — so a backend that could not honour one would answer
3143 /// `IncompatibleQos` to a profile that requested none of them.
3144 #[test]
3145 fn system_default_profile_requires_no_policy() {
3146 let required = QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT.required_policies();
3147 assert_eq!(required.0, 0, "SYSTEM_DEFAULT demanded {required:?}");
3148 // Therefore it is admissible against a backend that advertises nothing.
3149 assert!(
3150 QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
3151 .validate_against(QoSPolicyMask(0))
3152 .is_ok()
3153 );
3154 }
3155
3156 /// The relaxation is per FIELD, not all-or-nothing: a profile that states
3157 /// SOME policies still demands exactly those.
3158 #[test]
3159 fn a_partly_stated_profile_demands_only_what_it_states() {
3160 let qos = QoSProfile {
3161 reliability: QoSReliabilityPolicy::BestEffort,
3162 ..QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
3163 };
3164 let required = qos.required_policies();
3165 assert!(required.contains(QoSPolicyMask::RELIABILITY));
3166 assert!(!required.contains(QoSPolicyMask::HISTORY));
3167 assert!(!required.contains(QoSPolicyMask::DEPTH));
3168 assert!(!required.contains(QoSPolicyMask::DURABILITY_VOLATILE));
3169 assert!(!required.contains(QoSPolicyMask::DURABILITY_TRANSIENT_LOCAL));
3170 }
3171
3172 /// A fully concrete profile is unaffected by the relaxation — this is the
3173 /// guard on "do not silently stop demanding what the caller asked for".
3174 #[test]
3175 fn concrete_profiles_still_demand_their_core_policies() {
3176 let required = QoSProfile::QOS_PROFILE_DEFAULT.required_policies();
3177 assert!(required.contains(QoSPolicyMask::RELIABILITY));
3178 assert!(required.contains(QoSPolicyMask::DURABILITY_VOLATILE));
3179 assert!(required.contains(QoSPolicyMask::HISTORY));
3180 assert!(required.contains(QoSPolicyMask::DEPTH));
3181 // And a backend missing one still rejects it.
3182 let missing = QoSPolicyMask(required.0 & !QoSPolicyMask::DEPTH.0);
3183 assert_eq!(
3184 QoSProfile::QOS_PROFILE_DEFAULT.validate_against(missing),
3185 Err(TransportError::IncompatibleQos)
3186 );
3187 }
3188
3189 /// issue 0829 — resolution replaces ONLY the sentinel fields, and the
3190 /// answer is the backend's, not a constant this crate bakes.
3191 #[test]
3192 fn resolve_system_default_fills_absences_and_touches_nothing_else() {
3193 // Two backends, two different answers to the SAME sentinel — the whole
3194 // reason no concrete `QOS_PROFILE_SYSTEM_DEFAULT` could be right.
3195 const CYCLONE: QoSSystemDefaults = QoSSystemDefaults {
3196 reliability: QoSReliabilityPolicy::Reliable,
3197 durability: QoSDurabilityPolicy::Volatile,
3198 history: QoSHistoryPolicy::KeepLast,
3199 depth: 1,
3200 };
3201 const ZENOH: QoSSystemDefaults = QoSSystemDefaults {
3202 depth: 4,
3203 ..CYCLONE
3204 };
3205
3206 let a = QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT.resolve_system_default(&CYCLONE);
3207 let b = QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT.resolve_system_default(&ZENOH);
3208 assert_eq!(a.reliability, QoSReliabilityPolicy::Reliable);
3209 assert_eq!(a.history, QoSHistoryPolicy::KeepLast);
3210 assert_eq!(a.durability, QoSDurabilityPolicy::Volatile);
3211 assert_eq!(a.depth, 1);
3212 assert_eq!(b.depth, 4);
3213 assert!(!a.has_unresolved_system_default());
3214 assert!(!b.has_unresolved_system_default());
3215
3216 // A STATED policy is never overridden — resolution fills an absence.
3217 let stated = QoSProfile {
3218 reliability: QoSReliabilityPolicy::BestEffort,
3219 history: QoSHistoryPolicy::KeepAll,
3220 durability: QoSDurabilityPolicy::TransientLocal,
3221 depth: 7,
3222 ..QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
3223 };
3224 let resolved = stated.resolve_system_default(&CYCLONE);
3225 assert_eq!(resolved, stated, "resolution overrode a stated policy");
3226
3227 // Idempotent.
3228 assert_eq!(a.resolve_system_default(&ZENOH), a);
3229 }
3230
3231 #[test]
3232 fn services_default_validates_and_rejects_missing_policy() {
3233 // Phase 193.5 — the service-create path (node `create_service*_sized` +
3234 // the typed-arena `register_service*_on`) runs `validate_against` on the
3235 // caller's profile, exactly like pub/sub. A backend advertising the
3236 // profile's required policies admits it; dropping any required bit (here
3237 // RELIABILITY) rejects it with `IncompatibleQos` — no silent downgrade.
3238 let qos = QoSProfile::services_default();
3239 let required = qos.required_policies();
3240 assert!(qos.validate_against(required).is_ok());
3241 assert!(qos.validate_against(QoSPolicyMask(u32::MAX)).is_ok());
3242 let missing = QoSPolicyMask(required.0 & !QoSPolicyMask::RELIABILITY.0);
3243 assert_eq!(
3244 qos.validate_against(missing),
3245 Err(TransportError::IncompatibleQos)
3246 );
3247 }
3248
3249 /// issue 0793 — this asserted `TransientLocal`, which PINNED THE DEFECT:
3250 /// upstream `rmw_qos_profile_parameters` is KEEP_LAST(1000) + RELIABLE +
3251 /// **VOLATILE** (`/opt/ros/<distro>/include/rmw/rmw/qos_profiles.h`), and
3252 /// our own second copy of the profile, `nros::qos::PARAMETERS`, was already
3253 /// correct. The test agreed with the wrong copy, so the disagreement between
3254 /// the two survived every run.
3255 #[test]
3256 fn test_qos_profile_parameters() {
3257 let qos = QoSProfile::QOS_PROFILE_PARAMETERS;
3258 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
3259 assert_eq!(
3260 qos.durability,
3261 QoSDurabilityPolicy::Volatile,
3262 "rmw_qos_profile_parameters is VOLATILE; transient-local would make \
3263 every parameter server retain for late joiners, which ROS 2 does not do"
3264 );
3265 assert_eq!(qos.depth, 1000);
3266 }
3267
3268 #[test]
3269 fn test_qos_profile_clock() {
3270 let qos = QoSProfile::QOS_PROFILE_CLOCK;
3271 assert_eq!(qos.reliability, QoSReliabilityPolicy::BestEffort);
3272 assert_eq!(qos.depth, 1);
3273 }
3274
3275 #[test]
3276 fn test_qos_profile_parameter_events() {
3277 let qos = QoSProfile::QOS_PROFILE_PARAMETER_EVENTS;
3278 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
3279 assert_eq!(qos.history, QoSHistoryPolicy::KeepAll);
3280 }
3281
3282 #[test]
3283 fn test_qos_profile_action_status() {
3284 let qos = QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT;
3285 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
3286 assert_eq!(qos.durability, QoSDurabilityPolicy::TransientLocal);
3287 assert_eq!(qos.depth, 1);
3288 }
3289
3290 #[test]
3291 fn test_qos_static_constructors() {
3292 assert_eq!(
3293 QoSProfile::topics_default(),
3294 QoSProfile::QOS_PROFILE_DEFAULT
3295 );
3296 assert_eq!(
3297 QoSProfile::sensor_data_default(),
3298 QoSProfile::QOS_PROFILE_SENSOR_DATA
3299 );
3300 assert_eq!(
3301 QoSProfile::services_default(),
3302 QoSProfile::QOS_PROFILE_SERVICES_DEFAULT
3303 );
3304 assert_eq!(
3305 QoSProfile::parameters_default(),
3306 QoSProfile::QOS_PROFILE_PARAMETERS
3307 );
3308 assert_eq!(
3309 QoSProfile::action_status_default(),
3310 QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT
3311 );
3312 }
3313
3314 #[test]
3315 fn test_qos_builder_explicit_setters() {
3316 let qos = QoSProfile::new()
3317 .reliability(QoSReliabilityPolicy::Reliable)
3318 .durability(QoSDurabilityPolicy::TransientLocal)
3319 .history(QoSHistoryPolicy::KeepAll)
3320 .depth(100);
3321
3322 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
3323 assert_eq!(qos.durability, QoSDurabilityPolicy::TransientLocal);
3324 assert_eq!(qos.history, QoSHistoryPolicy::KeepAll);
3325 assert_eq!(qos.depth, 100);
3326 }
3327
3328 #[test]
3329 fn test_qos_builder_chaining() {
3330 // Test that builder methods can be chained in any order
3331 let qos = QoSProfile::sensor_data_default()
3332 .reliable()
3333 .transient_local()
3334 .keep_last(20);
3335
3336 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
3337 assert_eq!(qos.durability, QoSDurabilityPolicy::TransientLocal);
3338 assert_eq!(qos.history, QoSHistoryPolicy::KeepLast);
3339 assert_eq!(qos.depth, 20);
3340 }
3341
3342 #[test]
3343 fn test_qos_eq_impl() {
3344 // Verify that PartialEq works correctly via derive on QoSProfile
3345 let qos1 = QoSProfile::QOS_PROFILE_DEFAULT;
3346 let qos2 = QoSProfile::topics_default();
3347 // Both should have same values - verify field by field
3348 assert_eq!(qos1.reliability, qos2.reliability);
3349 assert_eq!(qos1.durability, qos2.durability);
3350 assert_eq!(qos1.history, qos2.history);
3351 assert_eq!(qos1.depth, qos2.depth);
3352 }
3353
3354 // --- Locator validation tests ---
3355
3356 #[test]
3357 fn test_locator_protocol_tcp() {
3358 assert_eq!(locator_protocol("tcp/127.0.0.1:7447"), LocatorProtocol::Tcp);
3359 }
3360
3361 #[test]
3362 fn test_locator_protocol_serial() {
3363 assert_eq!(
3364 locator_protocol("serial//dev/ttyUSB0#baudrate=115200"),
3365 LocatorProtocol::Serial
3366 );
3367 }
3368
3369 #[test]
3370 fn test_locator_protocol_unknown() {
3371 assert_eq!(locator_protocol(""), LocatorProtocol::Unknown);
3372 assert_eq!(locator_protocol("http://foo"), LocatorProtocol::Unknown);
3373 assert_eq!(locator_protocol("tls/host:port"), LocatorProtocol::Unknown);
3374 }
3375
3376 #[test]
3377 fn test_locator_protocol_udp() {
3378 assert_eq!(locator_protocol("udp/127.0.0.1:7447"), LocatorProtocol::Udp);
3379 assert_eq!(
3380 locator_protocol("udp/192.168.1.50:2019"),
3381 LocatorProtocol::Udp
3382 );
3383 }
3384
3385 #[test]
3386 fn test_validate_tcp_locator_ok() {
3387 assert!(validate_locator("tcp/127.0.0.1:7447").is_ok());
3388 assert!(validate_locator("tcp/192.168.1.1:7447").is_ok());
3389 }
3390
3391 #[test]
3392 fn test_validate_tcp_locator_missing_port() {
3393 assert!(validate_locator("tcp/127.0.0.1").is_err());
3394 }
3395
3396 #[test]
3397 fn test_validate_serial_locator_ok() {
3398 assert!(validate_locator("serial//dev/ttyUSB0#baudrate=115200").is_ok());
3399 assert!(validate_locator("serial//dev/ttyACM0#baudrate=9600").is_ok());
3400 assert!(validate_locator("serial/uart1#baudrate=921600").is_ok());
3401 }
3402
3403 #[test]
3404 fn test_validate_serial_locator_empty_device() {
3405 assert!(validate_locator("serial/").is_err());
3406 }
3407
3408 #[test]
3409 fn test_validate_serial_locator_missing_baudrate() {
3410 assert!(validate_locator("serial//dev/ttyUSB0").is_err());
3411 }
3412
3413 #[test]
3414 fn test_validate_serial_locator_invalid_baudrate() {
3415 assert!(validate_locator("serial//dev/ttyUSB0#baudrate=abc").is_err());
3416 }
3417
3418 #[test]
3419 fn test_validate_unknown_protocol() {
3420 assert!(validate_locator("http://foo").is_err());
3421 assert!(validate_locator("tls/host:port").is_err());
3422 }
3423
3424 #[test]
3425 fn test_validate_udp_locator_ok() {
3426 assert!(validate_locator("udp/127.0.0.1:7447").is_ok());
3427 assert!(validate_locator("udp/192.168.1.50:2019").is_ok());
3428 }
3429
3430 #[test]
3431 fn test_validate_udp_locator_missing_port() {
3432 assert!(validate_locator("udp/127.0.0.1").is_err());
3433 }
3434
3435 // Phase 233.2 — the PX4 companion QoS profile must be BEST_EFFORT +
3436 // TRANSIENT_LOCAL + KEEP_LAST so it matches PX4's uxrce_dds_client endpoints.
3437 #[test]
3438 fn px4_qos_profile_matches_uxrce_dds_client() {
3439 let q = QoSProfile::px4();
3440 assert_eq!(q.reliability, QoSReliabilityPolicy::BestEffort);
3441 // VOLATILE — PX4's /fmu/out writers are volatile; a TRANSIENT_LOCAL
3442 // reader silently fails to match (verified against real PX4 SITL).
3443 assert_eq!(q.durability, QoSDurabilityPolicy::Volatile);
3444 assert_eq!(q.history, QoSHistoryPolicy::KeepLast);
3445 assert_eq!(q, QoSProfile::QOS_PROFILE_PX4);
3446 // Depth is tunable via the builder without losing the PX4 policies.
3447 let deep = QoSProfile::px4().keep_last(5);
3448 assert_eq!(deep.depth, 5);
3449 assert_eq!(deep.reliability, QoSReliabilityPolicy::BestEffort);
3450 assert_eq!(deep.durability, QoSDurabilityPolicy::Volatile);
3451 }
3452
3453 // --- RmwConfig Tests ---
3454
3455 #[test]
3456 fn test_rmw_config_default() {
3457 let config = RmwConfig::default();
3458 assert_eq!(config.locator, "tcp/127.0.0.1:7447");
3459 assert_eq!(config.mode, SessionMode::Client);
3460 assert_eq!(config.domain_id, 0);
3461 assert_eq!(config.node_name, "node");
3462 assert_eq!(config.namespace, "");
3463 }
3464
3465 #[test]
3466 fn test_rmw_config_custom() {
3467 let config = RmwConfig {
3468 locator: "tcp/192.168.1.1:7447",
3469 mode: SessionMode::Peer,
3470 domain_id: 42,
3471 node_name: "talker",
3472 namespace: "/ns1",
3473 properties: &[("agent_port", "2019")],
3474 };
3475 assert_eq!(config.locator, "tcp/192.168.1.1:7447");
3476 assert_eq!(config.mode, SessionMode::Peer);
3477 assert_eq!(config.domain_id, 42);
3478 assert_eq!(config.node_name, "talker");
3479 assert_eq!(config.namespace, "/ns1");
3480 assert_eq!(config.properties.len(), 1);
3481 assert_eq!(config.properties[0].0, "agent_port");
3482 }
3483
3484 #[test]
3485 fn test_rmw_config_is_copy() {
3486 let config = RmwConfig::default();
3487 let config2 = config; // Copy
3488 assert_eq!(config.locator, config2.locator);
3489 assert_eq!(config.domain_id, config2.domain_id);
3490 }
3491
3492 #[test]
3493 fn test_rmw_config_clone() {
3494 let config = RmwConfig::default();
3495 let cloned = RmwConfig { ..config };
3496 assert_eq!(cloned.locator, config.locator);
3497 assert_eq!(cloned.node_name, config.node_name);
3498 }
3499}
3500
3501#[cfg(test)]
3502mod rx_buffer_hint_tests {
3503 use super::*;
3504
3505 /// issue 0896 / phase-402 — the hint must SURVIVE being set on `TopicInfo`.
3506 ///
3507 /// This is the seam every path funnels through: the C register writes it
3508 /// here, `nros-rmw-cffi` reads `topic.rx_buffer_hint` back out when
3509 /// building `rmw_subscription_options_t`, and a size-classing backend
3510 /// routes on it. A default that silently stayed 0 is the original defect,
3511 /// so the round trip is worth an assertion rather than an assumption.
3512 #[test]
3513 fn a_hint_set_on_topic_info_is_readable_back() {
3514 let t = TopicInfo::new("/chatter", "std_msgs/msg/Int32", "").with_rx_buffer_hint(4096);
3515 assert_eq!(t.rx_buffer_hint, 4096);
3516 }
3517
3518 /// Zero is "no opinion", not "zero bytes". The C options struct uses 0 as
3519 /// its unset sentinel and the executor only calls the setter when the
3520 /// caller stated something, so the default must stay 0 for the
3521 /// backend-default path to remain reachable.
3522 #[test]
3523 fn the_default_hint_is_zero_meaning_no_opinion() {
3524 let t = TopicInfo::new("/chatter", "std_msgs/msg/Int32", "");
3525 assert_eq!(t.rx_buffer_hint, 0);
3526 }
3527
3528 /// The builder must not disturb the rest of the descriptor — it is applied
3529 /// AFTER domain/namespace/node in the C path, so a setter that reset a
3530 /// field would drop identity that discovery depends on.
3531 #[test]
3532 fn setting_the_hint_preserves_the_rest_of_the_descriptor() {
3533 let make = || {
3534 TopicInfo::new("/chatter", "std_msgs/msg/Int32", "hash")
3535 .with_domain(7)
3536 .with_namespace("/ns")
3537 };
3538 let base = make();
3539 let hinted = make().with_rx_buffer_hint(1234);
3540 assert_eq!(hinted.name, base.name);
3541 assert_eq!(hinted.type_name, base.type_name);
3542 assert_eq!(hinted.type_hash, base.type_hash);
3543 assert_eq!(hinted.domain_id, base.domain_id);
3544 assert_eq!(hinted.namespace, base.namespace);
3545 assert_eq!(hinted.rx_buffer_hint, 1234);
3546 }
3547}