Custom nodes

When the built-in nodes are not enough, write your own. A custom node is a small piece of code that runs when the tree reaches it: it reads its input ports, does its work, writes its output ports and answers SUCCESS, FAILURE or RUNNING.

There are three ways to give a robot your own nodes:

Way

When to use it

Code nodes (Behaviour page → Code nodes)

The usual way. Write the node in Python, C++, JavaScript or Bash on the dashboard; it is sent to every robot of the project and loaded straight away.

External plugin paths

The code is already installed on the robot (your own ROS 2 package, for example). XPARO only gets the path.

Custom Nodes (inline code)

Python typed into the editor’s Custom Nodes panel. Off by default per project; changes are audit-logged.

The rest of this page covers code nodes; the other two use the same Python class shape.

How a code node gets to the robot

  1. You create the file and expose it as a node on the Behaviour page (XML tag, Action or Condition, ports).

  2. When you save, the server sends the source and the node’s settings to every connected robot (and to each robot when it next connects).

  3. The robot writes the file to custom_behaviors/custom_node_files/<language>/<name>.<py|cpp|js|sh>, compiles it if it is C++, and registers its tag.

  4. It answers with the list of tags it registered and any failures; the dashboard shows a failure as a red message with the reason (for example a C++ compile error).

The robot’s log confirms what it loaded:

[bt_engine] loaded custom node file tags: ['RoomIsFree']

Ports

Ports are the node’s inputs and outputs, written as XML attributes where the node is used in a tree:

<GreetPython person="{who}" greeting="msg" />
  • An input is a fixed value (person="Asha") or a blackboard value (person="{who}").

  • An output names the blackboard entry to write, without braces: greeting="msg" writes the result to msg, which later nodes read as {msg}. (Writing greeting="{msg}" would create an entry literally called {msg}.)

The examples below are the dashboard’s own generated templates for a node with one input person and one output greeting, with the one line of logic filled in. Each was run on the robot engine and wrote msg = "Hello Asha from …".

Python

from xparo.bt_engine.plugin_loader import CustomBTNode
from xparo.bt_engine.nodes.base import resolve_attrs, write_output
from py_trees.common import Status


class GreetPython(CustomBTNode):
    XML_TAG = "GreetPython"

    def update(self):
        attrs = resolve_attrs(self.attrs, self.blackboard, required=("person",))
        person = attrs.get("person", None)
        write_output(self.attrs, self.blackboard, "greeting", f"Hello {person} from Python")
        return Status.SUCCESS
  • XML_TAG is the tag trees use. It must match the tag set on the dashboard.

  • update() runs on every tick (10 times a second) while the node is active. Return Status.RUNNING for work that takes longer, and never block inside update(): the whole tree waits for it.

  • resolve_attrs turns the XML attributes into values ({name} read from the blackboard); required names inputs that must have a value.

  • write_output writes an output port.

  • self.feedback_message is shown in the run report, and as the reason when the node fails: The tree finished with FAILURE at Room_is_free (RoomIsFree): icu is busy.

  • self.ros_node is the robot’s xparo_ros node, for publishing to topics or calling services (None when the engine runs without ROS 2).

  • To clean up when the node is stopped early (the task was cancelled, hit its time limit, or its parent moved on), override terminate(self, new_status); it is called with Status.INVALID. The generated template has a halt() method, but the engine never calls it for Python nodes.

  • If update() raises an exception, that node fails with crashed: … in its message; the rest of the tree carries on as for any failure.

This is the condition used in the docs’ demo project, which fails for busy rooms:

from xparo.bt_engine.plugin_loader import CustomBTNode
from xparo.bt_engine.nodes.base import resolve_attrs
from py_trees.common import Status


class RoomIsFree(CustomBTNode):
    XML_TAG = "RoomIsFree"

    def update(self):
        attrs = resolve_attrs(self.attrs, self.blackboard, required=("room",))
        busy_rooms = {"icu", "operating_theatre"}
        if attrs["room"] in busy_rooms:
            self.feedback_message = f"{attrs['room']} is busy"
            return Status.FAILURE
        return Status.SUCCESS

C++

#include <behaviortree_cpp/behavior_tree.h>

class GreetCpp : public BT::SyncActionNode
{
public:
    GreetCpp(const std::string& name, const BT::NodeConfig& config)
        : BT::SyncActionNode(name, config) {}

    static BT::PortsList providedPorts()
    {
        return {
            BT::InputPort<std::string>("person"),
            BT::OutputPort<std::string>("greeting")
        };
    }

    BT::NodeStatus tick() override
    {
        auto person = getInput<std::string>("person");
        setOutput("greeting", std::string("Hello ") + person.value() + " from C++");
        return BT::NodeStatus::SUCCESS;
    }
};
  • A normal BehaviorTree.CPP node: derive from BT::SyncActionNode (action) or BT::ConditionNode (condition). Both finish within one tick, so return SUCCESS or FAILURE (not RUNNING).

  • The robot compiles it with g++ -std=c++17 against the BehaviorTree.CPP of its ROS 2 installation, so it needs g++, ros-jazzy-behaviortree-cpp and nlohmann-json3-dev. Extra libraries cannot be linked.

  • Each node in a tree runs as its own small program that stays alive for the run; every tick must answer within 5 seconds.

JavaScript

class GreetJavascript extends XparoNode {
  tick() {
    const person = this.input("person", null);
    this.output("greeting", `Hello ${person} from JavaScript`);
    return this.SUCCESS; // or this.RUNNING / this.FAILURE
  }

  halt() {
    // Only needed if tick() can return this.RUNNING.
  }
}

module.exports = GreetJavascript;
  • Needs Node.js (the node command) on the robot. XparoNode is provided by XPARO; don’t require it.

  • One Node.js process per node in a tree, alive for the whole run, so your object keeps its state between ticks. halt() is called when it is stopped.

  • this.input(key, default) reads an input; this.output(key, value) writes an output.

  • Log with console.error only: standard output carries XPARO’s messages. Every tick must answer within 5 seconds.

Bash

#!/usr/bin/env bash
set -euo pipefail

PERSON="${PERSON:-}"

echo "GREETING=Hello ${PERSON} from Bash"

exit 0  # SUCCESS, or 1 for FAILURE, or 2 for RUNNING
  • Inputs arrive as environment variables named in upper case (person → $PERSON).

  • Outputs are KEY=value lines on standard output (GREETING=… → the greeting port).

  • The exit code is the result: 0 SUCCESS, 1 FAILURE, 2 RUNNING.

  • The script runs again from scratch on every tick and must finish within 5 seconds. XPARO makes the file executable.

External plugin paths

If your node code is already on the robot, add its path under External Plugin Paths on the Behaviour page and switch it on. A path can be a Python file (/opt/my_nodes/nodes.py) or an installed module (my_package.bt_nodes). Every CustomBTNode subclass with an XML_TAG inside it becomes usable in trees. Only the path travels over the network; the code itself must be on each robot.

Inline Custom Nodes

The Custom Nodes panel on the Behaviour page stores Python node classes in the project itself and sends them to robots. Because that code runs inside the robot’s XPARO process, it is switched off for new projects: an Owner or Editor must allow it for the project first, and every created, changed or deleted node is recorded in an audit log.

When a node isn’t found

A tree using a tag the robot doesn’t know is refused before it runs, with the outcome invalid_tree and <Tag> isn't a node this robot knows. Check:

  • the robot was online when you saved the node (or has reconnected since);

  • the dashboard showed no red sync error for the file;

  • for Python, XML_TAG equals the tag used in the tree;

  • for an external plugin path, the path is switched on and the file exists on that robot.