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).
| Attribute | Semantics |
|---|
period_ms = N | Fire every N ms. N must be > 0. |
sync_window_ms = N | Bounded 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_sync | Unbounded 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. |
external | Externally triggered; the host calls Scheduler::trigger_external(node_id). |
tick_within_ms = N | QoS, 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 = N | Rate 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:
| Attribute | Reason |
|---|
type_name | Not 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 = N | Use #[input(trigger, expect_within_ms = N)] for per-input arrival watchdogs. |
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)].
| Key | Meaning |
|---|
trigger | This input fires the node (data trigger / sync member). |
fifo / lifo | Queue discipline (mutually exclusive; specifying both on one input is an error). |
depth = N | In-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 = N | Drop messages older than N ms. |
filter = "fn" | Named filter function. |
expect_within_ms = N | QoS (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)].
| Key | Meaning |
|---|
| 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 = N | QoS (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.