Skip to main content

nros_node/executor/
node.rs

1//! Node — borrows the session to create typed entities.
2
3use core::marker::PhantomData;
4
5use nros_core::{RosAction, RosMessage, RosService};
6use nros_rmw::{ActionInfo, QoSProfile, ServiceInfo, Session as _, TopicInfo, TransportError};
7
8use crate::{
9    rmw_type_registry::{MessageForRmw, register_type},
10    session,
11};
12
13use super::{
14    handles::{
15        ActionClient, ActionClientCallback, ActionServer, EmbeddedPublisher, EmbeddedServiceClient,
16        EmbeddedServiceServer, ServiceClientCallback, Subscription,
17    },
18    types::NodeError,
19};
20
21// ============================================================================
22// Node
23// ============================================================================
24
25/// Backend-agnostic node — borrows the session to create typed entities.
26pub struct NodeHandle<'a> {
27    name: heapless::String<64>,
28    namespace: heapless::String<64>,
29    session: &'a mut session::ConcreteSession,
30    domain_id: u32,
31    /// Phase 211.H — per-node QoS overrides lowered from the launch
32    /// `qos_overrides.<topic>.<role>.<policy>` params and baked into a
33    /// `&'static` table by the entry codegen. Folded into each entity's
34    /// `QoSProfile` at `create_publisher`/`create_subscription` time
35    /// (setup-time, no alloc). Empty (`&[]`) by default → zero cost for
36    /// systems without overrides.
37    qos_overrides: &'static [nros_rmw::QoSOverride],
38    /// RFC-0052 W3b.4 — baked contract-monitor table (mirror of
39    /// `qos_overrides`): `create_publisher` attaches the matching
40    /// endpoint's counter cell so `publish` can bump it lock-free.
41    monitors: &'static [crate::executor::monitor::MonitorSpec],
42    /// W3b.5 — baked subscriber age-contract table + epoch clock;
43    /// `create_subscription` attaches the matching endpoint's age cell.
44    age_monitors: &'static [crate::executor::monitor::AgeMonitorSpec],
45    epoch_us_fn: Option<fn() -> u64>,
46}
47
48impl<'a> NodeHandle<'a> {
49    /// Create a new node (called by Executor::create_node).
50    pub(crate) fn new(
51        name: heapless::String<64>,
52        namespace: heapless::String<64>,
53        session: &'a mut session::ConcreteSession,
54        domain_id: u32,
55    ) -> Self {
56        Self {
57            name,
58            namespace,
59            session,
60            domain_id,
61            qos_overrides: &[],
62            monitors: &[],
63            age_monitors: &[],
64            epoch_us_fn: None,
65        }
66    }
67
68    /// Phase 211.H — install the plan's QoS-override table on this node. Called
69    /// by the generated entry BEFORE the component constructs its entities, so
70    /// `create_publisher`/`create_subscription` fold the matching overrides in.
71    /// The table is `&'static` (codegen bakes it as a `static`), so there is no
72    /// lifetime to thread and no runtime allocation. Plan = authority: an
73    /// override for a topic the entity creates is applied transparently (the
74    /// user's `create_publisher(topic)` call is unchanged, matching rclcpp).
75    pub fn set_qos_overrides(&mut self, overrides: &'static [nros_rmw::QoSOverride]) {
76        self.qos_overrides = overrides;
77    }
78
79    /// RFC-0052 W3b.4 — install the executor's monitor table on this node
80    /// (called by the entry glue / fixture alongside `set_qos_overrides`).
81    pub fn set_monitors(&mut self, monitors: &'static [crate::executor::monitor::MonitorSpec]) {
82        self.monitors = monitors;
83    }
84
85    /// W3b.5 — install the subscriber age-contract table + epoch clock
86    /// (auto-seeded from the executor's `set_age_table` / config epoch).
87    pub fn set_age_monitors(
88        &mut self,
89        table: &'static [crate::executor::monitor::AgeMonitorSpec],
90        epoch_us: Option<fn() -> u64>,
91    ) {
92        self.age_monitors = table;
93        self.epoch_us_fn = epoch_us;
94    }
95
96    /// The installed QoS-override table (empty unless the entry set one).
97    #[must_use]
98    pub fn qos_overrides(&self) -> &'static [nros_rmw::QoSOverride] {
99        self.qos_overrides
100    }
101
102    /// Get the node name.
103    pub fn name(&self) -> &str {
104        &self.name
105    }
106
107    /// RFC-0088 — the serialization format this node's entities speak.
108    ///
109    /// The counterpart to ROS 2's `rmw_get_serialization_format()`, asked of
110    /// the node rather than the process: an `Executor::open_multi` image holds
111    /// two sessions and has no single answer, which is the case the reserved
112    /// vtable slot was cut for.
113    ///
114    /// A single-backend image already knows this at compile time — see
115    /// [`crate::session::IMAGE_SERIALIZATION_FORMAT`], which is what the
116    /// entity-creation assertions compare against. Use this accessor when the
117    /// answer must be a value: a bridge, a diagnostic, a tool.
118    pub fn serialization_format(&self) -> &'static str {
119        nros_rmw::Session::serialization_format(&*self.session)
120    }
121
122    /// Phase 88.12 — return the [`nros_log::Logger`] keyed on the
123    /// node name.
124    ///
125    /// Loggers are interned in nros-log's bounded global table
126    /// ([`nros_log::MAX_LOGGERS`] slots). If the caller has
127    /// pre-registered a `'static Logger` whose name matches this
128    /// node's name (via [`nros_log::register_logger`]), this method
129    /// returns that exact reference — so subsequent `nros_*!` calls
130    /// share per-logger runtime threshold state with any other call
131    /// site that resolves the same name. Otherwise the call returns
132    /// [`nros_log::DEFAULT_LOGGER`], keeping the API total.
133    ///
134    /// ```ignore
135    /// // Pre-register if you want a dedicated threshold:
136    /// static MY_NODE_LOGGER: nros_log::Logger =
137    ///     nros_log::Logger::new("my_node");
138    /// nros_log::register_logger(&MY_NODE_LOGGER);
139    ///
140    /// // Inside any node-creating code:
141    /// let logger = node.logger();
142    /// nros_log::nros_info!(logger, "started; domain = {}", node.domain_id());
143    /// ```
144    #[must_use]
145    pub fn logger(&self) -> &'static nros_log::Logger {
146        nros_log::get_logger(self.name())
147    }
148
149    /// Get the domain ID.
150    pub fn domain_id(&self) -> u32 {
151        self.domain_id
152    }
153
154    /// Set the domain ID.
155    pub fn set_domain_id(&mut self, domain_id: u32) {
156        self.domain_id = domain_id;
157    }
158
159    /// Get a mutable reference to the underlying session.
160    pub fn session_mut(&mut self) -> &mut session::ConcreteSession {
161        self.session
162    }
163
164    // ------------------------------------------------------------------
165    // Routing-info builders (Phase 91.F)
166    //
167    // Every `create_*` below threads the same node identity (domain_id +
168    // name + namespace) into a TopicInfo / ServiceInfo / ActionInfo. The
169    // shape repeats verbatim ~12 times across this file. Centralised
170    // here so a future change to the routing-info shape (e.g. adding a
171    // `with_security_context`) updates one site instead of twelve, and
172    // so the per-`create_*` function bodies focus on the parts that
173    // actually differ between them.
174    // ------------------------------------------------------------------
175
176    // Associated fns (NOT `&self` methods) so the returned `*Info`
177    // value's borrow tracks only the explicit `&str` arguments, not
178    // the whole `Node`. A `&self` form would block the immediately-
179    // following `self.session.create_*(&info, …)` mut borrow on the
180    // `name` / `namespace` reborrow held inside the returned `*Info`,
181    // because going through a method call hides the field-disjoint
182    // path that lets `&self.name` + `&mut self.session` coexist.
183    fn topic_info<'b>(
184        domain_id: u32,
185        node_name: &'b str,
186        namespace: &'b str,
187        topic_name: &'b str,
188        type_name: &'b str,
189        type_hash: &'b str,
190    ) -> TopicInfo<'b> {
191        TopicInfo::new(topic_name, type_name, type_hash)
192            .with_domain(domain_id)
193            .with_node_name(node_name)
194            .with_namespace(namespace)
195    }
196
197    fn service_info<'b>(
198        domain_id: u32,
199        node_name: &'b str,
200        namespace: &'b str,
201        service_name: &'b str,
202        type_name: &'b str,
203        type_hash: &'b str,
204    ) -> ServiceInfo<'b> {
205        ServiceInfo::new(service_name, type_name, type_hash)
206            .with_domain(domain_id)
207            .with_node_name(node_name)
208            .with_namespace(namespace)
209    }
210
211    fn action_info<'b>(
212        domain_id: u32,
213        action_name: &'b str,
214        type_name: &'b str,
215        type_hash: &'b str,
216    ) -> ActionInfo<'b> {
217        // Action root only needs the domain — per-channel ServiceInfo /
218        // TopicInfo derived from action_info.{send_goal,cancel_goal,...}_key()
219        // carry the full node identity via service_info() / topic_info().
220        ActionInfo::new(action_name, type_name, type_hash).with_domain(domain_id)
221    }
222
223    // -- Publishers --
224
225    /// Create a publisher for the given topic.
226    pub fn create_publisher<M: MessageForRmw>(
227        &mut self,
228        topic_name: &str,
229    ) -> Result<EmbeddedPublisher<M>, NodeError> {
230        self.create_publisher_with_qos::<M>(topic_name, QoSProfile::default())
231    }
232
233    /// Create a publisher with custom QoS settings.
234    pub fn create_publisher_with_qos<M: MessageForRmw>(
235        &mut self,
236        topic_name: &str,
237        qos: QoSProfile,
238    ) -> Result<EmbeddedPublisher<M>, NodeError> {
239        // RFC-0088 / phase-421 W1 — the message's declared format must be the
240        // one the linked backend speaks. Universal: the const lives on
241        // `RosMessage`, which `MessageForRmw` requires under every backend.
242        crate::format_check::assert_message_format::<M>();
243        // Phase 212.K.7.6.b — under `rmw-cyclonedds`, ensure the runtime
244        // type-descriptor exists before the cffi vtable creates the
245        // entity. No-op for other RMWs.
246        register_type::<M>()?;
247        // Phase 211.H — fold any plan qos_overrides for this topic+publisher
248        // into the profile (setup-time, no alloc) BEFORE validation, so an
249        // override the backend can't honour still errors loudly below.
250        let qos = qos.apply_overrides(
251            topic_name,
252            nros_rmw::QoSOverrideRole::Publisher,
253            self.qos_overrides,
254        );
255        // Phase 108.B — synchronous QoS validation against backend's
256        // `supported_qos_policies()` mask. No silent downgrade.
257        qos.validate_against(nros_rmw::Session::supported_qos_policies(self.session))
258            .map_err(NodeError::Transport)?;
259        let topic = Self::topic_info(
260            self.domain_id,
261            &self.name,
262            &self.namespace,
263            topic_name,
264            <M as RosMessage>::TYPE_NAME,
265            <M as RosMessage>::TYPE_HASH,
266        );
267        let handle = self
268            .session
269            .create_publisher(&topic, qos)
270            .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))?;
271        // RFC-0052 W3b.4 — attach the contracted endpoint's counter cell
272        // (exact topic-name match against the baked table; None = free).
273        let monitor = self
274            .monitors
275            .iter()
276            .find(|m| m.topic == topic_name)
277            .map(|m| m.cell);
278        Ok(EmbeddedPublisher {
279            handle,
280            event_regs: crate::executor::handles::empty_event_regs(),
281            monitor,
282            epoch: self.epoch_us_fn,
283            _phantom: PhantomData,
284        })
285    }
286
287    /// Create a typeless publisher for non-ROS wire formats (e.g. PX4 uORB
288    /// raw POD bytes, custom binary protocols). The caller supplies the
289    /// `type_name` and `type_hash` strings used by backends that need them
290    /// for liveliness/discovery; backends that don't (uORB) can pass any
291    /// stable string.
292    pub fn create_publisher_raw(
293        &mut self,
294        topic_name: &str,
295        type_name: &str,
296        type_hash: &str,
297    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
298        self.create_publisher_raw_with_qos(topic_name, type_name, type_hash, QoSProfile::default())
299    }
300
301    /// Typeless publisher with custom QoS.
302    pub fn create_publisher_raw_with_qos(
303        &mut self,
304        topic_name: &str,
305        type_name: &str,
306        type_hash: &str,
307        qos: QoSProfile,
308    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
309        // Phase 211.H — apply plan qos_overrides (publisher side) before validate.
310        let qos = qos.apply_overrides(
311            topic_name,
312            nros_rmw::QoSOverrideRole::Publisher,
313            self.qos_overrides,
314        );
315        qos.validate_against(nros_rmw::Session::supported_qos_policies(self.session))
316            .map_err(NodeError::Transport)?;
317        let topic = Self::topic_info(
318            self.domain_id,
319            &self.name,
320            &self.namespace,
321            topic_name,
322            type_name,
323            type_hash,
324        );
325        let handle = self
326            .session
327            .create_publisher(&topic, qos)
328            .map_err(|_| NodeError::Transport(TransportError::PublisherCreationFailed))?;
329        Ok(crate::executor::handles::EmbeddedRawPublisher {
330            handle,
331            arena: crate::executor::handles::TxArena::new(),
332            event_regs: crate::executor::handles::empty_event_regs(),
333        })
334    }
335
336    /// Phase 189.M1 — the customizable publisher **builder** (the `clone` tier;
337    /// see `docs/design/0022-entity-api-tiers.md`). Pick a mode with `.typed::<M>()`
338    /// or `.generic(type, hash)`, set knobs (`.qos`), then `.build()`. The
339    /// convenient `create_publisher` / `create_publisher_raw` are the `fork`
340    /// tier — sugar over this with defaults.
341    pub fn publisher<'t>(&mut self, topic: &'t str) -> PublisherBuilder<'_, 'a, 't> {
342        PublisherBuilder {
343            node: self,
344            topic,
345            qos: QoSProfile::default(),
346        }
347    }
348
349    // -- Subscriptions --
350
351    /// Create a subscription for the given topic.
352    pub fn create_subscription<M: MessageForRmw>(
353        &mut self,
354        topic_name: &str,
355    ) -> Result<Subscription<M>, NodeError> {
356        self.create_subscription_sized::<M, { crate::config::DEFAULT_RX_BUF_SIZE }>(topic_name)
357    }
358
359    /// Create a subscription with custom buffer size.
360    pub fn create_subscription_sized<M: MessageForRmw, const RX_BUF: usize>(
361        &mut self,
362        topic_name: &str,
363    ) -> Result<Subscription<M, RX_BUF>, NodeError> {
364        self.create_subscription_with_qos::<M, RX_BUF>(topic_name, QoSProfile::default())
365    }
366
367    /// Create a subscription with custom QoS and buffer size.
368    pub fn create_subscription_with_qos<M: MessageForRmw, const RX_BUF: usize>(
369        &mut self,
370        topic_name: &str,
371        qos: QoSProfile,
372    ) -> Result<Subscription<M, RX_BUF>, NodeError> {
373        // RFC-0088 / phase-421 W1 — the message's declared format must be the
374        // one the linked backend speaks. Universal: the const lives on
375        // `RosMessage`, which `MessageForRmw` requires under every backend.
376        crate::format_check::assert_message_format::<M>();
377        // Phase 212.K.7.6.b — see `create_publisher_with_qos`.
378        register_type::<M>()?;
379        // Phase 211.H — apply plan qos_overrides (subscription side) before validate.
380        let qos = qos.apply_overrides(
381            topic_name,
382            nros_rmw::QoSOverrideRole::Subscription,
383            self.qos_overrides,
384        );
385        qos.validate_against(nros_rmw::Session::supported_qos_policies(self.session))
386            .map_err(NodeError::Transport)?;
387        let topic = Self::topic_info(
388            self.domain_id,
389            &self.name,
390            &self.namespace,
391            topic_name,
392            <M as RosMessage>::TYPE_NAME,
393            <M as RosMessage>::TYPE_HASH,
394        );
395        let handle = self
396            .session
397            .create_subscription(&topic, qos)
398            .map_err(NodeError::Transport)?;
399        // W3b.5 — attach the contracted endpoint's age cell (stamped
400        // types only; needs an epoch source).
401        let age_mon = match (<M as RosMessage>::STAMP_OFFSET, self.epoch_us_fn) {
402            (Some(_), Some(epoch)) => self
403                .age_monitors
404                .iter()
405                .find(|a| a.topic == topic_name)
406                .map(|a| (a.cell, epoch)),
407            _ => None,
408        };
409        Ok(Subscription {
410            handle,
411            buffer: [0u8; RX_BUF],
412            event_regs: crate::executor::handles::empty_event_regs(),
413            age_mon,
414            _phantom: PhantomData,
415        })
416    }
417
418    /// Create a typeless subscription. Caller decodes raw bytes themselves.
419    pub fn create_subscription_raw(
420        &mut self,
421        topic_name: &str,
422        type_name: &str,
423        type_hash: &str,
424    ) -> Result<crate::executor::handles::RawSubscription, NodeError> {
425        self.create_subscription_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }>(
426            topic_name, type_name, type_hash,
427        )
428    }
429
430    /// Typeless subscription with custom buffer size.
431    pub fn create_subscription_raw_sized<const RX_BUF: usize>(
432        &mut self,
433        topic_name: &str,
434        type_name: &str,
435        type_hash: &str,
436    ) -> Result<crate::executor::handles::RawSubscription<RX_BUF>, NodeError> {
437        // Phase 211.H — apply plan qos_overrides (subscription side) before
438        // validate, mirroring `create_publisher_raw_with_qos`. The raw entity
439        // paths honour node overrides exactly like the typed ones — an
440        // override the active RMW can't meet errors loudly, never silently.
441        let qos = QoSProfile::default().apply_overrides(
442            topic_name,
443            nros_rmw::QoSOverrideRole::Subscription,
444            self.qos_overrides,
445        );
446        qos.validate_against(nros_rmw::Session::supported_qos_policies(self.session))
447            .map_err(NodeError::Transport)?;
448        let topic = Self::topic_info(
449            self.domain_id,
450            &self.name,
451            &self.namespace,
452            topic_name,
453            type_name,
454            type_hash,
455        );
456        let handle = self
457            .session
458            .create_subscription(&topic, qos)
459            .map_err(NodeError::Transport)?;
460        Ok(crate::executor::handles::RawSubscription {
461            handle,
462            buffer: [0u8; RX_BUF],
463            event_regs: crate::executor::handles::empty_event_regs(),
464        })
465    }
466
467    // -- Services --
468
469    /// Create a service server.
470    pub fn create_service<Svc: RosService>(
471        &mut self,
472        service_name: &str,
473    ) -> Result<EmbeddedServiceServer<Svc>, NodeError>
474    where
475        Svc::Request: MessageForRmw,
476        Svc::Reply: MessageForRmw,
477    {
478        self.create_service_sized::<Svc, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, QoSProfile::services_default())
479    }
480
481    /// Phase 193.2b — service server with an explicit QoS profile (applied to
482    /// both the request + reply endpoints; rclcpp's `create_service(name, qos)`).
483    pub fn create_service_with_qos<Svc: RosService>(
484        &mut self,
485        service_name: &str,
486        qos: QoSProfile,
487    ) -> Result<EmbeddedServiceServer<Svc>, NodeError>
488    where
489        Svc::Request: MessageForRmw,
490        Svc::Reply: MessageForRmw,
491    {
492        self.create_service_sized::<Svc, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, qos)
493    }
494
495    /// Create a service server with custom buffer sizes + QoS.
496    pub fn create_service_sized<Svc: RosService, const REQ_BUF: usize, const REPLY_BUF: usize>(
497        &mut self,
498        service_name: &str,
499        qos: QoSProfile,
500    ) -> Result<EmbeddedServiceServer<Svc, REQ_BUF, REPLY_BUF>, NodeError>
501    where
502        Svc::Request: MessageForRmw,
503        Svc::Reply: MessageForRmw,
504    {
505        // Phase 212.K.7.6.b — register both halves of the service round-trip
506        // under cyclonedds. No-op for other RMWs.
507        register_type::<Svc::Request>()?;
508        register_type::<Svc::Reply>()?;
509        // Phase 193.5 — validate the service profile against the backend's
510        // supported policies (mirrors pub/sub); no silent downgrade. RELIABLE is
511        // effectively required for request/reply, so a backend that only honours
512        // a fixed profile rejects an incompatible request here.
513        qos.validate_against(nros_rmw::Session::supported_qos_policies(self.session))
514            .map_err(NodeError::Transport)?;
515        let info = Self::service_info(
516            self.domain_id,
517            &self.name,
518            &self.namespace,
519            service_name,
520            Svc::SERVICE_NAME,
521            Svc::SERVICE_HASH,
522        );
523        let handle = self
524            .session
525            .create_service(&info, qos)
526            .map_err(NodeError::Transport)?;
527        Ok(EmbeddedServiceServer {
528            handle,
529            req_buffer: [0u8; REQ_BUF],
530            reply_buffer: [0u8; REPLY_BUF],
531            _phantom: PhantomData,
532        })
533    }
534
535    /// Create a service client.
536    pub fn create_client<Svc: RosService>(
537        &mut self,
538        service_name: &str,
539    ) -> Result<EmbeddedServiceClient<Svc>, NodeError>
540    where
541        Svc::Request: MessageForRmw,
542        Svc::Reply: MessageForRmw,
543    {
544        self.create_client_sized::<Svc, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, QoSProfile::services_default())
545    }
546
547    /// Phase 193.2b — service client with an explicit QoS profile.
548    pub fn create_client_with_qos<Svc: RosService>(
549        &mut self,
550        service_name: &str,
551        qos: QoSProfile,
552    ) -> Result<EmbeddedServiceClient<Svc>, NodeError>
553    where
554        Svc::Request: MessageForRmw,
555        Svc::Reply: MessageForRmw,
556    {
557        self.create_client_sized::<Svc, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(service_name, qos)
558    }
559
560    /// Create a service client with custom buffer sizes + QoS.
561    pub fn create_client_sized<Svc: RosService, const REQ_BUF: usize, const REPLY_BUF: usize>(
562        &mut self,
563        service_name: &str,
564        qos: QoSProfile,
565    ) -> Result<EmbeddedServiceClient<Svc, REQ_BUF, REPLY_BUF>, NodeError>
566    where
567        Svc::Request: MessageForRmw,
568        Svc::Reply: MessageForRmw,
569    {
570        // Phase 212.K.7.6.b — see `create_service_sized`.
571        register_type::<Svc::Request>()?;
572        register_type::<Svc::Reply>()?;
573        // Phase 193.5 — validate against the backend's supported policies (no
574        // silent downgrade); request/reply effectively requires RELIABLE.
575        qos.validate_against(nros_rmw::Session::supported_qos_policies(self.session))
576            .map_err(NodeError::Transport)?;
577        let info = Self::service_info(
578            self.domain_id,
579            &self.name,
580            &self.namespace,
581            service_name,
582            Svc::SERVICE_NAME,
583            Svc::SERVICE_HASH,
584        );
585        let handle = self
586            .session
587            .create_client(&info, qos)
588            .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?;
589        Ok(EmbeddedServiceClient {
590            handle,
591            req_buffer: [0u8; REQ_BUF],
592            reply_buffer: [0u8; REPLY_BUF],
593            in_flight: false,
594            _phantom: PhantomData,
595        })
596    }
597
598    /// Typeless service server. L1 counterpart of [`create_service`]
599    /// for the C / C++ FFI shims and callers that own their own
600    /// scheduler. Returns a [`crate::executor::handles::RawServiceServer`]
601    /// which polls request bytes directly.
602    pub fn create_service_raw(
603        &mut self,
604        service_name: &str,
605        type_name: &str,
606        type_hash: &str,
607    ) -> Result<crate::executor::handles::RawServiceServer, NodeError> {
608        self.create_service_raw_sized::<
609            { crate::config::DEFAULT_RX_BUF_SIZE },
610            { crate::config::DEFAULT_RX_BUF_SIZE },
611        >(service_name, type_name, type_hash)
612    }
613
614    /// Typeless service server with custom buffer sizes.
615    pub fn create_service_raw_sized<const REQ_BUF: usize, const RESP_BUF: usize>(
616        &mut self,
617        service_name: &str,
618        type_name: &str,
619        type_hash: &str,
620    ) -> Result<crate::executor::handles::RawServiceServer<REQ_BUF, RESP_BUF>, NodeError> {
621        let info = Self::service_info(
622            self.domain_id,
623            &self.name,
624            &self.namespace,
625            service_name,
626            type_name,
627            type_hash,
628        );
629        let handle = self
630            .session
631            .create_service(&info, QoSProfile::services_default())
632            .map_err(NodeError::Transport)?;
633        Ok(crate::executor::handles::RawServiceServer::new(handle))
634    }
635
636    /// Typeless service client. L1 counterpart of [`create_client`].
637    pub fn create_client_raw(
638        &mut self,
639        service_name: &str,
640        type_name: &str,
641        type_hash: &str,
642    ) -> Result<crate::executor::handles::RawServiceClient, NodeError> {
643        self.create_client_raw_sized::<
644            { crate::config::DEFAULT_RX_BUF_SIZE },
645            { crate::config::DEFAULT_RX_BUF_SIZE },
646        >(service_name, type_name, type_hash)
647    }
648
649    /// Typeless service client with custom buffer sizes.
650    pub fn create_client_raw_sized<const REQ_BUF: usize, const REPLY_BUF: usize>(
651        &mut self,
652        service_name: &str,
653        type_name: &str,
654        type_hash: &str,
655    ) -> Result<crate::executor::handles::RawServiceClient<REQ_BUF, REPLY_BUF>, NodeError> {
656        let info = Self::service_info(
657            self.domain_id,
658            &self.name,
659            &self.namespace,
660            service_name,
661            type_name,
662            type_hash,
663        );
664        let handle = self
665            .session
666            .create_client(&info, QoSProfile::services_default())
667            .map_err(|_| NodeError::Transport(TransportError::ServiceClientCreationFailed))?;
668        Ok(crate::executor::handles::RawServiceClient::new(handle))
669    }
670
671    // -- Actions --
672
673    /// Phase 122.3.c.6 — typeless action server. Builds the 5
674    /// transport channels (`send_goal` / `cancel_goal` / `get_result`
675    /// services + `feedback` / `status` publishers) and returns the
676    /// raw `ActionServerCore` directly. Caller owns scheduling —
677    /// drives `try_recv_goal_request` / `publish_feedback_raw` /
678    /// `complete_goal_raw` / `try_handle_cancel` /
679    /// `try_handle_get_result_raw` on the returned core.
680    pub fn create_action_server_raw(
681        &mut self,
682        action_name: &str,
683        type_name: &str,
684        type_hash: &str,
685    ) -> Result<
686        super::action_core::ActionServerCore<
687            { crate::config::DEFAULT_RX_BUF_SIZE },
688            { crate::config::DEFAULT_RX_BUF_SIZE },
689            { crate::config::DEFAULT_RX_BUF_SIZE },
690            4,
691        >,
692        NodeError,
693    > {
694        self.create_action_server_raw_sized::<
695            { crate::config::DEFAULT_RX_BUF_SIZE },
696            { crate::config::DEFAULT_RX_BUF_SIZE },
697            { crate::config::DEFAULT_RX_BUF_SIZE },
698            4,
699        >(action_name, type_name, type_hash)
700    }
701
702    /// Typeless action server with custom buffer + goal-slot sizes.
703    pub fn create_action_server_raw_sized<
704        const GOAL_BUF: usize,
705        const RESULT_BUF: usize,
706        const FEEDBACK_BUF: usize,
707        const MAX_GOALS: usize,
708    >(
709        &mut self,
710        action_name: &str,
711        type_name: &str,
712        type_hash: &str,
713    ) -> Result<
714        super::action_core::ActionServerCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
715        NodeError,
716    > {
717        let action_info = Self::action_info(self.domain_id, action_name, type_name, type_hash);
718
719        // Issue 0454 / phase-354 W3 — per-CHANNEL DDS type, not the bare
720        // action type (see `action_channel_type`). These two `_raw_sized`
721        // constructors are the last holders of the phase-338 W3 defect: the
722        // type name is baked into the keyexpr, so `…Fibonacci_` never matches a
723        // peer advertising `…Fibonacci_SendGoal_`.
724        let send_goal_type: heapless::String<256> =
725            super::action_core::action_channel_type(type_name, "SendGoal");
726        let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
727        let send_goal_info = Self::service_info(
728            self.domain_id,
729            &self.name,
730            &self.namespace,
731            &send_goal_keyexpr,
732            &send_goal_type,
733            type_hash,
734        );
735        let send_goal_server = self
736            .session
737            .create_service(&send_goal_info, QoSProfile::services_default())
738            .map_err(|_| NodeError::ActionCreationFailed)?;
739
740        let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
741        let cancel_goal_info = Self::service_info(
742            self.domain_id,
743            &self.name,
744            &self.namespace,
745            &cancel_goal_keyexpr,
746            "action_msgs::srv::dds_::CancelGoal_",
747            type_hash,
748        );
749        let cancel_goal_server = self
750            .session
751            .create_service(&cancel_goal_info, QoSProfile::services_default())
752            .map_err(|_| NodeError::ActionCreationFailed)?;
753
754        let get_result_type: heapless::String<256> =
755            super::action_core::action_channel_type(type_name, "GetResult");
756        let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
757        let get_result_info = Self::service_info(
758            self.domain_id,
759            &self.name,
760            &self.namespace,
761            &get_result_keyexpr,
762            &get_result_type,
763            type_hash,
764        );
765        let get_result_server = self
766            .session
767            .create_service(&get_result_info, QoSProfile::services_default())
768            .map_err(|_| NodeError::ActionCreationFailed)?;
769
770        let feedback_type: heapless::String<256> =
771            super::action_core::action_channel_type(type_name, "FeedbackMessage");
772        let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
773        let feedback_topic = Self::topic_info(
774            self.domain_id,
775            &self.name,
776            &self.namespace,
777            &feedback_keyexpr,
778            &feedback_type,
779            type_hash,
780        );
781        let feedback_publisher = self
782            .session
783            .create_publisher(&feedback_topic, QoSProfile::QOS_PROFILE_DEFAULT)
784            .map_err(|_| NodeError::ActionCreationFailed)?;
785
786        let status_keyexpr: heapless::String<256> = action_info.status_key();
787        let status_topic = Self::topic_info(
788            self.domain_id,
789            &self.name,
790            &self.namespace,
791            &status_keyexpr,
792            "action_msgs::msg::dds_::GoalStatusArray_",
793            type_hash,
794        );
795        let status_publisher = self
796            .session
797            .create_publisher(&status_topic, QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT)
798            .map_err(|_| NodeError::ActionCreationFailed)?;
799
800        Ok(super::action_core::ActionServerCore {
801            send_goal_server,
802            cancel_goal_server,
803            get_result_server,
804            feedback_publisher,
805            status_publisher,
806            active_goals: heapless::Vec::new(),
807            completed_results: heapless::Vec::new(),
808            pending_get_results: heapless::Vec::new(),
809            result_slab: [0u8; RESULT_BUF],
810            result_slab_used: 0,
811            goal_buffer: [0u8; GOAL_BUF],
812            feedback_buffer: [0u8; FEEDBACK_BUF],
813            cancel_buffer: [0u8; 256],
814        })
815    }
816
817    /// Phase 122.3.c.6 — typeless action client. Same shape as
818    /// `create_action_server_raw` but builds the 3 service clients
819    /// + 1 feedback subscriber, returns the raw `ActionClientCore`.
820    pub fn create_action_client_raw(
821        &mut self,
822        action_name: &str,
823        type_name: &str,
824        type_hash: &str,
825    ) -> Result<
826        super::action_core::ActionClientCore<
827            { crate::config::DEFAULT_RX_BUF_SIZE },
828            { crate::config::DEFAULT_RX_BUF_SIZE },
829            { crate::config::DEFAULT_RX_BUF_SIZE },
830        >,
831        NodeError,
832    > {
833        self.create_action_client_raw_sized::<
834            { crate::config::DEFAULT_RX_BUF_SIZE },
835            { crate::config::DEFAULT_RX_BUF_SIZE },
836            { crate::config::DEFAULT_RX_BUF_SIZE },
837        >(action_name, type_name, type_hash)
838    }
839
840    /// Typeless action client with custom buffer sizes.
841    pub fn create_action_client_raw_sized<
842        const GOAL_BUF: usize,
843        const RESULT_BUF: usize,
844        const FEEDBACK_BUF: usize,
845    >(
846        &mut self,
847        action_name: &str,
848        type_name: &str,
849        type_hash: &str,
850    ) -> Result<super::action_core::ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>, NodeError>
851    {
852        let action_info = Self::action_info(self.domain_id, action_name, type_name, type_hash);
853
854        // Issue 0454 / phase-354 W3 — per-CHANNEL DDS type, not the bare
855        // action type (see `action_channel_type`). These two `_raw_sized`
856        // constructors are the last holders of the phase-338 W3 defect: the
857        // type name is baked into the keyexpr, so `…Fibonacci_` never matches a
858        // peer advertising `…Fibonacci_SendGoal_`.
859        let send_goal_type: heapless::String<256> =
860            super::action_core::action_channel_type(type_name, "SendGoal");
861        let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
862        let send_goal_info = Self::service_info(
863            self.domain_id,
864            &self.name,
865            &self.namespace,
866            &send_goal_keyexpr,
867            &send_goal_type,
868            type_hash,
869        );
870        let send_goal_client = self
871            .session
872            .create_client(&send_goal_info, QoSProfile::services_default())
873            .map_err(|_| NodeError::ActionCreationFailed)?;
874
875        let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
876        let cancel_goal_info = Self::service_info(
877            self.domain_id,
878            &self.name,
879            &self.namespace,
880            &cancel_goal_keyexpr,
881            "action_msgs::srv::dds_::CancelGoal_",
882            type_hash,
883        );
884        let cancel_goal_client = self
885            .session
886            .create_client(&cancel_goal_info, QoSProfile::services_default())
887            .map_err(|_| NodeError::ActionCreationFailed)?;
888
889        let get_result_type: heapless::String<256> =
890            super::action_core::action_channel_type(type_name, "GetResult");
891        let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
892        let get_result_info = Self::service_info(
893            self.domain_id,
894            &self.name,
895            &self.namespace,
896            &get_result_keyexpr,
897            &get_result_type,
898            type_hash,
899        );
900        let get_result_client = self
901            .session
902            .create_client(&get_result_info, QoSProfile::services_default())
903            .map_err(|_| NodeError::ActionCreationFailed)?;
904
905        let feedback_type: heapless::String<256> =
906            super::action_core::action_channel_type(type_name, "FeedbackMessage");
907        let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
908        let feedback_topic = Self::topic_info(
909            self.domain_id,
910            &self.name,
911            &self.namespace,
912            &feedback_keyexpr,
913            &feedback_type,
914            type_hash,
915        );
916        let feedback_subscriber = self
917            .session
918            .create_subscription(&feedback_topic, QoSProfile::BEST_EFFORT)
919            .map_err(|_| NodeError::ActionCreationFailed)?;
920
921        Ok(super::action_core::ActionClientCore::new(
922            send_goal_client,
923            cancel_goal_client,
924            get_result_client,
925            feedback_subscriber,
926        ))
927    }
928
929    /// Create an action server.
930    pub fn create_action_server<A: RosAction>(
931        &mut self,
932        action_name: &str,
933    ) -> Result<ActionServer<A>, NodeError>
934    where
935        A::Goal: MessageForRmw,
936        A::Result: MessageForRmw,
937        A::Feedback: MessageForRmw,
938        A::SendGoalRequest: MessageForRmw,
939        A::SendGoalResponse: MessageForRmw,
940        A::GetResultRequest: MessageForRmw,
941        A::GetResultResponse: MessageForRmw,
942        A::FeedbackMessage: MessageForRmw,
943    {
944        self.create_action_server_sized::<A, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, 4>(action_name)
945    }
946
947    /// Create an action server with custom buffer sizes.
948    pub fn create_action_server_sized<
949        A: RosAction,
950        const GOAL_BUF: usize,
951        const RESULT_BUF: usize,
952        const FEEDBACK_BUF: usize,
953        const MAX_GOALS: usize,
954    >(
955        &mut self,
956        action_name: &str,
957    ) -> Result<ActionServer<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>, NodeError>
958    where
959        A::Goal: MessageForRmw,
960        A::Result: MessageForRmw,
961        A::Feedback: MessageForRmw,
962        A::SendGoalRequest: MessageForRmw,
963        A::SendGoalResponse: MessageForRmw,
964        A::GetResultRequest: MessageForRmw,
965        A::GetResultResponse: MessageForRmw,
966        A::FeedbackMessage: MessageForRmw,
967    {
968        // Phase 212.K.7.6.b + K.7.7.c — register the three user-facing
969        // message types AND the five action-protocol envelope types under
970        // cyclonedds. No-op for other RMWs. The envelopes are needed
971        // because the action service shapes (`*_SendGoal_Request`,
972        // `*_GetResult_Response`, …) are the actual on-wire CDR types,
973        // and the C++ Cyclone bridge auto-prepends a cdds_request_header_t
974        // for any TYPE_NAME ending `_Request`/`_Response`/`_Reply`.
975        register_type::<A::Goal>()?;
976        register_type::<A::Result>()?;
977        register_type::<A::Feedback>()?;
978        register_type::<A::SendGoalRequest>()?;
979        register_type::<A::SendGoalResponse>()?;
980        register_type::<A::GetResultRequest>()?;
981        register_type::<A::GetResultResponse>()?;
982        register_type::<A::FeedbackMessage>()?;
983        // issue #234 — also register the fixed `action_msgs` protocol types
984        // (`CancelGoal_{Request,Response}`, `GoalStatusArray`) the cancel service
985        // + status publisher created below serialize. They are not `RosAction`
986        // associated types (they live in `action_msgs`, which `nros-core` cannot
987        // name), so the generated `impl RosAction::register_protocol_types` — which
988        // routes them through the generic `nros_rmw::register_type_descriptor` seam —
989        // registers them. Without this the cancel_goal service + status publisher
990        // have no Cyclone descriptor → `ActionCreationFailed`. The callback executor
991        // path (`executor/action.rs`) already did this; this node.rs path — the one
992        // `create_action_server` materialises through — did not.
993        A::register_protocol_types().map_err(|()| NodeError::ActionCreationFailed)?;
994        let action_info =
995            Self::action_info(self.domain_id, action_name, A::ACTION_NAME, A::ACTION_HASH);
996
997        // Each underlying ServiceInfo / TopicInfo also carries the
998        // node identity so the Zenoh shim declares a liveliness token
999        // for it. Without `with_node_name` the shim's
1000        // `declare_entity_liveliness` short-circuits (`node_name.and_then`
1001        // → None) and `wait_for_action_server` has nothing to find.
1002        // Advertise the per-channel service / topic types ROS 2 matches on
1003        // (`<Action>_SendGoal` / `<Action>_GetResult` / `<Action>_FeedbackMessage`),
1004        // not the bare action type — see `action_core::action_service_base_type`.
1005        let send_goal_type = super::action_core::action_service_base_type(
1006            <A::SendGoalRequest as RosMessage>::TYPE_NAME,
1007            A::ACTION_NAME,
1008        );
1009        let get_result_type = super::action_core::action_service_base_type(
1010            <A::GetResultRequest as RosMessage>::TYPE_NAME,
1011            A::ACTION_NAME,
1012        );
1013        let feedback_type = <A::FeedbackMessage as RosMessage>::TYPE_NAME;
1014
1015        let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
1016        let send_goal_info = Self::service_info(
1017            self.domain_id,
1018            &self.name,
1019            &self.namespace,
1020            &send_goal_keyexpr,
1021            send_goal_type,
1022            A::SEND_GOAL_SERVICE_HASH,
1023        );
1024        let send_goal_server = self
1025            .session
1026            .create_service(&send_goal_info, QoSProfile::services_default())
1027            .map_err(|_| NodeError::ActionCreationFailed)?;
1028
1029        let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
1030        let cancel_goal_info = Self::service_info(
1031            self.domain_id,
1032            &self.name,
1033            &self.namespace,
1034            &cancel_goal_keyexpr,
1035            "action_msgs::srv::dds_::CancelGoal_",
1036            A::ACTION_HASH,
1037        );
1038        let cancel_goal_server = self
1039            .session
1040            .create_service(&cancel_goal_info, QoSProfile::services_default())
1041            .map_err(|_| NodeError::ActionCreationFailed)?;
1042
1043        let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
1044        let get_result_info = Self::service_info(
1045            self.domain_id,
1046            &self.name,
1047            &self.namespace,
1048            &get_result_keyexpr,
1049            get_result_type,
1050            A::GET_RESULT_SERVICE_HASH,
1051        );
1052        let get_result_server = self
1053            .session
1054            .create_service(&get_result_info, QoSProfile::services_default())
1055            .map_err(|_| NodeError::ActionCreationFailed)?;
1056
1057        let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
1058        let feedback_topic = Self::topic_info(
1059            self.domain_id,
1060            &self.name,
1061            &self.namespace,
1062            &feedback_keyexpr,
1063            feedback_type,
1064            <A::FeedbackMessage as RosMessage>::TYPE_HASH,
1065        );
1066        let feedback_publisher = self
1067            .session
1068            .create_publisher(&feedback_topic, QoSProfile::QOS_PROFILE_DEFAULT)
1069            .map_err(|_| NodeError::ActionCreationFailed)?;
1070
1071        let status_keyexpr: heapless::String<256> = action_info.status_key();
1072        let status_topic = Self::topic_info(
1073            self.domain_id,
1074            &self.name,
1075            &self.namespace,
1076            &status_keyexpr,
1077            "action_msgs::msg::dds_::GoalStatusArray_",
1078            A::ACTION_HASH,
1079        );
1080        let status_publisher = self
1081            .session
1082            .create_publisher(&status_topic, QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT)
1083            .map_err(|_| NodeError::ActionCreationFailed)?;
1084
1085        Ok(ActionServer {
1086            core: super::action_core::ActionServerCore {
1087                send_goal_server,
1088                cancel_goal_server,
1089                get_result_server,
1090                feedback_publisher,
1091                status_publisher,
1092                active_goals: heapless::Vec::new(),
1093                completed_results: heapless::Vec::new(),
1094                pending_get_results: heapless::Vec::new(),
1095                result_slab: [0u8; RESULT_BUF],
1096                result_slab_used: 0,
1097                goal_buffer: [0u8; GOAL_BUF],
1098                feedback_buffer: [0u8; FEEDBACK_BUF],
1099                cancel_buffer: [0u8; 256],
1100            },
1101            typed_goals: heapless::Vec::new(),
1102            completed_goals: heapless::Vec::new(),
1103        })
1104    }
1105
1106    /// Create an action client.
1107    pub fn create_action_client<A: RosAction>(
1108        &mut self,
1109        action_name: &str,
1110    ) -> Result<ActionClient<A>, NodeError>
1111    where
1112        A::Goal: MessageForRmw,
1113        A::Result: MessageForRmw,
1114        A::Feedback: MessageForRmw,
1115        A::SendGoalRequest: MessageForRmw,
1116        A::SendGoalResponse: MessageForRmw,
1117        A::GetResultRequest: MessageForRmw,
1118        A::GetResultResponse: MessageForRmw,
1119        A::FeedbackMessage: MessageForRmw,
1120    {
1121        self.create_action_client_sized::<A, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }>(action_name)
1122    }
1123
1124    /// Create an action client with custom buffer sizes.
1125    pub fn create_action_client_sized<
1126        A: RosAction,
1127        const GOAL_BUF: usize,
1128        const RESULT_BUF: usize,
1129        const FEEDBACK_BUF: usize,
1130    >(
1131        &mut self,
1132        action_name: &str,
1133    ) -> Result<ActionClient<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>, NodeError>
1134    where
1135        A::Goal: MessageForRmw,
1136        A::Result: MessageForRmw,
1137        A::Feedback: MessageForRmw,
1138        A::SendGoalRequest: MessageForRmw,
1139        A::SendGoalResponse: MessageForRmw,
1140        A::GetResultRequest: MessageForRmw,
1141        A::GetResultResponse: MessageForRmw,
1142        A::FeedbackMessage: MessageForRmw,
1143    {
1144        // Phase 212.K.7.6.b + K.7.7.c — see `create_action_server_sized`.
1145        register_type::<A::Goal>()?;
1146        register_type::<A::Result>()?;
1147        register_type::<A::Feedback>()?;
1148        register_type::<A::SendGoalRequest>()?;
1149        register_type::<A::SendGoalResponse>()?;
1150        register_type::<A::GetResultRequest>()?;
1151        register_type::<A::GetResultResponse>()?;
1152        register_type::<A::FeedbackMessage>()?;
1153        // issue #234 — register the `action_msgs` protocol types the cancel-goal
1154        // service client below serializes (`CancelGoal_{Request,Response}`; the impl
1155        // also registers `GoalStatusArray`, harmlessly unused client-side) via the
1156        // generic seam. Without it the cancel_goal client has no Cyclone descriptor
1157        // → `ActionCreationFailed`. Mirrors the server path.
1158        A::register_protocol_types().map_err(|()| NodeError::ActionCreationFailed)?;
1159        let action_info =
1160            Self::action_info(self.domain_id, action_name, A::ACTION_NAME, A::ACTION_HASH);
1161
1162        // Mirror `create_action_server_sized`: thread node identity through
1163        // each underlying ServiceInfo / TopicInfo so the Zenoh shim
1164        // declares the matching client-side liveliness tokens (and so the
1165        // discovery wildcard built from `send_goal_info` ends up in the
1166        // same domain as the server's tokens).
1167        // Same per-channel typing as the server side so the client's requesters
1168        // and feedback reader match a real ROS 2 action server over DDS.
1169        let send_goal_type = super::action_core::action_service_base_type(
1170            <A::SendGoalRequest as RosMessage>::TYPE_NAME,
1171            A::ACTION_NAME,
1172        );
1173        let get_result_type = super::action_core::action_service_base_type(
1174            <A::GetResultRequest as RosMessage>::TYPE_NAME,
1175            A::ACTION_NAME,
1176        );
1177        let feedback_type = <A::FeedbackMessage as RosMessage>::TYPE_NAME;
1178
1179        let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
1180        let send_goal_info = Self::service_info(
1181            self.domain_id,
1182            &self.name,
1183            &self.namespace,
1184            &send_goal_keyexpr,
1185            send_goal_type,
1186            A::ACTION_HASH,
1187        );
1188        let send_goal_client = self
1189            .session
1190            .create_client(&send_goal_info, QoSProfile::services_default())
1191            .map_err(|_| NodeError::ActionCreationFailed)?;
1192
1193        let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
1194        let cancel_goal_info = Self::service_info(
1195            self.domain_id,
1196            &self.name,
1197            &self.namespace,
1198            &cancel_goal_keyexpr,
1199            "action_msgs::srv::dds_::CancelGoal_",
1200            A::ACTION_HASH,
1201        );
1202        let cancel_goal_client = self
1203            .session
1204            .create_client(&cancel_goal_info, QoSProfile::services_default())
1205            .map_err(|_| NodeError::ActionCreationFailed)?;
1206
1207        let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
1208        let get_result_info = Self::service_info(
1209            self.domain_id,
1210            &self.name,
1211            &self.namespace,
1212            &get_result_keyexpr,
1213            get_result_type,
1214            A::ACTION_HASH,
1215        );
1216        let get_result_client = self
1217            .session
1218            .create_client(&get_result_info, QoSProfile::services_default())
1219            .map_err(|_| NodeError::ActionCreationFailed)?;
1220
1221        let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
1222        let feedback_topic = Self::topic_info(
1223            self.domain_id,
1224            &self.name,
1225            &self.namespace,
1226            &feedback_keyexpr,
1227            feedback_type,
1228            A::ACTION_HASH,
1229        );
1230        let feedback_subscriber = self
1231            .session
1232            .create_subscription(&feedback_topic, QoSProfile::BEST_EFFORT)
1233            .map_err(|_| NodeError::ActionCreationFailed)?;
1234
1235        Ok(ActionClient {
1236            core: super::action_core::ActionClientCore {
1237                send_goal_client,
1238                cancel_goal_client,
1239                get_result_client,
1240                feedback_subscriber,
1241                goal_buffer: [0u8; GOAL_BUF],
1242                result_buffer: [0u8; RESULT_BUF],
1243                feedback_buffer: [0u8; FEEDBACK_BUF],
1244                goal_counter: 0,
1245                in_flight_send_goal: false,
1246                in_flight_cancel: false,
1247                in_flight_get_result: false,
1248            },
1249            _phantom: PhantomData,
1250        })
1251    }
1252}
1253
1254// ===================================================================
1255// Phase 189.M1 — entity builders (the `clone` tier)
1256// ===================================================================
1257
1258/// Publisher builder — `node.publisher(topic)`. Choose `.typed::<M>()` or
1259/// `.generic(type, hash)`, optionally `.qos(..)`, then `.build()`.
1260pub struct PublisherBuilder<'n, 'a, 't> {
1261    node: &'n mut NodeHandle<'a>,
1262    topic: &'t str,
1263    qos: QoSProfile,
1264}
1265
1266impl<'n, 'a, 't> PublisherBuilder<'n, 'a, 't> {
1267    /// Set the QoS (also settable on the typed/generic builder).
1268    pub fn qos(mut self, qos: QoSProfile) -> Self {
1269        self.qos = qos;
1270        self
1271    }
1272
1273    /// Phase 282 (#145) — mark this publisher "express": its samples bypass
1274    /// transport tx batching (sent immediately even when the batching knob is
1275    /// on). For control-tier / latency-sensitive topics.
1276    pub fn tx_express(mut self, express: bool) -> Self {
1277        self.qos.tx_express = express;
1278        self
1279    }
1280
1281    /// Typed publisher for a ROS message `M` (mirrors rclcpp/rclrs).
1282    pub fn typed<M: MessageForRmw>(self) -> TypedPublisherBuilder<'n, 'a, 't, M> {
1283        TypedPublisherBuilder {
1284            node: self.node,
1285            topic: self.topic,
1286            qos: self.qos,
1287            _phantom: PhantomData,
1288        }
1289    }
1290
1291    /// Generic (type-erased) publisher — the rclcpp `create_generic_publisher`
1292    /// form; raw CDR bytes via `publish_raw`.
1293    pub fn generic(
1294        self,
1295        type_name: &'t str,
1296        type_hash: &'t str,
1297    ) -> GenericPublisherBuilder<'n, 'a, 't> {
1298        GenericPublisherBuilder {
1299            node: self.node,
1300            topic: self.topic,
1301            type_name,
1302            type_hash,
1303            qos: self.qos,
1304        }
1305    }
1306}
1307
1308/// Typed publisher builder (`.typed::<M>()`).
1309pub struct TypedPublisherBuilder<'n, 'a, 't, M> {
1310    node: &'n mut NodeHandle<'a>,
1311    topic: &'t str,
1312    qos: QoSProfile,
1313    _phantom: PhantomData<M>,
1314}
1315
1316impl<'n, 'a, 't, M: MessageForRmw> TypedPublisherBuilder<'n, 'a, 't, M> {
1317    pub fn qos(mut self, qos: QoSProfile) -> Self {
1318        self.qos = qos;
1319        self
1320    }
1321
1322    /// Phase 282 (#145) — see [`PublisherBuilder::tx_express`].
1323    pub fn tx_express(mut self, express: bool) -> Self {
1324        self.qos.tx_express = express;
1325        self
1326    }
1327
1328    pub fn build(self) -> Result<EmbeddedPublisher<M>, NodeError> {
1329        self.node
1330            .create_publisher_with_qos::<M>(self.topic, self.qos)
1331    }
1332}
1333
1334/// Generic (type-erased) publisher builder (`.generic(type, hash)`).
1335pub struct GenericPublisherBuilder<'n, 'a, 't> {
1336    node: &'n mut NodeHandle<'a>,
1337    topic: &'t str,
1338    type_name: &'t str,
1339    type_hash: &'t str,
1340    qos: QoSProfile,
1341}
1342
1343impl<'n, 'a, 't> GenericPublisherBuilder<'n, 'a, 't> {
1344    pub fn qos(mut self, qos: QoSProfile) -> Self {
1345        self.qos = qos;
1346        self
1347    }
1348
1349    /// Phase 282 (#145) — see [`PublisherBuilder::tx_express`].
1350    pub fn tx_express(mut self, express: bool) -> Self {
1351        self.qos.tx_express = express;
1352        self
1353    }
1354
1355    pub fn build(self) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
1356        self.node.create_publisher_raw_with_qos(
1357            self.topic,
1358            self.type_name,
1359            self.type_hash,
1360            self.qos,
1361        )
1362    }
1363}
1364
1365// ============================================================================
1366// Phase 273 (RFC-0047) — CallbackGroup token (rclcpp/rclrs shape)
1367// ============================================================================
1368
1369/// A first-class callback group — a **name-only token** (rclcpp/rclrs shape).
1370///
1371/// Created via [`NodeCtx::create_callback_group`].  Passed to the `_in`
1372/// entity-create variants (`create_timer_in`, `create_subscription_in`,
1373/// `create_publisher_in`) to label entities with a group name.
1374///
1375/// The group is just a name — the actual `SchedContext` binding is seeded at
1376/// boot by `Executor::bind_group_sched` (from `system.toml group_tiers`, phase
1377/// 273 W2) and resolved at entity-registration time by
1378/// `apply_node_default_sched` (phase 273 W1).  No concurrency type (Mutually-
1379/// Exclusive vs Reentrant) is stored here — that is RFC-0047 OQ1 follow-up.
1380pub struct CallbackGroup {
1381    name: heapless::String<32>,
1382}
1383
1384impl CallbackGroup {
1385    /// The group name (e.g. `"ctrl"`, `"telem"`).
1386    pub fn name(&self) -> &str {
1387        &self.name
1388    }
1389}
1390
1391/// An executor-borrowing node handle — `exec.node(id)`. Hosts the
1392/// callback-registering entity builders (subscriptions register into the
1393/// executor's dispatch arena). It is a **short-lived `&mut Executor` borrow**:
1394/// create entities, then drop it before acquiring the next node handle; entity
1395/// handles (`HandleId`, publishers) are owned and outlive it (no `Arc` — see
1396/// `docs/design/0022-entity-api-tiers.md` §Borrow model).
1397pub struct NodeCtx<'e, 's> {
1398    executor: &'e mut super::spin::Executor<'s>,
1399    node_id: super::node_record::NodeId,
1400}
1401
1402impl<'e, 's> NodeCtx<'e, 's> {
1403    pub(crate) fn new(
1404        executor: &'e mut super::spin::Executor<'s>,
1405        node_id: super::node_record::NodeId,
1406    ) -> Self {
1407        Self { executor, node_id }
1408    }
1409
1410    /// Subscription builder (the `clone` tier). Pick a mode with `.typed::<M>()`
1411    /// or `.generic(type, hash)`, set knobs (`.qos`), then `.build(callback)`.
1412    pub fn subscription<'t>(&mut self, topic: &'t str) -> SubscriptionBuilder<'_, 'e, 't, 's> {
1413        SubscriptionBuilder {
1414            ctx: self,
1415            topic,
1416            qos: QoSProfile::default(),
1417        }
1418    }
1419
1420    /// Publisher builder (the `clone` tier), symmetric with
1421    /// [`subscription`](Self::subscription). Pick `.typed::<M>()` or
1422    /// `.generic(type, hash)`, set `.qos()`, then `.build()`. The returned
1423    /// publisher handle is owned and outlives this `NodeCtx` — the bridge
1424    /// builds the dest publisher on one ctx, drops it, then registers the
1425    /// source subscription on another (see `0022-entity-api-tiers.md`).
1426    pub fn publisher<'t>(&mut self, topic: &'t str) -> CtxPublisherBuilder<'_, 'e, 't, 's> {
1427        CtxPublisherBuilder {
1428            ctx: self,
1429            topic,
1430            qos: QoSProfile::default(),
1431        }
1432    }
1433
1434    /// Convenient typed publisher (the `fork` tier — rclcpp/rclrs shape).
1435    pub fn create_publisher<M: MessageForRmw>(
1436        &mut self,
1437        topic: &str,
1438    ) -> Result<EmbeddedPublisher<M>, NodeError> {
1439        self.executor
1440            .create_publisher_on::<M>(self.node_id, topic, QoSProfile::default())
1441    }
1442
1443    /// Convenient generic (type-erased) publisher — rclcpp `create_generic_*`.
1444    pub fn create_generic_publisher(
1445        &mut self,
1446        topic: &str,
1447        type_name: &str,
1448        type_hash: &str,
1449    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
1450        self.create_generic_publisher_with_qos(topic, type_name, type_hash, QoSProfile::default())
1451    }
1452
1453    /// Issue 0306 — the same, with an explicit profile. The declarative
1454    /// component path needs it: a node that declares
1455    /// `create_publisher_for_topic_with_qos(...)` carries its profile in
1456    /// `EntityMetadata::qos`, and the runtime used to drop it on the floor by
1457    /// calling the default-QoS constructor here.
1458    pub fn create_generic_publisher_with_qos(
1459        &mut self,
1460        topic: &str,
1461        type_name: &str,
1462        type_hash: &str,
1463        qos: QoSProfile,
1464    ) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
1465        self.executor
1466            .create_publisher_raw_on(self.node_id, topic, type_name, type_hash, qos)
1467    }
1468
1469    /// Convenient typed subscription (the `fork` tier — rclcpp/rclrs shape).
1470    /// Sugar over the builder with default QoS + buffer.
1471    pub fn create_subscription<M, F>(
1472        &mut self,
1473        topic: &str,
1474        callback: F,
1475    ) -> Result<super::types::HandleId, NodeError>
1476    where
1477        M: MessageForRmw + 'static,
1478        F: FnMut(&M) + 'static,
1479    {
1480        self.executor
1481            .register_subscription_buffered_on::<M, F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
1482                self.node_id,
1483                topic,
1484                QoSProfile::default(),
1485                callback,
1486                None, // no group — node default
1487                None, // phase-403 W2: the configured default, unchanged
1488            )
1489    }
1490
1491    /// Subscribe `/clock` and install every sample as this image's ROS time —
1492    /// phase-425 W3, the thing `rclcpp`'s `use_sim_time` parameter switches on.
1493    ///
1494    /// After this, `Clock::ros_time().now()` is the simulator's or the bag
1495    /// player's time, and a timer registered with `TimerClockSource::Ros`
1496    /// follows it: it stops while the simulation is paused and tracks the replay
1497    /// rate. Nothing else changes — a wall timer on the same executor keeps its
1498    /// own cadence, which is what a watchdog needs.
1499    ///
1500    /// QoS is `QoSProfile::clock_default()`, i.e. `rclcpp::ClockQoS`: best
1501    /// effort, keep-last 1, volatile. A late subscriber wants the NEXT sample,
1502    /// not a replay of the simulation's history.
1503    ///
1504    /// Returns the subscription handle. Cancelling it stops the source but does
1505    /// NOT clear the override: a node that unsubscribes mid-run keeps the last
1506    /// simulated time rather than jumping back to the wall clock, which every
1507    /// ROS-time timer would otherwise have to absorb as a jump.
1508    ///
1509    /// The override is process-global — one simulated clock per image — so
1510    /// calling this on a second node in the same image adds a second subscriber
1511    /// to the same global, which is redundant rather than wrong.
1512    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1513    pub fn install_ros_time_source(&mut self) -> Result<super::types::HandleId, NodeError> {
1514        self.install_ros_time_source_on(crate::time_source::CLOCK_TOPIC)
1515    }
1516
1517    /// [`install_ros_time_source`](Self::install_ros_time_source) against a
1518    /// topic other than `/clock` — a remapped or namespaced clock, which is what
1519    /// a launch file creates when two simulations share a graph.
1520    #[cfg(all(feature = "sim-time", any(has_rmw, test)))]
1521    pub fn install_ros_time_source_on(
1522        &mut self,
1523        topic: &str,
1524    ) -> Result<super::types::HandleId, NodeError> {
1525        // One spelling of the QoS and the sample conversion, on the executor:
1526        // the `use_sim_time` reconciliation (phase-425 W3b) installs the same
1527        // subscription, and two copies would be two places for the QoS to drift.
1528        self.executor.install_ros_time_source(self.node_id, topic)
1529    }
1530
1531    // -----------------------------------------------------------------------
1532    // Phase 273 (RFC-0047) — callback-group API (rclcpp/rclrs shape)
1533    // -----------------------------------------------------------------------
1534
1535    /// Create a named callback group — a thin token wrapping the group name.
1536    ///
1537    /// Pass the returned [`CallbackGroup`] to the `_in` create variants
1538    /// (`create_timer_in`, `create_subscription_in`, `create_publisher_in`)
1539    /// to label entities with this group. The executor binds the entity's
1540    /// callback to the `SchedContext` seeded via `bind_group_sched` for
1541    /// `(node_name, namespace, group_name)` (phase 273 W1/W2).
1542    ///
1543    /// ```ignore
1544    /// let ctrl = node.create_callback_group("ctrl");
1545    /// node.create_timer_in(&ctrl, TimerDuration::from_millis(10), || { /* … */ })?;
1546    /// ```
1547    ///
1548    /// Names longer than 32 bytes are silently truncated.
1549    pub fn create_callback_group(&self, name: &str) -> CallbackGroup {
1550        let mut s = heapless::String::<32>::new();
1551        // Silently truncate if the name is too long (defensive; caller
1552        // should use short ASCII group names like "ctrl"/"telem").
1553        for ch in name.chars() {
1554            if s.push(ch).is_err() {
1555                break;
1556            }
1557        }
1558        CallbackGroup { name: s }
1559    }
1560
1561    /// Create a repeating timer **in** a callback group (phase 273, rclcpp shape).
1562    ///
1563    /// The timer's callback is bound to the `SchedContext` associated with
1564    /// `group` in the executor's `group_sched_table` for this node. If no
1565    /// entry was seeded for the group, the node's default `SchedContext` applies
1566    /// (same as `register_timer`). `period` fires the callback repeatedly.
1567    pub fn create_timer_in<F>(
1568        &mut self,
1569        group: &CallbackGroup,
1570        period: crate::timer::TimerDuration,
1571        callback: F,
1572    ) -> Result<super::types::HandleId, NodeError>
1573    where
1574        F: FnMut() + 'static,
1575    {
1576        self.executor
1577            .register_timer_on(Some(self.node_id), period, callback, Some(group.name()))
1578    }
1579
1580    /// Create a typed subscription **in** a callback group (phase 273, rclcpp shape).
1581    ///
1582    /// The subscription's callback is bound to the `SchedContext` associated
1583    /// with `group` in the `group_sched_table` for this node. If no entry was
1584    /// seeded, the node default applies.
1585    pub fn create_subscription_in<M, F>(
1586        &mut self,
1587        group: &CallbackGroup,
1588        topic: &str,
1589        callback: F,
1590    ) -> Result<super::types::HandleId, NodeError>
1591    where
1592        M: MessageForRmw + 'static,
1593        F: FnMut(&M) + 'static,
1594    {
1595        self.executor
1596            .register_subscription_buffered_on::<M, F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
1597                self.node_id,
1598                topic,
1599                QoSProfile::default(),
1600                callback,
1601                Some(group.name()),
1602                None, // phase-403 W2: the configured default, unchanged
1603            )
1604    }
1605
1606    /// Create a typed publisher **in** a callback group (phase 273, rclcpp shape).
1607    ///
1608    /// Publishers do not have an executor-dispatched callback, so the group
1609    /// name has no scheduling effect today (publishers are explicitly driven by
1610    /// the user via `publish()`). The API is provided for symmetry and
1611    /// forward-compatibility (intra-process / loaned-message knobs may use
1612    /// it in the future).
1613    pub fn create_publisher_in<M: MessageForRmw>(
1614        &mut self,
1615        _group: &CallbackGroup,
1616        topic: &str,
1617    ) -> Result<EmbeddedPublisher<M>, NodeError> {
1618        // Publishers carry no executor callback slot; group is forward-compat.
1619        self.executor
1620            .create_publisher_on::<M>(self.node_id, topic, QoSProfile::default())
1621    }
1622
1623    /// RFC-0041 / Phase 239.1 — callback-based service client (rclcpp
1624    /// `async_send_request(req, cb)` analogue). The reply is delivered to
1625    /// `callback` at `spin_once` (no `Promise` poll). Returns a
1626    /// [`ServiceClientCallback`] send handle; dual-mode — the `Promise`-based
1627    /// [`create_client`](Self::create_client) is unchanged.
1628    pub fn create_client_with_callback<Svc, F>(
1629        &mut self,
1630        service_name: &str,
1631        callback: F,
1632    ) -> Result<ServiceClientCallback<Svc>, NodeError>
1633    where
1634        Svc: RosService + 'static,
1635        Svc::Request: MessageForRmw,
1636        Svc::Reply: MessageForRmw,
1637        F: FnMut(&Svc::Reply) + 'static,
1638    {
1639        self.create_client_with_callback_sized::<
1640            Svc,
1641            F,
1642            { crate::config::DEFAULT_RX_BUF_SIZE },
1643            { crate::config::DEFAULT_RX_BUF_SIZE },
1644        >(service_name, callback)
1645    }
1646
1647    /// Callback-based service client with custom buffer sizes (Phase 239.1).
1648    pub fn create_client_with_callback_sized<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
1649        &mut self,
1650        service_name: &str,
1651        callback: F,
1652    ) -> Result<ServiceClientCallback<Svc, REQ_BUF, REPLY_BUF>, NodeError>
1653    where
1654        Svc: RosService + 'static,
1655        Svc::Request: MessageForRmw,
1656        Svc::Reply: MessageForRmw,
1657        F: FnMut(&Svc::Reply) + 'static,
1658    {
1659        register_type::<Svc::Request>()?;
1660        register_type::<Svc::Reply>()?;
1661        let (_id, hdr) = self
1662            .executor
1663            .register_service_client_callback::<Svc, F, REPLY_BUF>(
1664                Some(self.node_id),
1665                service_name,
1666                Svc::SERVICE_NAME,
1667                Svc::SERVICE_HASH,
1668                QoSProfile::services_default(),
1669                callback,
1670            )?;
1671        Ok(ServiceClientCallback::new(hdr))
1672    }
1673
1674    /// RFC-0041 / Phase 239.2 — callback-based action client (rclcpp
1675    /// `SendGoalOptions{goal_response_callback, feedback_callback,
1676    /// result_callback}` analogue). Goal-response / feedback / result are
1677    /// delivered to the closures at `spin_once`. Returns an
1678    /// [`ActionClientCallback`] send handle (`send_goal` / `get_result`);
1679    /// dual-mode — the `Promise`-based [`create_action_client`](Self::create_action_client)
1680    /// is unchanged.
1681    #[allow(clippy::type_complexity)]
1682    pub fn create_action_client_with_callbacks<A, GRespF, FbF, ResF>(
1683        &mut self,
1684        action_name: &str,
1685        on_goal_response: GRespF,
1686        on_feedback: FbF,
1687        on_result: ResF,
1688    ) -> Result<ActionClientCallback<A>, NodeError>
1689    where
1690        A: RosAction + 'static,
1691        A::Goal: MessageForRmw,
1692        A::Result: MessageForRmw,
1693        A::Feedback: MessageForRmw,
1694        GRespF: FnMut(&nros_core::GoalId, bool) + 'static,
1695        FbF: FnMut(&nros_core::GoalId, &A::Feedback) + 'static,
1696        ResF: FnMut(&nros_core::GoalId, nros_core::GoalStatus, &A::Result) + 'static,
1697    {
1698        self.create_action_client_with_callbacks_sized::<
1699            A,
1700            GRespF,
1701            FbF,
1702            ResF,
1703            { crate::config::DEFAULT_RX_BUF_SIZE },
1704            { crate::config::DEFAULT_RX_BUF_SIZE },
1705            { crate::config::DEFAULT_RX_BUF_SIZE },
1706        >(action_name, on_goal_response, on_feedback, on_result)
1707    }
1708
1709    /// Callback-based action client with custom buffer sizes (Phase 239.2).
1710    #[allow(clippy::type_complexity)]
1711    pub fn create_action_client_with_callbacks_sized<
1712        A,
1713        GRespF,
1714        FbF,
1715        ResF,
1716        const GOAL_BUF: usize,
1717        const RESULT_BUF: usize,
1718        const FEEDBACK_BUF: usize,
1719    >(
1720        &mut self,
1721        action_name: &str,
1722        on_goal_response: GRespF,
1723        on_feedback: FbF,
1724        on_result: ResF,
1725    ) -> Result<ActionClientCallback<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>, NodeError>
1726    where
1727        A: RosAction + 'static,
1728        A::Goal: MessageForRmw,
1729        A::Result: MessageForRmw,
1730        A::Feedback: MessageForRmw,
1731        GRespF: FnMut(&nros_core::GoalId, bool) + 'static,
1732        FbF: FnMut(&nros_core::GoalId, &A::Feedback) + 'static,
1733        ResF: FnMut(&nros_core::GoalId, nros_core::GoalStatus, &A::Result) + 'static,
1734    {
1735        register_type::<A::Goal>()?;
1736        register_type::<A::Result>()?;
1737        register_type::<A::Feedback>()?;
1738        let (_id, core) = self
1739            .executor
1740            .register_action_client_callback::<A, GRespF, FbF, ResF, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>(
1741                Some(self.node_id),
1742                action_name,
1743                A::ACTION_NAME,
1744                A::ACTION_HASH,
1745                // Feedback is a stream → buffer a short QoS-depth history (Phase
1746                // 239.5). Goal-response / result are single-outstanding (gated).
1747                8u16,
1748                on_goal_response,
1749                on_feedback,
1750                on_result,
1751            )?;
1752        Ok(ActionClientCallback::new(core))
1753    }
1754
1755    /// Convenient generic (type-erased) subscription — rclcpp `create_generic_*`.
1756    pub fn create_generic_subscription<F>(
1757        &mut self,
1758        topic: &str,
1759        type_name: &str,
1760        type_hash: &str,
1761        callback: F,
1762    ) -> Result<super::types::HandleId, NodeError>
1763    where
1764        F: FnMut(&[u8]) + 'static,
1765    {
1766        self.create_generic_subscription_with_qos(
1767            topic,
1768            type_name,
1769            type_hash,
1770            QoSProfile::default(),
1771            callback,
1772        )
1773    }
1774
1775    /// Issue 0306 — the same, with an explicit profile (see
1776    /// [`Self::create_generic_publisher_with_qos`] for why).
1777    pub fn create_generic_subscription_with_qos<F>(
1778        &mut self,
1779        topic: &str,
1780        type_name: &str,
1781        type_hash: &str,
1782        qos: QoSProfile,
1783        callback: F,
1784    ) -> Result<super::types::HandleId, NodeError>
1785    where
1786        F: FnMut(&[u8]) + 'static,
1787    {
1788        self.executor
1789            .register_subscription_buffered_raw_on::<F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
1790                self.node_id,
1791                topic,
1792                type_name,
1793                type_hash,
1794                qos,
1795                callback,
1796            )
1797    }
1798
1799    /// Phase 250 (Wave 2) — generic (type-erased) subscription that surfaces E2E
1800    /// [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC + sequence gap/dup) to
1801    /// the callback (`FnMut(&[u8], &IntegrityStatus)`). The declarative-`Node`
1802    /// analog of the typed `.typed::<M>().safety()` builder: the validator lives
1803    /// in the `RmwSubscriber`, so the raw bytes + status arrive together without
1804    /// a typed `M`. Wired by the declarative runtime's `.safety()` opt-in.
1805    #[cfg(feature = "safety-e2e")]
1806    pub fn create_generic_subscription_with_integrity<F>(
1807        &mut self,
1808        topic: &str,
1809        type_name: &str,
1810        type_hash: &str,
1811        callback: F,
1812    ) -> Result<super::types::HandleId, NodeError>
1813    where
1814        F: FnMut(&[u8], &nros_rmw::IntegrityStatus) + 'static,
1815    {
1816        self.executor
1817            .register_subscription_buffered_raw_safety_on::<F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
1818                self.node_id,
1819                topic,
1820                type_name,
1821                type_hash,
1822                QoSProfile::default(),
1823                callback,
1824            )
1825    }
1826
1827    /// Deprecated spelling of [`create_subscription_viewable`](Self::create_subscription_viewable).
1828    ///
1829    /// phase-390 renamed RFC-0033's `borrowed` mode to `view`, because the fact
1830    /// that mattered was never that the data is borrowed but that NOTHING WAS
1831    /// DESERIALIZED. A forwarder rather than a hard break: this is a public
1832    /// Rust API, the rename is cosmetic, and the C ABI break in W2 was accepted
1833    /// only because a C type name cannot carry a deprecation.
1834    #[deprecated(
1835        since = "0.5.0",
1836        note = "renamed to `create_subscription_viewable` (phase-390: RFC-0033 \
1837                `borrowed` mode is now `view`)"
1838    )]
1839    pub fn create_subscription_borrowed<B, F>(
1840        &mut self,
1841        topic: &str,
1842        callback: F,
1843    ) -> Result<super::types::HandleId, NodeError>
1844    where
1845        B: nros_core::ViewableMessage + 'static,
1846        F: for<'a> FnMut(&B::View<'a>) + 'static,
1847    {
1848        self.create_subscription_viewable::<B, F>(topic, callback)
1849    }
1850
1851    /// Convenient zero-copy subscription (Phase 229.6, issue 0007 / RFC-0033
1852    /// `view` mode).
1853    ///
1854    /// `B` is the code-generated VIEWABLE marker (e.g. `ImageViewable`, emitted
1855    /// alongside the `inline` `Image` for a `.msg` with a `view`-mode field).
1856    /// The marker is zero-sized and carries NO lifetime, which is the whole
1857    /// reason it exists: `B::View<'a>` does, and a generic parameter cannot be
1858    /// the lifetime-carrying type itself. That is also why phase-390 could not
1859    /// simply rename it to `{Msg}View` — the view struct already owns that
1860    /// name, and the marker points AT it.
1861    ///
1862    /// The callback receives `&B::View<'a>` — a message whose unbounded
1863    /// sequence/string fields point directly into the receive buffer (no
1864    /// `heapless::Vec` copy); valid only for the callback's duration.
1865    ///
1866    /// Uses `KEEP_LAST(1)` QoS → triple buffer, as view subscriptions require
1867    /// (a single well-defined slot for the callback). For an explicit deeper
1868    /// queue use the `inline` [`create_subscription`](Self::create_subscription);
1869    /// a view subscription registered with `KEEP_LAST(N>1)` is rejected.
1870    pub fn create_subscription_viewable<B, F>(
1871        &mut self,
1872        topic: &str,
1873        callback: F,
1874    ) -> Result<super::types::HandleId, NodeError>
1875    where
1876        B: nros_core::ViewableMessage + 'static,
1877        F: for<'a> FnMut(&B::View<'a>) + 'static,
1878    {
1879        self.executor
1880            .register_subscription_buffered_borrowed_on::<B, F, { crate::config::DEFAULT_RX_BUF_SIZE }>(
1881                self.node_id,
1882                topic,
1883                QoSProfile::default().keep_last(1),
1884                callback,
1885            )
1886    }
1887
1888    /// Service-server builder (the `clone` tier) — `node.service(name)`.
1889    /// Set `.qos()` (defaults to the services profile = RELIABLE+VOLATILE+
1890    /// KEEP_LAST(10)), then `.build::<Svc, _>(callback)` (Phase 193.2).
1891    pub fn service<'t>(&mut self, name: &'t str) -> CtxServiceBuilder<'_, 'e, 't, 's> {
1892        CtxServiceBuilder {
1893            ctx: self,
1894            name,
1895            qos: QoSProfile::services_default(),
1896        }
1897    }
1898
1899    /// Convenient service server (the `fork` tier — rclrs/rclcpp shape), default
1900    /// services QoS. Mirror of `create_subscription`.
1901    pub fn create_service<Svc, F>(
1902        &mut self,
1903        name: &str,
1904        callback: F,
1905    ) -> Result<super::types::HandleId, NodeError>
1906    where
1907        Svc: RosService + 'static,
1908        Svc::Request: crate::rmw_type_registry::MessageForRmw,
1909        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
1910        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
1911    {
1912        self.executor.register_service_sized_on::<
1913            Svc,
1914            F,
1915            { crate::config::DEFAULT_RX_BUF_SIZE },
1916            { crate::config::DEFAULT_RX_BUF_SIZE },
1917        >(self.node_id, name, QoSProfile::services_default(), callback)
1918    }
1919}
1920
1921/// Service-server builder on a [`NodeCtx`] — `node.service(name)`.
1922pub struct CtxServiceBuilder<'c, 'e, 't, 's> {
1923    ctx: &'c mut NodeCtx<'e, 's>,
1924    name: &'t str,
1925    qos: QoSProfile,
1926}
1927
1928impl<'c, 'e, 't, 's> CtxServiceBuilder<'c, 'e, 't, 's> {
1929    /// Service QoS (applies to both the request + reply endpoints). Defaults to
1930    /// `QoSProfile::services_default()`.
1931    pub fn qos(mut self, qos: QoSProfile) -> Self {
1932        self.qos = qos;
1933        self
1934    }
1935
1936    pub fn build<Svc, F>(self, callback: F) -> Result<super::types::HandleId, NodeError>
1937    where
1938        Svc: RosService + 'static,
1939        Svc::Request: crate::rmw_type_registry::MessageForRmw,
1940        Svc::Reply: crate::rmw_type_registry::MessageForRmw,
1941        F: FnMut(&Svc::Request) -> Svc::Reply + 'static,
1942    {
1943        self.ctx.executor.register_service_sized_on::<
1944            Svc,
1945            F,
1946            { crate::config::DEFAULT_RX_BUF_SIZE },
1947            { crate::config::DEFAULT_RX_BUF_SIZE },
1948        >(self.ctx.node_id, self.name, self.qos, callback)
1949    }
1950}
1951
1952/// Publisher builder on a [`NodeCtx`] — `node.publisher(topic)`.
1953pub struct CtxPublisherBuilder<'c, 'e, 't, 's> {
1954    ctx: &'c mut NodeCtx<'e, 's>,
1955    topic: &'t str,
1956    qos: QoSProfile,
1957}
1958
1959impl<'c, 'e, 't, 's> CtxPublisherBuilder<'c, 'e, 't, 's> {
1960    pub fn qos(mut self, qos: QoSProfile) -> Self {
1961        self.qos = qos;
1962        self
1963    }
1964
1965    /// Typed publisher for a ROS message `M`.
1966    pub fn typed<M: MessageForRmw>(self) -> CtxTypedPublisherBuilder<'c, 'e, 't, 's, M> {
1967        CtxTypedPublisherBuilder {
1968            ctx: self.ctx,
1969            topic: self.topic,
1970            qos: self.qos,
1971            _phantom: PhantomData,
1972        }
1973    }
1974
1975    /// Generic (type-erased) publisher.
1976    pub fn generic(
1977        self,
1978        type_name: &'t str,
1979        type_hash: &'t str,
1980    ) -> CtxGenericPublisherBuilder<'c, 'e, 't, 's> {
1981        CtxGenericPublisherBuilder {
1982            ctx: self.ctx,
1983            topic: self.topic,
1984            type_name,
1985            type_hash,
1986            qos: self.qos,
1987        }
1988    }
1989}
1990
1991/// Typed publisher builder on a `NodeCtx` (`.typed::<M>()`).
1992pub struct CtxTypedPublisherBuilder<'c, 'e, 't, 's, M> {
1993    ctx: &'c mut NodeCtx<'e, 's>,
1994    topic: &'t str,
1995    qos: QoSProfile,
1996    _phantom: PhantomData<M>,
1997}
1998
1999impl<'c, 'e, 't, 's, M: MessageForRmw> CtxTypedPublisherBuilder<'c, 'e, 't, 's, M> {
2000    pub fn qos(mut self, qos: QoSProfile) -> Self {
2001        self.qos = qos;
2002        self
2003    }
2004
2005    pub fn build(self) -> Result<EmbeddedPublisher<M>, NodeError> {
2006        self.ctx
2007            .executor
2008            .create_publisher_on::<M>(self.ctx.node_id, self.topic, self.qos)
2009    }
2010}
2011
2012/// Generic publisher builder on a `NodeCtx` (`.generic(type, hash)`).
2013pub struct CtxGenericPublisherBuilder<'c, 'e, 't, 's> {
2014    ctx: &'c mut NodeCtx<'e, 's>,
2015    topic: &'t str,
2016    type_name: &'t str,
2017    type_hash: &'t str,
2018    qos: QoSProfile,
2019}
2020
2021impl<'c, 'e, 't, 's> CtxGenericPublisherBuilder<'c, 'e, 't, 's> {
2022    pub fn qos(mut self, qos: QoSProfile) -> Self {
2023        self.qos = qos;
2024        self
2025    }
2026
2027    pub fn build(self) -> Result<crate::executor::handles::EmbeddedRawPublisher, NodeError> {
2028        self.ctx.executor.create_publisher_raw_on(
2029            self.ctx.node_id,
2030            self.topic,
2031            self.type_name,
2032            self.type_hash,
2033            self.qos,
2034        )
2035    }
2036}
2037
2038/// Subscription builder — `node.subscription(topic)`.
2039pub struct SubscriptionBuilder<'c, 'e, 't, 's> {
2040    ctx: &'c mut NodeCtx<'e, 's>,
2041    topic: &'t str,
2042    qos: QoSProfile,
2043}
2044
2045impl<'c, 'e, 't, 's> SubscriptionBuilder<'c, 'e, 't, 's> {
2046    pub fn qos(mut self, qos: QoSProfile) -> Self {
2047        self.qos = qos;
2048        self
2049    }
2050
2051    /// Typed subscription for a ROS message `M`.
2052    pub fn typed<M: MessageForRmw + 'static>(self) -> TypedSubscriptionBuilder<'c, 'e, 't, 's, M> {
2053        TypedSubscriptionBuilder {
2054            ctx: self.ctx,
2055            topic: self.topic,
2056            qos: self.qos,
2057            sched: None,
2058            _phantom: PhantomData,
2059        }
2060    }
2061
2062    /// Generic (type-erased) subscription — raw CDR bytes to the callback.
2063    pub fn generic(
2064        self,
2065        type_name: &'t str,
2066        type_hash: &'t str,
2067    ) -> GenericSubscriptionBuilder<'c, 'e, 't, 's> {
2068        GenericSubscriptionBuilder {
2069            ctx: self.ctx,
2070            topic: self.topic,
2071            type_name,
2072            type_hash,
2073            qos: self.qos,
2074            sched: None,
2075        }
2076    }
2077}
2078
2079/// Typed subscription builder (`.typed::<M>()`). `RX` is the staging-buffer
2080/// size, set via `.rx_buffer::<N>()` (defaults to `DEFAULT_RX_BUF_SIZE`).
2081pub struct TypedSubscriptionBuilder<
2082    'c,
2083    'e,
2084    't,
2085    's,
2086    M,
2087    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2088> {
2089    ctx: &'c mut NodeCtx<'e, 's>,
2090    topic: &'t str,
2091    qos: QoSProfile,
2092    sched: Option<super::sched_context::SchedContextId>,
2093    _phantom: PhantomData<M>,
2094}
2095
2096impl<'c, 'e, 't, 's, M: MessageForRmw + 'static, const RX: usize>
2097    TypedSubscriptionBuilder<'c, 'e, 't, 's, M, RX>
2098{
2099    pub fn qos(mut self, qos: QoSProfile) -> Self {
2100        self.qos = qos;
2101        self
2102    }
2103
2104    /// Bind the subscription's callback to a scheduling context.
2105    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
2106        self.sched = Some(sc);
2107        self
2108    }
2109
2110    /// Set the staging-buffer size (const-generic).
2111    pub fn rx_buffer<const N: usize>(self) -> TypedSubscriptionBuilder<'c, 'e, 't, 's, M, N> {
2112        TypedSubscriptionBuilder {
2113            ctx: self.ctx,
2114            topic: self.topic,
2115            qos: self.qos,
2116            sched: self.sched,
2117            _phantom: PhantomData,
2118        }
2119    }
2120
2121    /// Phase 403 W2 -- size the receive buffer from `M`'s OWN serialized bound
2122    /// instead of the configured default.
2123    ///
2124    /// ```ignore
2125    /// node.subscription("/chatter")
2126    ///     .typed::<Int32>()
2127    ///     .rx_buffer_from_type()   // 12 bytes per slot, not 1024
2128    ///     .build(on_msg)?;
2129    /// ```
2130    ///
2131    /// **Opt-in, and that is the design.** A subscription that does not call
2132    /// this claims exactly the bytes it claimed before the knob existed, so an
2133    /// image that does not opt in is byte-identical -- the same rule issue 0900's
2134    /// arena knob keeps (`the_default_derivation_is_unchanged`). Sizing every
2135    /// subscription from its type by default would move every image's arena
2136    /// occupancy at once, which is a decision, not a refactor.
2137    ///
2138    /// **What it costs and what it buys.** The buffered path stores `depth <= 1
2139    /// ? 3 : depth+1` slots of this size in the executor arena, so a bounded
2140    /// type recovers `slots * (default - bound)` bytes per subscription. The
2141    /// number is the LARGER of the type's XCDR1 and XCDR2 bounds, because the
2142    /// stack writes XCDR1 but a peer may send XCDR2 (see
2143    /// [`subscription_rx_bytes`](crate::rmw_type_registry::subscription_rx_bytes)).
2144    ///
2145    /// **An unbounded `M` fails the BUILD.** Every message type is required to
2146    /// carry a bound -- stated in the `.msg` (`string<=64`) or capped in
2147    /// `nros-codegen.toml` -- so "this type has no bound" is a defect to report,
2148    /// not a case to absorb. The alternative, quietly keeping the configured
2149    /// default, is precisely the substitution phase 380 forbids: `None` means
2150    /// "no bound EXISTS", never "unknown", and a buffer sized from a fallback is
2151    /// the failure that rule was written to prevent. The C header already refuses
2152    /// the same thing the same way -- naming an unbounded type's
2153    /// `{PREFIX}_RX_MAX_SERIALIZED_SIZE` expands to a deliberate compile error
2154    /// carrying the member that costs the bound (`unbounded_token`) -- and this
2155    /// is that refusal on the Rust path. rustc names `M` in the instantiation
2156    /// note; the member comes from the codegen diagnostic for the same type.
2157    ///
2158    /// **The bound never GROWS the buffer.** A type larger than `RX` keeps `RX`;
2159    /// spending unbudgeted arena silently is the worse failure, and the
2160    /// too-small case already reports itself (`report_dropped_take`).
2161    ///
2162    /// Returns a builder without `.message_info()` / `.safety()` on purpose:
2163    /// those entries hold a real `[u8; RX]` array, so their size can only come
2164    /// from a const generic -- `nros::rx_buffer_for!(M)` at the call site -- and
2165    /// carrying the flag into them would silently drop it.
2166    pub fn rx_buffer_from_type(self) -> TypedSubBoundBuilder<'c, 'e, 't, 's, M, RX>
2167    where
2168        M: nros_serdes::schema::Message,
2169    {
2170        // The diagnostic lives HERE, at the opt-in, rather than in `build()`:
2171        // this is the call that asserts the type has a bound, so it is the call
2172        // the error should point at.
2173        const {
2174            assert!(
2175                crate::rmw_type_registry::subscription_rx_bytes::<M>(RX).is_some(),
2176                "this message type has NO maximum serialized size, so its receive \
2177                 buffer cannot be sized from it. Every message type must carry a \
2178                 bound: give the unbounded member one in the `.msg` \
2179                 (`string<=64`, `int32[<=8]`) or a `cap` in `nros-codegen.toml`. \
2180                 The generated C header for this type names the member that costs \
2181                 it the bound (`NROS_UNBOUNDED__<type>__field_<member>`)."
2182            )
2183        }
2184        TypedSubBoundBuilder {
2185            ctx: self.ctx,
2186            topic: self.topic,
2187            qos: self.qos,
2188            sched: self.sched,
2189            _phantom: PhantomData,
2190        }
2191    }
2192
2193    /// Surface per-message [`MessageInfo`](nros_core::MessageInfo) (seq,
2194    /// publisher GID, timestamps) to the callback — `FnMut(&M, Option<&MessageInfo>)`,
2195    /// the rclrs shape. Distinct from the generic builder's `.message_info()`
2196    /// (which yields a `RawMessageInfo` with the wire attachment).
2197    pub fn message_info(self) -> TypedSubInfoBuilder<'c, 'e, 't, 's, M, RX> {
2198        TypedSubInfoBuilder {
2199            ctx: self.ctx,
2200            topic: self.topic,
2201            qos: self.qos,
2202            sched: self.sched,
2203            _phantom: PhantomData,
2204        }
2205    }
2206
2207    /// Surface E2E-safety validation (CRC + sequence gap/duplicate) to the
2208    /// callback — `FnMut(&M, &IntegrityStatus)`.
2209    #[cfg(feature = "safety-e2e")]
2210    pub fn safety(self) -> TypedSubSafetyBuilder<'c, 'e, 't, 's, M, RX> {
2211        TypedSubSafetyBuilder {
2212            ctx: self.ctx,
2213            topic: self.topic,
2214            qos: self.qos,
2215            sched: self.sched,
2216            _phantom: PhantomData,
2217        }
2218    }
2219
2220    pub fn build<F: FnMut(&M) + 'static>(
2221        self,
2222        callback: F,
2223    ) -> Result<super::types::HandleId, NodeError> {
2224        let handle = self
2225            .ctx
2226            .executor
2227            .register_subscription_buffered_on::<M, F, RX>(
2228                self.ctx.node_id,
2229                self.topic,
2230                self.qos,
2231                callback,
2232                None, // group threaded via create_subscription_in; builder uses sched override
2233                None, // phase-403 W2: `RX` verbatim, the pre-knob behaviour
2234            )?;
2235        if let Some(sc) = self.sched {
2236            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
2237        }
2238        Ok(handle)
2239    }
2240}
2241
2242/// Phase 403 W2 -- a typed subscription whose buffer is sized by `M`'s own
2243/// serialized bound (`.typed::<M>().rx_buffer_from_type()`).
2244///
2245/// Terminal by construction: `.message_info()` and `.safety()` are absent
2246/// because their entries store `[u8; RX]` and cannot take a runtime size. `RX`
2247/// survives as the CEILING -- an unbounded `M`, or one whose bound exceeds `RX`,
2248/// gets `RX` unchanged.
2249pub struct TypedSubBoundBuilder<
2250    'c,
2251    'e,
2252    't,
2253    's,
2254    M,
2255    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2256> {
2257    ctx: &'c mut NodeCtx<'e, 's>,
2258    topic: &'t str,
2259    qos: QoSProfile,
2260    sched: Option<super::sched_context::SchedContextId>,
2261    _phantom: PhantomData<M>,
2262}
2263
2264impl<'c, 'e, 't, 's, M: MessageForRmw + nros_serdes::schema::Message + 'static, const RX: usize>
2265    TypedSubBoundBuilder<'c, 'e, 't, 's, M, RX>
2266{
2267    pub fn qos(mut self, qos: QoSProfile) -> Self {
2268        self.qos = qos;
2269        self
2270    }
2271
2272    /// Bind the subscription's callback to a scheduling context.
2273    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
2274        self.sched = Some(sc);
2275        self
2276    }
2277
2278    pub fn build<F: FnMut(&M) + 'static>(
2279        self,
2280        callback: F,
2281    ) -> Result<super::types::HandleId, NodeError> {
2282        // Unreachable: `rx_buffer_from_type` is the only constructor of this
2283        // builder and its `const` assert already refused an unbounded `M`.
2284        let rx_bytes = crate::rmw_type_registry::subscription_rx_bytes::<M>(RX)
2285            .expect("rx_buffer_from_type asserts the bound exists at build time");
2286        let handle = self
2287            .ctx
2288            .executor
2289            .register_subscription_buffered_on::<M, F, RX>(
2290                self.ctx.node_id,
2291                self.topic,
2292                self.qos,
2293                callback,
2294                None,
2295                Some(rx_bytes),
2296            )?;
2297        if let Some(sc) = self.sched {
2298            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
2299        }
2300        Ok(handle)
2301    }
2302}
2303
2304/// Typed subscription builder with `MessageInfo` (`.typed::<M>().message_info()`).
2305/// Callback is `FnMut(&M, Option<&MessageInfo>)`.
2306pub struct TypedSubInfoBuilder<
2307    'c,
2308    'e,
2309    't,
2310    's,
2311    M,
2312    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2313> {
2314    ctx: &'c mut NodeCtx<'e, 's>,
2315    topic: &'t str,
2316    qos: QoSProfile,
2317    sched: Option<super::sched_context::SchedContextId>,
2318    _phantom: PhantomData<M>,
2319}
2320
2321impl<'c, 'e, 't, 's, M: MessageForRmw + 'static, const RX: usize>
2322    TypedSubInfoBuilder<'c, 'e, 't, 's, M, RX>
2323{
2324    pub fn qos(mut self, qos: QoSProfile) -> Self {
2325        self.qos = qos;
2326        self
2327    }
2328
2329    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
2330        self.sched = Some(sc);
2331        self
2332    }
2333
2334    pub fn rx_buffer<const N: usize>(self) -> TypedSubInfoBuilder<'c, 'e, 't, 's, M, N> {
2335        TypedSubInfoBuilder {
2336            ctx: self.ctx,
2337            topic: self.topic,
2338            qos: self.qos,
2339            sched: self.sched,
2340            _phantom: PhantomData,
2341        }
2342    }
2343
2344    pub fn build<F: FnMut(&M, Option<&nros_core::MessageInfo>) + 'static>(
2345        self,
2346        callback: F,
2347    ) -> Result<super::types::HandleId, NodeError> {
2348        let handle = self
2349            .ctx
2350            .executor
2351            .register_subscription_with_info_sized_inner::<M, F, RX>(
2352                Some(self.ctx.node_id),
2353                self.topic,
2354                self.qos,
2355                callback,
2356            )?;
2357        if let Some(sc) = self.sched {
2358            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
2359        }
2360        Ok(handle)
2361    }
2362}
2363
2364/// Typed subscription builder with E2E-safety validation
2365/// (`.typed::<M>().safety()`). Callback is `FnMut(&M, &IntegrityStatus)`.
2366#[cfg(feature = "safety-e2e")]
2367pub struct TypedSubSafetyBuilder<
2368    'c,
2369    'e,
2370    't,
2371    's,
2372    M,
2373    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2374> {
2375    ctx: &'c mut NodeCtx<'e, 's>,
2376    topic: &'t str,
2377    qos: QoSProfile,
2378    sched: Option<super::sched_context::SchedContextId>,
2379    _phantom: PhantomData<M>,
2380}
2381
2382#[cfg(feature = "safety-e2e")]
2383impl<'c, 'e, 't, 's, M: MessageForRmw + 'static, const RX: usize>
2384    TypedSubSafetyBuilder<'c, 'e, 't, 's, M, RX>
2385{
2386    pub fn qos(mut self, qos: QoSProfile) -> Self {
2387        self.qos = qos;
2388        self
2389    }
2390
2391    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
2392        self.sched = Some(sc);
2393        self
2394    }
2395
2396    pub fn rx_buffer<const N: usize>(self) -> TypedSubSafetyBuilder<'c, 'e, 't, 's, M, N> {
2397        TypedSubSafetyBuilder {
2398            ctx: self.ctx,
2399            topic: self.topic,
2400            qos: self.qos,
2401            sched: self.sched,
2402            _phantom: PhantomData,
2403        }
2404    }
2405
2406    pub fn build<F: FnMut(&M, &nros_rmw::IntegrityStatus) + 'static>(
2407        self,
2408        callback: F,
2409    ) -> Result<super::types::HandleId, NodeError> {
2410        let handle = self
2411            .ctx
2412            .executor
2413            .register_subscription_with_safety_sized_inner::<M, F, RX>(
2414                Some(self.ctx.node_id),
2415                self.topic,
2416                self.qos,
2417                callback,
2418            )?;
2419        if let Some(sc) = self.sched {
2420            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
2421        }
2422        Ok(handle)
2423    }
2424}
2425
2426/// Generic (type-erased) subscription builder (`.generic(type, hash)`).
2427pub struct GenericSubscriptionBuilder<
2428    'c,
2429    'e,
2430    't,
2431    's,
2432    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2433> {
2434    ctx: &'c mut NodeCtx<'e, 's>,
2435    topic: &'t str,
2436    type_name: &'t str,
2437    type_hash: &'t str,
2438    qos: QoSProfile,
2439    sched: Option<super::sched_context::SchedContextId>,
2440}
2441
2442impl<'c, 'e, 't, 's, const RX: usize> GenericSubscriptionBuilder<'c, 'e, 't, 's, RX> {
2443    pub fn qos(mut self, qos: QoSProfile) -> Self {
2444        self.qos = qos;
2445        self
2446    }
2447
2448    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
2449        self.sched = Some(sc);
2450        self
2451    }
2452
2453    pub fn rx_buffer<const N: usize>(self) -> GenericSubscriptionBuilder<'c, 'e, 't, 's, N> {
2454        GenericSubscriptionBuilder {
2455            ctx: self.ctx,
2456            topic: self.topic,
2457            type_name: self.type_name,
2458            type_hash: self.type_hash,
2459            qos: self.qos,
2460            sched: self.sched,
2461        }
2462    }
2463
2464    /// Surface the sample's wire attachment + metadata to the callback
2465    /// (`FnMut(&[u8], &RawMessageInfo)`). The cross-RMW bridge reads the
2466    /// `bridge_origin` tag from `info.attachment()` for echo suppression.
2467    pub fn message_info(self) -> GenericSubInfoBuilder<'c, 'e, 't, 's, RX> {
2468        GenericSubInfoBuilder {
2469            ctx: self.ctx,
2470            topic: self.topic,
2471            type_name: self.type_name,
2472            type_hash: self.type_hash,
2473            qos: self.qos,
2474            sched: self.sched,
2475        }
2476    }
2477
2478    pub fn build<F: FnMut(&[u8]) + 'static>(
2479        self,
2480        callback: F,
2481    ) -> Result<super::types::HandleId, NodeError> {
2482        let handle = self
2483            .ctx
2484            .executor
2485            .register_subscription_buffered_raw_on::<F, RX>(
2486                self.ctx.node_id,
2487                self.topic,
2488                self.type_name,
2489                self.type_hash,
2490                self.qos,
2491                callback,
2492            )?;
2493        if let Some(sc) = self.sched {
2494            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
2495        }
2496        Ok(handle)
2497    }
2498}
2499
2500/// Generic subscription builder with `MessageInfo` surfaced
2501/// (`.message_info()`). Callback is `FnMut(&[u8], &RawMessageInfo)`.
2502pub struct GenericSubInfoBuilder<
2503    'c,
2504    'e,
2505    't,
2506    's,
2507    const RX: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2508> {
2509    ctx: &'c mut NodeCtx<'e, 's>,
2510    topic: &'t str,
2511    type_name: &'t str,
2512    type_hash: &'t str,
2513    qos: QoSProfile,
2514    sched: Option<super::sched_context::SchedContextId>,
2515}
2516
2517impl<'c, 'e, 't, 's, const RX: usize> GenericSubInfoBuilder<'c, 'e, 't, 's, RX> {
2518    pub fn qos(mut self, qos: QoSProfile) -> Self {
2519        self.qos = qos;
2520        self
2521    }
2522
2523    pub fn sched_context(mut self, sc: super::sched_context::SchedContextId) -> Self {
2524        self.sched = Some(sc);
2525        self
2526    }
2527
2528    pub fn rx_buffer<const N: usize>(self) -> GenericSubInfoBuilder<'c, 'e, 't, 's, N> {
2529        GenericSubInfoBuilder {
2530            ctx: self.ctx,
2531            topic: self.topic,
2532            type_name: self.type_name,
2533            type_hash: self.type_hash,
2534            qos: self.qos,
2535            sched: self.sched,
2536        }
2537    }
2538
2539    pub fn build<F: FnMut(&[u8], &nros_core::RawMessageInfo) + 'static>(
2540        self,
2541        callback: F,
2542    ) -> Result<super::types::HandleId, NodeError> {
2543        let handle = self
2544            .ctx
2545            .executor
2546            .register_subscription_buffered_raw_info_on::<F, RX>(
2547                self.ctx.node_id,
2548                self.topic,
2549                self.type_name,
2550                self.type_hash,
2551                self.qos,
2552                callback,
2553            )?;
2554        if let Some(sc) = self.sched {
2555            self.ctx.executor.bind_handle_to_sched_context(handle, sc)?;
2556        }
2557        Ok(handle)
2558    }
2559}
2560
2561// `not(feature = "rmw-cffi")` — these tests use the `mock` backend
2562// (`crate::mock`, itself `cfg(all(test, not(rmw-cffi)))`); a workspace test
2563// build that unifies `rmw-cffi` on swaps `ConcreteSession` to the cffi session
2564// and drops `mock`, so the module must drop with it (matches the
2565// `mock_integration` gate in lifecycle_services.rs).
2566#[cfg(all(test, feature = "std", not(feature = "rmw-cffi")))]
2567mod builder_tests {
2568    use super::*;
2569    use crate::{executor::Executor, mock::MockSession};
2570    use nros_core::{CdrReader, CdrWriter, DeserError, Deserialize, SerError, Serialize};
2571
2572    struct TestMsg;
2573    impl RosMessage for TestMsg {
2574        const TYPE_NAME: &'static str = "test/msg/TestMsg";
2575        const TYPE_HASH: &'static str = "test_hash";
2576    }
2577    impl Serialize for TestMsg {
2578        fn serialize(&self, _w: &mut CdrWriter) -> Result<(), SerError> {
2579            Ok(())
2580        }
2581    }
2582    impl Deserialize for TestMsg {
2583        fn deserialize(_r: &mut CdrReader) -> Result<Self, DeserError> {
2584            Ok(Self)
2585        }
2586    }
2587    // Phase 212.K.7.6.b — minimal single-field `Message` impl so
2588    // `TypedPublisherBuilder::build` resolves under the cyclonedds-tightened
2589    // bound AND the runtime register call succeeds. `DescriptorBuilder`
2590    // rejects empty `FIELDS` with `BuildError::EmptySchema`; pretend
2591    // there's one byte so the bridge stub returns a non-NULL pointer.
2592    // phase-380 W4 — was `#[cfg(rmw_needs_type_descriptors)]`: the schema is
2593    // now required by `MessageForRmw` on EVERY backend, because that is where
2594    // a subscription's build-time size bound comes from.
2595    impl nros_serdes::schema::Message for TestMsg {
2596        const TYPE_NAME: &'static str = "test/msg/TestMsg";
2597        const FIELDS: &'static [nros_serdes::schema::Field] = &[nros_serdes::schema::Field {
2598            name: "data",
2599            ty: nros_serdes::schema::FieldType::Uint8,
2600            offset: 0,
2601        }];
2602    }
2603
2604    fn s(v: &str) -> heapless::String<64> {
2605        heapless::String::try_from(v).unwrap()
2606    }
2607
2608    #[test]
2609    fn publisher_builder_typed_and_generic() {
2610        let mut session = MockSession::new();
2611        let mut node = NodeHandle::new(s("n"), s("/"), &mut session, 0);
2612
2613        // typed: node.publisher(t).typed::<M>().qos(..).build()
2614        let _typed = node
2615            .publisher("/chatter")
2616            .typed::<TestMsg>()
2617            .qos(QoSProfile::default().keep_last(5))
2618            .build()
2619            .expect("typed publisher builds");
2620
2621        // generic: node.publisher(t).qos(..).generic(type, hash).build()
2622        let _generic = node
2623            .publisher("/chatter")
2624            .qos(QoSProfile::default())
2625            .generic("std_msgs/msg/Int32", "hash")
2626            .build()
2627            .expect("generic publisher builds");
2628    }
2629
2630    #[test]
2631    fn subscription_builder_and_convenient() {
2632        let mut exec: Executor = Executor::from_session(MockSession::new());
2633        let id = exec.node_builder("n").build().expect("node");
2634
2635        // builder: typed
2636        let _h = exec
2637            .node_mut(id)
2638            .subscription("/chatter")
2639            .typed::<TestMsg>()
2640            .qos(QoSProfile::default().keep_last(5))
2641            .build(|_m: &TestMsg| {})
2642            .expect("typed subscription builds");
2643
2644        // builder: generic (raw bytes)
2645        let _g = exec
2646            .node_mut(id)
2647            .subscription("/raw")
2648            .generic("std_msgs/msg/Int32", "hash")
2649            .build(|_b: &[u8]| {})
2650            .expect("generic subscription builds");
2651
2652        // builder: sized + sched-context (slice 3 knobs)
2653        let sc = exec.default_sched_context_id();
2654        let _s = exec
2655            .node_mut(id)
2656            .subscription("/sized")
2657            .typed::<TestMsg>()
2658            .rx_buffer::<64>()
2659            .sched_context(sc)
2660            .build(|_m: &TestMsg| {})
2661            .expect("sized + sched subscription builds");
2662
2663        // convenient (fork tier) — one node-ctx at a time, re-acquired
2664        let _c = exec
2665            .node_mut(id)
2666            .create_subscription::<TestMsg, _>("/conv", |_m: &TestMsg| {})
2667            .expect("convenient typed subscription builds");
2668    }
2669
2670    #[test]
2671    fn generic_message_info_builder() {
2672        // slice 3b — the bridge echo path: generic sub whose callback
2673        // receives the wire attachment via RawMessageInfo.
2674        let mut exec: Executor = Executor::from_session(MockSession::new());
2675        let id = exec.node_builder("n").build().expect("node");
2676
2677        let _i = exec
2678            .node_mut(id)
2679            .subscription("/info")
2680            .generic("std_msgs/msg/Int32", "hash")
2681            .message_info()
2682            .rx_buffer::<256>()
2683            .build(|_payload: &[u8], info: &nros_core::RawMessageInfo| {
2684                let _ = info.attachment();
2685            })
2686            .expect("generic + message_info subscription builds");
2687    }
2688
2689    #[test]
2690    fn typed_message_info_builder() {
2691        // M2.a — typed .message_info() (rclrs shape FnMut(&M, Option<&MessageInfo>)),
2692        // replacing register_subscription_with_info.
2693        let mut exec: Executor = Executor::from_session(MockSession::new());
2694        let id = exec.node_builder("n").build().expect("node");
2695        let _h = exec
2696            .node_mut(id)
2697            .subscription("/chatter")
2698            .typed::<TestMsg>()
2699            .qos(QoSProfile::default().keep_last(5))
2700            .message_info()
2701            .build(|_m: &TestMsg, _info: Option<&nros_core::MessageInfo>| {})
2702            .expect("typed + message_info subscription builds");
2703    }
2704
2705    #[cfg(feature = "safety-e2e")]
2706    #[test]
2707    fn typed_safety_builder() {
2708        // M2.a — typed .safety(), replacing register_subscription_with_safety.
2709        let mut exec: Executor = Executor::from_session(MockSession::new());
2710        let id = exec.node_builder("n").build().expect("node");
2711        let _h = exec
2712            .node_mut(id)
2713            .subscription("/chatter")
2714            .typed::<TestMsg>()
2715            .safety()
2716            .build(|_m: &TestMsg, _status: &nros_rmw::IntegrityStatus| {})
2717            .expect("typed + safety subscription builds");
2718    }
2719
2720    #[test]
2721    fn generator_emitted_chain_compiles() {
2722        // Locks the exact builder chain the orchestration generator emits
2723        // for a subscriber (replaces register_subscription_raw_with_qos_sized_on).
2724        let mut exec: Executor = Executor::from_session(MockSession::new());
2725        let id = exec.node_builder("n").build().expect("node");
2726        let _h = exec
2727            .node_mut(id)
2728            .subscription("/topic")
2729            .generic("std_msgs/msg/Int32", "hash")
2730            .qos(QoSProfile::default().keep_last(1))
2731            .rx_buffer::<1024>()
2732            .build(|_data: &[u8]| {})
2733            .expect("generator-shape subscription builds");
2734    }
2735
2736    #[test]
2737    fn nodectx_publisher_and_bridge_shape() {
2738        // NodeCtx publisher symmetry + the bridge two-ctx borrow pattern:
2739        // build the dest publisher on one NodeCtx (dropped), then register
2740        // the source subscription on another — the owned publisher outlives.
2741        let mut exec: Executor = Executor::from_session(MockSession::new());
2742        let id = exec.node_builder("n").build().expect("node");
2743
2744        // convenient + builder publisher on NodeCtx
2745        let _p = exec
2746            .node_mut(id)
2747            .create_publisher::<TestMsg>("/p")
2748            .expect("ctx convenient publisher");
2749        let dest_pub = exec
2750            .node_mut(id)
2751            .publisher("/fwd")
2752            .generic("std_msgs/msg/Int32", "hash")
2753            .build()
2754            .expect("ctx generic publisher builds"); // NodeCtx dropped here
2755
2756        // re-borrow exec for the source sub; closure owns dest_pub
2757        let _s = exec
2758            .node_mut(id)
2759            .subscription("/src")
2760            .generic("std_msgs/msg/Int32", "hash")
2761            .message_info()
2762            .build(move |payload: &[u8], _info: &nros_core::RawMessageInfo| {
2763                let _ = dest_pub.publish_raw(payload);
2764            })
2765            .expect("bridge-shape source subscription builds");
2766    }
2767}