Basic Usage

This guide covers the fundamental building blocks of an rclgd application: nodes, logging, communication patterns, and periodic tasks.

Initializing a Node

The RosNode is the entry point for all ROS 2 communication. It should be initialized with a unique name and optional namespace.

extends Node

var ros_node: RosNode

func _ready():
    if not rclgd.ok():
        rclgd.init()

    ros_node = RosNode.new()
    # init(name, namespace)
    ros_node.init("my_godot_node", "/my_robot")

    print("Node Name: ", ros_node.get_name())
    print("Namespace: ", ros_node.get_namespace())

Warning

All rclgd communication objects (RosNode, RosPublisher, RosSubscriber, RosTimer, ...) are RefCounted: they live only as long as something holds a reference to them. Always store the value returned by the create_* factories in a member variable — a discarded subscription or timer is freed immediately and silently stops working.

Logging

Use the node's logger instead of print when the output should reach ROS tooling. RosLogger has the same API as rclpy's logger: messages get the usual severity formatting on the console and are published on /rosout, where rqt_console and ros2 bag can pick them up.

var logger: RosLogger

func _ready():
    # ... after ros_node.init(...)
    logger = ros_node.get_logger()
    logger.debug("Raw sensor value: %f" % value)
    logger.info("Simulation started")
    logger.warning("Wheel slip detected")
    logger.error("Lost connection to the controller")
    logger.fatal("Vehicle state is unrecoverable")

GDScript has no keyword arguments, so rclpy's logging filters are passed as a dictionary. As in rclpy, filter state is kept per call site:

func _physics_process(_delta):
    logger.info("Contact detected", {"once": true})
    logger.warning("Speed limit exceeded", {"throttle_duration_sec": 1.0})
    logger.info("Tick", {"skip_first": true})

Messages below the logger's level are dropped (the log methods return false). The default level is INFO. Change it at runtime with set_level, or at launch with --ros-args --log-level my_godot_node:=debug. Use get_child to give a subsystem its own logger name and level:

var lidar_logger := logger.get_child("lidar")   # "my_godot_node.lidar"
lidar_logger.set_level(RosLogger.DEBUG)
print(lidar_logger.get_effective_level() == RosLogger.DEBUG)  # true

Code without a node, such as autoloads or editor tools, can use rclgd.get_logger("my_tool"), which works like rclpy.logging.get_logger() and logs to the console only. The singleton also has set_logger_level, get_logger_effective_level and get_logging_severity_from_string, matching the rclpy.logging functions.

Note

RosNode.get_logger() is only available after init().

Topics (Pub/Sub)

Topics allow for continuous data streams between nodes.

Publishing

var pub: RosPublisher

func setup_publisher():
    pub = ros_node.create_publisher("/cmd_vel", "geometry_msgs/msg/Twist")

func send_velocity(linear: float, angular: float):
    var msg = RosMsg.from_type("geometry_msgs/msg/Twist")
    msg.linear.x = linear
    msg.angular.z = angular
    pub.publish(msg)

Subscribing

var sub: RosSubscriber

func setup_subscription():
    sub = ros_node.create_subscription("/odom", "nav_msgs/msg/Odometry", _on_odom)

func _on_odom(msg: RosMsg):
    # Access nested members directly
    var x = msg.pose.pose.position.x
    print("Robot X position: ", x)

Note

Subscription callbacks are always invoked on the Godot main thread, so it is safe to touch the scene tree from them.

Timers

Use RosTimer for periodic logic instead of Godot's built-in timers when synchronization with the ROS executor is required. Timers run on the node's clock: with use_sim_time:=true they follow /clock and pause when the simulation pauses.

var timer: RosTimer

func setup_timer():
    # create_timer(seconds, callback)
    timer = ros_node.create_timer(1.0, _on_timer_timeout)

func _on_timer_timeout():
    print("Periodic task executed at ROS time: ", ros_node.now())

func stop_timer():
    if timer.is_ready():
        timer.cancel()

Services

Services provide a request-response pattern.

# Client (Async)
func call_status_service():
    var client = ros_node.create_client("/get_status", "std_srvs/srv/Trigger")
    if not client.wait_for_service(2.0):
        return

    var request = client.create_request()
    var future = client.async_send_request(request)
    var response = await future.completed
    if response:
        print("Status Success: ", response.success)

# Server: fill the response in-place, it is sent when the callback returns
func setup_service_server():
    var srv = ros_node.create_service("/get_status", "std_srvs/srv/Trigger", _handle_request)

func _handle_request(_req: RosMsg, res: RosMsg):
    res.success = true
    res.message = "System OK"

Warning

Service server callbacks run synchronously on the ROS executor thread (the response must be filled before returning to the client). Keep them self-contained and use call_deferred for side effects on the scene. See RosService for details.

Actions

Actions are made for long-running tasks: they stream feedback while the goal executes and can be canceled mid-flight. Servers receive each accepted goal as a RosServerGoalHandle on the main thread and must finish it with succeed, abort or canceled.

Goals are accepted by default. To reject some, pass a goal callback as the fourth argument of create_action_server; it receives the goal message and returns true to accept. Cancel requests are always accepted, and the execute callback decides how to react through is_cancel_requested().

Warning

The goal callback runs synchronously on a ROS executor thread, because ROS needs the answer immediately. Only inspect the goal message there and don't touch the scene tree. For checks that need game state, accept the goal and call abort at the start of the execute callback.

# Server: classic Fibonacci action
func setup_action_server():
    var server = ros_node.create_action_server("fibonacci",
        "example_interfaces/action/Fibonacci", _execute_goal)

func _execute_goal(goal_handle: RosServerGoalHandle):
    var sequence = [0, 1]
    for i in range(2, goal_handle.get_goal().order):
        if goal_handle.is_cancel_requested():
            var canceled_result = goal_handle.create_result()
            canceled_result.sequence = sequence
            goal_handle.canceled(canceled_result)
            return
        sequence.append(sequence[i - 1] + sequence[i - 2])
        var fb = goal_handle.create_feedback()
        fb.sequence = sequence
        goal_handle.publish_feedback(fb)
        # Spread long-running goals over frames instead of blocking
        await get_tree().process_frame
    var result = goal_handle.create_result()
    result.sequence = sequence
    goal_handle.succeed(result)

# Client
func send_fibonacci_goal():
    var client = ros_node.create_action_client("fibonacci",
        "example_interfaces/action/Fibonacci")
    if not client.wait_for_server(2.0):
        return

    var goal = client.create_goal()
    goal.order = 10
    var goal_handle = client.send_goal(goal)
    goal_handle.feedback.connect(func(msg): print("Partial: ", msg.sequence))

    await goal_handle.completed
    if goal_handle.get_status() == RosGoalHandle.STATUS_SUCCEEDED:
        print("Result: ", goal_handle.get_result().sequence)

A running goal can be canceled from the client with goal_handle.cancel(); the final status then arrives through the completed signal as STATUS_CANCELED.