Skip to main content
A node is a Rust struct annotated with #[cerulion_node(...)] paired with an adjacent impl block annotated with #[cerulion_node_impl]. Together they turn your struct into a node that cerulion node build compiles and cerulion graph run loads β€” you never call the generated code yourself. Import everything from the prelude:
use cerulion_core::prelude::*;
The prelude re-exports cerulion_node, cerulion_node_impl, NodeError, NodeResult, and the message/transport types.

The two macros

The macros are used as a pair:
  • #[cerulion_node(...)] on the struct declares the node type, its ports (via field attributes), and its trigger policy.
  • #[cerulion_node_impl] on an adjacent impl <Name> { fn tick(&mut self) -> Result<(), NodeError> { ... } } lets tick() read inputs and write outputs through ordinary field access (self.<port>.…) β€” reads and writes go directly to shared memory, zero-copy.
#[cerulion_node_impl] takes no arguments. The struct must appear before the impl block.

Complete example

use cerulion_core::prelude::*;
use native_ros2_messages::geometry_msgs::Vector3;
use native_ros2_messages::sensor_msgs::LaserScan;

#[cerulion_node(period_ms = 10)]      // period-driven; reads latest scan each tick
#[derive(Default)]
struct SafetyController {
    #[input(lifo, depth = 1)]         // regular input (use `trigger` for data-triggered)
    scan: LaserScan,

    #[output]                         // Vector3 = fixed-only schema (pub x/y/z: f64)
    linear_velocity: Vector3,
}

#[cerulion_node_impl]
impl SafetyController {
    fn tick(&mut self) -> Result<(), NodeError> {
        // Variable field read via typed accessor:
        let obstacle_close = self.scan.ranges().iter().any(|&r| r < 0.5);
        // Fixed fields are plain field writes β€” they go straight to shared memory:
        self.linear_velocity.x = if obstacle_close { 0.0 } else { 0.3 };
        Ok(())
    }
}
// Build with `cerulion node build safety_controller`, then wire it into a
// graph with `cerulion node stage safety_controller`.

What the macros do for you

The macros generate the glue that lets the Cerulion runtime build, load, and tick your node β€” lifecycle wiring, port plumbing, and the dynamic-library entry points. You never interact with the generated code: write the struct and the tick(), then drive everything through the CLI (cerulion node build, cerulion node stage, cerulion graph run).

Node-level attributes

#[cerulion_node(...)] attributes are all optional. Exactly one trigger-policy hint applies (or the trigger is inferred from a field marked #[input(trigger)]). tick_within_ms is orthogonal and stacks with any trigger policy. throttle_ms stacks with any trigger policy except period_ms (mutually exclusive with it).
AttributeSemantics
period_ms = NFire every N ms. N must be > 0.
sync_window_ms = NBounded sync: fire when all #[input(trigger)] ports have an unconsumed message that arrived within an N-ms window. Requires β‰₯2 trigger inputs. N must be > 0.
unbounded_syncUnbounded sync: fire when every #[input(trigger)] port has an unconsumed message, with no time bound. Requires β‰₯2 trigger inputs. Best for batch and offline fusion; for control loops, use sync_window_ms to keep a bounded latency guarantee.
externalExternally triggered; the host calls Scheduler::trigger_external(node_id).
tick_within_ms = NQoS, not a trigger. Per-node tick-execution deadline; tick callbacks taking longer than N ms increment tick_within_missed_count and emit tracing::warn!. Stacks with any trigger policy. N must be > 0.
throttle_ms = NRate cap, not a trigger. Defers the node’s tick while now βˆ’ last_fire < N ms, capping the fire rate regardless of the trigger policy. Mutually exclusive with period_ms. N must be > 0.

Mutual exclusion

unbounded_sync is mutually exclusive with sync_window_ms, period_ms, and external. unbounded_sync and sync_window_ms each require β‰₯2 trigger inputs. throttle_ms is mutually exclusive with period_ms. tick_within_ms stacks with any trigger policy; throttle_ms stacks with any trigger policy except period_ms.

Rejected attributes

These attributes are rejected at parse time:
AttributeReason
type_nameNot an attribute β€” the node type is the folder name.
inputs(...)Ports are declared as field attributes, not in the macro arguments.
outputs(...)Ports are declared as field attributes, not in the macro arguments.
deadline_ms = NUse #[input(trigger, expect_within_ms = N)] for per-input arrival watchdogs.

#[input(...)] attribute

Forms: #[input], #[input(trigger)], #[input(trigger, lifo, depth = 1)], #[input(fifo, depth = 100, backpressure = drop_oldest, max_age_ms = 200)], #[input(filter = "fn_name")], #[input(expect_within_ms = N)].
KeyMeaning
triggerThis input fires the node (data trigger / sync member).
fifo / lifoQueue discipline (mutually exclusive; specifying both on one input is an error).
depth = NIn-flight receive queue depth. N must be β‰₯ 1.
backpressure = drop_oldest | block | sample(ms)Overflow policy. drop_oldest (default) evicts the oldest queued message. block defers the upstream producer’s tick. sample(ms) decimates reads by wire timestamp.
max_age_ms = NDrop messages older than N ms.
filter = "fn"Named filter function.
expect_within_ms = NQoS (subscriber-side): if N ms elapse with no new message on this input, increment expect_within_missed_count and warn. Not a trigger. N must be > 0.

#[output(...)] attribute

Forms: #[output] (fixed-only schema), #[output(data, encoding)] (lists simple variable fields β†’ two write paths), #[output(data, complex(header))] (complex(...) lists nested-typed variable fields β†’ set_<f>_bytes(...)?), #[output(promise_within_ms = N)].
KeyMeaning
bare ident(s)Simple variable-length fields (string, T[]). Two write paths: self.<port>.<f> = expr copies your buffer into shared memory (one copy), or self.<port>.<f>.fill_from(producer)? hands your producer the destination buffer directly (zero copy).
complex(name, ...)Nested-type variable fields; write them with set_<name>_bytes(&bytes)?. At most one complex(...) per #[output].
promise_within_ms = NQoS (publisher-side): if N ms elapse with no publish on this output, increment promise_within_missed_count and warn. N must be > 0. At most one per #[output].
Fixed primitive fields are written directly (for example self.image.height = ...) β€” no attribute entry needed.

fill_from: zero-copy producer writes

For producers that write into a destination buffer β€” camera drivers, codecs, file readers, network sockets β€” fill_from passes the shared-memory destination buffer (&mut [T]) directly to the producer; the producer just fills it.
// Closure form: producer receives &mut [u8] pointing directly into shared memory.
self.image.data.fill_from(|buf: &mut [u8]| {
    let n = driver.read_frame_into(buf)?;
    Ok(n)  // bytes written; only the first n bytes are published
})?;

// Slice form: copy from an existing &[u8] without an intermediate Vec.
self.image.data.fill_from(SliceSource::new(&raw_bytes))?;
The producer type must implement FillFrom<T> (re-exported from cerulion_core::prelude). T defaults to u8; typed-array fields use the element type directly (for example &mut [f32] for a float32[] field). If the producer returns Err, nothing is published for that tick.