Skip to main content
Publish a message. Subscribe to it. ~60 ns later, it’s there β€” zero copies, zero config. Cerulion’s pub/sub system connects nodes through topics β€” named channels that carry typed messages. You declare outputs and inputs with attributes, wire them in a .crln graph file, and Cerulion handles transport, discovery, and routing automatically. Think of topics like labeled conveyor belts in a factory β€” a node places a part on a belt, and every downstream station watching that belt gets it instantly, without anyone stopping to make a photocopy.
Cerulion lion mascot connecting a camera publisher node to a display subscriber node with a glowing blue message arrow in a robotics lab
Outputs are attributes. Inputs are parameters. The graph file is the topology.

How It Works

Every pub/sub connection has three parts:
  1. A publisher declares output ports with #[out("name")]
  2. A subscriber receives data as function parameters
  3. A graph file (.crln) wires outputs to inputs by name
camera_pub publishes two topics β€” detector and logger each subscribe to what they need

Publishing (Outputs)

Use #[out("name")] on return values to declare output ports. Return a tuple of your outputs.

Single Output

use cerulion_core_v2::prelude::*;

#[node(period_ms = 100)]
pub fn temperature_sensor() -> (#[out("reading")] Temperature) {
    let mut msg = Temperature::new();
    msg.set_value(read_sensor());

    // This message goes to shared memory β€” subscribers read it in place
    (msg)
}

Multiple Outputs

use cerulion_core_v2::prelude::*;

// A single node can publish to multiple topics
#[node(period_ms = 50)]
pub fn imu_sensor() -> (
    #[out("accel")] Vector3,
    #[out("gyro")] Vector3,
) {
    let (accel, gyro) = read_imu();

    // Each output becomes a separate topic that subscribers can wire to independently
    (accel, gyro)
}

Subscribing (Inputs)

Inputs are function parameters. The parameter name must match the input port name in your graph wiring.

Single Input

use cerulion_core_v2::prelude::*;

#[node(trigger = "reading")]
pub fn temperature_logger(reading: &Temperature) {
    // reading is a reference into shared memory β€” no deserialization happened
    println!("Temperature: {}Β°C", reading.value());
}

Multiple Inputs

use cerulion_core_v2::prelude::*;

#[node(trigger = "image")]
pub fn image_processor(
    image: &Image,
    config: &ProcessorConfig,
) -> (#[out("processed")] Image) {
    // Triggers on "image" β€” but also reads latest "config" each time
    let processed = apply_filter(image, config.filter_type());
    (processed)
}
Parameter names must match graph wiring. If your graph connects source_output_name: frame to name: image, your function parameter must be called image β€” that’s the input port name. Mismatches cause silent wiring failures.

Trigger Types

Pick the trigger that matches your data flow pattern:
TriggerAttributeFires when…
Default#[node]Any input receives new data
Specific Input#[node(trigger = "input_name")]A named input receives new data
Periodic#[node(period_ms = N)]Every N milliseconds (clock-driven)
Synchronized#[node(sync = {"a": 50, "b": 50})]Multiple inputs arrive within time windows
External#[node(external_trigger = "fn_name")]A custom function returns true

Periodic β€” clock-driven publishers

Runs on a fixed interval. Ideal for sensors, data sources, and anything that produces data at a known rate.
use cerulion_core_v2::prelude::*;

/// Publishes camera frames at 30 FPS
#[node(period_ms = 33)]
pub fn camera() -> (#[out("frame")] Image) {
    let frame = capture_frame();
    (frame)
}
Set period_ms to match your sensor’s native rate. For a 30 Hz camera, that’s 33 ms. For a 100 Hz IMU, that’s 10 ms.
Runs when a named input receives new data. The most common pattern for processing pipelines.
use cerulion_core_v2::prelude::*;

/// Processes each frame as it arrives β€” no polling, no timers
#[node(trigger = "frame")]
pub fn detector(frame: &Image) -> (#[out("detections")] DetectionList) {
    let detections = run_detection(frame);
    (detections)
}
Runs whenever any input has new data. Useful for fusion nodes that should react to whichever sensor updates first.
use cerulion_core_v2::prelude::*;

/// Runs whenever either lidar or camera publishes new data
#[node]
pub fn fusion(
    lidar: &PointCloud,
    camera: &Image,
) -> (#[out("fused")] FusedData) {
    let fused = combine_sensors(lidar, camera);
    (fused)
}
With the default trigger, the node reads the latest value from all inputs β€” even ones that didn’t trigger the current run.
Waits for multiple inputs to arrive within specified time windows (in milliseconds). Essential for stereo vision, multi-sensor fusion, and any pipeline where data must be temporally aligned.
use cerulion_core_v2::prelude::*;

/// Only fires when left and right frames arrive within 50ms of each other
#[node(sync = {"left": 50, "right": 50})]
pub fn stereo_matcher(
    left: &Image,
    right: &Image,
) -> (#[out("depth")] DepthMap) {
    let depth = compute_stereo(left, right);
    (depth)
}
The sync window value is a tolerance in milliseconds. Set it based on your sensors’ clock drift β€” tighter windows mean stricter temporal alignment but more dropped frames if sensors aren’t well-synchronized.
Runs when a custom function returns true. Use this for button presses, external signals, hardware interrupts, or any trigger logic that doesn’t come from a Cerulion topic.
use cerulion_core_v2::prelude::*;

fn should_capture() -> bool {
    // Your logic: button press, GPIO pin, external signal, etc.
    check_trigger_signal()
}

#[node(external_trigger = "should_capture")]
pub fn triggered_capture() -> (#[out("image")] Image) {
    let image = capture_high_res();
    (image)
}

Graph Wiring

Connect node outputs to inputs in your .crln file. This is where your pipeline takes shape.
name: perception_pipeline

nodes:
  - id: camera
    type: camera

  - id: detector
    type: detector
    inputs:
      - name: frame
        source_node: camera
        source_output_name: frame

  - id: tracker
    type: tracker
    inputs:
      - name: detections
        source_node: detector
        source_output_name: detections
Three names must align:
  • source_output_name β†’ matches the publisher’s #[out("...")] attribute
  • source_node β†’ matches the id of the publishing node in this file
  • name β†’ matches the subscriber’s function parameter name

Transport

Cerulion automatically selects the fastest transport based on where nodes are running β€” you never configure this.
ScenarioTransportLatency
Same machineiceoryx2 (shared memory)< 1 ΞΌs
Different machinesZenoh (network)1–10 ms
When both nodes are on the same machine, messages stay in shared memory β€” the subscriber reads directly from where the publisher wrote. When nodes are on different machines, Cerulion automatically serializes and routes over the network. Same code, same API, different backend.
Core ROS2 message types β€” Image, Twist, PointCloud2, LaserScan, and more β€” work with both transports. See Wireformat Messages for the full list of included types.

Ready to wire nodes together? Build your first pipeline end-to-end in the Quickstart, or explore Trigger Types in depth.