ROS2 Bridge

Bridges horus topics to ROS2 topics using a multi-process architecture. The horus side runs a bridge node that reads from horus shared memory and forwards to a ROS2 process via a shared topic. This enables gradual migration — run horus for real-time control while keeping ROS2 for visualization (RViz) and navigation (Nav2).

horus.toml

[package]
name = "ros2-bridge"
version = "0.1.0"
description = "Bridge horus topics to ROS2"

Architecture

Loading diagram...
HORUS nodes communicate with ROS2 via a bridge process — shared memory on one side, DDS on the other

Horus-Side Bridge Node

use horus::prelude::*;
use serde::{Deserialize, Serialize};

/// Lightweight bridge payload — serialized for cross-framework transport
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, LogSummary)]
#[repr(C)]
struct BridgePacket {
    topic_id: u32,       // which topic this came from
    timestamp_ns: u64,
    linear: f32,         // for cmd_vel
    angular: f32,
}

// ── Bridge Node ─────────────────────────────────────────────

struct BridgeOutNode {
    cmd_sub: Topic<CmdVel>,
    imu_sub: Topic<Imu>,
    bridge_pub: Topic<BridgePacket>,
    tick_count: u64,
}

impl BridgeOutNode {
    fn new() -> Result<Self> {
        Ok(Self {
            cmd_sub: Topic::new("cmd_vel")?,
            imu_sub: Topic::new("imu.raw")?,
            bridge_pub: Topic::new("bridge.out")?,
            tick_count: 0,
        })
    }
}

impl Node for BridgeOutNode {
    fn name(&self) -> &str { "BridgeOut" }

    fn tick(&mut self) {
        self.tick_count += 1;

        // IMPORTANT: always recv() every tick to drain buffers
        if let Some(cmd) = self.cmd_sub.recv() {
            self.bridge_pub.send(BridgePacket {
                topic_id: 1, // cmd_vel
                timestamp_ns: self.tick_count * 20_000_000, // 50Hz
                linear: cmd.linear,
                angular: cmd.angular,
            });
        }

        // Drain IMU even if we don't bridge every reading
        if let Some(_imu) = self.imu_sub.recv() {
            // Bridge IMU at lower rate if needed
        }
    }
}

fn main() -> Result<()> {
    let mut scheduler = Scheduler::new();

    // Execution order: bridge reads horus topics and publishes bridge packets
    scheduler.add(BridgeOutNode::new()?)
        .order(90)              // runs after all horus nodes
        .rate(50_u64.hz())      // 50Hz bridge rate — RViz doesn't need 1kHz
        .build()?;

    scheduler.run()
}

ROS2-Side Bridge (Conceptual Python)

#!/usr/bin/env python3
"""
ROS2 node that reads horus bridge packets and republishes as ROS2 messages.
Run this in a separate process with ROS2 sourced.
"""
import horus
# import rclpy
# from geometry_msgs.msg import Twist

# ros_pub = create_publisher(Twist, '/cmd_vel', 10)

def bridge_in_tick(node):
    # IMPORTANT: always call recv() every tick
    packet = node.recv("bridge.out")
    if packet is None:
        return

    # Convert horus BridgePacket to ROS2 Twist
    # twist = Twist()
    # twist.linear.x = packet.linear
    # twist.angular.z = packet.angular
    # ros_pub.publish(twist)
    pass

if __name__ == "__main__":
    bridge_in = horus.Node(name="BridgeIn", tick=bridge_in_tick,
                           subs=["bridge.out"], rate=50)
    horus.run(bridge_in)

Expected Output

From the main() above, where the bridge is the only registered node — hence the counts of 1. A real deployment registers the control nodes in the same scheduler, which is what .order(90) sequences the bridge behind.

Abridged — startup also prints host-dependent RT and safety lines (SCHED_FIFO, CPU pinning, memory locking), which vary by machine and privileges. The Note: line is always printed, verbose or not, for a node configured with .rate() and no explicit .budget()/.deadline():

  Note: 'BridgeOut' is real-time (from .rate()). Budget 16.0ms, deadline 19.0ms were derived, and sustained misses will reduce its rate and then isolate it. Set .budget()/.deadline() to choose them, or an execution class (.compute(), .async_io()) to opt out.
Added RT node 'BridgeOut' with priority 90 at 50.0Hz
Initialized node 'BridgeOut'
Dependency graph (ready-dispatch mode): 1 steps from .order() tiers (no topic metadata yet)
Starting RT executor with 1 RT nodes on dedicated thread
^C
Ctrl+C received! Shutting down HORUS scheduler...
Shutdown node 'BridgeOut' successfully
Scheduler shutdown complete

On shutdown a per-node TIMING REPORT is also printed to stderr.

Key Points

  • Multi-process: horus and ROS2 run in separate processes — horus topics use shared memory across processes automatically
  • Rate decimation: horus control runs at 1kHz, bridge forwards at 50Hz — RViz doesn't need full rate
  • BridgePacket is a simplified carrier — in production, serialize full message types
  • Gradual migration: keep ROS2 for visualization/navigation, move real-time control to horus
  • No ROS2 dependency in horus: the bridge node is pure horus — the ROS2 side handles DDS
  • Cross-process topics: Topic::new("bridge.out") works across processes via shared memory — no special config needed