Skip to main content
Cerulion handles all pub/sub infrastructure automatically. You just write node logic β€” the framework manages transport, discovery, and message routing.

How It Works

Nodes communicate through topics β€” named channels that carry typed messages. You define:
  • Outputs using #[out("name")] on return values
  • Inputs as function parameters
  • Triggers that control when your node runs
The graph wiring (in .crln files) connects outputs to inputs. Cerulion handles the rest.

Trigger Types

Triggers control when your node executes:
TriggerAttributeBehavior
Default#[node]Fires when any input has new data
Specific Input#[node(trigger = "input_name")]Fires when named input has new data
Period#[node(period_ms = N)]Fires every N milliseconds
Sync#[node(sync = {"a": 50, "b": 50})]Fires when inputs arrive within time windows
External#[node(external_trigger = "fn_name")]Fires when trigger function returns true

Publishing (Outputs)

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

Single Output

use cerulion_core_v2::prelude::*;

#[node(period_ms = 100)]
pub fn temperature_sensor() -> (#[out("reading")] Temperature) {
    let temp = read_sensor();

    let mut msg = Temperature::new();
    msg.set_value(temp);

    (msg)
}

Multiple Outputs

use cerulion_core_v2::prelude::*;

#[node(period_ms = 50)]
pub fn imu_sensor() -> (
    #[out("accel")] Vector3,
    #[out("gyro")] Vector3,
) {
    let (accel, gyro) = read_imu();

    (accel, gyro)
}

Subscribing (Inputs)

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

Single Input

use cerulion_core_v2::prelude::*;

#[node(trigger = "reading")]
pub fn temperature_logger(reading: &Temperature) {
    println!("Temperature: {}Β°C", reading.value());
}

Multiple Inputs

use cerulion_core_v2::prelude::*;

#[node(trigger = "image")]
pub fn image_processor(
    image: &Image,
    config: &ProcessorConfig,
) {
    // Process image using config settings
    let result = apply_filter(image, config.filter_type());
}

Trigger Patterns

Periodic Publisher

Runs on a fixed interval. Ideal for sensors and data sources.
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)
}

Event-Triggered Subscriber

Runs when a specific input receives data.
use cerulion_core_v2::prelude::*;

/// Processes each frame as it arrives
#[node(trigger = "frame")]
pub fn detector(frame: &Image) -> (#[out("detections")] DetectionList) {
    let detections = run_detection(frame);
    (detections)
}

Default Trigger (Any Input)

Runs when any input has new data.
use cerulion_core_v2::prelude::*;

/// Runs whenever either sensor updates
#[node]
pub fn fusion(
    lidar: &PointCloud,
    camera: &Image,
) -> (#[out("fused")] FusedData) {
    let fused = combine_sensors(lidar, camera);
    (fused)
}

Synchronized Inputs

Waits for inputs to arrive within specified time windows (milliseconds).
use cerulion_core_v2::prelude::*;

/// Waits for both inputs 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)
}

External Trigger

Runs when a custom trigger function returns true.
use cerulion_core_v2::prelude::*;

fn should_capture() -> bool {
    // Custom logic: button press, 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:
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
The name field must match your function parameter name. The source_output_name must match the #[out("...")] attribute.

Message Types

Cerulion provides ROS2-compatible message primitives:
use cerulion_core_v2::prelude::*;

// Images
let img = Image::new();
img.set_width(640);
img.set_height(480);
img.set_encoding("rgb8");

// Geometry
let pose = Pose::new();
let twist = Twist::new();
let vector = Vector3::new();

// Sensors
let scan = LaserScan::new();
let imu = Imu::new();
let pointcloud = PointCloud2::new();
See Wireformat Messages for the complete list.

Transport

Cerulion automatically selects the optimal transport:
ScenarioTransportLatency
Same machineiceoryx2 (shared memory)< 1 ΞΌs
Different machinesZenoh (network)1-10 ms
You don’t need to configure this β€” it’s automatic based on where nodes are running.

Next Steps