Quick Start

This tutorial demonstrates building a temperature monitoring system with HORUS. Estimated time: 10 minutes.

The three concepts you need

HORUS has a large surface — execution classes, budgets and deadlines, miss policies, safe states, services, actions, transforms, drivers. None of it is required to finish this page. Three concepts carry the whole example:

  1. Node — a struct with a tick() method. The scheduler calls tick() over and over; that is your program.
  2. Topic — a named channel. Topic::new("temperature") opens it, send() writes, recv() reads. Any node in any language that opens the same name is connected to you.
  3. Scheduler — owns your nodes and calls their ticks.

The code below adds exactly two optional knobs, and you can delete both: .order(n) sets execution order within a tick (default 0) and .rate(hz) moves a node onto its own real-time thread (without it, a node ticks best-effort at the scheduler's default 100 Hz). Delete them and the program still runs — the temperature just scrolls at 100 Hz instead of once a second.

The path through Getting Started

If you read the docs in one order, read them in this one:

  1. Installation — get the horus binary
  2. Quick Start (this page) — node, topic, scheduler
  3. Second Application — several nodes, several rates
  4. Choosing a Language — Rust, Python or C++
  5. Common Mistakes — the nine things that bite first

Everything after that is reference material you can reach for when a specific need shows up.

What We're Building

A system with two components:

  1. Sensor - Generates temperature readings
  2. Monitor - Displays the readings

They'll communicate using HORUS's ultra-fast shared memory.

Step 1: Create a New Project

# Create a new HORUS project
horus new temperature-monitor

# Select options in the interactive prompt:
# Language: Rust (option 2)
# Use macros: No (we'll learn the basics first)

cd temperature-monitor

This creates:

  • src/main.rs - Your code (we'll customize this)
  • horus.toml - Dependencies and project metadata
  • .gitignore - Ignores the auto-managed build artifacts
  • .horus/ - Auto-managed environment (local workspace + global cache)

Note: .horus/ is automatically managed. For Rust projects, HORUS generates .horus/Cargo.toml from your horus.toml using path references (no source copying). See Environment Management for details.

Step 2: Write the Code

Replace the generated src/main.rs with this complete example:

use horus::prelude::*;

//===========================================
// SENSOR NODE - Generates temperature data
//===========================================

struct TemperatureSensor {
    publisher: Topic<f32>,
    temperature: f32,
}

impl TemperatureSensor {
    fn new() -> Result<Self> {
        Ok(Self {
            publisher: Topic::new("temperature")?,
            temperature: 20.0,
        })
    }
}

impl Node for TemperatureSensor {
    fn name(&self) -> &'static str {
        "TemperatureSensor"
    }

    fn tick(&mut self) {
        // Simulate temperature change
        self.temperature += 0.1;

        // Send the reading
        self.publisher.send(self.temperature);
    }
}

//============================================
// MONITOR NODE - Displays temperature data
//============================================

struct TemperatureMonitor {
    subscriber: Topic<f32>,
}

impl TemperatureMonitor {
    fn new() -> Result<Self> {
        Ok(Self {
            subscriber: Topic::new("temperature")?,
        })
    }
}

impl Node for TemperatureMonitor {
    fn name(&self) -> &'static str {
        "TemperatureMonitor"
    }

    fn tick(&mut self) {
        // Check for new temperature readings
        if let Some(temp) = self.subscriber.recv() {
            println!("Temperature: {:.1}°C", temp);
        }
    }
}

//============================================
// MAIN - Run both nodes
//============================================

fn main() -> Result<()> {
    eprintln!("Starting temperature monitoring system...\n");

    // Create the scheduler
    let mut scheduler = Scheduler::new();

    // Add both nodes using the fluent API
    // rate(1 Hz) puts the sensor on its own real-time thread, ticking once
    // per second. The monitor sets no .rate(), so it stays best-effort and
    // ticks on the scheduler's main loop at the default 100 Hz. order()
    // sequences main-loop nodes only until they publish or subscribe — after
    // that the scheduler orders them by the topics they share.
    // The topic still connects them: the monitor sees whatever the sensor
    // has published by the time it reads.
    scheduler.add(TemperatureSensor::new()?)
        .order(0)
        .rate(1_u64.hz())
        .build()?;

    scheduler.add(TemperatureMonitor::new()?)
        .order(1)
        .build()?;

    // Run forever (press Ctrl+C to stop)
    scheduler.run()?;

    Ok(())
}

Step 3: Run It!

horus run --release

HORUS will automatically:

  • Scan dependencies from horus.toml
  • Generate .horus/Cargo.toml from dependencies
  • Compile with Cargo (optimized)
  • Execute your program

You'll see:

Starting temperature monitoring system...

Temperature: 20.1°C
Temperature: 20.2°C
Temperature: 20.3°C
Temperature: 20.4°C
...

Press Ctrl+C to stop.

ℹ️Scheduler lines before the first reading are normal

Several lines of scheduler and real-time output appear above the temperatures. The one that alarms people is:

[RT-thread] Could not set SCHED_FIFO: Permission denied: SCHED_FIFO requires
CAP_SYS_NICE or root (continuing with normal priority)

That is not a failure — the message says so at the end. Calling .rate() promotes a node to the real-time class, and HORUS then asks the kernel for SCHED_FIFO, which an unprivileged user cannot have. It falls back to normal scheduling and your program runs exactly as written; on a desktop that is fine, since nothing here depends on real-time priority. On a robot that does depend on RT priority, run horus setup-rt: it writes an rtprio 99 / memlock unlimited limits entry for your user, and offers to install a PREEMPT_RT kernel. To give only this one binary the capability instead, sudo setcap cap_sys_nice+ep $(which horus).

Understanding the Code

The Topic - Communication Channel

// Create a publisher (sends data)
publisher: Topic::new("temperature")?

// Create a subscriber (receives data)
subscriber: Topic::new("temperature")?

Both use the same topic name ("temperature"). The Topic manages all shared memory operations automatically.

The Node Trait - Component Lifecycle

Each component implements the Node trait:

impl Node for TemperatureSensor {
    // Give your node a name
    fn name(&self) -> &'static str {
        "TemperatureSensor"
    }

    // This runs repeatedly
    fn tick(&mut self) {
        // Your logic here
    }
}

The Scheduler - Running Everything

The scheduler works out what depends on what, from the topics your nodes publish and subscribe to. Here the monitor subscribes to what the sensor publishes, so the sensor runs first:

let mut scheduler = Scheduler::new();

// publishes "robot.sensor"
scheduler.add(SensorNode::new()?)
    .order(0)
    .build()?;

// subscribes to "robot.sensor" — so it runs after the sensor
scheduler.add(MonitorNode::new()?)
    .order(1)
    .build()?;

// Run forever
scheduler.run()?;
📝When `.order()` decides the sequence, and when it does not

The scheduler builds its dependency graph from the topic metadata that exists when it starts — that is, from send()/recv() calls made during init(). Registration is lazy: Topic::new registers nothing, only the first send() or recv() does.

So if your nodes construct their topics in the constructor and first use them inside tick() — the common shape — there is no metadata at startup and the graph falls back to .order() tiers for tick 1. It does not stay there: those first send()/recv() calls register the topics, and at the start of tick 2 the scheduler rebuilds the graph from them and keeps it. From that point the pub/sub edges decide the order and .order() no longer sequences those nodes.

Use .deterministic(true) if you need an order that holds for the whole run.

A node that sends or receives during init() skips even that first tick: the graph has its edges from the start, so .order() never decides anything for it. Either way, once the graph exists, independent nodes run concurrently whatever their numbers.

It always matters on the RT, compute and async I/O executors, which start their pools sorted by it.

The fluent API lets you chain configuration:

  • .order(n) - Set execution priority (lower = runs first)
  • .rate(freq) - Set node-specific tick rate, e.g. .rate(100_u64.hz()) (auto-derives budget = 80% of period and deadline = 95% of period). This is how you pace a node — never sleep() inside tick(), which stalls every other node in the cycle.
  • .budget(dur) / .deadline(dur) - Override the auto-derived RT budget/deadline (either one implicitly makes the node real-time)
  • .build()? - Validate the configuration and register the node (.done() is a compatibility alias)

Running Nodes in Separate Processes

The example above runs both nodes in a single process. HORUS uses a flat namespace (like ROS), so multi-process communication works automatically!

Running in Separate Terminals

Split the two nodes into their own files — sensor.rs and monitor.rs, each with its own main() that schedules just that node — then run each file in a different terminal. They automatically share topics:

# Terminal 1: Run sensor
horus run sensor.rs

# Terminal 2: Run monitor (automatically connects!)
horus run monitor.rs

Both use the same topic name ("temperature") → communication works automatically!

Using Glob Pattern

Run multiple files together:

horus run "*.rs"  # All Rust files run as separate processes

Tip: See Topic for details on the shared memory architecture.

Next Steps

Add More Features

Try modifying the code:

1. Add a temperature alert:

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

            // Alert if temperature exceeds threshold
            if temp > 25.0 {
                eprintln!("WARNING: Temperature too high!");
            }
        }
    }
}

2. Add a second sensor:

// In main():
scheduler.add(HumiditySensor::new()?)
    .order(0)
    .build()?;
scheduler.add(HumidityMonitor::new()?)
    .order(1)
    .build()?;

3. Save data to a file:

use std::fs::OpenOptions;
use std::io::Write;

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

            // Save to file
            let mut file = OpenOptions::new()
                .create(true)
                .append(true)
                .open("temperature.log")
                .unwrap();
            writeln!(file, "{:.1}", temp).ok();
        }
    }
}

Learn More Concepts

Now that you've built your first app, learn the details:

Core Concepts:

  • Nodes - Deep dive into the Node pattern
  • Topic - How ultra-fast communication works
  • Scheduler - Priority-based execution

Make Development Easier:

See More Examples:

Common Questions

Do I need Box::new()?

No! The fluent API handles everything automatically:

scheduler.add(MyNode::new()?)
    .order(0)
    .build()?;

Can I use async/await?

Nodes use simple synchronous code — tick() is called repeatedly by the scheduler's main loop. This keeps things simple and deterministic, which is important for real-time robotics.

How do I stop the application?

Press Ctrl+C. The scheduler handles graceful shutdown automatically.

Where does the data go?

Data is stored in platform-specific shared memory:

  • Linux: /dev/shm/horus_<namespace>/topics/
  • macOS: /tmp/horus_<namespace>/topics/
  • Windows: %TEMP%\horus_<namespace>\topics\

<namespace> defaults to default (so /dev/shm/horus_default/topics/); set HORUS_NAMESPACE to isolate robots.

Check it out (Linux):

ls -lh /dev/shm/horus_default/topics/

Troubleshooting

"Failed to create Topic"

Usually an invalid topic name (allowed characters: a-z, A-Z, 0-9, _, -, ., /; max 255 chars, never empty), a shared-memory permission problem, or /dev/shm being full. Check the topic name, then clear stale shared memory:

horus clean --shm

Nothing prints

Make sure both nodes are added:

scheduler.add(Sensor::new()?)
    .order(0)
    .build()?;
scheduler.add(Monitor::new()?)
    .order(1)
    .build()?;

"Could not set SCHED_FIFO: Permission denied"

.rate(...) marks a node real-time, so the scheduler tries to raise its thread priority at startup. Without CAP_SYS_NICE (or root) it logs this once and continues at normal priority — the example still ticks at 1 Hz. See Real-Time Nodes if you need genuine RT scheduling.

What You've Learned

  • How to create a HORUS project
  • The Node trait pattern
  • Using Topic for communication
  • Running multiple nodes with a Scheduler
  • Sending and receiving messages

Ready for More?

Your next steps:

  1. Use the node! macro to eliminate boilerplate
  2. Run the examples to see real applications
  3. Open the monitor to monitor your system

For issues, see the Troubleshooting Guide.