Skip to main content

nros/
node.rs

1//! Rust component API shared by metadata discovery and generated runtimes.
2
3use core::marker::PhantomData;
4
5use crate::{
6    ActionTag, CallbackId, CancelResponse, EntityId, GoalId, GoalResponse, GoalStatus,
7    ParameterType, QosSettings, RosAction, RosMessage, RosService, ServiceTag, SubscriptionTag,
8    TimerDuration,
9    heapless::Vec,
10    node_metadata::{
11        CallbackEffectKind, CallbackEffectMetadata, CallbackSlot, EntityKind, EntityMetadata,
12        EntityMetadataSpec, EntitySlot, MetadataRecorder, MetadataString, NodeId,
13        NodeMetadataError, NodeSlot, ParameterDefault, SourceLocationMetadata, copy_str,
14        entity_callback_ids, entity_metadata,
15    },
16};
17
18// Phase 212.N.7 step-6 closing sweep — `component_register_symbol`
19// removed. It built the legacy `__nros_component_<pkg>_register`
20// symbol name for the M.5.a BSP baker to look up by literal. step-6
21// retired the macro emit + step-4 deleted the FreeRTOS BSP baker
22// crate that was the sole live consumer. The Phase 212.N Entry pkg
23// path calls `<pkg>::register(runtime)` through the path API, so this
24// helper has no live callers.
25
26/// Clear diagnostic for packages missing [`nros::node!`](crate::node!).
27pub const MISSING_NODE_EXPORT_ERROR: &str = "package has no exported nros component";
28
29/// Result type for component declarations.
30pub type NodeResult<T = ()> = Result<T, NodeDeclError>;
31
32/// Node declaration error.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum NodeDeclError {
35    /// Metadata recorder rejected the declaration.
36    Metadata(NodeMetadataError),
37    /// Host/runtime discovery could not find `nros::node!` export.
38    MissingExport,
39    /// Generated runtime rejected the declaration.
40    Runtime,
41    /// The executor's fixed callback-entry table is full — a timer /
42    /// subscription / service / action could not claim a slot (issue 0095).
43    /// Carries the capacity cause through the `NodeError → NodeDeclError`
44    /// collapse so the register seam can name `NROS_EXECUTOR_MAX_CBS`.
45    ExecutorFull,
46}
47
48impl NodeDeclError {
49    /// Human-readable static message for diagnostics that cross FFI/plugin boundaries.
50    pub const fn message(self) -> &'static str {
51        match self {
52            Self::Metadata(NodeMetadataError::Capacity) => "component metadata capacity exceeded",
53            Self::Metadata(NodeMetadataError::NameTooLong) => "component metadata name too long",
54            Self::Metadata(NodeMetadataError::UnknownNode) => {
55                "component entity references an unknown node"
56            }
57            Self::Metadata(NodeMetadataError::UnknownEntity) => {
58                "component callback effect references an unknown entity"
59            }
60            Self::Metadata(NodeMetadataError::DuplicateId) => {
61                "component metadata contains a duplicate stable ID"
62            }
63            Self::MissingExport => MISSING_NODE_EXPORT_ERROR,
64            Self::Runtime => "component runtime rejected declaration",
65            Self::ExecutorFull => {
66                "executor callback table full — raise NROS_EXECUTOR_MAX_CBS \
67                 (build-time, default 4)"
68            }
69        }
70    }
71}
72
73impl From<NodeMetadataError> for NodeDeclError {
74    fn from(value: NodeMetadataError) -> Self {
75        Self::Metadata(value)
76    }
77}
78
79/// Rust component entry point.
80pub trait Node {
81    /// Source component name used in metadata and diagnostics.
82    const NAME: &'static str;
83
84    /// Phase 216.A.3 — declares which dispatch strategy this Node
85    /// requires from the runtime. Defaults to
86    /// [`crate::DispatchStrategy::Inline`] so every existing component
87    /// keeps compiling without source change; the substrate (Phase
88    /// 216.A.2) and `nros check` (Phase 216.D.1) consume it to
89    /// pick / validate the board-side dispatch path.
90    const DISPATCH: crate::DispatchStrategy = crate::DispatchStrategy::Inline;
91
92    /// Declare nodes, entities, callbacks, params, and optional effects.
93    fn register(context: &mut NodeContext<'_>) -> NodeResult<()>;
94}
95
96/// Runtime-neutral node construction options.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct NodeOptions<'a> {
99    /// Source node name. Launch planning may remap/namespace later.
100    pub name: &'a str,
101    /// Source namespace. Defaults to `/`.
102    pub namespace: &'a str,
103    /// ROS domain ID hint. Defaults to `0`.
104    pub domain_id: u32,
105}
106
107/// Runtime callback event delivered to an executable Node.
108///
109/// The value carries the source callback name declared by the component, but
110/// does not expose the generated/internal callback ID type to product code.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
112pub struct Callback<'a> {
113    id: CallbackId<'a>,
114}
115
116impl<'a> Callback<'a> {
117    /// Borrow the source callback name.
118    pub const fn as_str(self) -> &'a str {
119        self.id.as_str()
120    }
121
122    /// Return true when this callback matches `name`.
123    pub fn is_named(self, name: &str) -> bool {
124        self.as_str() == name
125    }
126
127    /// Build a callback event from the internal/generated callback ID.
128    #[doc(hidden)]
129    pub const fn __from_id(id: CallbackId<'a>) -> Self {
130        Self { id }
131    }
132}
133
134impl<'a> NodeOptions<'a> {
135    /// Create node options with default namespace and domain.
136    pub const fn new(name: &'a str) -> Self {
137        Self {
138            name,
139            namespace: "/",
140            domain_id: 0,
141        }
142    }
143
144    /// Set source namespace.
145    pub const fn namespace(mut self, namespace: &'a str) -> Self {
146        self.namespace = namespace;
147        self
148    }
149
150    /// Set ROS domain ID hint.
151    pub const fn domain_id(mut self, domain_id: u32) -> Self {
152        self.domain_id = domain_id;
153        self
154    }
155}
156
157/// Declaration sink implemented by metadata recorders and generated runtimes.
158pub trait NodeRuntime {
159    /// Declare a component node.
160    fn create_node(&mut self, id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()>;
161
162    /// Declare a publisher, subscription, timer, service, action, or parameter.
163    fn create_entity(&mut self, metadata: EntityMetadata) -> NodeResult<()>;
164
165    /// Add optional callback effect metadata.
166    fn record_callback_effect(
167        &mut self,
168        callback_id: CallbackId<'_>,
169        kind: CallbackEffectKind,
170        entity_id: EntityId<'_>,
171    ) -> NodeResult<()>;
172}
173
174impl<const MAX_NODES: usize, const MAX_ENTITIES: usize, const MAX_CALLBACKS: usize> NodeRuntime
175    for MetadataRecorder<MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
176{
177    fn create_node(&mut self, id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
178        self.push_node(id, options.name, options.namespace, options.domain_id)?;
179        Ok(())
180    }
181
182    fn create_entity(&mut self, metadata: EntityMetadata) -> NodeResult<()> {
183        self.push_entity(metadata)?;
184        Ok(())
185    }
186
187    fn record_callback_effect(
188        &mut self,
189        callback_id: CallbackId<'_>,
190        kind: CallbackEffectKind,
191        entity_id: EntityId<'_>,
192    ) -> NodeResult<()> {
193        self.push_callback_effect(callback_id, kind, entity_id)?;
194        Ok(())
195    }
196}
197
198/// Runtime node sink used by generated component executors.
199///
200/// Metadata mode records declarations only. Runtime mode maps each stable
201/// component node ID to a concrete executor-side node handle; entity callback
202/// registration is completed by generated code that owns the actual callback
203/// functions.
204pub trait DeclaredNodeRuntime {
205    /// Concrete node handle owned by the runtime executor.
206    type NodeHandle: Copy + Eq;
207
208    /// Create a runtime node from source-level component options.
209    fn build_component_node(
210        &mut self,
211        id: NodeId<'_>,
212        options: NodeOptions<'_>,
213    ) -> NodeResult<Self::NodeHandle>;
214}
215
216/// Recorded runtime node mapping.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct RuntimeNodeRecord<H: Copy + Eq> {
219    slot: NodeSlot,
220    stable_id: MetadataString,
221    source_default_name: MetadataString,
222    handle: H,
223}
224
225impl<H: Copy + Eq> RuntimeNodeRecord<H> {
226    /// Declaration-order node slot.
227    pub const fn slot(&self) -> NodeSlot {
228        self.slot
229    }
230
231    /// Stable component node ID.
232    pub fn stable_id(&self) -> &str {
233        &self.stable_id
234    }
235
236    /// Source-authored default ROS node name.
237    pub fn source_default_name(&self) -> &str {
238        &self.source_default_name
239    }
240
241    /// Runtime executor node handle.
242    pub const fn handle(&self) -> H {
243        self.handle
244    }
245}
246
247/// Runtime adapter used by generated main ownership code.
248pub struct NodeRuntimeAdapter<
249    'a,
250    R: DeclaredNodeRuntime + ?Sized,
251    const MAX_NODES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_NODES },
252    const MAX_ENTITIES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_ENTITIES },
253    const MAX_CALLBACKS: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_CALLBACKS },
254> {
255    node_runtime: &'a mut R,
256    nodes: Vec<RuntimeNodeRecord<R::NodeHandle>, MAX_NODES>,
257    entities: Vec<EntityMetadata, MAX_ENTITIES>,
258    callback_effects: Vec<CallbackEffectMetadata, MAX_CALLBACKS>,
259}
260
261impl<
262    'a,
263    R: DeclaredNodeRuntime + ?Sized,
264    const MAX_NODES: usize,
265    const MAX_ENTITIES: usize,
266    const MAX_CALLBACKS: usize,
267> NodeRuntimeAdapter<'a, R, MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
268{
269    /// Build a runtime adapter around a generated executor owner.
270    pub fn new(node_runtime: &'a mut R) -> Self {
271        Self {
272            node_runtime,
273            nodes: Vec::new(),
274            entities: Vec::new(),
275            callback_effects: Vec::new(),
276        }
277    }
278
279    /// Runtime node mappings in declaration order.
280    pub fn nodes(&self) -> &[RuntimeNodeRecord<R::NodeHandle>] {
281        &self.nodes
282    }
283
284    /// Entity declarations accepted for generated runtime binding.
285    pub fn entities(&self) -> &[EntityMetadata] {
286        &self.entities
287    }
288
289    /// Optional callback effects accepted for generated runtime binding.
290    pub fn callback_effects(&self) -> &[CallbackEffectMetadata] {
291        &self.callback_effects
292    }
293
294    /// Lookup an executor node handle by stable component node ID.
295    pub fn node_handle(&self, stable_id: NodeId<'_>) -> Option<R::NodeHandle> {
296        self.nodes
297            .iter()
298            .find(|node| node.stable_id() == stable_id.as_str())
299            .map(RuntimeNodeRecord::handle)
300    }
301
302    fn contains_node(&self, stable_id: &str) -> bool {
303        self.nodes.iter().any(|node| node.stable_id() == stable_id)
304    }
305
306    fn contains_entity(&self, stable_id: &str) -> bool {
307        self.entities
308            .iter()
309            .any(|entity| entity.id.as_str() == stable_id)
310    }
311
312    fn node_slot_for_id(&self, stable_id: &str) -> Option<NodeSlot> {
313        self.nodes
314            .iter()
315            .find(|node| node.stable_id() == stable_id)
316            .map(RuntimeNodeRecord::slot)
317    }
318
319    fn entity_slot_for_id(&self, stable_id: &str) -> Option<EntitySlot> {
320        self.entities
321            .iter()
322            .find(|entity| entity.id.as_str() == stable_id)
323            .and_then(|entity| entity.slot)
324    }
325
326    fn callback_slot_for_current_entity(
327        &self,
328        id: &str,
329        current_callbacks: &mut Vec<MetadataString, 3>,
330        next_callback_slot: &mut usize,
331    ) -> CallbackSlot {
332        if let Some(slot) = self.callback_slot_for_id(id) {
333            return slot;
334        }
335        if let Some((index, _)) = current_callbacks
336            .iter()
337            .enumerate()
338            .find(|(_, callback_id)| callback_id.as_str() == id)
339        {
340            return CallbackSlot::new(self.callback_slot_count() + index);
341        }
342        let slot = CallbackSlot::new(*next_callback_slot);
343        let _ = current_callbacks
344            .push(copy_str(id).expect("callback ID already fits metadata string capacity"));
345        *next_callback_slot += 1;
346        slot
347    }
348
349    fn callback_slot_for_id(&self, id: &str) -> Option<CallbackSlot> {
350        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
351        for entity in &self.entities {
352            for callback_id in entity_callback_ids(entity) {
353                let Some(callback_id) = callback_id else {
354                    continue;
355                };
356                let callback_id = callback_id.as_str();
357                if seen.contains(&callback_id) {
358                    continue;
359                }
360                if callback_id == id {
361                    return Some(CallbackSlot::new(seen.len()));
362                }
363                let _ = seen.push(callback_id);
364            }
365        }
366        None
367    }
368
369    fn callback_slot_count(&self) -> usize {
370        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
371        for entity in &self.entities {
372            for callback_id in entity_callback_ids(entity) {
373                let Some(callback_id) = callback_id else {
374                    continue;
375                };
376                let callback_id = callback_id.as_str();
377                if !seen.contains(&callback_id) {
378                    let _ = seen.push(callback_id);
379                }
380            }
381        }
382        seen.len()
383    }
384}
385
386impl<
387    R: DeclaredNodeRuntime + ?Sized,
388    const MAX_NODES: usize,
389    const MAX_ENTITIES: usize,
390    const MAX_CALLBACKS: usize,
391> NodeRuntime for NodeRuntimeAdapter<'_, R, MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
392{
393    fn create_node(&mut self, id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
394        if self.contains_node(id.as_str()) {
395            return Err(NodeMetadataError::DuplicateId.into());
396        }
397        let handle = self.node_runtime.build_component_node(id, options)?;
398        let slot = NodeSlot::new(self.nodes.len());
399        self.nodes
400            .push(RuntimeNodeRecord {
401                slot,
402                stable_id: copy_str(id.as_str())?,
403                source_default_name: copy_str(options.name)?,
404                handle,
405            })
406            .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
407        Ok(())
408    }
409
410    fn create_entity(&mut self, mut metadata: EntityMetadata) -> NodeResult<()> {
411        if !self.contains_node(metadata.node_id.as_str()) {
412            return Err(NodeMetadataError::UnknownNode.into());
413        }
414        if self.contains_entity(metadata.id.as_str()) {
415            return Err(NodeMetadataError::DuplicateId.into());
416        }
417        metadata.slot = Some(EntitySlot::new(self.entities.len()));
418        metadata.node_slot = self.node_slot_for_id(&metadata.node_id);
419        let mut current_callbacks = Vec::<MetadataString, 3>::new();
420        let mut next_callback_slot = self.callback_slot_count();
421        metadata.callback_slot = metadata.callback_id.as_ref().map(|callback_id| {
422            self.callback_slot_for_current_entity(
423                callback_id.as_str(),
424                &mut current_callbacks,
425                &mut next_callback_slot,
426            )
427        });
428        metadata.action_cancel_callback_slot =
429            metadata
430                .action_cancel_callback_id
431                .as_ref()
432                .map(|callback_id| {
433                    self.callback_slot_for_current_entity(
434                        callback_id.as_str(),
435                        &mut current_callbacks,
436                        &mut next_callback_slot,
437                    )
438                });
439        metadata.action_accepted_callback_slot =
440            metadata
441                .action_accepted_callback_id
442                .as_ref()
443                .map(|callback_id| {
444                    self.callback_slot_for_current_entity(
445                        callback_id.as_str(),
446                        &mut current_callbacks,
447                        &mut next_callback_slot,
448                    )
449                });
450        self.entities
451            .push(metadata)
452            .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
453        Ok(())
454    }
455
456    fn record_callback_effect(
457        &mut self,
458        callback_id: CallbackId<'_>,
459        kind: CallbackEffectKind,
460        entity_id: EntityId<'_>,
461    ) -> NodeResult<()> {
462        if !self.contains_entity(entity_id.as_str()) {
463            return Err(NodeMetadataError::UnknownEntity.into());
464        }
465        self.callback_effects
466            .push(CallbackEffectMetadata {
467                callback_id: copy_str(callback_id.as_str())?,
468                callback_slot: self.callback_slot_for_id(callback_id.as_str()),
469                kind,
470                entity_id: copy_str(entity_id.as_str())?,
471                entity_slot: self.entity_slot_for_id(entity_id.as_str()),
472            })
473            .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
474        Ok(())
475    }
476}
477
478#[cfg(feature = "rmw-cffi")]
479impl DeclaredNodeRuntime for crate::Executor<'static> {
480    type NodeHandle = nros_node::executor::NodeId;
481
482    fn build_component_node(
483        &mut self,
484        _id: NodeId<'_>,
485        options: NodeOptions<'_>,
486    ) -> NodeResult<Self::NodeHandle> {
487        self.node_builder(options.name)
488            .namespace(options.namespace)
489            .domain_id(options.domain_id)
490            .build()
491            .map_err(|_| NodeDeclError::Runtime)
492    }
493}
494
495/// Runtime adapter backed by [`Executor`](crate::Executor).
496#[cfg(feature = "rmw-cffi")]
497pub type NodeExecutorRuntime<
498    'a,
499    const MAX_NODES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_NODES },
500    const MAX_ENTITIES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_ENTITIES },
501    const MAX_CALLBACKS: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_CALLBACKS },
502> = NodeRuntimeAdapter<'a, crate::Executor<'static>, MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>;
503
504/// Node declaration context. Does not own middleware transport.
505pub struct NodeContext<'a, R: NodeRuntime + ?Sized = dyn NodeRuntime + 'a> {
506    component_name: &'static str,
507    runtime: &'a mut R,
508    /// Phase 264 W4a — this node instance's parameters, the COMPILE-BAKED initial
509    /// values from the launch `<param name=… value=…/>` entries (`nros::main!`
510    /// bakes them + threads them through `install_node_typed_with_params`). Empty
511    /// for the metadata-recorder / no-launch paths. A node reads them in
512    /// `register()` via [`param`](Self::param) and stashes the typed value on its
513    /// `State` (RFC-0004 §10 — baked initials; runtime reconfig is W4b).
514    params: &'a [(&'a str, &'a str)],
515}
516
517impl<'a, R: NodeRuntime + ?Sized> NodeContext<'a, R> {
518    /// Build a context over a metadata recorder or generated runtime.
519    pub fn new(component_name: &'static str, runtime: &'a mut R) -> Self {
520        Self {
521            component_name,
522            runtime,
523            params: &[],
524        }
525    }
526
527    /// Phase 264 W4a — seed this node instance's baked launch parameters (called by
528    /// `install_node_typed_with_params` before `Node::register`).
529    pub fn set_params(&mut self, params: &'a [(&'a str, &'a str)]) {
530        self.params = params;
531    }
532
533    /// Phase 264 W4a — the baked initial value of launch parameter `name`, or
534    /// `None` if the launch declared no `<param name="…"/>` for this node
535    /// instance. Read in `register()` and parse/stash on `State` (RFC-0004 §10).
536    pub fn param(&self, name: &str) -> Option<&'a str> {
537        self.params
538            .iter()
539            .find(|(k, _)| *k == name)
540            .map(|(_, v)| *v)
541    }
542
543    /// Source component name.
544    pub const fn component_name(&self) -> &'static str {
545        self.component_name
546    }
547
548    /// Declare a node with an explicit stable node ID.
549    ///
550    /// Generated/internal form; product code should use
551    /// [`create_node`](Self::create_node).
552    #[doc(hidden)]
553    pub fn create_node_with_id<'id>(
554        &mut self,
555        id: NodeId<'id>,
556        options: NodeOptions<'_>,
557    ) -> NodeResult<DeclaredNode<'_, 'id, R>> {
558        self.runtime.create_node(id, options)?;
559        Ok(DeclaredNode {
560            runtime: self.runtime,
561            id,
562            current_group: None,
563        })
564    }
565
566    /// Declare a node using `options.name` as the stable node ID.
567    ///
568    /// This mirrors the common rclcpp/rclrs shape where a node package supplies
569    /// node options and the node name, while nano-ros keeps the generated stable
570    /// ID as internal metadata.
571    pub fn create_node<'id>(
572        &mut self,
573        options: NodeOptions<'id>,
574    ) -> NodeResult<DeclaredNode<'_, 'id, R>> {
575        self.create_node_with_id(NodeId::new(options.name), options)
576    }
577
578    /// Deprecated alias for [`create_node`](Self::create_node).
579    #[deprecated(note = "use create_node(NodeOptions)")]
580    pub fn create_node_with_options<'id>(
581        &mut self,
582        options: NodeOptions<'id>,
583    ) -> NodeResult<DeclaredNode<'_, 'id, R>> {
584        self.create_node(options)
585    }
586
587    /// Record optional effects for a callback not tied to a node wrapper.
588    #[doc(hidden)]
589    pub fn callback<'id>(&mut self, id: CallbackId<'id>) -> CallbackEffects<'_, 'id, R> {
590        CallbackEffects {
591            runtime: self.runtime,
592            id,
593        }
594    }
595}
596
597/// Declared component node.
598pub struct DeclaredNode<'ctx, 'id, R: NodeRuntime + ?Sized = dyn NodeRuntime + 'ctx> {
599    runtime: &'ctx mut R,
600    id: NodeId<'id>,
601    /// Phase 228.C sticky callback-group label. When set (via
602    /// [`callback_group`](Self::callback_group)), every subsequently
603    /// declared entity that does not carry its own group inherits it,
604    /// so the tier filter in the executor can include/exclude the
605    /// callback per the `system.toml` group→tier map. `None` →
606    /// unlabeled (wildcard-eligible).
607    current_group: Option<MetadataString>,
608}
609
610impl<'ctx, 'id, R: NodeRuntime + ?Sized> DeclaredNode<'ctx, 'id, R> {
611    /// Stable node ID.
612    #[doc(hidden)]
613    pub const fn id(&self) -> NodeId<'id> {
614        self.id
615    }
616
617    /// Set the sticky callback-group label applied to every entity
618    /// declared after this call (until changed again). The group is the
619    /// symbolic name the node author exposes; `system.toml` maps it to a
620    /// scheduling tier (RFC-0015). Entities declared while no group is set
621    /// remain unlabeled (wildcard-eligible). Reusing the Phase-216 tag
622    /// string as the group id keeps one identifier per logical callback.
623    #[track_caller]
624    pub fn callback_group(&mut self, group: &str) -> NodeResult<&mut Self> {
625        self.current_group = Some(copy_str(group)?);
626        Ok(self)
627    }
628
629    /// Phase 228.C chokepoint: stamp the sticky group onto the entity
630    /// (when the entity carries no group of its own) before forwarding to
631    /// the runtime. Every `create_*` helper routes its declaration here so
632    /// the label is applied uniformly in one place.
633    fn declare_entity(&mut self, mut metadata: EntityMetadata) -> NodeResult<()> {
634        if metadata.callback_group.is_none() {
635            metadata.callback_group = self.current_group.clone();
636        }
637        self.runtime.create_entity(metadata)
638    }
639
640    /// Declare a publisher with default QoS. Stable publisher ID is required.
641    #[track_caller]
642    #[doc(hidden)]
643    pub fn create_publisher<'entity, M: RosMessage>(
644        &mut self,
645        id: EntityId<'entity>,
646        topic: &str,
647    ) -> NodeResult<NodePublisher<'entity, M>> {
648        self.create_publisher_with_qos::<M>(id, topic, QosSettings::default())
649    }
650
651    /// Declare a publisher using `topic` as the stable entity ID.
652    ///
653    /// Use the explicit [`create_publisher`](Self::create_publisher) form when
654    /// a node declares more than one publisher on the same topic or needs a
655    /// stable metadata ID that differs from the ROS topic name.
656    #[track_caller]
657    pub fn create_publisher_for_topic<'entity, M: RosMessage>(
658        &mut self,
659        topic: &'entity str,
660    ) -> NodeResult<NodePublisher<'entity, M>> {
661        self.create_publisher_for_topic_with_qos::<M>(topic, QosSettings::default())
662    }
663
664    /// Declare a publisher with explicit QoS, using `topic` as the stable entity ID.
665    #[track_caller]
666    pub fn create_publisher_for_topic_with_qos<'entity, M: RosMessage>(
667        &mut self,
668        topic: &'entity str,
669        qos: QosSettings,
670    ) -> NodeResult<NodePublisher<'entity, M>> {
671        self.create_publisher_with_qos::<M>(EntityId::new(topic), topic, qos)
672    }
673
674    /// Declare a publisher with explicit QoS.
675    #[track_caller]
676    #[doc(hidden)]
677    pub fn create_publisher_with_qos<'entity, M: RosMessage>(
678        &mut self,
679        id: EntityId<'entity>,
680        topic: &str,
681        qos: QosSettings,
682    ) -> NodeResult<NodePublisher<'entity, M>> {
683        let mut metadata = entity_metadata(EntityMetadataSpec {
684            id,
685            node_id: self.id,
686            kind: EntityKind::Publisher,
687            source_name: topic,
688            type_name: M::TYPE_NAME,
689            type_hash: M::TYPE_HASH,
690            qos,
691        })?;
692        metadata.source = SourceLocationMetadata::caller()?;
693        self.declare_entity(metadata)?;
694        Ok(NodePublisher::new(id))
695    }
696
697    /// Declare a subscription. Stable subscription and callback IDs are required.
698    #[track_caller]
699    #[doc(hidden)]
700    pub fn create_subscription<'entity, 'callback, M: RosMessage>(
701        &mut self,
702        id: EntityId<'entity>,
703        callback_id: CallbackId<'callback>,
704        topic: &str,
705    ) -> NodeResult<NodeSubscription<'entity, M>> {
706        self.create_subscription_with_qos::<M>(id, callback_id, topic, QosSettings::default())
707    }
708
709    /// Declare a subscription using `callback_id` as the stable entity ID.
710    ///
711    /// Generated/internal form; product code should use
712    /// [`create_subscription_for_callback_name`](Self::create_subscription_for_callback_name).
713    #[track_caller]
714    #[doc(hidden)]
715    pub fn create_subscription_for_callback<'callback, M: RosMessage>(
716        &mut self,
717        callback_id: CallbackId<'callback>,
718        topic: &str,
719    ) -> NodeResult<NodeSubscription<'callback, M>> {
720        self.create_subscription_for_callback_with_qos::<M>(
721            callback_id,
722            topic,
723            QosSettings::default(),
724        )
725    }
726
727    /// Declare a subscription using `callback_name` as the source callback
728    /// name and synthesized entity ID.
729    #[track_caller]
730    pub fn create_subscription_for_callback_name<'callback, M: RosMessage>(
731        &mut self,
732        callback_name: &'callback str,
733        topic: &str,
734    ) -> NodeResult<NodeSubscription<'callback, M>> {
735        self.create_subscription_for_callback::<M>(CallbackId::new(callback_name), topic)
736    }
737
738    /// Phase 250 (Wave 2b) — declare a subscription with E2E message-integrity
739    /// validation enabled (the declarative `.safety()` opt-in). Identical to
740    /// [`create_subscription_for_callback_name`](Self::create_subscription_for_callback_name)
741    /// but flags the entity so the runtime registers it via
742    /// `create_generic_subscription_with_integrity`; the callback then reads
743    /// [`CallbackCtx::integrity`](CallbackCtx::integrity) alongside the message.
744    /// The config-driven `[safety]` axis (Wave 4 codegen) emits this call; it is
745    /// also usable by hand. Ungated — when `safety-e2e` is off the flag is simply
746    /// ignored and the subscription registers as a basic one.
747    #[track_caller]
748    pub fn create_subscription_for_callback_name_with_safety<'callback, M: RosMessage>(
749        &mut self,
750        callback_name: &'callback str,
751        topic: &str,
752    ) -> NodeResult<NodeSubscription<'callback, M>> {
753        let callback_id = CallbackId::new(callback_name);
754        let id = EntityId::new(callback_id.as_str());
755        let mut metadata = entity_metadata(EntityMetadataSpec {
756            id,
757            node_id: self.id,
758            kind: EntityKind::Subscription,
759            source_name: topic,
760            type_name: M::TYPE_NAME,
761            type_hash: M::TYPE_HASH,
762            qos: QosSettings::default(),
763        })?;
764        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
765        metadata.callback_source = SourceLocationMetadata::caller()?;
766        metadata.source = metadata.callback_source.clone();
767        metadata.safety = true;
768        self.declare_entity(metadata)?;
769        Ok(NodeSubscription::new(id))
770    }
771
772    /// Declare a subscription with explicit QoS, using `callback_id` as the stable entity ID.
773    #[track_caller]
774    #[doc(hidden)]
775    pub fn create_subscription_for_callback_with_qos<'callback, M: RosMessage>(
776        &mut self,
777        callback_id: CallbackId<'callback>,
778        topic: &str,
779        qos: QosSettings,
780    ) -> NodeResult<NodeSubscription<'callback, M>> {
781        self.create_subscription_with_qos::<M>(
782            EntityId::new(callback_id.as_str()),
783            callback_id,
784            topic,
785            qos,
786        )
787    }
788
789    /// Declare a subscription using `topic` as both the stable entity ID and callback ID.
790    #[track_caller]
791    pub fn create_subscription_for_topic<'entity, M: RosMessage>(
792        &mut self,
793        topic: &'entity str,
794    ) -> NodeResult<NodeSubscription<'entity, M>> {
795        self.create_subscription_for_topic_with_qos::<M>(topic, QosSettings::default())
796    }
797
798    /// Declare a subscription with explicit QoS, using `topic` as both IDs.
799    #[track_caller]
800    pub fn create_subscription_for_topic_with_qos<'entity, M: RosMessage>(
801        &mut self,
802        topic: &'entity str,
803        qos: QosSettings,
804    ) -> NodeResult<NodeSubscription<'entity, M>> {
805        self.create_subscription_with_qos::<M>(
806            EntityId::new(topic),
807            CallbackId::new(topic),
808            topic,
809            qos,
810        )
811    }
812
813    /// Declare a subscription with explicit QoS.
814    #[track_caller]
815    #[doc(hidden)]
816    pub fn create_subscription_with_qos<'entity, 'callback, M: RosMessage>(
817        &mut self,
818        id: EntityId<'entity>,
819        callback_id: CallbackId<'callback>,
820        topic: &str,
821        qos: QosSettings,
822    ) -> NodeResult<NodeSubscription<'entity, M>> {
823        let mut metadata = entity_metadata(EntityMetadataSpec {
824            id,
825            node_id: self.id,
826            kind: EntityKind::Subscription,
827            source_name: topic,
828            type_name: M::TYPE_NAME,
829            type_hash: M::TYPE_HASH,
830            qos,
831        })?;
832        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
833        metadata.callback_source = SourceLocationMetadata::caller()?;
834        metadata.source = metadata.callback_source.clone();
835        self.declare_entity(metadata)?;
836        Ok(NodeSubscription::new(id))
837    }
838
839    /// Declare a subscription whose stable entity and callback IDs are
840    /// both synthesized from the topic literal, returning a
841    /// [`SubscriptionTag`] the Node author stores on `Self::State` and
842    /// matches against the `Callback<'_>` delivered to
843    /// [`ExecutableNode::on_callback`].
844    ///
845    /// Use this on the Phase 216.A Deferred Node path where the Node
846    /// author does not need to invent a separate stable entity ID — the
847    /// topic literal becomes both the entity ID and the callback ID,
848    /// and the returned tag preserves that identifier for compile-time
849    /// `state.sub_chatter == cb` matches in `on_callback`.
850    #[track_caller]
851    pub fn create_subscription_static<M: RosMessage>(
852        &mut self,
853        topic: &'static str,
854    ) -> NodeResult<SubscriptionTag> {
855        let id = EntityId::new(topic);
856        let callback_id = CallbackId::new(topic);
857        let mut metadata = entity_metadata(EntityMetadataSpec {
858            id,
859            node_id: self.id,
860            kind: EntityKind::Subscription,
861            source_name: topic,
862            type_name: M::TYPE_NAME,
863            type_hash: M::TYPE_HASH,
864            qos: QosSettings::default(),
865        })?;
866        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
867        metadata.callback_source = SourceLocationMetadata::caller()?;
868        metadata.source = metadata.callback_source.clone();
869        self.declare_entity(metadata)?;
870        Ok(SubscriptionTag::new(topic))
871    }
872
873    /// Declare a timer. Stable timer and callback IDs are required.
874    #[track_caller]
875    #[doc(hidden)]
876    pub fn create_timer<'entity, 'callback>(
877        &mut self,
878        id: EntityId<'entity>,
879        callback_id: CallbackId<'callback>,
880        period: TimerDuration,
881    ) -> NodeResult<NodeTimer<'entity>> {
882        let mut metadata = entity_metadata(EntityMetadataSpec {
883            id,
884            node_id: self.id,
885            kind: EntityKind::Timer,
886            source_name: "",
887            type_name: "",
888            type_hash: "",
889            qos: QosSettings::default(),
890        })?;
891        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
892        metadata.callback_source = SourceLocationMetadata::caller()?;
893        metadata.source = metadata.callback_source.clone();
894        metadata.period_ms = Some(period.as_millis());
895        self.declare_entity(metadata)?;
896        Ok(NodeTimer::new(id))
897    }
898
899    /// Declare a timer using `callback_id` as the stable timer entity ID.
900    #[track_caller]
901    #[doc(hidden)]
902    pub fn create_timer_for_callback<'callback>(
903        &mut self,
904        callback_id: CallbackId<'callback>,
905        period: TimerDuration,
906    ) -> NodeResult<NodeTimer<'callback>> {
907        self.create_timer(EntityId::new(callback_id.as_str()), callback_id, period)
908    }
909
910    /// Declare a timer using `callback_name` as the source callback name and
911    /// synthesized entity ID.
912    #[track_caller]
913    pub fn create_timer_for_callback_name<'callback>(
914        &mut self,
915        callback_name: &'callback str,
916        period: TimerDuration,
917    ) -> NodeResult<NodeTimer<'callback>> {
918        self.create_timer_for_callback(CallbackId::new(callback_name), period)
919    }
920
921    /// Declare a service server. Stable service and callback IDs are required.
922    #[track_caller]
923    #[doc(hidden)]
924    pub fn create_service_server<'entity, 'callback, S: RosService>(
925        &mut self,
926        id: EntityId<'entity>,
927        callback_id: CallbackId<'callback>,
928        service_name: &str,
929    ) -> NodeResult<NodeServiceServer<'entity, S>> {
930        let mut metadata = entity_metadata(EntityMetadataSpec {
931            id,
932            node_id: self.id,
933            kind: EntityKind::ServiceServer,
934            source_name: service_name,
935            type_name: S::SERVICE_NAME,
936            type_hash: S::SERVICE_HASH,
937            qos: QosSettings::default(),
938        })?;
939        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
940        metadata.callback_source = SourceLocationMetadata::caller()?;
941        metadata.source = metadata.callback_source.clone();
942        self.declare_entity(metadata)?;
943        Ok(NodeServiceServer::new(id))
944    }
945
946    /// Declare a service server using `name` as both the stable entity ID
947    /// and callback ID.
948    #[track_caller]
949    pub fn create_service_server_for_name<'entity, S: RosService>(
950        &mut self,
951        name: &'entity str,
952    ) -> NodeResult<NodeServiceServer<'entity, S>> {
953        self.create_service_server::<S>(EntityId::new(name), CallbackId::new(name), name)
954    }
955
956    /// Declare a service server using `name` as the stable entity ID and
957    /// `callback_name` as the source callback name.
958    #[track_caller]
959    pub fn create_service_server_for_name_with_callback<'entity, S: RosService>(
960        &mut self,
961        name: &'entity str,
962        callback_name: &str,
963    ) -> NodeResult<NodeServiceServer<'entity, S>> {
964        self.create_service_server::<S>(EntityId::new(name), CallbackId::new(callback_name), name)
965    }
966
967    /// Declare a service server whose stable entity and callback IDs are
968    /// both synthesized from the service-name literal, returning a
969    /// [`ServiceTag`] the Node author stores on `Self::State` and matches
970    /// against the `Callback<'_>` delivered to
971    /// [`ExecutableNode::on_callback`].
972    ///
973    /// Tag-only registration is restricted to the SERVER side: clients
974    /// need a USABLE handle (`NodeServiceClient`) to issue requests, so
975    /// use the existing
976    /// [`create_service_client_for_name`](Self::create_service_client_for_name) builder
977    /// for the client side.
978    #[track_caller]
979    pub fn create_service_static<S: RosService>(
980        &mut self,
981        name: &'static str,
982    ) -> NodeResult<ServiceTag> {
983        self.create_service_server_for_name::<S>(name)?;
984        Ok(ServiceTag::new(name))
985    }
986
987    /// Declare a service client. Stable service client ID is required.
988    #[track_caller]
989    #[doc(hidden)]
990    pub fn create_service_client<'entity, S: RosService>(
991        &mut self,
992        id: EntityId<'entity>,
993        service_name: &str,
994    ) -> NodeResult<NodeServiceClient<'entity, S>> {
995        let mut metadata = entity_metadata(EntityMetadataSpec {
996            id,
997            node_id: self.id,
998            kind: EntityKind::ServiceClient,
999            source_name: service_name,
1000            type_name: S::SERVICE_NAME,
1001            type_hash: S::SERVICE_HASH,
1002            qos: QosSettings::default(),
1003        })?;
1004        metadata.source = SourceLocationMetadata::caller()?;
1005        self.declare_entity(metadata)?;
1006        Ok(NodeServiceClient::new(id))
1007    }
1008
1009    /// Declare a service client using `name` as the stable entity ID.
1010    #[track_caller]
1011    pub fn create_service_client_for_name<'entity, S: RosService>(
1012        &mut self,
1013        name: &'entity str,
1014    ) -> NodeResult<NodeServiceClient<'entity, S>> {
1015        self.create_service_client::<S>(EntityId::new(name), name)
1016    }
1017
1018    /// Declare an action server. Stable action and callback IDs are required.
1019    #[track_caller]
1020    #[doc(hidden)]
1021    pub fn create_action_server<'entity, 'callback, A: RosAction>(
1022        &mut self,
1023        id: EntityId<'entity>,
1024        callback_id: CallbackId<'callback>,
1025        action_name: &str,
1026    ) -> NodeResult<NodeActionServer<'entity, A>> {
1027        self.create_action_server_with_callbacks::<A>(
1028            id,
1029            callback_id,
1030            callback_id,
1031            callback_id,
1032            action_name,
1033        )
1034    }
1035
1036    /// Declare an action server with distinct goal/cancel/accepted callbacks.
1037    #[track_caller]
1038    #[doc(hidden)]
1039    pub fn create_action_server_with_callbacks<'entity, 'goal, 'cancel, 'accepted, A: RosAction>(
1040        &mut self,
1041        id: EntityId<'entity>,
1042        goal_callback_id: CallbackId<'goal>,
1043        cancel_callback_id: CallbackId<'cancel>,
1044        accepted_callback_id: CallbackId<'accepted>,
1045        action_name: &str,
1046    ) -> NodeResult<NodeActionServer<'entity, A>> {
1047        let mut metadata = entity_metadata(EntityMetadataSpec {
1048            id,
1049            node_id: self.id,
1050            kind: EntityKind::ActionServer,
1051            source_name: action_name,
1052            type_name: A::ACTION_NAME,
1053            type_hash: A::ACTION_HASH,
1054            qos: QosSettings::default(),
1055        })?;
1056        metadata.callback_id = Some(copy_str(goal_callback_id.as_str())?);
1057        metadata.callback_source = SourceLocationMetadata::caller()?;
1058        metadata.action_cancel_callback_id = Some(copy_str(cancel_callback_id.as_str())?);
1059        metadata.action_cancel_source = metadata.callback_source.clone();
1060        metadata.action_accepted_callback_id = Some(copy_str(accepted_callback_id.as_str())?);
1061        metadata.action_accepted_source = metadata.callback_source.clone();
1062        metadata.source = metadata.callback_source.clone();
1063        self.declare_entity(metadata)?;
1064        Ok(NodeActionServer::new(id))
1065    }
1066
1067    /// Declare an action server using `name` as the stable entity ID and
1068    /// default goal/cancel/accepted callback ID.
1069    #[track_caller]
1070    pub fn create_action_server_for_name<'entity, A: RosAction>(
1071        &mut self,
1072        name: &'entity str,
1073    ) -> NodeResult<NodeActionServer<'entity, A>> {
1074        self.create_action_server::<A>(EntityId::new(name), CallbackId::new(name), name)
1075    }
1076
1077    /// Declare an action server using `name` as the stable entity ID and
1078    /// explicit source callback names for goal, cancel, and accepted events.
1079    #[track_caller]
1080    pub fn create_action_server_for_name_with_callbacks<'entity, A: RosAction>(
1081        &mut self,
1082        name: &'entity str,
1083        goal_callback_name: &str,
1084        cancel_callback_name: &str,
1085        accepted_callback_name: &str,
1086    ) -> NodeResult<NodeActionServer<'entity, A>> {
1087        self.create_action_server_with_callbacks::<A>(
1088            EntityId::new(name),
1089            CallbackId::new(goal_callback_name),
1090            CallbackId::new(cancel_callback_name),
1091            CallbackId::new(accepted_callback_name),
1092            name,
1093        )
1094    }
1095
1096    /// Declare an action server whose stable entity and callback IDs are
1097    /// both synthesized from the action-name literal, returning an
1098    /// [`ActionTag`] the Node author stores on `Self::State` and matches
1099    /// against the `Callback<'_>` delivered to
1100    /// [`ExecutableNode::on_callback`].
1101    ///
1102    /// The synthesized callback ID is shared by the goal / cancel /
1103    /// accepted callbacks (matching the default behavior of
1104    /// [`create_action_server`](Self::create_action_server)).
1105    ///
1106    /// Tag-only registration is restricted to the SERVER side: clients
1107    /// need a USABLE handle (`NodeActionClient`) to dispatch goals, so
1108    /// use the existing
1109    /// [`create_action_client_for_name`](Self::create_action_client_for_name) builder
1110    /// for the client side.
1111    #[track_caller]
1112    pub fn create_action_static<A: RosAction>(
1113        &mut self,
1114        name: &'static str,
1115    ) -> NodeResult<ActionTag> {
1116        self.create_action_server_for_name::<A>(name)?;
1117        Ok(ActionTag::new(name))
1118    }
1119
1120    /// Declare an action client. Stable action client ID is required.
1121    #[track_caller]
1122    #[doc(hidden)]
1123    pub fn create_action_client<'entity, A: RosAction>(
1124        &mut self,
1125        id: EntityId<'entity>,
1126        action_name: &str,
1127    ) -> NodeResult<NodeActionClient<'entity, A>> {
1128        let mut metadata = entity_metadata(EntityMetadataSpec {
1129            id,
1130            node_id: self.id,
1131            kind: EntityKind::ActionClient,
1132            source_name: action_name,
1133            type_name: A::ACTION_NAME,
1134            type_hash: A::ACTION_HASH,
1135            qos: QosSettings::default(),
1136        })?;
1137        metadata.source = SourceLocationMetadata::caller()?;
1138        self.declare_entity(metadata)?;
1139        Ok(NodeActionClient::new(id))
1140    }
1141
1142    /// Declare an action client using `name` as the stable entity ID.
1143    #[track_caller]
1144    pub fn create_action_client_for_name<'entity, A: RosAction>(
1145        &mut self,
1146        name: &'entity str,
1147    ) -> NodeResult<NodeActionClient<'entity, A>> {
1148        self.create_action_client::<A>(EntityId::new(name), name)
1149    }
1150
1151    /// Declare an action client that delivers the goal RESULT + FEEDBACK to
1152    /// named callbacks (Phase 212.M-F.23). `name` is the stable entity ID. The
1153    /// executor auto-drives accept → feedback stream → result during spin and
1154    /// dispatches `ExecutableNode::on_callback` with `result_callback_name`
1155    /// (payload = result CDR) on completion, and with `feedback_callback_name`
1156    /// (payload = feedback CDR) per feedback message. Read either with
1157    /// `CallbackCtx::message::<A::Result>()` / `::<A::Feedback>()`. Without
1158    /// these the client can only `send_goal`; result + feedback are dropped.
1159    ///
1160    /// (Layout note: the action-client variant reuses the server-side
1161    /// `action_accepted_callback_id` metadata slot for the feedback callback —
1162    /// that field is unused on a client, so no new schema field is needed.)
1163    #[track_caller]
1164    pub fn create_action_client_with_callbacks_for_name<'entity, A: RosAction>(
1165        &mut self,
1166        name: &'entity str,
1167        result_callback_name: &str,
1168        feedback_callback_name: &str,
1169    ) -> NodeResult<NodeActionClient<'entity, A>> {
1170        let mut metadata = entity_metadata(EntityMetadataSpec {
1171            id: EntityId::new(name),
1172            node_id: self.id,
1173            kind: EntityKind::ActionClient,
1174            source_name: name,
1175            type_name: A::ACTION_NAME,
1176            type_hash: A::ACTION_HASH,
1177            qos: QosSettings::default(),
1178        })?;
1179        metadata.callback_id = Some(copy_str(result_callback_name)?);
1180        metadata.action_accepted_callback_id = Some(copy_str(feedback_callback_name)?);
1181        metadata.callback_source = SourceLocationMetadata::caller()?;
1182        metadata.source = metadata.callback_source.clone();
1183        self.declare_entity(metadata)?;
1184        Ok(NodeActionClient::new(EntityId::new(name)))
1185    }
1186
1187    /// Declare a parameter. Stable parameter ID is required.
1188    #[track_caller]
1189    #[doc(hidden)]
1190    pub fn declare_parameter<'entity>(
1191        &mut self,
1192        id: EntityId<'entity>,
1193        name: &str,
1194        parameter_type: ParameterType,
1195    ) -> NodeResult<NodeParameter<'entity>> {
1196        self.declare_parameter_with_default(id, name, ParameterDefault::for_type(parameter_type)?)
1197    }
1198
1199    /// Declare a parameter with a concrete source default.
1200    #[track_caller]
1201    #[doc(hidden)]
1202    pub fn declare_parameter_with_default<'entity>(
1203        &mut self,
1204        id: EntityId<'entity>,
1205        name: &str,
1206        default: ParameterDefault,
1207    ) -> NodeResult<NodeParameter<'entity>> {
1208        let mut metadata = entity_metadata(EntityMetadataSpec {
1209            id,
1210            node_id: self.id,
1211            kind: EntityKind::Parameter,
1212            source_name: name,
1213            type_name: "",
1214            type_hash: "",
1215            qos: QosSettings::default(),
1216        })?;
1217        metadata.parameter_type = Some(default.parameter_type());
1218        metadata.parameter_default = Some(default);
1219        metadata.source = SourceLocationMetadata::caller()?;
1220        self.declare_entity(metadata)?;
1221        Ok(NodeParameter::new(id))
1222    }
1223
1224    /// Declare a parameter using `name` as the generated stable entity ID.
1225    #[track_caller]
1226    pub fn declare_parameter_for_name<'entity>(
1227        &mut self,
1228        name: &'entity str,
1229        parameter_type: ParameterType,
1230    ) -> NodeResult<NodeParameter<'entity>> {
1231        self.declare_parameter(EntityId::new(name), name, parameter_type)
1232    }
1233
1234    /// Declare a parameter with a concrete source default, using `name` as
1235    /// the generated stable entity ID.
1236    #[track_caller]
1237    pub fn declare_parameter_for_name_with_default<'entity>(
1238        &mut self,
1239        name: &'entity str,
1240        default: ParameterDefault,
1241    ) -> NodeResult<NodeParameter<'entity>> {
1242        self.declare_parameter_with_default(EntityId::new(name), name, default)
1243    }
1244
1245    /// Record optional effects for a callback.
1246    #[doc(hidden)]
1247    pub fn callback<'callback>(
1248        &mut self,
1249        id: CallbackId<'callback>,
1250    ) -> CallbackEffects<'_, 'callback, R> {
1251        CallbackEffects {
1252            runtime: self.runtime,
1253            id,
1254        }
1255    }
1256
1257    /// Record optional effects for a named callback without exposing
1258    /// `CallbackId` at the declaration site.
1259    pub fn callback_for_name<'callback>(
1260        &mut self,
1261        name: &'callback str,
1262    ) -> CallbackEffects<'_, 'callback, R> {
1263        self.callback(CallbackId::new(name))
1264    }
1265}
1266
1267/// Builder for optional callback effect metadata.
1268pub struct CallbackEffects<'ctx, 'id, R: NodeRuntime + ?Sized = dyn NodeRuntime + 'ctx> {
1269    runtime: &'ctx mut R,
1270    id: CallbackId<'id>,
1271}
1272
1273impl<'ctx, 'id, R: NodeRuntime + ?Sized> CallbackEffects<'ctx, 'id, R> {
1274    /// Record that callback reads from an entity.
1275    #[doc(hidden)]
1276    pub fn reads(self, entity_id: EntityId<'_>) -> NodeResult<Self> {
1277        self.runtime
1278            .record_callback_effect(self.id, CallbackEffectKind::Reads, entity_id)?;
1279        Ok(self)
1280    }
1281
1282    /// Record that callback reads from a declared entity handle.
1283    pub fn reads_entity(self, entity: &impl DeclaredEntity) -> NodeResult<Self> {
1284        self.reads(entity.entity_id())
1285    }
1286
1287    /// Record that callback publishes via an entity.
1288    #[doc(hidden)]
1289    pub fn publishes(self, entity_id: EntityId<'_>) -> NodeResult<Self> {
1290        self.runtime
1291            .record_callback_effect(self.id, CallbackEffectKind::Publishes, entity_id)?;
1292        Ok(self)
1293    }
1294
1295    /// Record that callback publishes via a declared entity handle.
1296    pub fn publishes_entity(self, entity: &impl DeclaredEntity) -> NodeResult<Self> {
1297        self.publishes(entity.entity_id())
1298    }
1299
1300    /// Record that callback writes to an entity or parameter.
1301    #[doc(hidden)]
1302    pub fn writes(self, entity_id: EntityId<'_>) -> NodeResult<Self> {
1303        self.runtime
1304            .record_callback_effect(self.id, CallbackEffectKind::Writes, entity_id)?;
1305        Ok(self)
1306    }
1307
1308    /// Record that callback writes to a declared entity handle.
1309    pub fn writes_entity(self, entity: &impl DeclaredEntity) -> NodeResult<Self> {
1310        self.writes(entity.entity_id())
1311    }
1312}
1313
1314/// A declared source-level entity handle that can be referenced by callback effects.
1315#[doc(hidden)]
1316pub trait DeclaredEntity {
1317    /// Stable entity ID for metadata and generated runtime lookup.
1318    fn entity_id(&self) -> EntityId<'_>;
1319}
1320
1321macro_rules! component_handle {
1322    ($name:ident $(, $type_param:ident)?) => {
1323        /// Source-level component entity handle.
1324        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1325        pub struct $name<'id $(, $type_param)?> {
1326            id: EntityId<'id>,
1327            _marker: PhantomData<($($type_param,)?)>,
1328        }
1329
1330        impl<'id $(, $type_param)?> $name<'id $(, $type_param)?> {
1331            const fn new(id: EntityId<'id>) -> Self {
1332                Self {
1333                    id,
1334                    _marker: PhantomData,
1335                }
1336            }
1337
1338            /// Stable entity ID.
1339            #[doc(hidden)]
1340            pub const fn id(&self) -> EntityId<'id> {
1341                self.id
1342            }
1343        }
1344
1345        impl<'id $(, $type_param)?> DeclaredEntity for $name<'id $(, $type_param)?> {
1346            fn entity_id(&self) -> EntityId<'_> {
1347                self.id
1348            }
1349        }
1350    };
1351}
1352
1353component_handle!(NodePublisher, M);
1354component_handle!(NodeSubscription, M);
1355component_handle!(NodeServiceServer, S);
1356component_handle!(NodeServiceClient, S);
1357component_handle!(NodeActionServer, A);
1358component_handle!(NodeActionClient, A);
1359component_handle!(NodeTimer);
1360component_handle!(NodeParameter);
1361
1362// ============================================================================
1363// Phase 172 W.5.1 — executable component layer (callback bodies)
1364// ============================================================================
1365//
1366// The declarative `Node::register` above stays the planning/metadata SSOT.
1367// This layer binds *runnable* bodies: the generated runtime builds the
1368// component `State` once, then routes each fired callback to `on_callback` with
1369// a `CallbackCtx` that exposes the triggering payload + an immediate publish
1370// path. Publishers are self-contained transport handles
1371// (`EmbeddedRawPublisher::publish_raw(&self)`), so a body publishes immediately
1372// mid-spin with no executor re-entrancy and no deferred queue (causality
1373// preserved). Shared state across a component's callbacks is `&mut State`
1374// behind the generated runtime's `'static` storage — `no_std`, no `alloc`.
1375
1376/// Resolves a component publisher by its stable [`EntityId`] for the
1377/// callback-body publish path (W.5.1).
1378///
1379/// The generated runtime implements this over its owned `'static` publishers;
1380/// metadata/discovery mode never constructs a [`CallbackCtx`], so it need not
1381/// implement this.
1382pub trait PublisherResolver {
1383    /// Publish raw CDR bytes through the publisher with this stable entity id.
1384    /// `Err(NodeDeclError::Runtime)` if no such publisher is registered or the
1385    /// transport rejects the write.
1386    fn publish_raw(&self, entity_id: &str, data: &[u8]) -> NodeResult<()>;
1387}
1388
1389/// Where a service / action-result callback body writes its reply (W.5.3): the
1390/// generated trampoline lends a `buf`; the body fills it via
1391/// [`CallbackCtx::reply`] and the trampoline reads `*written` back out.
1392struct ReplySink<'a> {
1393    buf: &'a mut [u8],
1394    written: &'a mut usize,
1395}
1396
1397/// Where an action goal / cancel-decision callback writes its accept/reject
1398/// (W.5.3): the generated trampoline lends the out-slot, the body fills it via
1399/// [`CallbackCtx::set_goal_response`] / [`set_cancel_response`](CallbackCtx::set_cancel_response),
1400/// and the trampoline returns it. Decisions need no executor — unlike feedback /
1401/// result, which do (see the action-execution note in Phase 172 W.5.3).
1402enum DecisionSink<'a> {
1403    Goal(&'a mut GoalResponse),
1404    Cancel(&'a mut CancelResponse),
1405}
1406
1407/// Context handed to an executable component callback body (W.5.1).
1408///
1409/// Carries the triggering payload (raw CDR — empty for timers) plus the
1410/// publisher resolver, so a body can read its message and publish immediately.
1411/// Service / action-result callbacks additionally carry a `ReplySink` the body
1412/// fills via [`reply`](Self::reply); action goal / cancel callbacks carry a
1413/// `DecisionSink` the body fills via
1414/// [`set_goal_response`](Self::set_goal_response) /
1415/// [`set_cancel_response`](Self::set_cancel_response) (W.5.3).
1416pub struct CallbackCtx<'a> {
1417    payload: &'a [u8],
1418    publishers: &'a dyn PublisherResolver,
1419    reply: Option<ReplySink<'a>>,
1420    decision: Option<DecisionSink<'a>>,
1421    /// Phase 250 (Wave 2) — E2E message-integrity status for a subscription that
1422    /// opted in via `.safety()`; `None` for every other callback (timers,
1423    /// services, non-safety subscriptions). Read with
1424    /// [`integrity`](Self::integrity). Gated with the capability so it is
1425    /// zero-cost when `safety-e2e` is off.
1426    #[cfg(feature = "safety-e2e")]
1427    integrity: Option<&'a crate::IntegrityStatus>,
1428    /// Phase 264 W4c — the executor's volatile parameter store, threaded by the dispatch
1429    /// site from the component cell (`None` until `[param_services]` registers the store).
1430    /// Read with [`parameter`](Self::parameter). Gated so it is zero-cost when
1431    /// `param-services` is off.
1432    #[cfg(feature = "param-services")]
1433    params: Option<&'a crate::ParameterServer>,
1434}
1435
1436impl<'a> CallbackCtx<'a> {
1437    /// Build a callback context with no reply sink (timer / subscription).
1438    /// `payload` is the entity's raw CDR (empty slice for timers).
1439    pub fn new(payload: &'a [u8], publishers: &'a dyn PublisherResolver) -> Self {
1440        Self {
1441            payload,
1442            publishers,
1443            reply: None,
1444            decision: None,
1445            #[cfg(feature = "safety-e2e")]
1446            integrity: None,
1447            #[cfg(feature = "param-services")]
1448            params: None,
1449        }
1450    }
1451
1452    /// Phase 250 (Wave 2) — build a subscription context carrying E2E
1453    /// [`IntegrityStatus`](crate::IntegrityStatus) (the declarative `.safety()`
1454    /// path). The body reads both the message ([`message`](Self::message)) and
1455    /// the status ([`integrity`](Self::integrity)) in one callback, mirroring the
1456    /// imperative `FnMut(&M, &IntegrityStatus)` shape.
1457    #[cfg(feature = "safety-e2e")]
1458    pub fn new_with_integrity(
1459        payload: &'a [u8],
1460        publishers: &'a dyn PublisherResolver,
1461        integrity: &'a crate::IntegrityStatus,
1462    ) -> Self {
1463        Self {
1464            payload,
1465            publishers,
1466            reply: None,
1467            decision: None,
1468            integrity: Some(integrity),
1469            #[cfg(feature = "param-services")]
1470            params: None,
1471        }
1472    }
1473
1474    /// Build a callback context with a reply sink (service / action-result;
1475    /// W.5.3). The body fills `reply_buf` via [`reply`](Self::reply); the
1476    /// generated trampoline reads `*reply_written` back as the response length.
1477    pub fn with_reply(
1478        payload: &'a [u8],
1479        publishers: &'a dyn PublisherResolver,
1480        reply_buf: &'a mut [u8],
1481        reply_written: &'a mut usize,
1482    ) -> Self {
1483        *reply_written = 0;
1484        Self {
1485            payload,
1486            publishers,
1487            reply: Some(ReplySink {
1488                buf: reply_buf,
1489                written: reply_written,
1490            }),
1491            decision: None,
1492            #[cfg(feature = "safety-e2e")]
1493            integrity: None,
1494            #[cfg(feature = "param-services")]
1495            params: None,
1496        }
1497    }
1498
1499    /// Build a context for an action **goal** callback (W.5.3): the body decides
1500    /// accept/reject via [`set_goal_response`](Self::set_goal_response); the
1501    /// generated trampoline returns `*out`. `payload` is the goal CDR.
1502    pub fn with_goal_decision(
1503        payload: &'a [u8],
1504        publishers: &'a dyn PublisherResolver,
1505        out: &'a mut GoalResponse,
1506    ) -> Self {
1507        Self {
1508            payload,
1509            publishers,
1510            reply: None,
1511            decision: Some(DecisionSink::Goal(out)),
1512            #[cfg(feature = "safety-e2e")]
1513            integrity: None,
1514            #[cfg(feature = "param-services")]
1515            params: None,
1516        }
1517    }
1518
1519    /// Build a context for an action **cancel** callback (W.5.3): the body decides
1520    /// accept/reject via [`set_cancel_response`](Self::set_cancel_response).
1521    pub fn with_cancel_decision(
1522        payload: &'a [u8],
1523        publishers: &'a dyn PublisherResolver,
1524        out: &'a mut CancelResponse,
1525    ) -> Self {
1526        Self {
1527            payload,
1528            publishers,
1529            reply: None,
1530            decision: Some(DecisionSink::Cancel(out)),
1531            #[cfg(feature = "safety-e2e")]
1532            integrity: None,
1533            #[cfg(feature = "param-services")]
1534            params: None,
1535        }
1536    }
1537
1538    /// Phase 264 W4c — thread the executor's volatile parameter store into this context.
1539    /// The dispatch site calls this after construction (the store reaches the callback via
1540    /// the component cell, not the constructor args). No-op-equivalent when `None`.
1541    #[cfg(feature = "param-services")]
1542    pub fn set_param_server(&mut self, params: Option<&'a crate::ParameterServer>) {
1543        self.params = params;
1544    }
1545
1546    /// Phase 264 W4c — read this node's parameter `name` as `T`, or `None` if the
1547    /// parameter is undeclared, the wrong type, or `[param_services]` is not enabled.
1548    /// Returns the **live** value — the launch-baked initial, or whatever a `ros2 param
1549    /// set` last wrote (RFC-0004 §10; values are volatile, lost at the next boot).
1550    #[cfg(feature = "param-services")]
1551    pub fn parameter<T: crate::ParameterVariant>(&self, name: &str) -> Option<T> {
1552        self.params
1553            .and_then(|server| server.get(name))
1554            .and_then(T::from_parameter_value)
1555    }
1556
1557    /// Set the action goal-callback's accept/reject decision (W.5.3). `Err` when
1558    /// the callback is not a goal decision.
1559    pub fn set_goal_response(&mut self, response: GoalResponse) -> NodeResult<()> {
1560        match &mut self.decision {
1561            Some(DecisionSink::Goal(slot)) => {
1562                **slot = response;
1563                Ok(())
1564            }
1565            _ => Err(NodeDeclError::Runtime),
1566        }
1567    }
1568
1569    /// Set the action cancel-callback's accept/reject decision (W.5.3). `Err` when
1570    /// the callback is not a cancel decision.
1571    pub fn set_cancel_response(&mut self, response: CancelResponse) -> NodeResult<()> {
1572        match &mut self.decision {
1573            Some(DecisionSink::Cancel(slot)) => {
1574                **slot = response;
1575                Ok(())
1576            }
1577            _ => Err(NodeDeclError::Runtime),
1578        }
1579    }
1580
1581    /// Write the service / action reply as raw CDR bytes (W.5.3). `Err` when the
1582    /// callback has no reply sink (timer / subscription) or the reply exceeds the
1583    /// lent buffer.
1584    pub fn reply_raw(&mut self, data: &[u8]) -> NodeResult<()> {
1585        let sink = self.reply.as_mut().ok_or(NodeDeclError::Runtime)?;
1586        if data.len() > sink.buf.len() {
1587            return Err(NodeDeclError::Runtime);
1588        }
1589        sink.buf[..data.len()].copy_from_slice(data);
1590        *sink.written = data.len();
1591        Ok(())
1592    }
1593
1594    /// Serialize `msg` and write it as the service / action reply (W.5.3).
1595    pub fn reply<M: RosMessage, const N: usize>(&mut self, msg: &M) -> NodeResult<()> {
1596        let mut buf = [0u8; N];
1597        let mut writer =
1598            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1599        msg.serialize(&mut writer)
1600            .map_err(|_| NodeDeclError::Runtime)?;
1601        let len = writer.position();
1602        self.reply_raw(&buf[..len])
1603    }
1604
1605    /// Raw CDR payload of the triggering message / request. Empty for timers.
1606    pub fn payload(&self) -> &[u8] {
1607        self.payload
1608    }
1609
1610    /// Phase 250 (Wave 2) — E2E message-integrity status (CRC + sequence gap/dup)
1611    /// for this dispatch. `Some` only when the firing subscription opted in via
1612    /// `.safety()`; `None` for timers, services, and non-safety subscriptions.
1613    /// Read it alongside [`message`](Self::message) — the status describes the
1614    /// message you just received.
1615    #[cfg(feature = "safety-e2e")]
1616    pub fn integrity(&self) -> Option<&crate::IntegrityStatus> {
1617        self.integrity
1618    }
1619
1620    /// Deserialize the triggering payload as `M` (subscription / service-request
1621    /// bodies). `Err` if the payload is malformed for `M`.
1622    pub fn message<M: RosMessage>(&self) -> NodeResult<M> {
1623        let mut reader =
1624            crate::CdrReader::new_with_header(self.payload).map_err(|_| NodeDeclError::Runtime)?;
1625        M::deserialize(&mut reader).map_err(|_| NodeDeclError::Runtime)
1626    }
1627
1628    /// Publish raw CDR bytes through the named publisher entity (immediate).
1629    #[doc(hidden)]
1630    pub fn publish_raw(&self, publisher: EntityId<'_>, data: &[u8]) -> NodeResult<()> {
1631        self.publishers.publish_raw(publisher.as_str(), data)
1632    }
1633
1634    /// Serialize `msg` into an `N`-byte stack buffer and publish it (immediate).
1635    /// `N` must be ≥ the CDR-encoded size of `msg`; the generated runtime picks
1636    /// it from the message type.
1637    #[doc(hidden)]
1638    pub fn publish<M: RosMessage, const N: usize>(
1639        &self,
1640        publisher: EntityId<'_>,
1641        msg: &M,
1642    ) -> NodeResult<()> {
1643        let mut buf = [0u8; N];
1644        let mut writer =
1645            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1646        msg.serialize(&mut writer)
1647            .map_err(|_| NodeDeclError::Runtime)?;
1648        let len = writer.position();
1649        self.publish_raw(publisher, &buf[..len])
1650    }
1651
1652    /// Serialize `msg` and publish through the entity synthesized from `topic`.
1653    ///
1654    /// This pairs with
1655    /// [`DeclaredNode::create_publisher_for_topic`], allowing simple callback
1656    /// bodies to use the ROS topic literal instead of restating an unrelated
1657    /// stable entity ID.
1658    pub fn publish_to_topic<M: RosMessage, const N: usize>(
1659        &self,
1660        topic: &str,
1661        msg: &M,
1662    ) -> NodeResult<()> {
1663        self.publish::<M, N>(EntityId::new(topic), msg)
1664    }
1665}
1666
1667/// The executable counterpart of [`Node`] (W.5.1).
1668///
1669/// `register` (declarative) stays the planning SSOT; this binds runnable
1670/// bodies. The generated runtime builds [`State`](ExecutableNode::State) once via
1671/// [`init`](ExecutableNode::init), then routes every fired callback to
1672/// [`on_callback`](ExecutableNode::on_callback). Trait-dispatch (no boxed `dyn`, no
1673/// `alloc`) keeps it `no_std`.
1674/// Executor-backed action operations a [`TickCtx`] drives (W.5.6).
1675///
1676/// Action result/feedback need `&mut Executor` (`complete_goal_raw` /
1677/// `publish_feedback_raw`), which a mid-spin *callback* can't hold (the executor
1678/// is borrowed) — so they run from [`ExecutableNode::tick`], between spins.
1679/// The generated runtime implements this over the real executor + the action
1680/// servers' handles (resolved by stable action entity id); the component never
1681/// sees the executor directly. Kept as a trait so [`TickCtx`] stays `no_std` +
1682/// free of the `rmw-cffi`-gated `Executor` type.
1683pub trait ActionExecutor {
1684    /// Complete the goal `goal_id` on action `action_entity` with raw CDR result.
1685    fn complete_goal_raw(
1686        &mut self,
1687        action_entity: &str,
1688        goal_id: &GoalId,
1689        status: GoalStatus,
1690        result: &[u8],
1691    ) -> NodeResult<()>;
1692
1693    /// Publish raw CDR feedback for `goal_id` on action `action_entity`.
1694    fn publish_feedback_raw(
1695        &mut self,
1696        action_entity: &str,
1697        goal_id: &GoalId,
1698        feedback: &[u8],
1699    ) -> NodeResult<()>;
1700
1701    /// Visit every goal on `action_entity` that has been accepted but not yet
1702    /// completed, with its id + current status. The execution seam: a `tick` body
1703    /// has no other way to learn an accepted goal's id (the goal-decision callback
1704    /// doesn't surface it), so it iterates here to drive feedback / completion.
1705    fn for_each_active_goal(&self, action_entity: &str, visit: &mut dyn FnMut(&GoalId, GoalStatus));
1706}
1707
1708/// Executor-backed CLIENT operations a [`TickCtx`] drives (Phase 212.M-F.4).
1709///
1710/// Service-client `call` + action-client `send_goal` need `&mut Executor`
1711/// (the W.5.6 client handles live on the executor), which a mid-spin
1712/// callback can't hold. They run from [`ExecutableNode::tick`], between
1713/// spins. The generated runtime impls this over the real executor + the
1714/// service/action client handles (resolved by stable client entity id); the
1715/// component never sees the executor directly. Kept as a trait so [`TickCtx`]
1716/// stays `no_std` + free of the `rmw-cffi`-gated `Executor` type.
1717///
1718/// Mirrors the sibling [`ActionExecutor`] (server-side ops). Splitting
1719/// client vs server keeps each trait small + lets the codegen-side
1720/// `GenClientDispatch` impl resolve client handles independently from
1721/// server handles.
1722pub trait ClientDispatch {
1723    /// Issue a service-client request on `service_entity` carrying CDR
1724    /// `request_cdr`; block on the reply, write the response CDR into
1725    /// `response_buf`, return the response length in bytes.
1726    ///
1727    /// The synchronous block model matches the existing
1728    /// `ServiceClientTrait::call_raw` surface in nros-node — the tick
1729    /// hook drives the executor between callback dispatch, so a blocked
1730    /// `call_raw` does not starve other callbacks (each tick yields back
1731    /// to the runtime after returning).
1732    fn call_raw(
1733        &mut self,
1734        service_entity: &str,
1735        request_cdr: &[u8],
1736        response_buf: &mut [u8],
1737    ) -> NodeResult<usize>;
1738
1739    /// Send an action-client goal request on `action_entity` carrying
1740    /// CDR `goal_cdr`; return the assigned [`GoalId`] (server-stamped on
1741    /// the goal-accept response). Result + feedback streams arrive via
1742    /// callback dispatch — not this method.
1743    fn send_goal_raw(&mut self, action_entity: &str, goal_cdr: &[u8]) -> NodeResult<GoalId>;
1744}
1745
1746/// Context handed to [`ExecutableNode::tick`] (W.5.6 + M-F.4): the per-spin
1747/// hook that runs *between* callback dispatch, where the executor is free.
1748/// Exposes the immediate publish path (like `CallbackCtx`) plus executor-backed
1749/// action-server ops (complete goal / publish feedback) AND executor-backed
1750/// client-side ops (service `call` / action-client `send_goal`). Callbacks
1751/// can't perform any of these since they don't hold the executor.
1752pub struct TickCtx<'a> {
1753    publishers: &'a dyn PublisherResolver,
1754    actions: &'a mut dyn ActionExecutor,
1755    clients: &'a mut dyn ClientDispatch,
1756    /// Phase 264 W4c — the executor's volatile parameter store, threaded by the tick
1757    /// driver (`tick_one_cell` already holds the executor). `None` until
1758    /// `[param_services]` registers the store. Read with [`parameter`](Self::parameter).
1759    #[cfg(feature = "param-services")]
1760    params: Option<&'a crate::ParameterServer>,
1761}
1762
1763impl<'a> TickCtx<'a> {
1764    /// Build a tick context (called by the generated runtime each spin).
1765    pub fn new(
1766        publishers: &'a dyn PublisherResolver,
1767        actions: &'a mut dyn ActionExecutor,
1768        clients: &'a mut dyn ClientDispatch,
1769    ) -> Self {
1770        Self {
1771            publishers,
1772            actions,
1773            clients,
1774            #[cfg(feature = "param-services")]
1775            params: None,
1776        }
1777    }
1778
1779    /// Phase 264 W4c — thread the executor's volatile parameter store in (the tick
1780    /// driver holds the executor directly). No-op-equivalent when `None`.
1781    #[cfg(feature = "param-services")]
1782    pub fn set_param_server(&mut self, params: Option<&'a crate::ParameterServer>) {
1783        self.params = params;
1784    }
1785
1786    /// Phase 264 W4c — read this node's parameter `name` as `T` during `tick`, or `None`
1787    /// if undeclared, the wrong type, or `[param_services]` is off. Returns the live
1788    /// value (baked initial or last `ros2 param set`; volatile — RFC-0004 §10).
1789    #[cfg(feature = "param-services")]
1790    pub fn parameter<T: crate::ParameterVariant>(&self, name: &str) -> Option<T> {
1791        self.params
1792            .and_then(|server| server.get(name))
1793            .and_then(T::from_parameter_value)
1794    }
1795
1796    /// Publish raw CDR bytes through the named publisher entity (immediate).
1797    #[doc(hidden)]
1798    pub fn publish_raw(&self, publisher: EntityId<'_>, data: &[u8]) -> NodeResult<()> {
1799        self.publishers.publish_raw(publisher.as_str(), data)
1800    }
1801
1802    /// Serialize `msg` into an `N`-byte stack buffer and publish it (immediate).
1803    #[doc(hidden)]
1804    pub fn publish<M: RosMessage, const N: usize>(
1805        &self,
1806        publisher: EntityId<'_>,
1807        msg: &M,
1808    ) -> NodeResult<()> {
1809        let mut buf = [0u8; N];
1810        let mut writer =
1811            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1812        msg.serialize(&mut writer)
1813            .map_err(|_| NodeDeclError::Runtime)?;
1814        let len = writer.position();
1815        self.publish_raw(publisher, &buf[..len])
1816    }
1817
1818    /// Serialize `msg` and publish through the entity synthesized from `topic`.
1819    ///
1820    /// This pairs with [`DeclaredNode::create_publisher_for_topic`] for
1821    /// executable tick hooks.
1822    pub fn publish_to_topic<M: RosMessage, const N: usize>(
1823        &self,
1824        topic: &str,
1825        msg: &M,
1826    ) -> NodeResult<()> {
1827        self.publish::<M, N>(EntityId::new(topic), msg)
1828    }
1829
1830    /// Complete an action goal with a typed result (W.5.6 — needs the executor,
1831    /// hence tick-only).
1832    #[doc(hidden)]
1833    pub fn complete_goal<R: RosMessage, const N: usize>(
1834        &mut self,
1835        action: EntityId<'_>,
1836        goal_id: &GoalId,
1837        status: GoalStatus,
1838        result: &R,
1839    ) -> NodeResult<()> {
1840        // CDR-LE encapsulation header included: the executor's `complete_goal_raw`
1841        // frames only the outer envelope ([header][goal_id]) + this payload
1842        // verbatim — it does NOT add an inner header — and the consumer reads the
1843        // result via `CallbackCtx::message` (`CdrReader::new_with_header`), as do
1844        // the C++/ffi clients. Without the header the reader eats the first data
1845        // word (e.g. a sequence length) → empty/garbage payload (issue #35 M-F.23
1846        // follow-up: action result `sequence` deserialized to len 0).
1847        let mut buf = [0u8; N];
1848        let mut writer =
1849            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1850        result
1851            .serialize(&mut writer)
1852            .map_err(|_| NodeDeclError::Runtime)?;
1853        let len = writer.position();
1854        self.actions
1855            .complete_goal_raw(action.as_str(), goal_id, status, &buf[..len])
1856    }
1857
1858    /// Complete an action goal on the action entity synthesized from `name`.
1859    ///
1860    /// This pairs with
1861    /// [`DeclaredNode::create_action_server_for_name`] and
1862    /// [`DeclaredNode::create_action_server_for_name_with_callbacks`].
1863    pub fn complete_goal_for_name<R: RosMessage, const N: usize>(
1864        &mut self,
1865        name: &str,
1866        goal_id: &GoalId,
1867        status: GoalStatus,
1868        result: &R,
1869    ) -> NodeResult<()> {
1870        self.complete_goal::<R, N>(EntityId::new(name), goal_id, status, result)
1871    }
1872
1873    /// Visit each active (accepted, not yet completed) goal on `action` with its
1874    /// id + status — how a `tick` body discovers goals to feed / complete. Collect
1875    /// the ids you want to act on, then call [`Self::publish_feedback`] /
1876    /// [`Self::complete_goal`] after the visit returns (those borrow `self`
1877    /// mutably, so they can't run inside `visit`).
1878    #[doc(hidden)]
1879    pub fn for_each_active_goal(
1880        &self,
1881        action: EntityId<'_>,
1882        visit: &mut dyn FnMut(&GoalId, GoalStatus),
1883    ) {
1884        self.actions.for_each_active_goal(action.as_str(), visit);
1885    }
1886
1887    /// Visit active goals on the action entity synthesized from `name`.
1888    pub fn for_each_active_goal_for_name(
1889        &self,
1890        name: &str,
1891        visit: &mut dyn FnMut(&GoalId, GoalStatus),
1892    ) {
1893        self.for_each_active_goal(EntityId::new(name), visit);
1894    }
1895
1896    /// Publish typed feedback for an active action goal (W.5.6 — tick-only).
1897    #[doc(hidden)]
1898    pub fn publish_feedback<F: RosMessage, const N: usize>(
1899        &mut self,
1900        action: EntityId<'_>,
1901        goal_id: &GoalId,
1902        feedback: &F,
1903    ) -> NodeResult<()> {
1904        // CDR-LE encapsulation header included — see `complete_goal` above. The
1905        // executor frames the outer [header][goal_id] envelope only; the consumer
1906        // reads feedback via `CallbackCtx::message` (`new_with_header`), so the
1907        // payload itself must carry the header (issue #35 M-F.23 follow-up).
1908        let mut buf = [0u8; N];
1909        let mut writer =
1910            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1911        feedback
1912            .serialize(&mut writer)
1913            .map_err(|_| NodeDeclError::Runtime)?;
1914        let len = writer.position();
1915        self.actions
1916            .publish_feedback_raw(action.as_str(), goal_id, &buf[..len])
1917    }
1918
1919    /// Publish feedback on the action entity synthesized from `name`.
1920    pub fn publish_feedback_for_name<F: RosMessage, const N: usize>(
1921        &mut self,
1922        name: &str,
1923        goal_id: &GoalId,
1924        feedback: &F,
1925    ) -> NodeResult<()> {
1926        self.publish_feedback::<F, N>(EntityId::new(name), goal_id, feedback)
1927    }
1928
1929    /// Issue a service-client raw-CDR request and block on the reply
1930    /// (M-F.4 — tick-only). Writes the response CDR into `response_buf`
1931    /// and returns the response length in bytes.
1932    #[doc(hidden)]
1933    pub fn call_raw(
1934        &mut self,
1935        service: EntityId<'_>,
1936        request_cdr: &[u8],
1937        response_buf: &mut [u8],
1938    ) -> NodeResult<usize> {
1939        self.clients
1940            .call_raw(service.as_str(), request_cdr, response_buf)
1941    }
1942
1943    /// Issue a raw service-client request through the entity synthesized
1944    /// from `name`.
1945    pub fn call_raw_for_name(
1946        &mut self,
1947        name: &str,
1948        request_cdr: &[u8],
1949        response_buf: &mut [u8],
1950    ) -> NodeResult<usize> {
1951        self.call_raw(EntityId::new(name), request_cdr, response_buf)
1952    }
1953
1954    /// Issue a typed service-client request and decode the reply
1955    /// (M-F.4 — tick-only). `REQ_N` / `RESP_N` stack-size the request /
1956    /// response CDR buffers; size them via
1957    /// `<<Req as RosMessage>::SerializedSize as nros::SerializedSize>::SIZE`.
1958    #[doc(hidden)]
1959    pub fn call<Req: RosMessage, Resp: RosMessage, const REQ_N: usize, const RESP_N: usize>(
1960        &mut self,
1961        service: EntityId<'_>,
1962        request: &Req,
1963    ) -> NodeResult<Resp> {
1964        let mut req_buf = [0u8; REQ_N];
1965        let mut writer =
1966            crate::CdrWriter::new_with_header(&mut req_buf).map_err(|_| NodeDeclError::Runtime)?;
1967        request
1968            .serialize(&mut writer)
1969            .map_err(|_| NodeDeclError::Runtime)?;
1970        let req_len = writer.position();
1971
1972        let mut resp_buf = [0u8; RESP_N];
1973        let resp_len =
1974            self.clients
1975                .call_raw(service.as_str(), &req_buf[..req_len], &mut resp_buf)?;
1976
1977        let mut reader = crate::CdrReader::new_with_header(&resp_buf[..resp_len])
1978            .map_err(|_| NodeDeclError::Runtime)?;
1979        Resp::deserialize(&mut reader).map_err(|_| NodeDeclError::Runtime)
1980    }
1981
1982    /// Issue a typed service-client request through the entity synthesized
1983    /// from `name`.
1984    pub fn call_for_name<
1985        Req: RosMessage,
1986        Resp: RosMessage,
1987        const REQ_N: usize,
1988        const RESP_N: usize,
1989    >(
1990        &mut self,
1991        name: &str,
1992        request: &Req,
1993    ) -> NodeResult<Resp> {
1994        self.call::<Req, Resp, REQ_N, RESP_N>(EntityId::new(name), request)
1995    }
1996
1997    /// Send a raw-CDR action-client goal and return the assigned
1998    /// [`GoalId`] (M-F.4 — tick-only). Result + feedback streams arrive
1999    /// via callback dispatch; this method only kicks off the request.
2000    #[doc(hidden)]
2001    pub fn send_goal_raw(&mut self, action: EntityId<'_>, goal_cdr: &[u8]) -> NodeResult<GoalId> {
2002        self.clients.send_goal_raw(action.as_str(), goal_cdr)
2003    }
2004
2005    /// Send a raw-CDR action-client goal through the entity synthesized
2006    /// from `name`.
2007    pub fn send_goal_raw_for_name(&mut self, name: &str, goal_cdr: &[u8]) -> NodeResult<GoalId> {
2008        self.send_goal_raw(EntityId::new(name), goal_cdr)
2009    }
2010
2011    /// Send a typed action-client goal and return the assigned
2012    /// [`GoalId`] (M-F.4 — tick-only). `N` stack-sizes the goal CDR
2013    /// buffer.
2014    #[doc(hidden)]
2015    pub fn send_goal<G: RosMessage, const N: usize>(
2016        &mut self,
2017        action: EntityId<'_>,
2018        goal: &G,
2019    ) -> NodeResult<GoalId> {
2020        let mut buf = [0u8; N];
2021        let mut writer =
2022            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
2023        goal.serialize(&mut writer)
2024            .map_err(|_| NodeDeclError::Runtime)?;
2025        let len = writer.position();
2026        self.clients.send_goal_raw(action.as_str(), &buf[..len])
2027    }
2028
2029    /// Send a typed action-client goal through the entity synthesized
2030    /// from `name`.
2031    pub fn send_goal_for_name<G: RosMessage, const N: usize>(
2032        &mut self,
2033        name: &str,
2034        goal: &G,
2035    ) -> NodeResult<GoalId> {
2036        self.send_goal::<G, N>(EntityId::new(name), goal)
2037    }
2038}
2039
2040pub trait ExecutableNode: Node {
2041    /// Per-instance mutable state shared across the component's callbacks.
2042    type State;
2043
2044    /// Build the initial state (called once by the generated runtime).
2045    fn init() -> Self::State;
2046
2047    /// Run the body for `callback`. `ctx` exposes the triggering payload + the
2048    /// immediate publish path. Bodies match on the source callback name declared
2049    /// by `create_*_for_callback_name` and related helpers.
2050    fn on_callback(state: &mut Self::State, callback: Callback<'_>, ctx: &mut CallbackCtx<'_>);
2051
2052    /// Per-spin execution hook (W.5.6), run *between* callback dispatch by the
2053    /// generated runtime — where the executor is free, so this is the only place
2054    /// a component can complete action goals / publish feedback (via `ctx`) or do
2055    /// periodic work. Default: no-op (timer/sub/service-only components).
2056    fn tick(_state: &mut Self::State, _ctx: &mut TickCtx<'_>) {}
2057}
2058
2059/// Emit a no-op [`ExecutableNode`] impl for a declarative-only component
2060/// (W.5.1). The generated runtime calls `on_callback` unconditionally, so a
2061/// component instantiated into a generated binary must impl `ExecutableNode`;
2062/// components without callback bodies use this to satisfy that contract:
2063///
2064/// ```ignore
2065/// pub struct Node;
2066/// impl nros::Node for Node { /* register(...) */ }
2067/// nros::declarative_component!(Node);
2068/// ```
2069#[macro_export]
2070macro_rules! declarative_component {
2071    ($ty:ty) => {
2072        impl $crate::ExecutableNode for $ty {
2073            type State = ();
2074            fn init() -> Self::State {}
2075            fn on_callback(
2076                _state: &mut Self::State,
2077                _callback: $crate::Callback<'_>,
2078                _ctx: &mut $crate::CallbackCtx<'_>,
2079            ) {
2080            }
2081        }
2082    };
2083}
2084
2085/// Run component registration against any component runtime.
2086pub fn register_node<C: Node>(runtime: &mut dyn NodeRuntime) -> NodeResult<()> {
2087    let mut context = NodeContext::new(C::NAME, runtime);
2088    C::register(&mut context)
2089}
2090
2091/// Phase 212.M.5.a.4 internal — `Box`-erase a freshly built component
2092/// `State` to the type-erased `*mut ()` ABI the BSP path uses. Called
2093/// only from the `nros::node!()` macro emit; not public API.
2094///
2095/// The returned pointer is a leaked `Box`; the BSP runtime keeps it
2096/// alive for the firmware lifetime (embedded slots never deallocate).
2097#[cfg(feature = "alloc")]
2098#[doc(hidden)]
2099pub fn __private_node_state_into_raw<C: ExecutableNode>(state: C::State) -> *mut () {
2100    extern crate alloc;
2101    alloc::boxed::Box::into_raw(alloc::boxed::Box::new(state)) as *mut ()
2102}
2103
2104/// Run component registration against an in-memory metadata recorder.
2105pub fn record_node_metadata<C: Node>(recorder: &mut dyn NodeRuntime) -> NodeResult<()> {
2106    register_node::<C>(recorder)
2107}
2108
2109#[cfg(test)]
2110mod tests {
2111    use super::*;
2112    use crate::{CdrReader, CdrWriter, DeserError, SerError, SourceNameKind};
2113
2114    #[derive(Default)]
2115    struct FakeNodeRuntime {
2116        next: u8,
2117        created: Vec<MetadataString, 4>,
2118    }
2119
2120    impl DeclaredNodeRuntime for FakeNodeRuntime {
2121        type NodeHandle = u8;
2122
2123        fn build_component_node(
2124            &mut self,
2125            _id: NodeId<'_>,
2126            options: NodeOptions<'_>,
2127        ) -> NodeResult<Self::NodeHandle> {
2128            self.created
2129                .push(copy_str(options.name)?)
2130                .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
2131            let handle = self.next;
2132            self.next += 1;
2133            Ok(handle)
2134        }
2135    }
2136
2137    #[derive(Debug, Clone, Copy, Default)]
2138    struct TestMsg;
2139
2140    impl crate::Serialize for TestMsg {
2141        fn serialize(&self, _writer: &mut CdrWriter) -> Result<(), SerError> {
2142            Ok(())
2143        }
2144    }
2145
2146    impl crate::Deserialize for TestMsg {
2147        fn deserialize(_reader: &mut CdrReader) -> Result<Self, DeserError> {
2148            Ok(Self)
2149        }
2150    }
2151
2152    impl RosMessage for TestMsg {
2153        const TYPE_NAME: &'static str = "test_msgs::msg::dds_::Test_";
2154        const TYPE_HASH: &'static str = "test_hash";
2155    }
2156
2157    struct TestService;
2158
2159    impl RosService for TestService {
2160        type Request = TestMsg;
2161        type Reply = TestMsg;
2162
2163        const SERVICE_NAME: &'static str = "test_msgs::srv::dds_::Test_";
2164        const SERVICE_HASH: &'static str = "test_service_hash";
2165    }
2166
2167    struct TestAction;
2168
2169    impl RosAction for TestAction {
2170        type Goal = TestMsg;
2171        type Result = TestMsg;
2172        type Feedback = TestMsg;
2173        type SendGoalRequest = TestMsg;
2174        type SendGoalResponse = TestMsg;
2175        type GetResultRequest = TestMsg;
2176        type GetResultResponse = TestMsg;
2177        type FeedbackMessage = TestMsg;
2178
2179        const ACTION_NAME: &'static str = "test_msgs::action::dds_::Test_";
2180        const ACTION_HASH: &'static str = "test_action_hash";
2181    }
2182
2183    struct TalkerComponent;
2184
2185    impl Node for TalkerComponent {
2186        const NAME: &'static str = "talker_component";
2187
2188        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2189            let mut node =
2190                context.create_node_with_id(NodeId::new("node"), NodeOptions::new("talker"))?;
2191            let _publisher =
2192                node.create_publisher::<TestMsg>(EntityId::new("pub_chatter"), "chatter")?;
2193            let _subscription = node.create_subscription::<TestMsg>(
2194                EntityId::new("sub_cmd"),
2195                CallbackId::new("on_cmd"),
2196                "~/cmd",
2197            )?;
2198            let _timer = node.create_timer(
2199                EntityId::new("timer_tick"),
2200                CallbackId::new("on_tick"),
2201                TimerDuration::from_millis(10),
2202            )?;
2203            let _parameter =
2204                node.declare_parameter(EntityId::new("param_gain"), "gain", ParameterType::Double)?;
2205            node.callback(CallbackId::new("on_tick"))
2206                .publishes(EntityId::new("pub_chatter"))?
2207                .writes(EntityId::new("param_gain"))?;
2208            Ok(())
2209        }
2210    }
2211
2212    #[test]
2213    fn component_records_metadata_without_transport() {
2214        let mut recorder = MetadataRecorder::<2, 8, 4>::new();
2215        record_node_metadata::<TalkerComponent>(&mut recorder).unwrap();
2216
2217        assert_eq!(recorder.nodes().len(), 1);
2218        assert_eq!(recorder.nodes()[0].name.as_str(), "talker");
2219        assert_eq!(recorder.entities().len(), 4);
2220        assert_eq!(recorder.entities()[0].kind, EntityKind::Publisher);
2221        assert_eq!(recorder.entities()[1].source_name.as_str(), "~/cmd");
2222        assert_eq!(
2223            recorder.entities()[1]
2224                .callback_id
2225                .as_ref()
2226                .map(|id| id.as_str()),
2227            Some("on_cmd")
2228        );
2229        assert_eq!(recorder.callback_effects().len(), 2);
2230    }
2231
2232    // Phase 250 Wave 2b — the declarative `.safety()` opt-in records the
2233    // `EntityMetadata.safety` flag so the runtime registers the integrity-aware
2234    // subscription. A plain subscription stays `safety == false`.
2235    struct SafetyComponent;
2236    impl Node for SafetyComponent {
2237        const NAME: &'static str = "safety_component";
2238        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2239            let mut node =
2240                context.create_node_with_id(NodeId::new("node"), NodeOptions::new("listener"))?;
2241            let _plain = node.create_subscription_for_callback_name::<TestMsg>("on_plain", "/a")?;
2242            let _safe =
2243                node.create_subscription_for_callback_name_with_safety::<TestMsg>("on_safe", "/b")?;
2244            Ok(())
2245        }
2246    }
2247
2248    #[test]
2249    fn safety_opt_in_records_metadata_flag() {
2250        let mut recorder = MetadataRecorder::<2, 8, 4>::new();
2251        record_node_metadata::<SafetyComponent>(&mut recorder).unwrap();
2252        let ents = recorder.entities();
2253        assert_eq!(ents.len(), 2);
2254        // Plain subscription on /a — no safety.
2255        assert_eq!(ents[0].source_name.as_str(), "/a");
2256        assert!(!ents[0].safety, "plain sub must not be flagged");
2257        // `.safety()` subscription on /b — flagged.
2258        assert_eq!(ents[1].source_name.as_str(), "/b");
2259        assert!(ents[1].safety, "safety sub must be flagged");
2260    }
2261
2262    struct GroupedComponent;
2263
2264    impl Node for GroupedComponent {
2265        const NAME: &'static str = "grouped_component";
2266
2267        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2268            let mut node =
2269                context.create_node_with_id(NodeId::new("node"), NodeOptions::new("grouped"))?;
2270            // Unlabeled entity declared before any group is set.
2271            let _pub = node.create_publisher::<TestMsg>(EntityId::new("pub_plain"), "plain")?;
2272            // Sticky "control" group covers the next two entities.
2273            node.callback_group("control")?;
2274            let _sub = node.create_subscription::<TestMsg>(
2275                EntityId::new("sub_cmd"),
2276                CallbackId::new("on_cmd"),
2277                "~/cmd",
2278            )?;
2279            let _timer = node.create_timer(
2280                EntityId::new("timer_tick"),
2281                CallbackId::new("on_tick"),
2282                TimerDuration::from_millis(10),
2283            )?;
2284            // Switch to "telemetry" for the last entity.
2285            node.callback_group("telemetry")?;
2286            let _sub2 = node.create_subscription::<TestMsg>(
2287                EntityId::new("sub_diag"),
2288                CallbackId::new("on_diag"),
2289                "~/diag",
2290            )?;
2291            Ok(())
2292        }
2293    }
2294
2295    #[test]
2296    fn sticky_callback_group_stamps_subsequent_entities() {
2297        let mut recorder = MetadataRecorder::<2, 8, 4>::new();
2298        record_node_metadata::<GroupedComponent>(&mut recorder).unwrap();
2299
2300        let group_of = |idx: usize| {
2301            recorder.entities()[idx]
2302                .callback_group
2303                .as_ref()
2304                .map(|g| g.as_str())
2305        };
2306        // pub_plain — declared before any group → unlabeled.
2307        assert_eq!(group_of(0), None);
2308        // sub_cmd + timer_tick — under "control".
2309        assert_eq!(group_of(1), Some("control"));
2310        assert_eq!(group_of(2), Some("control"));
2311        // sub_diag — under "telemetry".
2312        assert_eq!(group_of(3), Some("telemetry"));
2313    }
2314
2315    #[test]
2316    fn runtime_adapter_maps_stable_nodes_to_runtime_handles() {
2317        let mut node_runtime = FakeNodeRuntime::default();
2318        let mut runtime = NodeRuntimeAdapter::<_, 2, 8, 4>::new(&mut node_runtime);
2319
2320        register_node::<TalkerComponent>(&mut runtime).unwrap();
2321
2322        assert_eq!(runtime.nodes().len(), 1);
2323        assert_eq!(runtime.nodes()[0].slot(), NodeSlot::new(0));
2324        assert_eq!(runtime.nodes()[0].stable_id(), "node");
2325        assert_eq!(runtime.nodes()[0].source_default_name(), "talker");
2326        assert_eq!(runtime.node_handle(NodeId::new("node")), Some(0));
2327        assert_eq!(runtime.entities().len(), 4);
2328        assert_eq!(runtime.entities()[0].slot, Some(EntitySlot::new(0)));
2329        assert_eq!(runtime.entities()[0].node_slot, Some(NodeSlot::new(0)));
2330        assert_eq!(
2331            runtime.entities()[1].callback_slot,
2332            Some(CallbackSlot::new(0))
2333        );
2334        assert_eq!(
2335            runtime.entities()[2].callback_slot,
2336            Some(CallbackSlot::new(1))
2337        );
2338        assert_eq!(runtime.callback_effects().len(), 2);
2339        assert_eq!(
2340            runtime.callback_effects()[0].callback_slot,
2341            Some(CallbackSlot::new(1))
2342        );
2343        assert_eq!(
2344            runtime.callback_effects()[0].entity_slot,
2345            Some(EntitySlot::new(0))
2346        );
2347    }
2348
2349    #[test]
2350    fn context_can_synthesize_stable_node_id_from_options_name() {
2351        let mut recorder = MetadataRecorder::<1, 0, 0>::new();
2352        let mut context = NodeContext::new("test", &mut recorder);
2353        let node = context
2354            .create_node(NodeOptions::new("talker").namespace("/demo").domain_id(42))
2355            .unwrap();
2356
2357        assert_eq!(node.id(), NodeId::new("talker"));
2358        // End the `node`/`context` borrows before re-borrowing `recorder`.
2359        let _ = node;
2360        let _ = context;
2361        assert_eq!(recorder.nodes().len(), 1);
2362        assert_eq!(recorder.nodes()[0].id.as_str(), "talker");
2363        assert_eq!(recorder.nodes()[0].name.as_str(), "talker");
2364        assert_eq!(recorder.nodes()[0].namespace.as_str(), "/demo");
2365        assert_eq!(recorder.nodes()[0].domain_id, 42);
2366    }
2367
2368    #[test]
2369    fn synthesized_node_ids_reject_duplicate_names() {
2370        let mut node_runtime = FakeNodeRuntime::default();
2371        let mut runtime = NodeRuntimeAdapter::<_, 2, 0, 0>::new(&mut node_runtime);
2372        {
2373            let mut context = NodeContext::new("test", &mut runtime);
2374            context.create_node(NodeOptions::new("talker")).unwrap();
2375        }
2376        let mut context = NodeContext::new("test", &mut runtime);
2377        let result = context.create_node(NodeOptions::new("talker"));
2378
2379        assert!(matches!(
2380            result,
2381            Err(NodeDeclError::Metadata(NodeMetadataError::DuplicateId))
2382        ));
2383    }
2384
2385    #[test]
2386    fn synthesized_entity_helpers_record_topic_and_callback_ids() {
2387        let mut recorder = MetadataRecorder::<1, 3, 2>::new();
2388        let mut context = NodeContext::new("test", &mut recorder);
2389        let mut node = context.create_node(NodeOptions::new("talker")).unwrap();
2390
2391        let publisher = node
2392            .create_publisher_for_topic::<TestMsg>("/chatter")
2393            .unwrap();
2394        let subscription = node
2395            .create_subscription_for_callback::<TestMsg>(CallbackId::new("on_message"), "/cmd")
2396            .unwrap();
2397        let _timer = node
2398            .create_timer_for_callback(CallbackId::new("on_tick"), TimerDuration::from_millis(10))
2399            .unwrap();
2400
2401        node.callback(CallbackId::new("on_tick"))
2402            .publishes_entity(&publisher)
2403            .unwrap();
2404        node.callback(CallbackId::new("on_message"))
2405            .reads_entity(&subscription)
2406            .unwrap();
2407
2408        assert_eq!(publisher.id(), EntityId::new("/chatter"));
2409        assert_eq!(subscription.id(), EntityId::new("on_message"));
2410        assert_eq!(recorder.entities().len(), 3);
2411
2412        let publisher = &recorder.entities()[0];
2413        assert_eq!(publisher.id.as_str(), "/chatter");
2414        assert_eq!(publisher.kind, EntityKind::Publisher);
2415        assert_eq!(publisher.source_name.as_str(), "/chatter");
2416
2417        let subscription = &recorder.entities()[1];
2418        assert_eq!(subscription.id.as_str(), "on_message");
2419        assert_eq!(subscription.kind, EntityKind::Subscription);
2420        assert_eq!(subscription.source_name.as_str(), "/cmd");
2421        assert_eq!(
2422            subscription.callback_id.as_ref().map(|id| id.as_str()),
2423            Some("on_message")
2424        );
2425
2426        let timer = &recorder.entities()[2];
2427        assert_eq!(timer.id.as_str(), "on_tick");
2428        assert_eq!(timer.kind, EntityKind::Timer);
2429        assert_eq!(
2430            timer.callback_id.as_ref().map(|id| id.as_str()),
2431            Some("on_tick")
2432        );
2433
2434        assert_eq!(recorder.callback_effects().len(), 2);
2435        assert_eq!(
2436            recorder.callback_effects()[0].entity_id.as_str(),
2437            "/chatter"
2438        );
2439        assert_eq!(
2440            recorder.callback_effects()[1].entity_id.as_str(),
2441            "on_message"
2442        );
2443    }
2444
2445    #[test]
2446    fn named_callback_helpers_avoid_manual_callback_ids() {
2447        let mut recorder = MetadataRecorder::<1, 3, 2>::new();
2448        let mut context = NodeContext::new("test", &mut recorder);
2449        let mut node = context.create_node(NodeOptions::new("listener")).unwrap();
2450
2451        let publisher = node
2452            .create_publisher_for_topic::<TestMsg>("/chatter")
2453            .unwrap();
2454        let subscription = node
2455            .create_subscription_for_callback_name::<TestMsg>("on_message", "/chatter")
2456            .unwrap();
2457        let timer = node
2458            .create_timer_for_callback_name("on_tick", TimerDuration::from_millis(10))
2459            .unwrap();
2460
2461        node.callback_for_name("on_message")
2462            .reads_entity(&subscription)
2463            .unwrap();
2464        node.callback_for_name("on_tick")
2465            .publishes_entity(&publisher)
2466            .unwrap();
2467
2468        assert_eq!(subscription.id().as_str(), "on_message");
2469        assert_eq!(timer.id().as_str(), "on_tick");
2470        assert_eq!(
2471            recorder.entities()[1]
2472                .callback_id
2473                .as_ref()
2474                .map(|id| id.as_str()),
2475            Some("on_message")
2476        );
2477        assert_eq!(
2478            recorder.entities()[2]
2479                .callback_id
2480                .as_ref()
2481                .map(|id| id.as_str()),
2482            Some("on_tick")
2483        );
2484        assert_eq!(
2485            recorder.callback_effects()[0].callback_id.as_str(),
2486            "on_message"
2487        );
2488        assert_eq!(
2489            recorder.callback_effects()[0].entity_id.as_str(),
2490            "on_message"
2491        );
2492        assert_eq!(
2493            recorder.callback_effects()[1].callback_id.as_str(),
2494            "on_tick"
2495        );
2496        assert_eq!(
2497            recorder.callback_effects()[1].entity_id.as_str(),
2498            "/chatter"
2499        );
2500    }
2501
2502    #[test]
2503    fn synthesized_entity_ids_reject_collisions() {
2504        let mut recorder = MetadataRecorder::<1, 2, 0>::new();
2505        let mut context = NodeContext::new("test", &mut recorder);
2506        let mut node = context.create_node(NodeOptions::new("talker")).unwrap();
2507
2508        node.create_publisher_for_topic::<TestMsg>("/chatter")
2509            .unwrap();
2510        let result = node.create_publisher_for_topic::<TestMsg>("/chatter");
2511
2512        assert!(matches!(
2513            result,
2514            Err(NodeDeclError::Metadata(NodeMetadataError::DuplicateId))
2515        ));
2516    }
2517
2518    /// Verifies the runtime adapter rejects duplicate nodes and unknown effect entities.
2519    #[test]
2520    fn runtime_adapter_rejects_unknown_entities() {
2521        let mut node_runtime = FakeNodeRuntime::default();
2522        let mut runtime = NodeRuntimeAdapter::<_, 1, 1, 1>::new(&mut node_runtime);
2523        runtime
2524            .create_node(NodeId::new("node"), NodeOptions::new("talker"))
2525            .unwrap();
2526
2527        assert_eq!(
2528            runtime.create_node(NodeId::new("node"), NodeOptions::new("other")),
2529            Err(NodeDeclError::Metadata(NodeMetadataError::DuplicateId))
2530        );
2531        assert_eq!(
2532            runtime.record_callback_effect(
2533                CallbackId::new("cb"),
2534                CallbackEffectKind::Reads,
2535                EntityId::new("missing")
2536            ),
2537            Err(NodeDeclError::Metadata(NodeMetadataError::UnknownEntity))
2538        );
2539    }
2540
2541    #[test]
2542    fn component_rejects_effect_for_unknown_entity() {
2543        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
2544        let mut context = NodeContext::new("test", &mut recorder);
2545        let result = context
2546            .callback(CallbackId::new("cb"))
2547            .reads(EntityId::new("missing"));
2548        assert!(matches!(
2549            result,
2550            Err(NodeDeclError::Metadata(NodeMetadataError::UnknownEntity))
2551        ));
2552    }
2553
2554    #[test]
2555    fn component_missing_export_error_message_is_clear() {
2556        assert_eq!(
2557            NodeDeclError::MissingExport.message(),
2558            MISSING_NODE_EXPORT_ERROR
2559        );
2560        assert_eq!(
2561            NodeDeclError::MissingExport.message(),
2562            "package has no exported nros component"
2563        );
2564    }
2565
2566    struct RobotComponent;
2567
2568    impl Node for RobotComponent {
2569        const NAME: &'static str = "robot_component";
2570
2571        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2572            {
2573                let mut sensors = context.create_node_with_id(
2574                    NodeId::new("node_sensors"),
2575                    NodeOptions::new("sensors"),
2576                )?;
2577                let _status =
2578                    sensors.create_publisher::<TestMsg>(EntityId::new("pub_status"), "~/status")?;
2579            }
2580
2581            let mut control = context
2582                .create_node_with_id(NodeId::new("node_control"), NodeOptions::new("control"))?;
2583            let _cmd = control.create_subscription::<TestMsg>(
2584                EntityId::new("sub_cmd"),
2585                CallbackId::new("cb_cmd"),
2586                "~/cmd",
2587            )?;
2588            let _reset = control.create_service_server::<TestService>(
2589                EntityId::new("srv_reset"),
2590                CallbackId::new("cb_reset"),
2591                "reset",
2592            )?;
2593            let _navigate = control.create_action_server_with_callbacks::<TestAction>(
2594                EntityId::new("act_navigate"),
2595                CallbackId::new("cb_nav_goal"),
2596                CallbackId::new("cb_nav_cancel"),
2597                CallbackId::new("cb_nav_accepted"),
2598                "~/navigate",
2599            )?;
2600            let _gain = control.declare_parameter_with_default(
2601                EntityId::new("param_gain"),
2602                "gain",
2603                ParameterDefault::Double(copy_str("1.5")?),
2604            )?;
2605
2606            control
2607                .callback(CallbackId::new("cb_cmd"))
2608                .publishes(EntityId::new("pub_status"))?
2609                .reads(EntityId::new("param_gain"))?;
2610            control
2611                .callback(CallbackId::new("cb_nav_accepted"))
2612                .writes(EntityId::new("param_gain"))?;
2613
2614            Ok(())
2615        }
2616    }
2617
2618    /// Verifies the component API records multi-node services, actions, and defaults.
2619    #[test]
2620    fn component_api_records_multi_node_services() {
2621        let mut recorder = MetadataRecorder::<4, 12, 4>::new();
2622        record_node_metadata::<RobotComponent>(&mut recorder).unwrap();
2623
2624        assert_eq!(recorder.nodes().len(), 2);
2625        assert_eq!(recorder.nodes()[0].id.as_str(), "node_sensors");
2626        assert_eq!(recorder.nodes()[1].id.as_str(), "node_control");
2627
2628        let status = recorder
2629            .entities()
2630            .iter()
2631            .find(|entity| entity.id.as_str() == "pub_status")
2632            .unwrap();
2633        assert_eq!(status.kind, EntityKind::Publisher);
2634        assert_eq!(status.source_name.as_str(), "~/status");
2635        assert_eq!(status.source_name_kind, SourceNameKind::Private);
2636
2637        let reset = recorder
2638            .entities()
2639            .iter()
2640            .find(|entity| entity.id.as_str() == "srv_reset")
2641            .unwrap();
2642        assert_eq!(reset.kind, EntityKind::ServiceServer);
2643        assert_eq!(
2644            reset.callback_id.as_ref().map(|id| id.as_str()),
2645            Some("cb_reset")
2646        );
2647
2648        let navigate = recorder
2649            .entities()
2650            .iter()
2651            .find(|entity| entity.id.as_str() == "act_navigate")
2652            .unwrap();
2653        assert_eq!(navigate.kind, EntityKind::ActionServer);
2654        assert_eq!(
2655            navigate.callback_id.as_ref().map(|id| id.as_str()),
2656            Some("cb_nav_goal")
2657        );
2658        assert_eq!(
2659            navigate
2660                .action_cancel_callback_id
2661                .as_ref()
2662                .map(|id| id.as_str()),
2663            Some("cb_nav_cancel")
2664        );
2665        assert_eq!(
2666            navigate
2667                .action_accepted_callback_id
2668                .as_ref()
2669                .map(|id| id.as_str()),
2670            Some("cb_nav_accepted")
2671        );
2672
2673        let gain = recorder
2674            .entities()
2675            .iter()
2676            .find(|entity| entity.id.as_str() == "param_gain")
2677            .unwrap();
2678        assert_eq!(gain.kind, EntityKind::Parameter);
2679        assert!(matches!(
2680            gain.parameter_default.as_ref(),
2681            Some(ParameterDefault::Double(value)) if value.as_str() == "1.5"
2682        ));
2683
2684        assert_eq!(recorder.callback_effects().len(), 3);
2685        assert!(recorder.callback_effects().iter().any(|effect| {
2686            effect.callback_id.as_str() == "cb_cmd"
2687                && effect.kind == CallbackEffectKind::Publishes
2688                && effect.entity_id.as_str() == "pub_status"
2689        }));
2690        assert!(recorder.callback_effects().iter().any(|effect| {
2691            effect.callback_id.as_str() == "cb_nav_accepted"
2692                && effect.kind == CallbackEffectKind::Writes
2693                && effect.entity_id.as_str() == "param_gain"
2694        }));
2695    }
2696
2697    #[cfg(feature = "std")]
2698    #[test]
2699    fn component_api_json_contains_planner_callback_links() {
2700        let mut recorder = MetadataRecorder::<4, 12, 4>::new();
2701        record_node_metadata::<RobotComponent>(&mut recorder).unwrap();
2702
2703        let json = recorder
2704            .to_source_metadata_json(&crate::SourceMetadataExport::new(
2705                "demo_robot",
2706                RobotComponent::NAME,
2707            ))
2708            .unwrap();
2709
2710        assert!(json.contains("\"callbacks\":["));
2711        assert!(json.contains("\"id\":\"cb_cmd\",\"declaration_slot\":0"));
2712        assert!(json.contains("\"kind\":\"subscription\""));
2713        assert!(json.contains("\"id\":\"cb_reset\",\"declaration_slot\":1"));
2714        assert!(json.contains("\"kind\":\"service\""));
2715        assert!(json.contains("\"id\":\"cb_nav_goal\",\"declaration_slot\":2"));
2716        assert!(json.contains("\"kind\":\"action_goal\""));
2717        assert!(json.contains("\"id\":\"cb_nav_cancel\",\"declaration_slot\":3"));
2718        assert!(json.contains("\"kind\":\"action_cancel\""));
2719        assert!(json.contains("\"id\":\"cb_nav_accepted\",\"declaration_slot\":4"));
2720        assert!(json.contains("\"kind\":\"action_accepted\""));
2721        assert!(json.contains("\"kind\":\"publishes\",\"entity\":\"pub_status\""));
2722        assert!(json.contains("\"kind\":\"reads_parameter\",\"entity\":\"param_gain\""));
2723        assert!(json.contains("\"kind\":\"writes_parameter\",\"entity\":\"param_gain\""));
2724        assert!(json.contains("\"goal_callback\":\"cb_nav_goal\""));
2725        assert!(json.contains("\"cancel_callback\":\"cb_nav_cancel\""));
2726        assert!(json.contains("\"accepted_callback\":\"cb_nav_accepted\""));
2727    }
2728
2729    // W.5.1 — an executable component callback runs its body: mutates state +
2730    // publishes immediately through the resolver (the substrate the generator
2731    // will wire). `TalkerComponent` already impls `Node` (declarative);
2732    // here it also impls `ExecutableNode`.
2733    impl ExecutableNode for TalkerComponent {
2734        type State = u32;
2735
2736        fn init() -> u32 {
2737            0
2738        }
2739
2740        fn on_callback(state: &mut u32, callback: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
2741            if callback.as_str() == "on_tick" {
2742                *state += 1;
2743                // Publish through the declared publisher entity.
2744                let _ = ctx.publish::<TestMsg, 64>(EntityId::new("pub_chatter"), &TestMsg);
2745            }
2746        }
2747    }
2748
2749    #[test]
2750    fn executable_component_callback_publishes_and_mutates_state() {
2751        use core::cell::RefCell;
2752
2753        struct RecordingResolver {
2754            last: RefCell<Option<(MetadataString, usize)>>,
2755        }
2756        impl PublisherResolver for RecordingResolver {
2757            fn publish_raw(&self, entity_id: &str, data: &[u8]) -> NodeResult<()> {
2758                *self.last.borrow_mut() = Some((copy_str(entity_id)?, data.len()));
2759                Ok(())
2760            }
2761        }
2762
2763        let resolver = RecordingResolver {
2764            last: RefCell::new(None),
2765        };
2766        let mut state = TalkerComponent::init();
2767        let mut ctx = CallbackCtx::new(&[], &resolver);
2768
2769        // An unrelated callback id does nothing.
2770        TalkerComponent::on_callback(
2771            &mut state,
2772            Callback::__from_id(CallbackId::new("other")),
2773            &mut ctx,
2774        );
2775        assert_eq!(state, 0);
2776        assert!(resolver.last.borrow().is_none());
2777
2778        // The bound callback bumps state + publishes through "pub_chatter".
2779        TalkerComponent::on_callback(
2780            &mut state,
2781            Callback::__from_id(CallbackId::new("on_tick")),
2782            &mut ctx,
2783        );
2784        assert_eq!(state, 1);
2785        let last = resolver.last.borrow();
2786        let (entity, len) = last.as_ref().expect("a publish was recorded");
2787        assert_eq!(entity.as_str(), "pub_chatter");
2788        // Empty TestMsg ⇒ just the 4-byte CDR header.
2789        assert_eq!(*len, 4);
2790    }
2791
2792    // W.5.3 — a service-style body writes its reply through the CallbackCtx
2793    // reply sink; the trampoline reads `*written` back. A timer/sub ctx (no
2794    // sink) rejects a reply.
2795    #[test]
2796    fn callback_ctx_reply_sink_roundtrips() {
2797        struct NoopResolver;
2798        impl PublisherResolver for NoopResolver {
2799            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
2800                Ok(())
2801            }
2802        }
2803        let resolver = NoopResolver;
2804        let mut reply_buf = [0u8; 64];
2805        let mut written = 0usize;
2806        {
2807            let mut ctx = CallbackCtx::with_reply(&[], &resolver, &mut reply_buf, &mut written);
2808            ctx.reply::<TestMsg, 64>(&TestMsg).unwrap();
2809        }
2810        // Empty TestMsg ⇒ just the 4-byte CDR header.
2811        assert_eq!(written, 4);
2812
2813        // A reply-less ctx (timer / subscription) rejects a reply.
2814        let mut ctx2 = CallbackCtx::new(&[], &resolver);
2815        assert!(ctx2.reply_raw(&[1, 2, 3]).is_err());
2816    }
2817
2818    // Phase 264 W4c — a callback reads the live parameter value through
2819    // `CallbackCtx::parameter::<T>` once the dispatch site threads the store in.
2820    #[cfg(feature = "param-services")]
2821    #[test]
2822    fn callback_ctx_reads_param() {
2823        struct NoopResolver;
2824        impl PublisherResolver for NoopResolver {
2825            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
2826                Ok(())
2827            }
2828        }
2829        let resolver = NoopResolver;
2830
2831        // No store threaded ⇒ every read is None (param-services off / not registered).
2832        let ctx_none = CallbackCtx::new(&[], &resolver);
2833        assert_eq!(ctx_none.parameter::<i64>("speed"), None);
2834
2835        // Seed a store with the typed value a `ros2 param set speed 7` would land on.
2836        let mut server = crate::ParameterServer::new();
2837        assert!(server.declare("speed", crate::ParameterValue::Integer(7)));
2838
2839        let mut ctx = CallbackCtx::new(&[], &resolver);
2840        ctx.set_param_server(Some(&server));
2841        assert_eq!(ctx.parameter::<i64>("speed"), Some(7));
2842        // Wrong type ⇒ None, not a panic.
2843        assert_eq!(ctx.parameter::<bool>("speed"), None);
2844        // Undeclared ⇒ None.
2845        assert_eq!(ctx.parameter::<i64>("missing"), None);
2846    }
2847
2848    // Phase 250 Wave 2 — the declarative `.safety()` surface: a normal ctx has
2849    // no integrity status; one built with `new_with_integrity` exposes it, read
2850    // alongside the message in the same callback (Shape A).
2851    #[cfg(feature = "safety-e2e")]
2852    #[test]
2853    fn callback_ctx_integrity_surface() {
2854        struct NoopResolver;
2855        impl PublisherResolver for NoopResolver {
2856            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
2857                Ok(())
2858            }
2859        }
2860        let resolver = NoopResolver;
2861
2862        // Non-safety dispatch (timer / plain sub) → None.
2863        let ctx = CallbackCtx::new(&[], &resolver);
2864        assert!(ctx.integrity().is_none());
2865
2866        // Safety dispatch → the status rides alongside the payload.
2867        let status = crate::IntegrityStatus {
2868            gap: 2,
2869            duplicate: false,
2870            crc_valid: Some(true),
2871        };
2872        let ctx = CallbackCtx::new_with_integrity(&[], &resolver, &status);
2873        let got = ctx.integrity().expect("safety ctx carries status");
2874        assert_eq!(got.gap, 2);
2875        assert!(!got.duplicate);
2876        assert_eq!(got.crc_valid, Some(true));
2877    }
2878
2879    // W.5.3 — an action goal / cancel body sets its accept/reject decision
2880    // through the CallbackCtx decision sink; the trampoline returns `*out`. A
2881    // wrong-kind setter (or a sink-less ctx) errors.
2882    #[test]
2883    fn callback_ctx_decision_sink() {
2884        struct NoopResolver;
2885        impl PublisherResolver for NoopResolver {
2886            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
2887                Ok(())
2888            }
2889        }
2890        let resolver = NoopResolver;
2891
2892        let mut gr = GoalResponse::Reject;
2893        {
2894            let mut ctx = CallbackCtx::with_goal_decision(&[], &resolver, &mut gr);
2895            ctx.set_goal_response(GoalResponse::AcceptAndExecute)
2896                .unwrap();
2897            // Wrong-kind setter on a goal ctx errors.
2898            assert!(ctx.set_cancel_response(CancelResponse::Ok).is_err());
2899        }
2900        assert!(matches!(gr, GoalResponse::AcceptAndExecute));
2901
2902        let mut cr = CancelResponse::Rejected;
2903        {
2904            let mut ctx = CallbackCtx::with_cancel_decision(&[], &resolver, &mut cr);
2905            ctx.set_cancel_response(CancelResponse::Ok).unwrap();
2906        }
2907        assert!(matches!(cr, CancelResponse::Ok));
2908
2909        // A timer/sub ctx (no decision sink) rejects both.
2910        let mut ctx3 = CallbackCtx::new(&[], &resolver);
2911        assert!(ctx3.set_goal_response(GoalResponse::Reject).is_err());
2912        assert!(ctx3.set_cancel_response(CancelResponse::Ok).is_err());
2913    }
2914
2915    // W.5.6 — the tick hook publishes (immediate) + drives executor-backed action
2916    // ops (complete goal / publish feedback) through the ActionExecutor seam.
2917    #[test]
2918    fn tick_ctx_publish_and_action_ops() {
2919        use core::cell::Cell;
2920        struct RecPub {
2921            published: Cell<bool>,
2922        }
2923        impl PublisherResolver for RecPub {
2924            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
2925                self.published.set(true);
2926                Ok(())
2927            }
2928        }
2929        struct RecAct {
2930            completed: bool,
2931            fed: bool,
2932            visited: usize,
2933        }
2934        impl ActionExecutor for RecAct {
2935            fn complete_goal_raw(
2936                &mut self,
2937                _action_entity: &str,
2938                _goal_id: &GoalId,
2939                _status: GoalStatus,
2940                _result: &[u8],
2941            ) -> NodeResult<()> {
2942                self.completed = true;
2943                Ok(())
2944            }
2945            fn publish_feedback_raw(
2946                &mut self,
2947                _action_entity: &str,
2948                _goal_id: &GoalId,
2949                _feedback: &[u8],
2950            ) -> NodeResult<()> {
2951                self.fed = true;
2952                Ok(())
2953            }
2954            fn for_each_active_goal(
2955                &self,
2956                _action_entity: &str,
2957                visit: &mut dyn FnMut(&GoalId, GoalStatus),
2958            ) {
2959                // One pretend-active goal, so the tick body has something to drive.
2960                visit(&GoalId::zero(), GoalStatus::Executing);
2961            }
2962        }
2963
2964        struct RecClients;
2965        impl ClientDispatch for RecClients {
2966            fn call_raw(
2967                &mut self,
2968                _service: &str,
2969                _req: &[u8],
2970                _resp: &mut [u8],
2971            ) -> NodeResult<usize> {
2972                Err(NodeDeclError::Runtime)
2973            }
2974            fn send_goal_raw(&mut self, _action: &str, _goal: &[u8]) -> NodeResult<GoalId> {
2975                Err(NodeDeclError::Runtime)
2976            }
2977        }
2978
2979        let pubs = RecPub {
2980            published: Cell::new(false),
2981        };
2982        let mut acts = RecAct {
2983            completed: false,
2984            fed: false,
2985            visited: 0,
2986        };
2987        let mut clients = RecClients;
2988        let goal = GoalId::zero();
2989        let mut seen = 0usize;
2990        {
2991            let mut ctx = TickCtx::new(&pubs, &mut acts, &mut clients);
2992            ctx.publish::<TestMsg, 64>(EntityId::new("pub_x"), &TestMsg)
2993                .unwrap();
2994            // Discover the active goal the way a real tick body does, then act on it.
2995            ctx.for_each_active_goal(EntityId::new("act"), &mut |_id, _status| seen += 1);
2996            ctx.publish_feedback::<TestMsg, 64>(EntityId::new("act"), &goal, &TestMsg)
2997                .unwrap();
2998            ctx.complete_goal::<TestMsg, 64>(
2999                EntityId::new("act"),
3000                &goal,
3001                GoalStatus::Succeeded,
3002                &TestMsg,
3003            )
3004            .unwrap();
3005        }
3006        acts.visited = seen;
3007        assert!(pubs.published.get());
3008        assert!(acts.completed);
3009        assert!(acts.fed);
3010        assert_eq!(acts.visited, 1);
3011    }
3012
3013    /// Phase 216.A.3 — `Node::DISPATCH` defaults to
3014    /// `DispatchStrategy::Inline` so every pre-216 `impl Node`
3015    /// keeps compiling unchanged.
3016    #[test]
3017    fn node_dispatch_default_is_inline() {
3018        struct Dummy;
3019        impl Node for Dummy {
3020            const NAME: &'static str = "dummy";
3021            fn register(_: &mut NodeContext<'_>) -> NodeResult<()> {
3022                Ok(())
3023            }
3024        }
3025        assert_eq!(Dummy::DISPATCH, crate::DispatchStrategy::Inline);
3026    }
3027
3028    // Phase 216.A.5 — `nros::node!()` emits the
3029    // `__nros_node_<pkg>_dispatch_strategy()` ABI export. We invoke the
3030    // macro on a dummy Node + ExecutableNode pair in a private sub-module
3031    // here so the macro expansion lives inside the `nros` crate itself;
3032    // the emitted `#[unsafe(no_mangle)] extern "C"` symbol is global, so
3033    // the test below re-declares + calls it. If the macro stopped
3034    // emitting the symbol (or renamed it) this would link-fail.
3035    //
3036    // `<pkg>` resolves to `CARGO_PKG_NAME` after
3037    // `sanitize_pkg_name_for_symbol`. The `nros` crate's pkg name is
3038    // literal `nros`, so the expected symbol is
3039    // `__nros_node_nros_dispatch_strategy`.
3040    // Phase 216 final wave — the macro emit now references
3041    // `::nros::Executor` (rmw-cffi-gated) in addition to the existing
3042    // alloc-gated `__private_node_state_into_raw`. Gate the test on
3043    // both features so the macro invocation only attempts to expand
3044    // when every referenced symbol is present.
3045    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3046    mod dispatch_probe_macro_test {
3047        // `extern crate self as nros;` at the crate root (in `lib.rs`,
3048        // `cfg(test)`-gated) lets the `::nros::*` paths the macro emits
3049        // resolve in-crate.
3050        use super::*;
3051
3052        pub struct DispatchProbe;
3053
3054        impl Node for DispatchProbe {
3055            const NAME: &'static str = "dispatch_probe";
3056            // Default `DISPATCH = Inline` ⇒ discriminant 0.
3057            fn register(_: &mut NodeContext<'_>) -> NodeResult<()> {
3058                Ok(())
3059            }
3060        }
3061
3062        impl ExecutableNode for DispatchProbe {
3063            type State = ();
3064            fn init() -> Self::State {}
3065            fn on_callback(
3066                _state: &mut Self::State,
3067                _callback: Callback<'_>,
3068                _ctx: &mut CallbackCtx<'_>,
3069            ) {
3070            }
3071        }
3072
3073        // Emits both the per-pkg `register` wrapper AND the new
3074        // `__nros_node_nros_dispatch_strategy` ABI symbol.
3075        nros_macros::node!(DispatchProbe);
3076    }
3077
3078    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3079    #[test]
3080    fn node_macro_emits_dispatch_strategy_symbol() {
3081        // Re-declare the ABI export the macro just emitted. If the macro
3082        // elides the symbol (or renames it) this fails to link — exactly
3083        // the regression the test is meant to catch.
3084        unsafe extern "C" {
3085            fn __nros_node_nros_dispatch_strategy() -> u8;
3086        }
3087        let strategy = unsafe { __nros_node_nros_dispatch_strategy() };
3088        // The probe Node uses the default `DISPATCH = Inline`
3089        // (discriminant 0) — confirms the macro is splicing
3090        // `<Type as Node>::DISPATCH as u8`, not a hard-coded zero.
3091        assert_eq!(strategy, crate::DispatchStrategy::Inline as u8);
3092        assert_eq!(strategy, 0);
3093    }
3094
3095    // The `nros::node!()` macro also emits
3096    // `__nros_node_<pkg>_on_callback`, the extern "C" trampoline the
3097    // RTIC / Embassy dispatch tasks call after dequeuing a
3098    // `SignaledCallback<'static>` (see `nros-platform::SignaledCallback`).
3099    // The expansion lives in the same `dispatch_probe_macro_test`
3100    // sub-module as the dispatch-strategy probe, so a single
3101    // `nros_macros::node!(DispatchProbe);` invocation covers both
3102    // symbols. Symbol name resolves to
3103    // `__nros_node_nros_on_callback` (CARGO_PKG_NAME = "nros").
3104    //
3105    // The test only confirms the symbol is linkable — actually
3106    // invoking the trampoline would need a live State + CallbackCtx
3107    // pointer pair, which is the dispatch-task author's contract
3108    // (documented in the macro emit). A link-only probe is enough to
3109    // catch the macro silently eliding the export — the exact
3110    // regression class this test is for.
3111    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3112    #[test]
3113    fn node_macro_emits_on_callback_symbol() {
3114        unsafe extern "C" {
3115            fn __nros_node_nros_on_callback(
3116                state: *mut core::ffi::c_void,
3117                cb_id_ptr: *const u8,
3118                cb_id_len: usize,
3119                ctx: *mut core::ffi::c_void,
3120            );
3121        }
3122        // Take the address of the symbol and feed it through
3123        // `core::hint::black_box` — forces the linker to resolve the
3124        // symbol and prevents the optimiser from folding the unused
3125        // reference away. If the macro stopped emitting the export
3126        // this line fails at link time, which is the exact regression
3127        // class this test catches. (`fn`-pointer values are never
3128        // null per Rust's type system, so a direct null check would
3129        // be a tautology — `-D useless-ptr-null-checks` would reject
3130        // it.)
3131        let fn_ptr: unsafe extern "C" fn(
3132            *mut core::ffi::c_void,
3133            *const u8,
3134            usize,
3135            *mut core::ffi::c_void,
3136        ) = __nros_node_nros_on_callback;
3137        core::hint::black_box(fn_ptr);
3138    }
3139
3140    #[test]
3141    fn create_subscription_static_returns_tag_matching_topic() {
3142        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
3143        let mut context = NodeContext::new("test", &mut recorder);
3144        let mut node = context.create_node(NodeOptions::new("listener")).unwrap();
3145        let tag = node
3146            .create_subscription_static::<TestMsg>("/chatter")
3147            .unwrap();
3148
3149        assert_eq!(tag.as_str(), "/chatter");
3150        assert!(tag == CallbackId::new("/chatter"));
3151        assert_eq!(recorder.entities().len(), 1);
3152        let entity = &recorder.entities()[0];
3153        assert_eq!(entity.kind, EntityKind::Subscription);
3154        assert_eq!(entity.source_name.as_str(), "/chatter");
3155        assert_eq!(
3156            entity.callback_id.as_ref().map(|id| id.as_str()),
3157            Some("/chatter")
3158        );
3159    }
3160
3161    #[test]
3162    fn create_service_static_returns_tag() {
3163        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
3164        let mut context = NodeContext::new("test", &mut recorder);
3165        let mut node = context.create_node(NodeOptions::new("server")).unwrap();
3166        let tag = node
3167            .create_service_static::<TestService>("/add_two_ints")
3168            .unwrap();
3169
3170        assert_eq!(tag.as_str(), "/add_two_ints");
3171        assert!(tag == CallbackId::new("/add_two_ints"));
3172        assert_eq!(recorder.entities().len(), 1);
3173        let entity = &recorder.entities()[0];
3174        assert_eq!(entity.kind, EntityKind::ServiceServer);
3175        assert_eq!(entity.source_name.as_str(), "/add_two_ints");
3176        assert_eq!(
3177            entity.callback_id.as_ref().map(|id| id.as_str()),
3178            Some("/add_two_ints")
3179        );
3180    }
3181
3182    #[test]
3183    fn create_service_helpers_use_name_as_entity_and_callback_id() {
3184        let mut recorder = MetadataRecorder::<1, 2, 1>::new();
3185        let mut context = NodeContext::new("test", &mut recorder);
3186        let mut node = context.create_node(NodeOptions::new("services")).unwrap();
3187        let server = node
3188            .create_service_server_for_name::<TestService>("/add_two_ints")
3189            .unwrap();
3190        let client = node
3191            .create_service_client_for_name::<TestService>("/reset")
3192            .unwrap();
3193
3194        assert_eq!(server.id(), EntityId::new("/add_two_ints"));
3195        assert_eq!(client.id(), EntityId::new("/reset"));
3196        assert_eq!(recorder.entities().len(), 2);
3197
3198        let server = &recorder.entities()[0];
3199        assert_eq!(server.kind, EntityKind::ServiceServer);
3200        assert_eq!(server.id.as_str(), "/add_two_ints");
3201        assert_eq!(server.source_name.as_str(), "/add_two_ints");
3202        assert_eq!(
3203            server.callback_id.as_ref().map(|id| id.as_str()),
3204            Some("/add_two_ints")
3205        );
3206
3207        let client = &recorder.entities()[1];
3208        assert_eq!(client.kind, EntityKind::ServiceClient);
3209        assert_eq!(client.id.as_str(), "/reset");
3210        assert_eq!(client.source_name.as_str(), "/reset");
3211        assert!(client.callback_id.is_none());
3212    }
3213
3214    #[test]
3215    fn create_action_static_returns_tag() {
3216        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
3217        let mut context = NodeContext::new("test", &mut recorder);
3218        let mut node = context.create_node(NodeOptions::new("server")).unwrap();
3219        let tag = node
3220            .create_action_static::<TestAction>("/fibonacci")
3221            .unwrap();
3222
3223        assert_eq!(tag.as_str(), "/fibonacci");
3224        assert!(tag == CallbackId::new("/fibonacci"));
3225        assert_eq!(recorder.entities().len(), 1);
3226        let entity = &recorder.entities()[0];
3227        assert_eq!(entity.kind, EntityKind::ActionServer);
3228        assert_eq!(entity.source_name.as_str(), "/fibonacci");
3229        assert_eq!(
3230            entity.callback_id.as_ref().map(|id| id.as_str()),
3231            Some("/fibonacci")
3232        );
3233        assert_eq!(
3234            entity
3235                .action_cancel_callback_id
3236                .as_ref()
3237                .map(|id| id.as_str()),
3238            Some("/fibonacci")
3239        );
3240        assert_eq!(
3241            entity
3242                .action_accepted_callback_id
3243                .as_ref()
3244                .map(|id| id.as_str()),
3245            Some("/fibonacci")
3246        );
3247    }
3248
3249    #[test]
3250    fn create_action_helpers_use_name_as_entity_and_default_callback_id() {
3251        let mut recorder = MetadataRecorder::<1, 2, 3>::new();
3252        let mut context = NodeContext::new("test", &mut recorder);
3253        let mut node = context.create_node(NodeOptions::new("actions")).unwrap();
3254        let server = node
3255            .create_action_server_for_name::<TestAction>("/fibonacci")
3256            .unwrap();
3257        let client = node
3258            .create_action_client_for_name::<TestAction>("/navigate")
3259            .unwrap();
3260
3261        assert_eq!(server.id(), EntityId::new("/fibonacci"));
3262        assert_eq!(client.id(), EntityId::new("/navigate"));
3263        assert_eq!(recorder.entities().len(), 2);
3264
3265        let server = &recorder.entities()[0];
3266        assert_eq!(server.kind, EntityKind::ActionServer);
3267        assert_eq!(server.id.as_str(), "/fibonacci");
3268        assert_eq!(server.source_name.as_str(), "/fibonacci");
3269        assert_eq!(
3270            server.callback_id.as_ref().map(|id| id.as_str()),
3271            Some("/fibonacci")
3272        );
3273        assert_eq!(
3274            server
3275                .action_cancel_callback_id
3276                .as_ref()
3277                .map(|id| id.as_str()),
3278            Some("/fibonacci")
3279        );
3280        assert_eq!(
3281            server
3282                .action_accepted_callback_id
3283                .as_ref()
3284                .map(|id| id.as_str()),
3285            Some("/fibonacci")
3286        );
3287
3288        let client = &recorder.entities()[1];
3289        assert_eq!(client.kind, EntityKind::ActionClient);
3290        assert_eq!(client.id.as_str(), "/navigate");
3291        assert_eq!(client.source_name.as_str(), "/navigate");
3292        assert!(client.callback_id.is_none());
3293    }
3294
3295    // Phase 268 W1 — unit test: launch node_identity injection overrides NodeOptions
3296    // default (RFC-0046). Uses a `CapturingRuntime` that applies the same
3297    // `match self.node_identity` logic as `ExecutorSink::create_node` and records the
3298    // resolved (name, namespace). No executor needed — tests the design contract.
3299    // Uses `MetadataString` (heapless::String re-export) to stay no_std-compatible.
3300    #[test]
3301    fn node_identity_injected_wins_over_node_options() {
3302        /// Minimal NodeRuntime that applies the Phase 268 W1 identity override
3303        /// (same logic as `ExecutorSink::create_node`) and records the resolved values.
3304        struct CapturingRuntime {
3305            node_identity: Option<(&'static str, &'static str)>,
3306            resolved_name: MetadataString,
3307            resolved_ns: MetadataString,
3308        }
3309        impl NodeRuntime for CapturingRuntime {
3310            fn create_node(&mut self, _id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
3311                // Mirror ExecutorSink::create_node override logic (RFC-0046).
3312                let (name, ns) = match self.node_identity {
3313                    Some((n, s)) => (n, s),
3314                    None => (options.name, options.namespace),
3315                };
3316                self.resolved_name.clear();
3317                let _ = self.resolved_name.push_str(name);
3318                self.resolved_ns.clear();
3319                let _ = self.resolved_ns.push_str(ns);
3320                Ok(())
3321            }
3322            fn create_entity(&mut self, _m: EntityMetadata) -> NodeResult<()> {
3323                Ok(())
3324            }
3325            fn record_callback_effect(
3326                &mut self,
3327                _id: CallbackId<'_>,
3328                _kind: crate::node_metadata::CallbackEffectKind,
3329                _entity: EntityId<'_>,
3330            ) -> NodeResult<()> {
3331                Ok(())
3332            }
3333        }
3334
3335        // (a) Injected identity wins over NodeOptions default.
3336        let mut rt_a = CapturingRuntime {
3337            node_identity: Some(("launched", "/ns")),
3338            resolved_name: MetadataString::new(),
3339            resolved_ns: MetadataString::new(),
3340        };
3341        {
3342            let mut ctx = NodeContext::new("test_node", &mut rt_a);
3343            ctx.create_node(NodeOptions::new("default").namespace("/d"))
3344                .unwrap();
3345        }
3346        assert_eq!(rt_a.resolved_name.as_str(), "launched");
3347        assert_eq!(rt_a.resolved_ns.as_str(), "/ns");
3348
3349        // (b) None → NodeOptions default stands (backward-compatible).
3350        let mut rt_b = CapturingRuntime {
3351            node_identity: None,
3352            resolved_name: MetadataString::new(),
3353            resolved_ns: MetadataString::new(),
3354        };
3355        {
3356            let mut ctx = NodeContext::new("test_node", &mut rt_b);
3357            ctx.create_node(NodeOptions::new("default").namespace("/d"))
3358                .unwrap();
3359        }
3360        assert_eq!(rt_b.resolved_name.as_str(), "default");
3361        assert_eq!(rt_b.resolved_ns.as_str(), "/d");
3362    }
3363}