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:
| Trigger | Attribute | Behavior |
|---|
| 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)
}
Inputs are function parameters. The parameter name must match the input port name in your graph.
use cerulion_core_v2::prelude::*;
#[node(trigger = "reading")]
pub fn temperature_logger(reading: &Temperature) {
println!("Temperature: {}Β°C", reading.value());
}
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)
}
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)
}
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:
| Scenario | Transport | Latency |
|---|
| Same machine | iceoryx2 (shared memory) | < 1 ΞΌs |
| Different machines | Zenoh (network) | 1-10 ms |
You donβt need to configure this β itβs automatic based on where nodes are running.
Next Steps