Topics: How Nodes Talk

For the full reference with capacity tuning, performance optimization, and multi-process details, see Topics — Full Reference.

What is a Topic?

A topic is a named channel that carries one type of data between nodes. Think of it like a mailbox with a specific name:

  • "temperature" carries f32 temperature readings
  • "camera.image" carries image frames
  • "motor.command" carries velocity commands

Any node can publish (send) to a topic. Any node can subscribe (receive) from it. The topic handles all the memory management automatically.

Basic Usage

Publishing Data

use horus::prelude::*;

struct Thermometer {
    publisher: Topic<f32>,
}

impl Node for Thermometer {
    fn tick(&mut self) {
        let temp = read_sensor();    // Your sensor code
        self.publisher.send(temp);   // Send to topic
    }
}

Receiving Data

struct Display {
    subscriber: Topic<f32>,
}

impl Node for Display {
    fn tick(&mut self) {
        if let Some(temp) = self.subscriber.recv() {
            println!("Temperature: {:.1}", temp);
        }
    }
}

Both nodes create a Topic with the same name — HORUS connects them automatically.

In Python:

import horus

def sensor_tick(node):
    node.send("temperature", 25.0)

def display_tick(node):
    temp = node.recv("temperature")  # Returns value or None
    if temp is not None:
        print(f"Temperature: {temp:.1f}")

sensor = horus.Node(name="Sensor", tick=sensor_tick, pubs=["temperature"])
display = horus.Node(name="Display", tick=display_tick, subs=["temperature"])
horus.run(sensor, display)

How It Works

Loading diagram...
Topics use shared memory — multiple subscribers can read the same data

Key facts:

  • Latency: ~3ns (same thread) to ~167ns (cross-process)
  • Zero-copy: Large data (images, point clouds) is shared, not copied
  • Automatic: HORUS picks the fastest path based on where your nodes run
  • One-to-many: Multiple subscribers can receive from the same topic

Creating Topics

Topics are created in your node's init() or constructor:

impl SensorNode {
    fn new() -> Result<Self> {
        Ok(Self {
            // Same constructor for publishing AND subscribing
            topic: Topic::new("sensor.data")?,
        })
    }
}

Topic names are simple strings. Convention: use dots for namespacing ("robot.sensors.imu"). Do not use slashes — they cause errors on macOS.

What Happens When There's No Subscriber?

send() always succeeds — even if nobody is listening. Data goes into shared memory and waits. When a subscriber connects later, it receives the latest value.

recv() returns None if no data has been published yet.

fn tick(&mut self) {
    // Always safe — never blocks, never panics
    self.publisher.send(42.0);

    // Returns None if no data yet
    match self.subscriber.recv() {
        Some(value) => println!("Got: {}", value),
        None => {} // Nothing published yet, that's fine
    }
}

Key Takeaways

  • A topic = named channel for one data type
  • send() to publish, recv() to subscribe
  • Same topic name = automatic connection
  • Latency is ~87ns on average — fast enough for any control loop
  • send() never fails, recv() returns None if no data

Next Steps