Skip to content

Transports and the bridge

btview separates what the bytes mean (the codec) from how they arrive (the transport). Above that line nothing cares: the TUI, snapshot, echo and record all work against anything that can hand over a tree and, when asked, a bag of statuses.

That is what makes a recording on disk, a live robot, and a future ROS 2 topic interchangeable — and what makes the bridge a five-line loop rather than a special case.


Naming: it is ZeroMQ, not MQTT

The two get confused constantly, so to be explicit: BehaviorTree.CPP monitoring is ZeroMQ. groot2_publisher.cpp includes zmq_addon.hpp and opens ZMQ_REP and ZMQ_PUB sockets. There is no broker, no CONNECT/PUBLISH packets, and nothing listening on 1883. Pointing an MQTT client at port 1667 will not work.

"Groot2" in btview names the wire format (the request/reply framing that Groot2Publisher speaks), not the transport and not the GUI. --protocol v4 is a shorthand for it; groot2://host:port says the same thing more precisely.


Source URIs

Every command that connects accepts --source URI as an alternative to --host/--port/--protocol:

btview --source groot2://robot.local:1667
btview snapshot --source btcpp3://robot:1667?publisher_port=1666
btview --source replay://run.btv.jsonl?speed=4&loop=1
btview --source mock://                       # a synthetic robot, no hardware

btview transports lists what the installed build can read and write.

Scheme Reads
groot2://host:port BehaviorTree.CPP 4.x Groot2Publisher (default port 1667)
btcpp3://host:port BehaviorTree.CPP 3.x PublisherZMQ (REP 1667, ?publisher_port=1666)
ros2://topic Nav2's behavior_tree_log — no publisher needed (?tree=, needs rclpy)
replay://path / file://path a recording from btview record (?speed=, ?loop=)
mock://[host:port] an in-process synthetic Nav2 publisher (?tick_hz=)

Options are query parameters, so a URI is copy-pasteable into a script or a systemd unit: groot2://robot:1667?timeout_ms=2000.


The bridge

btview bridge --from <source-uri> --to <sink-uri>

It reads one transport and republishes on another. The sinks:

Scheme Writes
groot2://bind:port re-serves the stream as a Groot2 publisher
file://path a .btv.jsonl recording (same format as btview record)

Let Groot2 watch a BehaviorTree.CPP 3.x robot

The Groot2 GUI speaks v4 only. A robot on BehaviorTree.CPP 3.x cannot talk to it — unless something translates:

btview bridge --from btcpp3://robot:1667?publisher_port=1666 --to groot2://0.0.0.0:1667

Now point Groot2 (or another btview) at the bridge. This path is covered by a test that drives a v3 publisher through the bridge and reads it back with a v4 client.

Replay a recording as if it were a live robot

btview bridge --from replay://yesterday.btv.jsonl?speed=2 --to groot2://0.0.0.0:1667

Useful for demoing, for teaching, and for pointing a GUI at a failure that already happened.

Capture from anywhere

btview bridge --from groot2://robot:1667 --to file://run.btv.jsonl

Which is exactly what btview record does — record is the ergonomic spelling, the bridge is the general one.


Adding a transport

A source is any class implementing MonitorSource — two methods:

from btview.client import MonitorSource, PollResult

class MyTopicSource(MonitorSource):
    label = "my-robot"
    protocol = "custom"
    reports_transitions = True     # True if you push transitions instead of us diffing

    def fetch_tree(self) -> TreeModel: ...
    def poll(self) -> Optional[PollResult]: ...

Register it against a scheme and every command can use it:

from btview.sources import register_source

@register_source("mytopic", "my fleet's status topic")
def _open(uri, **options):
    return MyTopicSource(host=uri.host, port=uri.port, **options)

Sinks are the mirror image — open(tree), publish(statuses, transitions, timestamp), close() — registered with register_sink. btview.sinks.RecordingSink is a short example; Groot2Sink shows how to re-serve a protocol.


ros2:// — no publisher required

Nav2 already puts everything on the ROS graph. nav2_behavior_tree's RosTopicLogger creates a publisher on behavior_tree_log carrying nav2_msgs/BehaviorTreeLog, and it does so next to the tree, regardless of enable_groot_monitoring. Each BehaviorTreeStatusChange has:

builtin_interfaces/Time timestamp
string node_name
uint16 uid                  # the same uid the ZMQ protocols use
string previous_status      # IDLE, RUNNING, SUCCESS or FAILURE
string current_status
btview --source ros2://behavior_tree_log
btview --source ros2://behavior_tree_log?tree=/opt/ros/bt/navigate_to_pose.xml
btview --source ros2:///robot1/behavior_tree_log      # namespaced

Two things this does better than the ZMQ path:

  • There is nothing to switch on. It works against stock Nav2 with Groot monitoring off and no port to reach. On a robot where you cannot edit parameters or open a port, this is the way in.
  • It cannot miss a transition. Groot2 only exposes snapshots, so btview derives transitions by diffing polls, and a node that goes RUNNING then SUCCESS between two polls shows up once. The log reports every change at any poll rate.

It needs rclpy, which btview does not depend on

The whole premise of the tool is that it installs on a laptop with no ROS. So rclpy is imported lazily, only by this transport, and only when you use it. Run btview from a sourced workspace; in a virtualenv, create it with --system-site-packages so rclpy stays visible:

source /opt/ros/jazzy/setup.bash
python3 -m venv --system-site-packages ~/.venvs/btview-ros
~/.venvs/btview-ros/bin/pip install btview
~/.venvs/btview-ros/bin/btview --source ros2://behavior_tree_log

Without it you get an error saying exactly that, rather than a stack trace.

The topic carries events, not a tree

This is the transport's real limitation. behavior_tree_log says what changed, never what the tree looks like, so there are two modes:

What you get
ros2://behavior_tree_log A flat list of nodes, discovered as they first tick. Statuses, sparklines and the transition log all work; there is no hierarchy to draw, and nodes appear as the tree reaches them
ros2://behavior_tree_log?tree=file.xml The real hierarchy from your behaviour tree XML

?timeout= (default 10s) is how long fetch_tree waits for the first message in discovered mode. A navigator only ticks while it holds a goal, so an idle robot legitimately says nothing — if it times out, send a goal and try again.

About tree= and uids

Statuses are keyed by uid, and a behaviour tree file as written by a human has none — _uid only appears in WriteTreeToXML output. So btview numbers the file itself, depth-first from 1, which is the order BehaviorTree.CPP hands uids out as it builds the tree.

That is an assumption about someone else's implementation detail, so it is checked rather than trusted, and it holds: CI strips _uid from a live Nav2 tree, re-infers the numbering and asserts it matches what the publisher said, and separately drives the whole transport against Nav2's own 28-node navigate_to_pose tree inside a container. At runtime any event whose logged node name disagrees with the node it lands on falls back to the logged name and is counted, so a distro that numbers differently reports itself rather than lighting up the wrong node.

If you ever suspect trouble, a tree exported from a Groot-enabled run carries the publisher's real uids and is used as-is:

btview export --format xml -o tree-with-uids.xml       # once, with monitoring on
btview --source ros2://behavior_tree_log?tree=tree-with-uids.xml

Still to come: an MQTT sink

For fleet dashboards. A broker cannot be exercised in this project's CI, so it needs its own testing story rather than being bolted on quietly — the same standard ros2:// was held to, which is why that one is verified against a real bt_navigator in a container.