Introduction

This book teaches you to drive Ubicoders virtual robots from code, and then serves as the reference you come back to.

What the SDK is

vrobots_sdk is the Rust SDK for controlling Ubicoders virtual robots running in a Unity simulator. It talks to the simulator over two transports, zenoh for state, commands and services and iceoryx2 for camera frames, with FlatBuffers on the wire in both directions.

The Rust crate is the single implementation. The C++ and Python SDKs are thin bindings over it, so the three surfaces cannot drift: the same lifecycle, the same snapshots, the same timestamps, and the same stable error codes in Appendix C.

What you need

RequirementNotes
Python 3.8 or newerpip install ubicoders-vrsdk is the entire SDK install. The wheel carries the compiled Rust core and the vrobots command, so no toolchain, no flatc and no clone are involved. Windows and Linux x86-64.
The example programsThe wheel ships the library, not the examples. A plain git clone of this repository gets them; the Python ones import vrsdk and nothing else.
The Unity simulator, in Play modeRequired for anything that talks to a robot.
Rust 1.89 or newerOnly to work in Rust or C++ from source. The crate is edition 2024, and the clone needs --recurse-submodules for the private vrobots_msgs submodule that ships the generated FlatBuffers code.

Installing the SDK and the simulator covers them in order.

The one idea to internalise first

This SDK is STM32-shaped, not Arduino-shaped. main() does setup and then owns a plain loop. There is no base class, no runner, no setup() and update() callbacks, and the SDK never calls your code. If you have used the older Python client, this is the single largest difference, and every page in the book assumes it.

The whole of main in the first example shows the shape.

From examples/rust/src/bin/ex01_hello_states.rs:

fn main() -> Result<(), VrError> {
    // ===== setup =====
    vrobots_sdk::init_logging("info");
    let robot = VirtualRobot::connect(RobotType::Multirotor, Some(SYS_ID))?;

    // ===== loop =====
    loop {
        let s = robot.states(); // immutable latest snapshot, never torn
        let [x, y, z] = s.kin.lin_pos;
        println!("State t={:.3} pos=({x:.3},{y:.2},{z:.2})", s.elapsed);
        robot.rate(HZ); // drift-compensated pacing, Hz
    }
}
The same in C++ (examples/cpp/ex01_hello_states.cpp)
int main() {
    try {
        // ===== setup =====
        vrsdk::check_version();  // header and library must be the same release
        vrsdk::set_log_callback(on_log);

        vrsdk::VirtualRobot robot(vrsdk::RobotType::Multirotor, SYS_ID);
        robot.connect();  // blocks until the first state snapshot arrives
        std::printf("connected to sys_id %u\n", robot.sys_id());

        // ===== loop =====
        for (;;) {
            const vrsdk::State s = robot.states();  // latest snapshot, never torn
            const double* p = s.kin().lin_pos;
            std::printf("State t=%.3f pos=(%.3f,%.2f,%.2f)\n", s.elapsed, p[0], p[1], p[2]);
            robot.rate(HZ);  // drift-compensated pacing, Hz
        }
    } catch (const vrsdk::Error& e) {
        // `code()` is the SDK's stable number -- the same one Python's
        // VrError.code and the CLI's `error [N]` report.
        std::fprintf(stderr, "error [%d] %s\n", e.code(), e.what());
        return 1;
    }
}
The same in Python (examples/python/ex01_hello_states.py)
def main() -> None:
    # ===== setup =====
    vrsdk.init_logging("info")
    mr = VirtualRobot(RobotType.MULTIROTOR, sys_id=SYS_ID)
    mr.connect()

    # ===== loop =====
    while True:
        s = mr.states  # immutable latest snapshot, never torn
        x, y, z = s.kin.lin_pos
        print(f"State t={s.elapsed:.3f} pos=({x:.3f},{y:.2f},{z:.2f})")
        mr.rate(HZ)  # drift-compensated pacing, Hz

The program prints one line per iteration at the rate robot.rate paces it to, until you stop it with Ctrl+C. The shape of a program explains why the loop belongs to you rather than to the SDK.

How the book is organised

PartChaptersWhat it gives you
Tutorial1, 2A robot moving, then the model that explains why it moved.
The API in four slices3 read, 4 write, 5 image, 6 configureOne slice of the surface per chapter, in the order you meet them.
Reference7Per-robot pages: identity, physical model, commands, services, quirks.
Diagnostics8The vrobots command, discovery, rates, logging, testing with the simulator closed.
AppendicesA, B, C, DLookup tables: topics, command ids, error codes, vocabulary.

Chapters 3 to 6 are independent of each other. Read chapter 2 before any of them, because they all lean on the five rules it sets out.

Reading paths

You wantStart at
A robot moving in ten minutesChapter 1, Getting started
To understand what you are doingChapter 2, Concepts
Something is brokenChapter 8, Tooling and diagnostics, then When nothing happens

Examples

Thirty-three complete programs live under examples/rust/src/bin/. Each is a real fn main rather than a snippet, takes no command-line arguments (settings are constants at the top of the file), and is mirrored one for one in Python and C++ under examples/python/ and examples/cpp/, with the same numbers and the same behaviour.

Run one by its bin name, or by the equivalent name in the language you are using:

cargo run -p vrobots-examples --bin ex01_hello_states
./target/cpp-build/ex01_hello_states
python examples/python/ex01_hello_states.py

The C++ line assumes the build in Installing the SDK and the simulator; on Windows the binary is target\cpp-build\Release\ex01_hello_states.exe.

Every code block in this book is copied from one of those files or from a signature in the SDK source, so anything you read here compiles as written.

Versions

This book documents SDK 0.1.4 against simulator v3.0.0. The IPC pins that build speaks are flatbuffers 25.12.19, iceoryx2 0.9.3 and zenoh 1.9.0, and vrobots --version prints the set your build actually carries. The pins are exact on purpose: Versions and pins explains what a caret pin one patch off does, and why it looks like the simulator has stopped publishing.

Next: Getting started

See also: Five rules that explain everything, Appendix D: Glossary

Getting started

What the simulator is, what it is not, and what you will have working by the end of this chapter.

What the simulator is

The Ubicoders virtual robots simulator is a Unity application that runs a rigid-body physics world containing one or more robots. Each robot publishes a full state snapshot at 25 Hz, accepts commands, answers a small set of request/response services, and can stream camera frames. It is a target for control code: you write the controller, the simulator provides the plant.

vrobots_sdk is the client side of that conversation. One program behaves like one embedded system bound to one robot: construct, connect, then run a plain control loop.

What it is not

  • It is not a robotics framework. There is no scheduler, no node graph, no message passing between your components. The SDK gives you a handle and gets out of the way.
  • It does not stabilise anything for you. Sending pulse widths to a multirotor drives the thrust curves directly. There is no attitude hold and no rate damping between your numbers and the rotors.
  • It is not a physical robot. Truth blocks in the state snapshot are simulator-exact values no real vehicle could know. They exist so you can measure your estimator against them, not so you can fly on them.
  • It does not manage your robot's lifetime. A robot you create outlives the process that created it. See Hello service.

One core, three languages

The Rust crate is the single implementation. The C++ SDK is a header-only RAII wrapper over a C ABI, and the Python SDK is a PyO3 binding, both over that same Rust core. The bindings add sugar, not behaviour, so lifecycle, snapshots and timestamps behave identically by construction and the three surfaces cannot drift.

This chapter is written in Rust. Every example under examples/rust/src/bin/ is mirrored one for one in examples/python/ and examples/cpp/, printing the same numbers.

How your program reaches the robot

Two transports carry the traffic, and they are not interchangeable.

flowchart LR
  P[Your program]
  Z([zenoh])
  I([iceoryx2])
  S[Unity simulator]

  P -->|commands, service requests| Z
  Z -->|commands, service requests| S
  S -->|state, service replies| Z
  Z -->|state, service replies| P
  S -->|camera frames| I
  I -->|camera frames| P

zenoh carries states, commands, setpoints and services, and it crosses a network: the simulator can run on another machine if you point the SDK at a router. iceoryx2 carries camera frames through shared memory, which makes it fast and makes it same-host only. A remote simulator will therefore answer states() and refuse to deliver a single frame.

The wire format on both is FlatBuffers, and the version pins are exact on purpose. A mismatch does not raise an error, it delivers nothing, which reads as "the simulator is not publishing". Installing the SDK and the simulator shows how to print the pins this build speaks.

Note. The transports and the topic names they carry are the subject of Two transports, one simulator and The topic namespace. This chapter uses them without explaining them further.

What you will have by the end

Working through the eight pages of this chapter, in order, leaves you with:

  • The SDK installed and the simulator running, on Windows, Ubuntu or WSL.
  • Proof, from the vrobots command line tool, that the simulator is publishing and under which system id.
  • A program that reads a multirotor's position at a rate you choose.
  • A multirotor climbing under pulse widths you sent.
  • A truck driving a gentle left arc.
  • A camera mounted, frames read, and the camera unmounted again.
  • A robot created from code and deleted from code.

Each of those is one of the first five example programs, run unmodified. Nothing in this chapter asks you to write a program from scratch.

Next: Installing the SDK and the simulator

See also: The shape of a program, Five rules that explain everything, When nothing happens

Installing the SDK and the simulator

Install the SDK with one pip command, then get a simulator running on Windows, Ubuntu or WSL.

pip install ubicoders-vrsdk

What pip gives you

That command is the whole SDK install: the wheel carries the compiled Rust core, so no Rust toolchain, no flatc, no protoc and no repository clone is involved. It puts two things on your machine: vrsdk, the package every Python example in this book imports, and vrobots, the command line tool of The vrobots command, which is the same program as the Rust build's rather than a second implementation.

RequirementVersionNotes
Python3.8 or newerone abi3 wheel per platform covers 3.8 through 3.13 and later
PlatformWindows x86-64, Linux x86-64Linux needs glibc 2.17 or newer (manylinux2014); macOS is not published yet
The Unity simulatorin Play moderequired by anything that talks to a robot

numpy arrives with the wheel, because frame.image hands back an ndarray. opencv-python does not, and it is what the camera examples want in order to open a window:

pip install "ubicoders-vrsdk[examples]"

Without it, Hello image prints metadata instead of opening a window and Saving a frame writes a PPM instead of a PNG. The one page that needs OpenCV outright is Showing frames in a window, in every language.

Gotcha. No source distribution is published, deliberately: a source build needs the Rust toolchain and a private submodule. On a platform with no wheel, pip therefore stops with No matching distribution found for ubicoders-vrsdk rather than starting a compile that cannot finish.

Getting the example programs

The wheel ships the library, not the example programs the pages of this book run. Those live in the repository, and the Python ones need nothing from it but themselves:

git clone https://github.com/ubicoders0/vrobots_sdk
python vrobots_sdk/examples/python/ex01_hello_states.py

Plain git clone, with no --recurse-submodules: the submodule only matters if you build the Rust or C++ surfaces. Every Python example imports vrsdk and nothing else from the tree, so one file copied out of it runs just as well on its own.

Building the Rust and C++ surfaces

Skip this section unless you are working in Rust or C++. Neither surface is published as a package, so both start from a clone that includes the vrobots_msgs submodule. That submodule ships the generated FlatBuffers code, which is why no flatc is needed, and it is a private repository, so the clone below needs access to it.

git clone --recurse-submodules https://github.com/ubicoders0/vrobots_sdk
cd vrobots_sdk
cargo build --workspace

Rust 1.89 or newer is required, since the crate is edition 2024; install it from https://rustup.rs/. If you already cloned without the submodule, the build fails and says so. Repair it with:

git submodule update --init --recursive

The C++ examples are a CMake project over the header-only wrapper. Building the C ABI crate generates the C header, so that is the only further prerequisite:

cargo build -p vrobots-sdk-capi --release
cmake -S examples/cpp -B target/cpp-build -DCMAKE_BUILD_TYPE=Release
cmake --build target/cpp-build --config Release

That puts one binary per example under target/cpp-build/, which is the path the sh block on each page names. On Windows the binaries land in target\cpp-build\Release\ and carry an .exe suffix, and the DLL is copied beside each one; on Linux the build rpath points at the cargo target directory, so no LD_LIBRARY_PATH is needed.

Getting the simulator

Prebuilt simulator packages are at https://www.ubicoders.com/virtualrobots. Windows 11 and Ubuntu 22.04 or newer are supported; macOS is not supported yet.

Windows

Run virtual_robots.exe.

Ubuntu

The build needs xdg-utils present and its own executable bit set.

sudo apt install xdg-utils -y
sudo chmod +x ./virtual_robots.x86_64
./virtual_robots.x86_64

Double-clicking virtual_robots.x86_64 in a file manager works as well.

WSL

WSL needs a graphics bridge before Unity will render. Install the Mesa and Vulkan packages and force the D3D12 gallium driver, which routes rendering to the Windows GPU rather than to the CPU rasteriser.

Save this as install_wsl_graphics.bash:

#!/bin/bash
# 1. Install necessary drivers and diagnostic tools
sudo apt-get update
sudo apt install xdg-utils -y
sudo apt install mesa-utils mesa-vulkan-drivers vulkan-tools -y

# 2. Add GPU bridge variables to .bashrc for persistence
# We use GALLIUM_DRIVER to force the D3D12 bridge (Windows GPU)
# and VK_ICD_FILENAMES to ensure Vulkan doesn't default to the CPU (llvmpipe)
if ! grep -q "GALLIUM_DRIVER=d3d12" ~/.bashrc; then
  echo 'export GALLIUM_DRIVER=d3d12' >> ~/.bashrc
  echo 'export MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA' >> ~/.bashrc
fi

# 3. Reload environment
source ~/.bashrc

# 4. Verify the setup
echo "--- Checking OpenGL  ---"
glxinfo -B | grep -E "Device|Accelerated"

echo "--- Checking Vulkan  ---"
vulkaninfo | grep "Vulkan Instance Version"
vkcube

Run it, then reload your shell so the exported variables apply:

bash install_wsl_graphics.bash && source ~/.bashrc

Check that Vulkan came up on the GPU. vkcube should open a spinning cube window; if it does not, the simulator will not render either.

vulkaninfo | grep "Vulkan Instance Version"
vkcube

Launch the simulator with Vulkan forced. The LD_LIBRARY_PATH edit removes /opt/zenoh-c/lib from the loader path, so the simulator loads its own vendored zenoh rather than a system copy.

#!/bin/bash
sudo chmod +x ./virtual_robots.x86_64
export LD_LIBRARY_PATH=$(echo "$LD_LIBRARY_PATH" | sed 's|:/opt/zenoh-c/lib||; s|/opt/zenoh-c/lib:||; s|/opt/zenoh-c/lib||')
nohup ./virtual_robots.x86_64 -force-vulkan > output.log 2>&1 &

Gotcha. Camera frames ride iceoryx2 shared memory, so they are same-host only. A simulator running under WSL and a client running on Windows are two hosts as far as iceoryx2 is concerned: states arrive over zenoh, frames never do. Run both sides in the same place when you want images.

Verifying the install

This prints what the SDK you just installed actually speaks, and it needs no simulator.

vrobots --version
vrobots-sdk 0.1.4
  vrobots_msgs  v2.0.2-31-gac335c0 (schema_version 3)
  flatbuffers   25.12.19
  zenoh         1.9.0
  iceoryx2      0.9.3
  src_id        122

The first line is the SDK release. The second is a git describe of the vrobots_msgs submodule the FlatBuffers code was generated from, so it moves when the schema does. The three pins are exact rather than caret ranges: iceoryx2 compares major, minor and patch on every shared-memory open, and a version one patch off does not error, it silently delivers nothing. That is the first thing to compare against the simulator build when fields look like garbage or a camera stream never appears.

In a source checkout, cargo run -p vrobots-sdk --bin vrobots -- --version prints the same block.

Next: First contact: is anything publishing?

See also: Versions and pins, When nothing happens

First contact: is anything publishing?

Use the vrobots command to prove the simulator is talking before you write any code.

cargo run -p vrobots-sdk --bin vrobots -- topic list

What a healthy scene looks like

topic list opens a zenoh session, listens for a window, and reads the iceoryx2 registry. Anything under vrobots/** that speaks during that window appears.

wire       Hz     bytes  topic
[z]      25.0     33800  vrobots/1/z/state
[i]         -         -  vrobots/1/i/cam/front_left/720p_rgba8

2 topic(s); zenoh observed over 1.5s
[z] zenoh, measured by listening.  [i] iceoryx2, read from the registry:
    it exists, but Hz/bytes were not measured -- same host only.

The four columns:

ColumnMeaningWhen it shows -
wire[z] zenoh, [i] iceoryx2never
Hzmeasured publish rate over the windowalways for [i] entries
bytestotal payload bytes seen in the windowalways for [i] entries
topicthe full key, which is also the iceoryx2 service namenever

The dashes are the load-bearing detail. zenoh has no registry, so a [z] row means the topic actually published while you were listening and its numbers are real measurements. iceoryx2 does have a registry, so an [i] row means the service is defined; it may be streaming or it may be a leftover, and a row marked (stale: no process attached) is one whose owning process is gone.

Note. The system id is the second path segment: vrobots/1/z/state is robot 1. This is how you find out which ids the scene really has, which matters because ids are allocated at scene load and keep incrementing. See System ids, and the two kinds of robot.

Two flags are worth knowing now: -k filters keys by case-insensitive substring, and -t sets the zenoh observation window in seconds (default 1.5).

What an empty list means

With nothing running, the tool tells you what it ruled out:

(no topics)

Nothing published on `vrobots/**` in 1.5s, and no iceoryx2 camera
stream is registered. Usually one of:
  - the simulator is not in Play mode
  - it is on another machine (pass --router tcp/<host>:7447)
  - the window was too short for zenoh discovery (try -t 5)

An empty list is a legitimate result, not an error, and the command still exits 0. Work the three causes in order.

flowchart TD
  A[topic list is empty] --> B{Simulator in Play mode?}
  B -->|No| B1[Press Play, run it again]
  B -->|Yes| C{Simulator on this machine?}
  C -->|No| C1[Add --router tcp/host:7447]
  C -->|Yes| D{Does -t 5 list topics?}
  D -->|Yes| D1[zenoh discovery needed a longer window]
  D -->|No| E{Did you pass -k?}
  E -->|Yes| E1[The filter excluded everything]
  E -->|No| F[Compare pins: vrobots --version]

Loading a project into the Unity editor is not enough: the robots publish only while the scene is playing. A remote simulator needs --router tcp/<host>:7447, and even then only the [z] rows will appear, because iceoryx2 camera streams cannot cross hosts at all.

What rate am I actually getting?

Once a topic is listed, watch that one key. The argument is an exact key expression; there are no wildcards.

cargo run -p vrobots-sdk --bin vrobots -- topic hz vrobots/1/z/state
vrobots/1/z/state  [z]  watched 5.0 s

  rate          25.00 Hz      126 samples over a 5.000 s span
  interval   mean 40.00 ms    min 39.10   max 41.20   jitter 0.42 (sd)
  latency    mean 1.20 ms    min 0.80   max 2.10   publish stamp -> here
  seq        1041 -> 1166      0 gap(s), 0 missed
  payload        1352 B avg   33.8 kB/s over the window

Every line reports a spread as well as a summary, because a mean interval on its own hides the one long stall that broke a control loop. The two numbers to read first are the max interval and the gap count: together they explain a loop that stutters even though the average rate looks correct. -w changes the window, which defaults to 5 seconds.

Gotcha. Silence here is a failure, unlike topic list. If nothing arrives, topic hz returns a timeout naming the three things it could be: an idle topic, a typo in the key (a zenoh subscribe on a wrong key is silent, never an error), or a simulator on another host.

Next: Hello states

See also: The vrobots command, Measuring rates, Discovery from code

Hello states

Your first program: connect to a robot and print its position at your own rate.

cargo run -p vrobots-examples --bin ex01_hello_states
./target/cpp-build/ex01_hello_states
python examples/python/ex01_hello_states.py

The whole program

There is no framework here. main does setup, then owns a plain infinite loop.

From examples/rust/src/bin/ex01_hello_states.rs:

use vrobots_sdk::{RobotType, VirtualRobot, VrError};

const SYS_ID: u32 = 1; // the multirotor in the test scene
const HZ: f64 = 50.0;

fn main() -> Result<(), VrError> {
    // ===== setup =====
    vrobots_sdk::init_logging("info");
    let robot = VirtualRobot::connect(RobotType::Multirotor, Some(SYS_ID))?;

    // ===== loop =====
    loop {
        let s = robot.states(); // immutable latest snapshot, never torn
        let [x, y, z] = s.kin.lin_pos;
        println!("State t={:.3} pos=({x:.3},{y:.2},{z:.2})", s.elapsed);
        robot.rate(HZ); // drift-compensated pacing, Hz
    }
}
The same in C++ (examples/cpp/ex01_hello_states.cpp)
#include <cstdio>
#include <vrobots_sdk.hpp>

constexpr std::uint32_t SYS_ID = 1;  // the multirotor in the test scene
constexpr double HZ = 50.0;

/// Everything the SDK waits on, retries or drops shows up here. Registering it
/// before connect() is the point: connect is the noisiest moment, and a hang
/// with no log is the hardest thing to debug.
static void on_log(vrsdk::LogLevel level, const char* target, const char* message) {
    std::printf("[%-5s %s] %s\n", vrsdk::to_string(level), target, message);
}

int main() {
    try {
        // ===== setup =====
        vrsdk::check_version();  // header and library must be the same release
        vrsdk::set_log_callback(on_log);

        vrsdk::VirtualRobot robot(vrsdk::RobotType::Multirotor, SYS_ID);
        robot.connect();  // blocks until the first state snapshot arrives
        std::printf("connected to sys_id %u\n", robot.sys_id());

        // ===== loop =====
        for (;;) {
            const vrsdk::State s = robot.states();  // latest snapshot, never torn
            const double* p = s.kin().lin_pos;
            std::printf("State t=%.3f pos=(%.3f,%.2f,%.2f)\n", s.elapsed, p[0], p[1], p[2]);
            robot.rate(HZ);  // drift-compensated pacing, Hz
        }
    } catch (const vrsdk::Error& e) {
        // `code()` is the SDK's stable number -- the same one Python's
        // VrError.code and the CLI's `error [N]` report.
        std::fprintf(stderr, "error [%d] %s\n", e.code(), e.what());
        return 1;
    }
}
The same in Python (examples/python/ex01_hello_states.py)
import vrsdk
from vrsdk import RobotType, VirtualRobot

SYS_ID = 1  # the multirotor in the test scene
HZ = 50


def main() -> None:
    # ===== setup =====
    vrsdk.init_logging("info")
    mr = VirtualRobot(RobotType.MULTIROTOR, sys_id=SYS_ID)
    mr.connect()

    # ===== loop =====
    while True:
        s = mr.states  # immutable latest snapshot, never torn
        x, y, z = s.kin.lin_pos
        print(f"State t={s.elapsed:.3f} pos=({x:.3f},{y:.2f},{z:.2f})")
        mr.rate(HZ)  # drift-compensated pacing, Hz


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\nstopped.")
    except vrsdk.VrError as e:
        # Every SDK failure is one exception type carrying a stable code.
        raise SystemExit(f"error [{e.code} {e.kind}] {e.detail}")

Three surfaces, one shape: setup, then a loop you own. The C++ version registers a log callback where Rust and Python call init_logging, and states is a method in Rust and C++ but a property in Python. Fifty lines a second, until you press Ctrl-C:

State t=0.000 pos=(0.000,1.05,0.00)
State t=0.020 pos=(0.000,1.05,0.00)
State t=0.040 pos=(0.000,1.05,0.00)
State t=0.060 pos=(0.000,1.05,0.00)

init_logging("info") also puts the SDK's own connect progress on stderr. Your position numbers will differ; a robot sitting still on the ground is the expected first sight.

The STM32 shape, line by line

Read the program as four moves.

  1. init_logging("info") installs a tracing subscriber. RUST_LOG overrides the filter you pass, and the call is a no-op if a subscriber is already installed.
  2. VirtualRobot::connect(RobotType::Multirotor, Some(SYS_ID)) attaches to a robot the scene already contains. Some(id) never touches the manager's create service. It subscribes the state topic and blocks until the first snapshot arrives, so states() is valid the instant connect returns.
  3. robot.states() hands back the latest snapshot. This is the whole read API.
  4. robot.rate(HZ) sleeps until the next tick, compensating for drift, so the loop runs at the rate you asked for rather than that rate minus your own work.

There is no base class, no runner, no setup() and no update() callback. The SDK never calls your code. This is the single biggest difference from the older Python client, and The shape of a program is the page that argues it.

What states() guarantees

Three guarantees, and one absence that surprises people.

  • It never blocks. An SDK-owned subscriber thread keeps a snapshot fresh; you read it at whatever rate suits your controller. A 50 Hz loop against a 25 Hz stream is legal and sees each sample twice.
  • It is never torn. You get a whole snapshot or the previous whole snapshot, never a position from one sample and a velocity from the next.
  • It never fails. There is no error return. If the simulator stops, states() keeps handing back the last snapshot forever, so a frozen robot and a stopped simulator look identical from inside the loop.
  • There is no "is it new" flag. Nothing on the snapshot says whether you have already seen it.

Gotcha. Because a stall presents as unchanging numbers rather than an error, polling states() cannot detect one. wait_new_state(timeout) blocks for a genuinely newer sample and returns VrError::Timeout when none arrives, which is a status rather than a failure. That is the detector, and ex09_state_paced_loop is the example.

elapsed is not a wall clock

s.elapsed is seconds since this robot's first state sample, as an f64, monotonic, and shared by every one of that robot's streams including its cameras. It is the field to print and to plot against.

It is not the time of day and it is not your process's uptime. When you need to compare across streams, use s.t_ns, which is nanoseconds since the unix epoch on the simulator's clock and is directly subtractable from a camera frame's t_ns. s.seq is the per-topic sequence number: a jump larger than one means a sample was dropped.

Which id is the multirotor

SYS_ID is 1 because in the test scene, booted straight into the Flatworld scene, sys_id 1 is the multirotor and sys_id 0 is the truck.

That is a convenience, not a contract. Ids are allocated at scene load and keep incrementing across loads, so the same scene reloaded gives you different numbers. A const at the top of an example is there so the example has something to run against, and vrobots topic list is what tells you the truth.

Next: Hello control

See also: Truth, measured and believed, Pacing your loop, Timestamps and sequence numbers

Hello control

Close the loop: send pulse widths to a multirotor and read them back from the state stream.

cargo run -p vrobots-examples --bin ex02_hello_control
./target/cpp-build/ex02_hello_control
python examples/python/ex02_hello_control.py

The whole program

The same loop as Hello states, with two lines added: one that computes a command and one that sends it.

From examples/rust/src/bin/ex02_hello_control.rs:

use vrobots_sdk::{RobotType, VirtualRobot, VrError};

const SYS_ID: u32 = 1; // the multirotor in the test scene
const PWM_US: f64 = 1501.0; // microseconds per rotor, on the 1100-2000 band
const HZ: f64 = 100.0;

fn main() -> Result<(), VrError> {
    // ===== setup =====
    vrobots_sdk::init_logging("info");
    let robot = VirtualRobot::connect(RobotType::Multirotor, Some(SYS_ID))?;

    // ===== loop =====
    loop {
        let s = robot.states();
        let [x, y, z] = s.kin.lin_pos;
        println!(
            "State t={:.3} pos=({x:.3},{y:.2},{z:.2}) echo={:?}",
            s.elapsed, s.actuator.pwm
        );

        // Do some COOL control here and publish -- PID/EKF is user code, NOT the SDK.
        let cool_control_result = [PWM_US; 4];
        robot.set_mr_pwm(cool_control_result)?;

        robot.rate(HZ);
    }
}
The same in C++ (examples/cpp/ex02_hello_control.cpp)
constexpr std::uint32_t SYS_ID = 1;  // the multirotor in the test scene
constexpr double PWM_US = 1501.0;    // microseconds per rotor, 1100-2000 band
constexpr double HZ = 100.0;

int main() {
    try {
        // ===== setup =====
        vrsdk::check_version();
        vrsdk::VirtualRobot robot(vrsdk::RobotType::Multirotor, SYS_ID);
        robot.connect();
        std::printf("connected to sys_id %u\n", robot.sys_id());

        // ===== loop =====
        for (;;) {
            const vrsdk::State s = robot.states();
            const double* p = s.kin().lin_pos;

            // Do some COOL control here and publish -- PID/EKF is user code,
            // NOT the SDK.
            const std::vector<double> cool_control_result = {PWM_US, PWM_US, PWM_US, PWM_US};
            robot.set_mr_pwm(cool_control_result);

            // The echo: what the robot actually latched, from the state stream.
            const std::vector<std::uint32_t> echo = s.pwm();
            std::printf("State t=%.3f pos=(%.3f,%.2f,%.2f)  pwm_echo=[", s.elapsed, p[0], p[1],
                        p[2]);
            for (std::size_t i = 0; i < echo.size(); ++i) {
                std::printf("%s%u", i ? "," : "", echo[i]);
            }
            std::printf("]  rotor0=%.1f rad/s\n",
                        s.actuator().measured_count > 0 ? s.actuator().measured[0] : 0.0);

            robot.rate(HZ);
        }
    } catch (const vrsdk::Error& e) {
        std::fprintf(stderr, "error [%d] %s\n", e.code(), e.what());
        return 1;
    }
}
The same in Python (examples/python/ex02_hello_control.py)
SYS_ID = 1  # the multirotor in the test scene
PWM_US = 1501.0  # microseconds per rotor, on the 1100-2000 band
HZ = 100


def main() -> None:
    # ===== setup =====
    vrsdk.init_logging("info")
    mr = VirtualRobot(RobotType.MULTIROTOR, sys_id=SYS_ID)
    mr.connect()

    # ===== loop =====
    while True:
        s = mr.states
        x, y, z = s.kin.lin_pos
        print(
            f"State t={s.elapsed:.3f} pos=({x:.3f},{y:.2f},{z:.2f}) "
            f"echo={s.actuator.pwm}"
        )

        # Do some COOL control here and publish -- PID/EKF is user code, NOT the
        # SDK. `set_mr_pwm(a, b, c, d)` and `set_mr_pwm([a, b, c, d])` are the
        # same call.
        cool_control_result = [PWM_US] * 4
        mr.set_mr_pwm(cool_control_result)

        mr.rate(HZ)

Python accepts the four values loose or as one sequence; C++ takes a std::vector<double> and reads the echo through s.pwm(), which is the same actuator.pwm array the other two print directly.

State t=0.000 pos=(0.000,1.05,0.00) echo=[1501, 1501, 1501, 1501]
State t=0.010 pos=(0.000,1.05,0.00) echo=[1501, 1501, 1501, 1501]
State t=0.020 pos=(0.000,1.05,0.00) echo=[1501, 1501, 1501, 1501]

The loop prints the snapshot before it sends, so the first line or two echo whatever was latched on that robot before you started. Your own value appears a physics step after your first send.

The 1100 to 2000 microsecond band

set_mr_pwm takes four pulse widths in microseconds, one per rotor, and every one must be finite and inside 1100 to 2000. The SDK checks that client-side and returns VrError::InvalidArgument before anything reaches the wire, so a bad value is one of the few command mistakes you find out about immediately.

1100 is idle. A flying drone commanded [1100; 4] falls. There is no fixed hover value: hover is wherever total thrust crosses weight for the robot's current mass and thrust curves, both of which are configurable.

Gotcha. Length is not validated the same way. set_mr_pwm_n accepts a slice of any non-empty length, and a wrong rotor count is dropped by the simulator with a log line no client can see, leaving the previous command latched. See Driving a multirotor.

You are the flight controller

SET_MR_PWM is the lowest actuation level the simulator offers. Nothing sits between these four numbers and the thrust curves: no attitude stabilisation, no rate damping, no mixer. Four equal pulse widths produce four equal thrusts, and any imbalance in mass or inertia tips the vehicle over with nothing to catch it.

That is deliberate. The point of the simulator is that the stabilisation is your code. A PID loop, an EKF, an LQR: all of it lives in your main, and the SDK contributes nothing to it.

The actuator echo is the only receipt

Commands are published to vrobots/{sys_id}/z/cmd and the robot acknowledges nothing. It drains its command queue at the start of the next physics step and moves on. A wrong command id, a wrong sys_id and a wrong array length all present identically from outside: the state stream does not change.

So the receipt is s.actuator.pwm, the commanded pulse widths echoed back inside the next snapshot. Watching it settle on [1501, 1501, 1501, 1501] is the proof that your command landed on the robot you meant.

Commands also latch. Each one is a setpoint, not an impulse, and the last one received stays in effect until the next arrives. There is no watchdog, so a 5 Hz sender and a 100 Hz sender are both fine, and a controller that stops sending leaves the robot flying its final command. This example runs at 100 Hz while physics runs at 50 Hz, which is harmless.

1501 will not lift it, 1700 will

PWM_US is 1501, barely off idle, so the printed position does not change: the example is about seeing the echo, not about flying. Edit the constant to 1700 and run it again to watch the vertical component of pos move.

Note. Which component that is depends on the robot's frame. The multirotor publishes frd, where the third component points down, so climbing makes it more negative. Read s.coord_frame_id rather than assuming, as Frames, axes and units explains.

Next: Hello car

See also: Commands latch, Driving a multirotor, Actuators

Hello car

Drive the truck, whose three channels use a different band from the multirotor's.

cargo run -p vrobots-examples --bin ex05_hello_car
./target/cpp-build/ex05_hello_car
python examples/python/ex05_hello_car.py

The whole program

The same loop shape as Hello control, a different actuator, and a different robot: SYS_ID is 0 because the truck is the other vehicle in the test scene.

From examples/rust/src/bin/ex05_hello_car.rs:

use vrobots_sdk::{RobotType, VirtualRobot, VrError};

const SYS_ID: u32 = 0; // the truck in the test scene
const STEER_US: f64 = 1400.0; // left of centre
const THROTTLE_US: f64 = 1650.0; // light forward
const BRAKE_US: f64 = 1100.0; // released
const HZ: f64 = 50.0;

fn main() -> Result<(), VrError> {
    // ===== setup =====
    vrobots_sdk::init_logging("info");
    let robot = VirtualRobot::connect(RobotType::Truck, Some(SYS_ID))?;

    // ===== loop =====
    loop {
        let s = robot.states();
        let [x, y, z] = s.kin.lin_pos;
        // lin_vel is a BODY-frame vector, so no single component is "the speed";
        // its magnitude is.
        let [vx, vy, vz] = s.kin.lin_vel;
        let speed = (vx * vx + vy * vy + vz * vz).sqrt();
        println!(
            "State t={:.3} pos=({x:.3},{y:.2},{z:.2}) speed={speed:.2} m/s echo={:?}",
            s.elapsed, s.actuator.pwm
        );

        // A gentle left arc: steering left of centre, light forward throttle.
        robot.set_car(STEER_US, THROTTLE_US, Some(BRAKE_US))?;

        robot.rate(HZ);
    }
}
The same in C++ (examples/cpp/ex05_hello_car.cpp)
constexpr std::uint32_t SYS_ID = 0;      // the truck in the test scene
constexpr double STEER_US = 1400.0;      // left of centre
constexpr double THROTTLE_US = 1650.0;   // light forward
constexpr double BRAKE_US = 1100.0;      // released
constexpr double HZ = 50.0;

int main() {
    try {
        // ===== setup =====
        vrsdk::check_version();
        vrsdk::VirtualRobot robot(vrsdk::RobotType::Truck, SYS_ID);
        robot.connect();
        std::printf("connected to sys_id %u\n", robot.sys_id());

        // ===== loop =====
        for (;;) {
            const vrsdk::State s = robot.states();
            const double* p = s.kin().lin_pos;

            // A gentle left arc: steering left of centre, light forward
            // throttle, brake released.
            robot.set_car(STEER_US, THROTTLE_US, BRAKE_US);

            // Speed from the body-frame twist, so it is visible that the truck
            // really is moving rather than that the command was merely accepted.
            const double* v = s.kin().lin_vel;
            const double speed = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);

            const std::vector<std::uint32_t> echo = s.pwm();
            std::printf("State t=%.3f pos=(%.3f,%.2f,%.2f) speed=%.2f m/s  pwm_echo=[", s.elapsed,
                        p[0], p[1], p[2], speed);
            for (std::size_t i = 0; i < echo.size(); ++i) {
                std::printf("%s%u", i ? "," : "", echo[i]);
            }
            std::printf("]\n");

            robot.rate(HZ);
        }
    } catch (const vrsdk::Error& e) {
        std::fprintf(stderr, "error [%d] %s\n", e.code(), e.what());
        return 1;
    }
}
The same in Python (examples/python/ex05_hello_car.py)
SYS_ID = 0  # the truck in the test scene
STEER_US = 1400.0  # left of centre
THROTTLE_US = 1650.0  # light forward
BRAKE_US = 1100.0  # released
HZ = 50


def main() -> None:
    # ===== setup =====
    vrsdk.init_logging("info")
    car = VirtualRobot(RobotType.TRUCK, sys_id=SYS_ID)
    car.connect()

    # ===== loop =====
    while True:
        s = car.states
        x, y, z = s.kin.lin_pos
        # lin_vel is a BODY-frame vector, so no single component is "the speed";
        # its magnitude is.
        speed = math.dist(s.kin.lin_vel, (0.0, 0.0, 0.0))
        print(
            f"State t={s.elapsed:.3f} pos=({x:.3f},{y:.2f},{z:.2f}) "
            f"speed={speed:.2f} m/s echo={s.actuator.pwm}"
        )

        # A gentle left arc: steering left of centre, light forward throttle.
        car.set_car(STEER_US, THROTTLE_US, BRAKE_US)

        car.rate(HZ)

The brake is the third argument in all three, and it is optional in all three: Rust wraps it in Some, C++ in a std::optional, Python defaults it to None. Omitting it sends the two-channel form, which brakes nothing.

The truck pulls away and curves left, so pos walks and speed climbs to a steady value:

State t=0.000 pos=(0.000,0.35,0.00) speed=0.00 m/s echo=[1400, 1650, 1100]
State t=0.020 pos=(0.021,0.35,0.00) speed=1.06 m/s echo=[1400, 1650, 1100]
State t=0.040 pos=(0.043,0.35,0.01) speed=1.09 m/s echo=[1400, 1650, 1100]

The three channels

set_car(steer, throttle, brake) takes pulse widths in microseconds, one per channel. The truck's factory band is 1100 to 1900, which is not the multirotor's 1100 to 2000.

Channel110015001900
steerfull leftcentrefull right
throttlefull reversestop (idle brake)full forward
brakereleased--full

The SDK's client-side check is the wider 1100 to 2000 band it applies to every pulse width, so a value of 1950 is accepted by the SDK and handled by the truck's own limits. Stay inside 1100 to 1900 and the two agree.

Brake is bottom-anchored

Steer and throttle are centre-anchored: 1500 is neutral for both, and the interesting values live on either side of it. Brake is not. 1100 is released and 1900 is full, so the neutral value for the brake channel is the bottom of the band, not the middle.

Sending 1500 on the brake channel therefore applies braking rather than none, which is a common reason a truck that should be accelerating crawls instead.

Gotcha. brake is an Option<f64>. Passing None sends the two-channel form of SET_CAR, which brakes nothing. That is a different statement from "the brake is released to 1100": the channel is absent from the message. Pass Some(1100.0) when you want to say released explicitly.

Speed is a magnitude, not a component

kin.lin_vel is a body-frame vector, so no single component of it is "the speed". The example takes the magnitude, which is why the arithmetic is spelled out rather than reading vy and calling it done.

kin.lin_pos, by contrast, is a world-frame position. That split (pose in world, twist and acceleration in body) is physics rather than configuration, and it holds for every robot and for both the truth and the estimate blocks.

The truck also disagrees with the multirotor about which way is up: it publishes "fru" where the multirotor publishes "frd", so the third component of a vector means opposite things on the two vehicles. s.coord_frame_id is the authoritative answer, and Frames, axes and units is the page that settles it.

Next: Hello image

See also: Driving the truck, The truck drivetrain, Kinematics

Hello image

Open the camera the robot already has, and read frames as they arrive.

cargo run -p vrobots-examples --bin ex03_hello_image
./target/cpp-build/ex03_hello_image
python examples/python/ex03_hello_image.py

Opening a camera

Every vrobot ships with front_left and front_right mounted, at 720p rgba8. Reading images does not start with creating a camera: it starts with attaching to one of those. open_camera opens the iceoryx2 subscriber and touches the simulator not at all. The name, resolution and format are constants, as they are in every example.

From examples/rust/src/bin/ex03_hello_image.rs:

const SYS_ID: u32 = 1; // the multirotor in the test scene
const CAMERA: &str = "front_left"; // every vrobot ships front_left and front_right
const RESOLUTION: &str = "720p";
const FORMAT: &str = "rgba8"; // Unity's native readback -- four channels, NOT rgb8
const FRAMES: u64 = 120; // then exit
const HZ: f64 = 100.0;

fn main() -> Result<(), VrError> {
    // ===== setup =====
    vrobots_sdk::init_logging("info");
    let robot = VirtualRobot::connect(RobotType::Multirotor, Some(SYS_ID))?;

    // open_camera SUBSCRIBES to a camera the robot already has, without mutating
    // the sim. The name, resolution and format must match the publisher exactly
    // -- on iceoryx2 those three strings are the stream identity -- so a mismatch
    // surfaces as VrError::Timeout (ex13 shows that path).
    let cam = robot.open_camera(CAMERA, RESOLUTION, FORMAT)?;
    println!("camera stream: {}", cam.service_name());

    let mut seen = 0u64;
The same in C++ (examples/cpp/ex03_hello_image.cpp)
constexpr std::uint32_t SYS_ID = 1;  // the multirotor in the test scene
constexpr const char* CAMERA = "front_left";  // every vrobot ships front_left and front_right
constexpr const char* RESOLUTION = "720p";
constexpr const char* FORMAT = "rgba8";  // Unity's native readback -- four channels, NOT rgb8
constexpr std::uint64_t FRAMES = 120;    // then exit
constexpr double HZ = 100.0;

int main() {
    try {
        // ===== setup =====
        vrsdk::check_version();
        vrsdk::VirtualRobot robot(vrsdk::RobotType::Multirotor, SYS_ID);
        robot.connect();

        // open_camera SUBSCRIBES to a camera the robot already has, without
        // mutating the sim. The name, resolution and format must match the
        // publisher exactly -- on iceoryx2 those three strings are the stream
        // identity -- so a mismatch surfaces as a timeout (ex13 shows that path).
        vrsdk::CameraStream cam = robot.open_camera(CAMERA, RESOLUTION, FORMAT);
        std::printf("camera stream: %s\n", cam.service_name().c_str());

        std::uint64_t seen = 0;
The same in Python (examples/python/ex03_hello_image.py)
SYS_ID = 1  # the multirotor in the test scene
CAMERA = "front_left"  # every vrobot ships front_left and front_right
RESOLUTION = "720p"
FORMAT = "rgba8"  # Unity's native readback -- four channels, NOT rgb8
FRAMES = 300  # then exit
HZ = 100


def main() -> None:
    # ===== setup =====
    vrsdk.init_logging("info")
    mr = VirtualRobot(RobotType.MULTIROTOR, sys_id=SYS_ID)
    mr.connect()

    # open_camera SUBSCRIBES to a camera the robot already has, without
    # mutating the sim. The name, resolution and format must match the
    # publisher exactly -- on iceoryx2 those three strings are the stream
    # identity -- so a mismatch surfaces as a TIMEOUT (ex13 shows that path).
    cam = mr.open_camera(CAMERA, RESOLUTION, FORMAT)
    print(f"camera stream: {cam.service_name}")

    seen = 0

The printed name is the iceoryx2 service, and it matches a row of vrobots topic list character for character:

camera stream: vrobots/1/i/cam/front_left/720p_rgba8

Two processes can open the same stream and neither disturbs the other, because neither owns the camera. mount_camera is the other verb: it creates a camera of your choosing, and it is what page Lens and mount pose covers. You need it only when the pair the robot ships cannot serve you.

The loop, and what fresh() means

Images and states are two independent streams. The image half of the loop runs once per frame while the state half runs every iteration, and the code says which by branching on fresh().

#![allow(unused)]
fn main() {
    // ===== loop =====
    while seen < FRAMES {
        let s = robot.states();

        // Images are a separate stream with their own timestamps -- never assume
        // they match the state's. Compare t_ns explicitly when fusing.
        if let Some(frame) = cam.fresh() {
            // Some only if new since the last read
            seen += 1;
            println!(
                "Image {} t={:.3} size=({}x{}) seq={} lag_vs_state={:.1} ms",
                frame.camera_name,
                frame.elapsed,
                frame.width,
                frame.height,
                frame.seq,
                (s.t_ns - frame.t_ns) as f64 / 1e6
            );
}
The same in C++ (examples/cpp/ex03_hello_image.cpp)
        // ===== loop =====
        while (seen < FRAMES) {
            const vrsdk::State s = robot.states();

            // A value only if new since the last read.
            if (auto frame = cam.fresh()) {
                ++seen;
                std::printf("Image %s t=%.3f size=(%ux%u) seq=%llu lag_vs_state=%.1f ms\n",
                            frame->camera_name.c_str(), frame->elapsed(), frame->width(),
                            frame->height(),
                            static_cast<unsigned long long>(frame->seq()),
                            static_cast<double>(s.t_ns - frame->t_ns()) / 1e6);
The same in Python (examples/python/ex03_hello_image.py)
    # ===== loop =====
    while seen < FRAMES:
        s = mr.states

        # Images are a separate stream with their own timestamps -- never assume
        # they match the state's. Compare t_ns explicitly when fusing.
        if cam.fresh:
            frame = cam.frame  # metadata for the image we are about to read
            img = cam.image  # numpy (h, w, c) uint8, top-down, RGB(A)
            seen += 1

            print(
                f"Image {frame.camera_name} t={frame.elapsed:.3f} "
                f"size=({frame.width}x{frame.height}) seq={frame.seq} "
                f"lag_vs_state={(s.t_ns - frame.t_ns) / 1e6:.1f} ms"
            )

Rust and C++ ask and receive in one move, so the frame arrives inside an Option that the if unwraps. Python splits it: cam.fresh is a boolean property and cam.image is the numpy array, and reading the image is what consumes the freshness.

State t=0.410 (no new frame)
Image front_left t=0.412 size=(1280x720) seq=17 lag_vs_state=12.4 ms
      sky-ness (B-R) top=+98 bottom=-25 (top-down: sky above ground), fov_y=61.9 deg
State t=0.420 (no new frame)

fresh() returns Some only when a frame has arrived since the last call, and it hands each frame out exactly once. That makes it the right read for work that must not run twice on one image. Its counterpart latest() returns the current frame regardless and does not consume freshness.

The lag_vs_state figure is why the two streams are never paired by the SDK. Frames arrive at the render rate and states at 25 Hz, so no frame belongs to any state. Both t_ns values are on the same clock, so subtracting them is meaningful, and doing that subtraction explicitly is what fusion looks like here.

Gotcha. Camera frames ride iceoryx2 shared memory, so they are same-host only. zenoh will happily reach a simulator on another machine and deliver states, and not one frame will follow. There is no error: open_camera times out after ConnectOptions::camera_timeout, five seconds by default.

Rows in frame.data are row-major and top-down: row 0 is the top of the picture. The wire is bottom-up, following Unity's render order, and the SDK flips while copying. The sky-ness figures are the check on that, measuring blue minus red on the first and last rows: outdoors the top row is sky and the bottom is ground. Channel order is the renderer's own RGBA, never swapped, so OpenCV users convert to BGR at the call site.

Nothing to clean up

There is no unmount at the end, and that is the point of opening rather than mounting. This handle never created a camera, so it has nothing to remove; letting the stream go ends this subscription and front_left keeps rendering and publishing for everyone else.

#![allow(unused)]
fn main() {
    // Nothing to unmount: this handle never created a camera. Dropping the stream
    // ends this subscription only -- front_left keeps rendering and publishing for
    // everyone else.
    let stats = cam.stats();
    println!(
        "{seen} frame(s), received={} decode_errors={} seq_gaps={}",
        stats.received, stats.decode_errors, stats.seq_gaps
    );
    Ok(())
}
}
The same in C++ (examples/cpp/ex03_hello_image.cpp)
        // Nothing to unmount: this handle never created a camera. Letting the
        // stream go ends this subscription only -- front_left keeps rendering and
        // publishing for everyone else.
        const vrsdk_camera_stats_t st = cam.stats();
        std::printf("%llu frame(s), received=%llu decode_errors=%llu seq_gaps=%llu\n",
                    static_cast<unsigned long long>(seen),
                    static_cast<unsigned long long>(st.received),
                    static_cast<unsigned long long>(st.decode_errors),
                    static_cast<unsigned long long>(st.seq_gaps));
        return 0;
The same in Python (examples/python/ex03_hello_image.py)
    # Nothing to unmount: this handle never created a camera. Letting the stream
    # be collected ends this subscription only -- front_left keeps rendering and
    # publishing for everyone else.
    st = cam.stats
    print(
        f"{seen} frame(s), received={st.received} "
        f"decode_errors={st.decode_errors} seq_gaps={st.seq_gaps}"
    )
120 frame(s), received=120 decode_errors=0 seq_gaps=0

That count is FRAMES, so the Python run prints 300 rather than 120: it opens a window and wants more of them. Because nothing was mutated, the loop bound is a convenience rather than a cleanup deadline, and Ctrl-C is as safe an exit as running to the end. The one example that does have a cleanup step is ex17_camera_pose, which mounts a camera of its own.

Next: Hello service

See also: Mount, open and unmount, Freshness, Inside a frame, Showing frames in a window

Hello service

Create a robot from code and delete it again, which is what explicit lifecycle means.

cargo run -p vrobots-examples --bin ex04_hello_service
./target/cpp-build/ex04_hello_service
python examples/python/ex04_hello_service.py

The whole program

Lifecycle and configuration are one-shot request and response, so this is the first example with no loop in it.

From examples/rust/src/bin/ex04_hello_service.rs:

use vrobots_sdk::{RobotType, VirtualRobot, VrError};

const ROBOT_TYPE: RobotType = RobotType::Multirotor;

fn main() -> Result<(), VrError> {
    vrobots_sdk::init_logging("info");

    // Create a NEW robot in the sim (no sys_id -> manager create; reply carries the id).
    let robot = VirtualRobot::connect(ROBOT_TYPE, None)?;
    let sys_id = robot.sys_id();
    println!("created sys_id = {sys_id}");

    // The create reply is a receipt; the robot *exists* once its state topic
    // publishes, which connect() already waited for -- so this is real data.
    let s = robot.states();
    println!(
        "first state: t={:.3} seq={} name={:?}",
        s.elapsed, s.seq, s.name
    );
    println!("its state topic: {}", vrobots_sdk::topics::state(sys_id));

    // Deletion is explicit and never implicit. delete() waits for the state topic
    // to fall silent: the manager's ack is only a receipt, absence is the proof.
    robot.delete()?;
    println!(
        "deleted sys_id = {sys_id} (is_deleted={})",
        robot.is_deleted()
    );

    // The handle is spent. Commands do not silently do nothing -- they say why.
    match robot.set_mr_pwm([1500.0; 4]) {
        Ok(()) => println!("unexpected: a deleted robot accepted a command"),
        Err(e) => println!("the handle is spent, as expected: [{}] {e}", e.code()),
    }
    Ok(())
}
The same in C++ (examples/cpp/ex04_hello_service.cpp)
int main() {
    try {
        vrsdk::check_version();

        // Create a NEW robot in the sim: `create` means "no sys_id", so the
        // manager assigns one and the reply carries it. (A constructor would be
        // ambiguous with the attach form -- see the header.)
        vrsdk::VirtualRobot robot = vrsdk::VirtualRobot::create(vrsdk::RobotType::Multirotor);
        robot.connect();
        const std::uint32_t sys_id = robot.sys_id();
        std::printf("created sys_id = %u\n", sys_id);

        // The create reply is a receipt; the robot *exists* once its state topic
        // publishes, which connect() already waited for -- so this is real data.
        const vrsdk::State s = robot.states();
        std::printf("first state: t=%.3f seq=%llu name=\"%s\"\n", s.elapsed,
                    static_cast<unsigned long long>(s.seq), s.name.c_str());
        // The C++ surface has no topic-name builder (Rust has
        // `vrobots_sdk::topics`, Python has `vrsdk.topics`), and the shape is
        // fixed by the wire, so compose it here.
        std::printf("its state topic: vrobots/%u/z/state\n", sys_id);

        // Deletion is explicit and never implicit. The manager's ack is only a
        // receipt, so remove() also waits for the robot's state topic to fall
        // silent -- that is the real confirmation.
        robot.remove();
        std::printf("deleted sys_id = %u (removed=%s)\n", sys_id,
                    robot.removed() ? "true" : "false");

        // The handle is spent. Commands do not silently do nothing -- they say
        // why.
        try {
            robot.set_mr_pwm({1500.0, 1500.0, 1500.0, 1500.0});
            std::printf("unexpected: a deleted robot accepted a command\n");
        } catch (const vrsdk::Error& e) {
            std::printf("the handle is spent, as expected: [%d] %s\n", e.code(), e.what());
        }
        return 0;
    } catch (const vrsdk::Error& e) {
        std::fprintf(stderr, "error [%d] %s\n", e.code(), e.what());
        return 1;
    }
}
The same in Python (examples/python/ex04_hello_service.py)
ROBOT_TYPE = RobotType.MULTIROTOR  # or RobotType.TRUCK / RobotType.from_key("truck")


def main() -> None:
    vrsdk.init_logging("info")

    # Create a NEW robot in the sim (no sys_id -> manager create; the reply
    # carries the assigned id).
    robot = VirtualRobot(ROBOT_TYPE)
    robot.connect()
    sys_id = robot.sys_id
    print(f"created sys_id = {sys_id}")

    # The create reply is a receipt; the robot *exists* once its state topic
    # publishes, which connect() already waited for -- so this is real data.
    s = robot.states
    print(f"first state: t={s.elapsed:.3f} seq={s.seq} name={s.name!r}")
    print(f"its state topic: {vrsdk.topics(sys_id)['state']}")

    # Deletion is explicit and never implicit. delete() waits for the state topic
    # to fall silent: the manager's ack is only a receipt, absence is the proof.
    robot.delete()
    print(f"deleted sys_id = {sys_id} (is_deleted={robot.is_deleted})")

    # The handle is spent. Commands do not silently do nothing -- they say why.
    try:
        robot.set_mr_pwm(1500, 1500, 1500, 1500)
        print("unexpected: a deleted robot accepted a command")
    except vrsdk.VrError as e:
        print(f"the handle is spent, as expected: [{e.code} {e.kind}] {e.detail}")

Three differences worth naming. C++ spells creation VirtualRobot::create rather than a one-argument constructor, because a literal 0 would be ambiguous between "attach to sys_id 0" and a null options pointer. C++ spells deletion remove(), because delete is a keyword. And C++ has no topic-name builder, so it composes vrobots/<id>/z/state inline where Rust calls topics::state and Python calls vrsdk.topics.

The run takes a second or two, most of it spent waiting for the new robot's state topic to start and then to stop:

created sys_id = 7
first state: t=0.000 seq=0 name="multirotor"
its state topic: vrobots/7/z/state
deleted sys_id = 7 (is_deleted=true)
the handle is spent, as expected: [8] deleted: sys_id 7 was deleted from the sim; this handle is spent

Create versus attach

The second argument to connect decides which of two quite different things happens.

ArgumentWhat it doesTouches srv/create
Some(id)attaches to a robot the scene already containsno
Noneasks the manager to spawn a new one; the reply carries the idyes

Every example so far passed Some(SYS_ID). This one passes None, so the manager allocates the id and robot.sys_id() is the only way to learn it.

Creating is limited by the scene's catalog, not the SDK's. The sandbox scene registers multirotor, truck and msd; any other key is refused with a message naming the ones it does know, which is also the only live way to enumerate the catalog. Robot types that are scene-authored only, such as the cart pole and the Global Hawk, can be attached to but never created.

Note. Create is the one non-idempotent service in the system. The SDK sends it exactly once and never retries, because every retry that reaches the manager reserves another id and spawns another robot. What connect actually does walks the four steps.

Robots outlive the process

Dropping a VirtualRobot closes its zenoh session. It does not delete the robot. The robot keeps flying its last latched command, keeps publishing state, and is still there after your program exits, after you rebuild, and after you run something else.

This is why the example calls delete(): without it, every run would leave another multirotor in the scene. Comment the delete() line out and run it twice to see exactly that, then attach ex01_hello_states to one of the ids it printed. Create in one process, attach from another, is the normal shape of a multi-program session.

The way back from a littered scene is to reload it. Ids are allocated at scene load and keep incrementing, so the reloaded scene numbers its robots differently.

Absence is the proof, both ways

connect(type, None) returns only once the new robot's state topic has published, and delete() returns only once that topic has fallen silent. Neither waits on the service acknowledgement, because an acknowledgement is packed the instant the request arrives and says nothing about whether the work happened.

That principle runs through the whole services chapter: an ack is a receipt, not a result, and the state stream is the confirmation. It is rule two of Five rules that explain everything.

After delete() the handle is spent, and it says so rather than failing quietly. Every command on it returns VrError::Deleted, error code 8, with a message naming the id.

Next: When nothing happens

See also: Robot lifecycle, System ids, and the two kinds of robot, Appendix C: Error reference

When nothing happens

A symptom-to-cause table for the failures that stop people on their first afternoon.

Almost every one of these is a correct behaviour presenting as a broken one. The SDK reports what it can, but a great deal of the system communicates by silence: a command that landed on the wrong robot, a topic name with a typo, and a simulator that is not playing all look the same from inside your loop.

Two commands answer most of it before you read any further:

cargo run -p vrobots-sdk --bin vrobots -- topic list
cargo run -p vrobots-sdk --bin vrobots -- --version

Symptoms and causes

SymptomLikely causeWhat to doWhere it is explained
topic list shows (no topics)The simulator is not in Play modePress Play; loading the project is not enoughFirst contact
The simulator is on another machinePass --router tcp/<host>:7447Two transports, one simulator
zenoh discovery had too short a windowRetry with -t 5The vrobots command
A -k filter excluded everythingDrop the flagThe vrobots command
connect hangs, then times outThe sys_id does not exist in this sceneRead the ids out of topic list; ids are reallocated on every scene loadSystem ids
connect(type, None) was refused by the scene's catalogOnly multirotor, truck and msd are creatable in the sandbox; attach to the rest by idRobot lifecycle
The first state sample never arrived within probe_timeoutRun with RUST_LOG=vrobots_sdk=debug to see which of the four connect steps stalledWhat connect actually does
A command has no visible effectIt reached a different robotCompare the sys_id you connected with against topic listSystem ids
The value is inside the band but does nothing useful, for example 1501 on a multirotorWatch s.actuator.pwm: if it echoes your value, the command landed and the plant is the questionHello control
The array length is wrong for that robotThe simulator logs and ignores it, keeping the previous latched value; the SDK cannot see that logCommands latch
Nothing acts on that command id yetSET_MR_THROTTLE and the body wrench commands are on the wire and unimplementedCommands nothing acts on
A service acked ok and refused the valueOnly srv/skin ever answers ok = false; measure the result in the state stream instead of trusting the ackFive rules that explain everything
Camera frames never arriveThe simulator is not on this hosticeoryx2 is shared memory: frames cannot cross machines even when zenoh canTwo transports, one simulator
The iceoryx2 pin differs from the simulator'sCompare vrobots --version against the simulator build; a patch mismatch delivers nothing and raises nothingVersions and pins
open_camera was given a name, resolution or format that does not match the publisherCopy the stream key from topic list and split it back into its three partsMount, open and unmount
You are calling fresh() faster than frames arriveThat is correct: fresh() returns None until a new frame lands. Use latest() if you want the current one regardlessFreshness
Fields look like garbageSchema drift between the SDK and the simulatorCompare vrobots_msgs and schema_version from vrobots --version against the simulator buildVersions and pins
A vector was read in the wrong frameRead s.coord_frame_id: the truck publishes fru while the multirotor and the Global Hawk publish frd, so the third component means opposite thingsFrames, axes and units
The quaternion was unpacked as [w, x, y, z]The order is [x, y, z, w], matching the wire's Vec4 field orderFrames, axes and units
Pose and twist were assumed to share a framePose is world frame, twist and acceleration are body frame, in both the truth and the estimate blocksKinematics
An unconverged estimator is mirroring truthCheck estimate.valid before trusting estimate.kinTruth, measured and believed
A created multirotor will not moveSimulator bug: its rigidbody never integratesAttach to the scene's multirotor by id instead of creating oneSee the callout below
The numbers stopped changingThe simulator stopped, and states() keeps returning the last snapshot foreverDetect it with wait_new_state(timeout), not by expecting an errorStream health
The loop stutters at the right average rateSamples are being droppedtopic hz reports max interval and gap count, which is what a mean rate hidesMeasuring rates

Sim bug. In simulator v3.0.0 a client-created multirotor does not move. Its rigidbody never integrates, so it hangs where it spawned and ignores every pulse width and even a direct body force, while its actuator echo and rotor-speed model answer perfectly normally. Created trucks and mass-spring-dampers have live physics, and the scene's own multirotor flies. See issues/created-multirotor-frozen-dynamics.md. The examples that need a multirotor to move take an optional sys_id so you can attach to the scene's one instead.

When the table does not have it

Turn the logs up. RUST_LOG overrides whatever filter the program passed to init_logging, and the SDK's own tracing events name each connect step as it happens.

RUST_LOG=vrobots_sdk=debug cargo run -p vrobots-examples --bin ex01_hello_states

RUST_LOG reaches the Rust core, so the other two surfaces turn the volume up in their own idiom instead. Python routes the same events into the standard logging module, so vrsdk.init_logging("debug") (or raising the vrobots_sdk logger's level yourself) is the equivalent. C++ registers a handler with vrsdk::set_log_callback and then calls vrsdk::set_log_level(vrsdk::LogLevel::Debug). Logging covers all three.

Add zenoh's own view with RUST_LOG=vrobots_sdk=debug,zenoh=info. iceoryx2 logs to stderr outside tracing entirely and is controlled by IOX2_LOG_LEVEL, which the SDK defaults to errors only.

Two counters are worth printing from inside a loop that misbehaves without failing: stats() carries received, decode error, sequence gap and missed sample counts, and last_error() holds the most recent decode error. Neither tears down the session, which is the point: a malformed payload is counted and the loop keeps running.

Next: Concepts

See also: Logging, Appendix C: Error reference, Known simulator issues

Concepts

The model you need before writing a real control loop, and why five correct behaviours still surprise everyone.

Chapter 1 got a program talking to the simulator. This chapter explains the system it is talking to, so that the four API chapters after it can be short: once you know how a command travels, "commands latch" is one sentence rather than one chapter.

Five behaviours that are correct and still surprise everyone

Almost every confused bug report against this SDK is one of five behaviours. None of them is a defect. Each is invisible until it bites, and each has a different failure signature, which is why Five rules that explain everything is the page to read twice.

The behaviourWhat people expect instead
Commands latch and get no replyA command is an impulse, and a bad one comes back as an error
An ack is a receipt, not a resultok = true means the change was applied
states() never failsA stopped simulator makes the next read return an error
Robots outlive the processDropping the handle removes the robot from the scene
Every vector is in the robot's frame, not yoursOne scene has one axis convention

The common thread is that the simulator is a separate process with its own clock. You are not calling into it; you are publishing to it and reading what it publishes back. Nothing in this SDK hides that, because hiding it is what produces the bugs above.

What this chapter covers

Read it in order the first time. Every later page assumes the vocabulary this one defines, and terms are defined once, here or in Appendix D: Glossary.

PageWhat you get
Two transports, one simulatorWhy states travel over zenoh and camera frames over iceoryx2, and why the version pins are exact
The topic namespaceEvery key the simulator publishes or subscribes, and where a robot's id sits in it
System ids, and the two kinds of robotScene-authored robots you attach to, created robots you spawn
The shape of a programmain() does setup and owns the loop; the SDK never calls your code
What connect actually doesThe four steps behind a create, and every option you can change
Five rules that explain everythingThe five behaviours above, with the failure signature of each
Frames, axes and unitsWhich vector is in which frame, and what coord_frame_id decides
Rotation conversionsQuaternions, angles and matrices, and re-expressing a state in another frame

What it does not cover

This chapter is the model, not the API. It names methods where a name makes a concept concrete, and it does not tabulate their arguments: that is what Reading state, Sending commands, Cameras and images and Services and configuration are for. Per-robot behaviour lives in Supported virtual robots.

Next: Two transports, one simulator

See also: Getting started, Appendix D: Glossary

Two transports, one simulator

Why states travel over zenoh and camera frames over iceoryx2, and what that costs you.

Publish and subscribe, not call and return

Nothing in this system is a function call. The simulator publishes what it knows and subscribes to what you send, and both ends run on their own clocks. A publisher does not know who is listening, a subscriber does not know who is sending, and neither blocks waiting for the other. Services are the one exception, and even they are request/response over the same publish machinery rather than a procedure call: the reply says the request arrived, not that anything happened.

That is the whole reason the rest of this chapter exists. Every surprise in Five rules that explain everything follows from the fact that you are talking to a process that is not waiting for you.

Two transports carry that traffic, and they are not interchangeable.

zenohiceoryx2
Carriesstates, commands, services, setpointscamera frames
Scopeacross a network (--router tcp/host:7447)same host only, shared memory
Discoverynone; a topic appears only if it published during your windowa registry, so entries can exist without a live publisher
Tag in topic list[z][i]

Why the split is not arbitrary

A state snapshot is around a kilobyte and arrives 25 times a second. A 720p RGBA frame is nearly four megabytes. Pushing frames through a network-capable transport would mean serialising and copying them for a subscriber that is, in practice, always the process next door. iceoryx2 hands over a pointer into shared memory instead, so the frame is never copied across a socket at all.

The price is exactly the property that makes it fast. Shared memory does not cross a machine boundary. Set ConnectOptions::router_endpoint at a simulator on another host and you get states, commands and services, and no images: the camera stream never appears and open_camera times out after camera_timeout. That is not a misconfiguration you can fix with a flag.

Gotcha. A remote connection that works perfectly for control and returns nothing but timeouts from every camera call is not broken. Check whether the simulator is on this host before debugging the camera code.

The second consequence of the split is discovery. Zenoh has no registry, so vrobots topic list can only report what actually published during its observation window: a robot that is paused is a robot that does not exist as far as that listing is concerned. iceoryx2 does have a registry, so a camera service can be listed without anything streaming through it, and a dead entry is marked stale rather than vanishing. Discovery from code calls this the observed-versus-registered distinction, and it is what the [z] and [i] tags are telling you:

wire       Hz     bytes  topic
[z]      25.0      1234  vrobots/1/z/state
[i]         -         -  vrobots/0/i/cam/front/720p_rgba8

The - columns are not missing data. They are a registry entry, which by construction has no measured rate.

FlatBuffers on the wire

Both transports carry FlatBuffers payloads, generated from the schemas in the vrobots_msgs submodule and shared byte for byte with the simulator's C# side. The submodule ships the generated Rust, so building the SDK needs no flatc.

Two properties of that choice show up in the API. Decoding verifies the buffer before reading any field, so a truncated or hostile payload produces VrError::Decode rather than an out-of-bounds read. And a nested table that is missing from the wire decodes to its Default (all zero, false, empty) rather than failing, which is what lets an older simulator talk to a newer SDK: fields it does not know about arrive as zero, not as an error.

The pins are exact, and that is not pedantry

ipc_versions.json at the repository root is the source of truth for the three IPC versions this SDK must match.

PackagePinKind of pin
flatbuffers25.12.19exact, =X.Y.Z
iceoryx20.9.3exact, =X.Y.Z
zenoh1.9.0exact, =X.Y.Z

The exactness is load-bearing for one specific reason. iceoryx2 compares major.minor.patch on every shared-memory open. A caret pin that resolves one patch release away from the simulator's does not error and does not warn: it silently delivers nothing. The symptom is a camera stream that never produces a frame, which reads as "the simulator is not publishing" and sends you looking in entirely the wrong place.

Three mechanisms keep the pins honest, so this is a failure you should never actually see: build.rs fails the build on drift from ipc_versions.json, scripts/check_versions.ps1 fails CI, and the release workflow refuses to build. vrobots --version prints the versions a given binary was built against, which is the first thing to check when a simulator and an SDK disagree. Versions and pins covers the whole procedure.

Next: The topic namespace

See also: Cameras and images, Versions and pins, Discovery from code

The topic namespace

Every key the simulator publishes or subscribes, and how a robot's id fits into it.

One rule generates every name

Every key in the system has the shape vrobots/<sys_id>/<transport>/<subject...>. The segment after the id names the transport: z for zenoh, i for iceoryx2. Two reserved words, manager and scene, sit where a system id would, so a swarm-wide service can never collide with a robot's own.

flowchart LR
  R["vrobots"] --> ID["{sys_id}"]
  R --> MGR["manager"]
  R --> SCN["scene"]
  ID --> Z["z"]
  ID --> I["i"]
  Z --> ST["state"]
  Z --> FR["frames"]
  Z --> CM["cmd"]
  Z --> SRV["srv/{segment}"]
  I --> CAM["cam/{name}/{res}_{fmt}"]
  MGR --> MZ["z/srv/create<br/>z/srv/delete"]
  SCN --> SZ["z/srv/frame"]

Both ends of a wire must agree on these names byte for byte, and a mismatch is silent: a subscriber on a slightly wrong key never fires at all. That is why no call site in the SDK builds a name inline. Every one of them comes from the vrobots_sdk::topics module, which mirrors the simulator's own VRobotsTopics.cs, and it is public so your code can print the same string the simulator uses.

The full pattern table

PatternTransportDirectionContents
vrobots/{sys_id}/z/statezenohsim publishesfull state, 25 Hz
vrobots/{sys_id}/z/cmdzenohsim subscribescommands; many-to-many, readable by clients
vrobots/{sys_id}/z/srv/{segment}zenohrequest/responseper-robot services
vrobots/{sys_id}/i/cam/{name}/{res}_{fmt}iceoryx2sim publishesraw camera frames
vrobots/manager/z/srv/createzenohrequest/responsespawn a robot
vrobots/manager/z/srv/deletezenohrequest/responseremove a robot
vrobots/scene/z/srv/framezenohrequest/responsescene-level coordinate frame

Two further per-robot zenoh keys exist and are covered where they are used. vrobots/{sys_id}/z/frames publishes the coordinate-frame definitions that result from a robot's frame configuration, which is a different thing from the srv/frames service that sets them: Coordinate frames covers both. vrobots/{sys_id}/z/estimate carries an external state estimate that the fixed wing can be told to fly on instead of the truth, described in Fixed wing control.

A concrete camera key, with every placeholder filled in:

vrobots/1/i/cam/front_left/720p_rgba8

The {res}_{fmt} segment is built from the resolution and pixel format you asked for, which is why changing a camera's format renames its stream. The full list of service segments is in Appendix A: Topic reference; the ones a given robot answers depend on its type, and asking for one it does not serve is how a capability probe works.

The id slot is the routing

There is no addressing anywhere else in a message. A robot subscribes to its own z/cmd and nothing else, so the id in the key is the delivery decision. Send a command with the wrong id and it is not misrouted to another robot; it is received by whichever robot owns that key, and the robot you meant hears nothing.

Gotcha. A wrong sys_id and a command a robot does not implement look identical from outside: the state stream does not change. Confirm the id against vrobots topic list before suspecting the command.

Wildcards

Two wildcard keys are worth knowing, both zenoh only.

ConstantKeyUse
topics::ALLvrobots/**the discovery subscribe; everything the sim publishes
topics::ALL_STATESvrobots/*/z/stateone subscriber for every robot's state

ALL_STATES is the shape a swarm program wants: fifty VirtualRobot handles is fifty sessions and fifty subscriber threads, where one subscription on the wildcard is one of each. That is a different program from the one this book teaches, and the key is here so it is buildable.

Wildcards work for subscribing, not for measuring. measure_rate and vrobots topic hz need an exact key and reject a wildcard with VrError::InvalidArgument.

Reading the namespace back

vrobots topic list is the ground truth for what exists right now, and it is the first command to run when something is not responding. It reports the transport tag, the measured rate for zenoh topics, and the registry entry for iceoryx2 ones. The vrobots command documents the flags; Discovery from code does the same listing from inside a program via list_topics.

topics::sys_id_of(key) parses the id back out of a key, returning None for manager and scene. That is the right answer rather than a parse failure: those two scopes belong to no robot.

Next: System ids, and the two kinds of robot

See also: Appendix A: Topic reference, The vrobots command, More than one robot

System ids, and the two kinds of robot

Scene-authored robots you attach to, created robots you spawn, and why an id is never a constant.

Two kinds of robot

A sys_id is a u32 that names one robot for as long as the scene is loaded. Every topic carries it, every command is routed by it, and there are exactly two ways to end up holding one.

Scene-authored robots are the ones the scene placed. They exist the moment the simulator enters Play mode, before any client connects, and they are still there after every client exits. You reach them by naming their id: VirtualRobot::connect(RobotType::Multirotor, Some(1)).

Created robots are the ones the SDK spawned. connect(type, None) asks the manager's srv/create and the reply carries a fresh id. They are yours in the sense that you know their id, and not in any stronger sense: nothing stops another client attaching to them, and they survive your process exiting.

Scene-authoredCreated
How you get oneconnect(type, Some(id))connect(type, None)
Touches srv/createneveronce
Constrained by the spawn catalognoyes
Available typeswhatever the scene containsthe running scene's catalog
Exists before you connectyesno

The attach path is the more capable of the two. It never queries the catalog, so it works for any robot the scene contains regardless of what can be created, and it is the only way to reach three of the six robot types.

Ids are allocated at load time and keep incrementing

On a fresh boot straight into the Flatworld scene, sys_id 0 is the truck and sys_id 1 is the multirotor. Every example up to ex20 names its id in a const for exactly that reason.

Those constants are a convenience and never a contract. Ids are allocated as the scene loads and keep incrementing across scene loads, so reloading the scene, or creating and deleting a robot, moves the numbers. A const SYS_ID: u32 = 1 that worked this morning can address a robot that no longer exists this afternoon, and the failure is a connect that times out waiting for a first state sample rather than anything that names the id as the problem.

Gotcha. connect reporting no state on vrobots/<id>/z/state within probe_timeout usually means that id is wrong, not that the simulator is down. Run vrobots topic list and read the ids that are actually publishing.

The habit that makes this a non-issue: take the id as a command-line argument rather than compiling it in. That is what ex29 through ex33 do, because a cart-pole, a half-drone and a Global Hawk cannot be created and their ids are not knowable when the example is written.

The spawn catalog belongs to the scene

The set of types srv/create will spawn is registered by the scene's own spawner, not by the SDK. The sandbox scene registers three:

Catalog keyRobotTypeCreatable in the sandbox
multirotorRobotType::Multirotoryes
truckRobotType::Truckyes
msdRobotType::Msdyes
cartpoleRobotType::CartPoleno, scene-authored only
halfdroneRobotType::HalfDroneno, scene-authored only
globalhawkRobotType::GlobalHawkno, scene-authored only

Keys are matched exactly and case-sensitively on the wire. RobotType::catalog_key() produces the wire form, and RobotType::from_catalog_key(&str) parses it back case-insensitively, accepting the synonyms the examples and the simulator's UI use: car for a truck, cart_pole and invpen for a cart-pole, mass_spring_damper for an MSD, half_drone, global_hawk and rq4b. Only catalog_key() is ever put on the wire. RobotType::is_in_sandbox_catalog() reports the third column above.

The refusal message is the catalog

A key the running scene does not register is refused with ok = false, and the refusal names every key that scene does know:

unknown type 'globalhawk' (known: multirotor, truck, msd)

connect surfaces that string verbatim as VrError::Service. That makes a failed create the only live way to enumerate the catalog: there is no list service, and the table above is a measurement of the sandbox scene rather than a property of the SDK. Another scene may register more, and the refusal from that scene will say so.

The three you can only attach to

cartpole, halfdrone and globalhawk exist as scene-authored robots and must be reached by id.

The Global Hawk is a further step removed: it lives in the IMU scene, not the sandbox. Launching the sandbox and looking for one finds nothing, and no amount of correcting the id helps. Load the right scene first, then read the ids off vrobots topic list.

Note. Tier gating changes what is reachable. Under Pro every robot in the scene serves simultaneously; under Guest only the robot selected in the simulator's SYS-ID dropdown has open ports, so a correct id can still time out. Known simulator issues has the detail.

Deletion is explicit, always

Nothing in the SDK removes a robot implicitly. Dropping a VirtualRobot closes the session and leaves the robot exactly as it was, still running, still publishing, still latched on its last command. Only delete() removes one, and a handle whose robot has been deleted is spent: further commands return an error rather than silently doing nothing.

That is the fourth of the five rules, and Robot lifecycle covers the full sequence.

Next: The shape of a program

See also: Robot lifecycle, Supported virtual robots, What connect actually does

The shape of a program

The SDK never calls your code: main does setup and owns the loop.

Two shapes, and this SDK is the second one

Robotics clients come in two shapes. In the first, a framework owns the program: you subclass something, fill in setup() and update(), hand the class to a runner, and the runner calls you. In the second, you own the program: main() does its setup and then runs a plain loop, calling the library when it wants something.

The first is the Arduino shape. This SDK is deliberately the second, the STM32 shape.

flowchart TB
  subgraph AR["Arduino shape: the framework calls you"]
    direction TB
    A1["runner starts"] --> A2["your setup"]
    A2 --> A3["your update callback"]
    A3 --> A4["runner decides when"]
    A4 --> A3
  end
  subgraph ST["STM32 shape: you call the SDK"]
    direction TB
    B1["main starts"] --> B2["connect"]
    B2 --> B3["read states"]
    B3 --> B4["your control law"]
    B4 --> B5["send command"]
    B5 --> B6["rate or wait_new_state"]
    B6 --> B3
  end

There is no base class, no runner, no setup()/update() pair, and no registration call. The SDK never calls your code. Every arrow in the right-hand diagram is a call your main makes.

The canonical program

This is the whole shape, and every example in the book is a variation on it. From book/.research/00-shared.md, which quotes the crate's own documentation in crates/vrobots-sdk/src/lib.rs:

use vrobots_sdk::{RobotType, VirtualRobot, VrError};

fn main() -> Result<(), VrError> {
    // ===== setup =====
    let robot = VirtualRobot::connect(RobotType::Multirotor, Some(1))?;

    // ===== loop =====
    loop {
        let s = robot.states();               // latest snapshot, never torn, never blocks
        let [x, y, z] = s.kin.lin_pos;
        println!("State t={:.3} pos=({x:.3},{y:.2},{z:.2})", s.elapsed);

        robot.set_mr_pwm([1501.0; 4])?;       // your controller's output
        robot.rate(100.0);                    // drift-compensated pacing, Hz
    }
}
The same in C++ (the header comment on cpp/include/vrobots_sdk.hpp)
#include <vrobots_sdk.hpp>

int main() {
    vrsdk::VirtualRobot robot(vrsdk::RobotType::Multirotor, /*sys_id=*/1);
    robot.connect();
    while (true) {
        vrsdk::State s = robot.states();
        std::printf("t=%.3f\n", s.elapsed);
        robot.set_mr_pwm({1501, 1501, 1501, 1501});
        robot.rate(100.0);
    }
}
The same in Python (the module docstring on crates/vrobots-sdk-py/python/vrsdk/__init__.py)
from vrsdk import VirtualRobot, RobotType

def main():
    # ===== setup =====
    mr = VirtualRobot(RobotType.MULTIROTOR, sys_id=1)
    mr.connect()

    # ===== loop =====
    while True:
        s = mr.states
        x, y, z = s.kin.lin_pos
        print(f"State t={s.elapsed:.3f} pos=({x:.3f},{y:.2f},{z:.2f})")
        mr.set_mr_pwm(1501, 1501, 1501, 1501)
        mr.rate(100)

if __name__ == "__main__":
    main()

Each surface documents this same shape as its own opening example, which is the clearest evidence that the shape is the API rather than a Rust convention. The one structural difference is that C++ and Python split construction from connection into two statements, where Rust's connect is a constructor that does both.

There is no expected output to quote: the loop never terminates, printing one line per iteration until you stop it with Ctrl-C. examples/rust/src/bin/ex01_hello_states.rs is this program with the printing worked out, and it is the subject of Hello states.

Read the four calls in the loop as four separate decisions you are making:

CallWhat it isWhat it is not
states()a read of the latest snapshota request to the simulator
your control lawthe whole point of the programanything the SDK participates in
set_mr_pwm(..)a publish, returning when the bytes are queueda round trip with a result
rate(100.0)drift-compensated sleep, a helpera scheduler that owns your timing

Why this shape

Three reasons, in the order they matter.

A control loop has a rate, and it is yours. A callback framework decides when your code runs. A cascaded controller with an inner loop at 200 Hz and an outer at 20 Hz does not fit that, and neither does a program that wants to run as fast as samples arrive. Owning the loop means the rate is a line of your code: rate for a fixed schedule, wait_new_state for one iteration per published sample.

It is the shape the target hardware has. Code written against this SDK is meant to move to a real vehicle, where main is a while (1) over a timer. Keeping the simulator client the same shape means the loop body ports; a callback body does not.

There is nothing to learn. No lifecycle, no ordering rules between setup and the first update, no question about which thread a callback runs on. The SDK does own background threads (one subscriber per state stream, one reader per camera stream), and none of them ever enters your code. They keep a snapshot fresh; you read it when you like.

Note. One process is not limited to one robot. A VirtualRobot is a handle, so construct as many as you need, each with its own session and snapshot. Call rate() on exactly one of them, or you sleep once per handle per iteration. examples/rust/src/bin/ex18_multi_robot.rs is the worked case.

Dropping the handle does not stop the robot

Because main owns the program, it is tempting to read the end of main as the end of everything. It is not. Dropping a VirtualRobot closes the zenoh session, unsubscribes, and stops the SDK's own threads. It leaves the robot running, still latched on the last command it received, until something explicitly deletes it or the scene is reloaded.

That is the intended behaviour rather than an oversight, and it is rule four. If your program should leave nothing behind, call delete() before returning, which is what the examples that create a robot do.

Next: What connect actually does

See also: Pacing your loop, More than one robot, Robot lifecycle

What connect actually does

The four steps behind a create, why only one of them is sent once, and every option you can change.

One call, two paths

There is one entry point, and a second that takes options. The two signatures, from crates/vrobots-sdk/src/robot.rs:

#![allow(unused)]
fn main() {
pub fn connect(robot_type: RobotType, sys_id: Option<u32>) -> VrResult<VirtualRobot>

pub fn connect_with(
    robot_type: RobotType,
    sys_id: Option<u32>,
    options: ConnectOptions,
) -> VrResult<VirtualRobot>
}
The same in C++ (cpp/include/vrobots_sdk.hpp)
explicit VirtualRobot(RobotType type, std::uint32_t sys_id,
                      const vrsdk_connect_options_t* options = nullptr)

[[nodiscard]] static VirtualRobot create(RobotType type,
                                         const vrsdk_connect_options_t* options = nullptr)

void connect()
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
class VirtualRobot:
    def __init__(
        self,
        robot_type: RobotType,
        sys_id: Optional[int] = None,
        *,
        src_id: Optional[int] = None,
        router: Optional[str] = None,
        connect_timeout: Optional[float] = None,
        probe_timeout: Optional[float] = None,
        service_timeout: Optional[float] = None,
        camera_timeout: Optional[float] = None,
        robot_name: Optional[str] = None,
        client_name: Optional[str] = None,
        start_active: Optional[bool] = None,
        activate_after_create: Optional[bool] = None,
        coord_frame_id: Optional[str] = None,
        axis_convention: Optional[int] = None,
    ) -> None: ...
    def connect(self) -> None: ...

The three surfaces split the same work differently. Rust folds construction and connection into one call and takes options as a second entry point; C++ and Python construct first and connect() second, and neither has a connect_with because options ride on construction: C++ takes a pointer to the C options struct (null for the defaults) and Python takes the same fields as keyword arguments. C++ also needs a named create factory for the None path, because a literal 0 would be ambiguous between "attach to sys_id 0" and a null options pointer.

connect is connect_with with ConnectOptions::default(). Both open a zenoh session first, and both end by subscribing the robot's state topic and blocking until the first snapshot arrives, so states() is guaranteed to return real data the instant connect returns. What differs is the middle.

Some(id) attaches. It never touches srv/create, so it works for any robot the scene contains whatever the spawn catalog says, and the only thing that can go wrong is that nothing publishes on that id: VrError::Timeout after probe_timeout.

None creates, in four steps.

The four-step create path

sequenceDiagram
  participant P as Your program
  participant M as manager
  participant R as New robot
  P->>M: 1. payload-less GET on srv/create
  M-->>P: ack, reserves nothing
  P->>M: 2. the real create, sent exactly once
  M-->>P: ack carrying the new sys_id
  P->>R: 3. srv/activate, retried
  R-->>P: ack
  R->>P: 4. first sample on z/state
  Note over P,R: connect returns here

Step 1 is a reachability probe. A payload-less GET on the create endpoint is answered ok = false and reserves nothing, so it is free to send and free to retry. It answers one question: is the manager there at all? Without it, a manager that is absent and a manager that is slow are indistinguishable at the moment it matters.

Step 2 is the create, sent exactly once. This is the only non-idempotent service in the system. The manager allocates an id and spawns a robot for every request that reaches it, so a retry that lands leaves you with two robots and a handle to one of them. The SDK therefore sends this one query with no retry loop at all, and a timeout here is reported rather than papered over. A refusal comes back as VrError::Service carrying the simulator's own message, which lists the catalog: see System ids, and the two kinds of robot.

Step 3 activates, with retry. srv/activate releases a dormant robot's dynamics hold, and an already-active robot acks and no-ops, so it is idempotent and safe to re-send. It needs the retry: a robot created a moment ago takes about a second before zenoh has discovered its srv/* queryables, and the first attempt often finds nobody. This step is skipped when activate_after_create is false.

Step 4 waits for the robot to publish. An ok create reply means the id is allocated, not that a robot exists in the scene. The SDK waits for the state topic to produce a sample, so "spawned but never published" fails at connect rather than three lines later at the first states() call.

Note. Confirmation is by presence throughout this system, not by acknowledgement. connect returns when the state topic speaks; delete returns when it has been silent for a second. The ack in between is a receipt that the request arrived. That is rule two, and it is the same reason configuration services are confirmed by measuring the state stream.

Retry, and the one place it is forbidden

Almost every service in the SDK is queried through a retry loop, because a GET that finds no responder yet is the normal state of affairs for a second or so after a robot appears. Two are deliberately excluded.

ServiceRetriedWhy
srv/createnonon-idempotent; every retry that lands spawns another robot
manager/srv/deletenoa re-send after success returns a false negative
everything elseyesidempotent; a re-send costs a message

ConnectOptions

Every timeout, the router endpoint and the identity live in one struct with documented defaults. It is #[non_exhaustive], so build it with ConnectOptions::default() and chain the with_* setters.

FieldTypeUnitsDefaultNotes
src_idu32n/a122 (DEFAULT_SRC_ID)must be non-zero; 0 is reserved for the simulator, and service replies route back by it
router_endpointOption<String>n/aNoneNone uses zenoh peer discovery; set e.g. tcp/192.168.1.10:7447 for a routed network. Camera streams stay same-host only
connect_timeoutDurations5budget for opening the zenoh session
probe_timeoutDurations15first state sample, and the wait in step 4. Generous because discovery takes seconds after a sim starts
service_timeoutDurations8one GET; doubles as the capability-probe timeout
camera_timeoutDurations5a camera stream appearing on iceoryx2; shorter because there is no discovery to wait out
robot_nameOption<String>n/aNonewire name for a robot this connection creates; None uses the catalog default, and it is ignored when attaching
client_nameStringn/aDEFAULT_CLIENT_NAMEstamps header.name, naming this client rather than the robot
start_activebooln/atruefalse spawns dormant, for a deterministic configure-then-activate start
activate_after_createbooln/atrueharmless when start_active is true, required when it is false
coord_frame_idStringn/a"unity" (DEFAULT_COORD_FRAME_ID)the frame your outgoing vectors are in; the robot converts before acting
axis_conventionAxesn/aAxes::UNITYthe enum tag beside coord_frame_id; the string wins if they disagree

The three most commonly changed, chained. From the doctest on ConnectOptions in crates/vrobots-sdk/src/options.rs:

#![allow(unused)]
fn main() {
let opts = ConnectOptions::default()
    .with_router("tcp/192.168.1.10:7447")   // sim on another machine
    .with_src_id(200)                       // second client in the session
    .with_probe_timeout(Duration::from_secs(20));
}
The same in C++ (the pattern from examples/cpp/ex30_hello_halfdrone.cpp)
vrsdk_connect_options_t options{};
vrsdk_options_default(&options);
options.service_timeout_s = PROBE_TIMEOUT_S;

vrsdk::VirtualRobot robot(vrsdk::RobotType::HalfDrone, sys_id, &options);
robot.connect();
The same in Python (constructor keywords, listed in full above)
mr = VirtualRobot(
    RobotType.MULTIROTOR,
    sys_id=1,
    router="tcp/192.168.1.10:7447",   # sim on another machine
    src_id=200,                       # second client in the session
    probe_timeout=20.0,
)

The table above is the reference for all three, with two spelling changes. C++ carries the plain C struct, so the fields are router_endpoint, connect_timeout_s, probe_timeout_s and so on, and it must be initialised with vrsdk_options_default: a zeroed struct is not the defaults, it is every timeout set to zero. Python takes them as keyword arguments, named without the _s suffix, and omitting one means "keep the default" rather than "set it to zero".

Rust constructs a value and produces no output; the C++ and Python forms above connect with it.

src_id is worth one more sentence. It identifies your traffic, so two clients sharing a session that both leave it at 122 are indistinguishable in a topic dump, and a program reading the command bus cannot filter out its own publishes. Give the second client its own.

Next: Five rules that explain everything

See also: Robot lifecycle, When nothing happens, Reading someone else's commands

Five rules that explain everything

The behaviours that account for almost every confused bug report against this SDK.

Every rule below is deliberate, and every one of them has a failure mode that looks like something else. Learn the detection column and the rest of the book gets easier.

RuleThe failure looks likeHow you detect it
1. Commands latch and get no replythe robot ignores you, or keeps doing the last thingcompare actuator.pwm with what you sent
2. An ack is a receipt, not a resulta configuration call succeeded and changed nothingmeasure the change in the state stream
3. states() never failsthe simulator is running and the numbers are frozenwait_new_state, and stats().received
4. Robots outlive the processa scene fills with robots, or one keeps flyingvrobots topic list, and delete()
5. Every vector is in the robot's framea sign error in altitude, or in a two-robot distanceprint coord_frame_id beside every vector

Rule 1: commands latch and get no reply

What it means. Every command is a setpoint, not an impulse. The last one received stays in effect until the next arrives. There is no watchdog and no expiry, so a controller that stops sending leaves the robot flying its final command, and a 5 Hz sender and a 50 Hz sender are equally valid. Nothing is acknowledged: publishing a command returns as soon as the bytes are queued.

Why. A command topic is a bus, not a call. Making it a call would mean the simulator blocking on each client, and making commands expire would mean a control loop that stutters becomes a robot that falls out of the sky. Latching is what real actuator interfaces do.

How you detect the failure. The only evidence a command landed is the actuator echo in the state stream. actuator.pwm is your last command echoed back, so a command that never arrived shows as an echo that never changes. From examples/rust/src/bin/ex02_hello_control.rs:

#![allow(unused)]
fn main() {
let s = robot.states();
let [x, y, z] = s.kin.lin_pos;
println!(
    "State t={:.3} pos=({x:.3},{y:.2},{z:.2}) echo={:?}",
    s.elapsed, s.actuator.pwm
);
}
The same in C++ (examples/cpp/ex02_hello_control.cpp)
// The echo: what the robot actually latched, from the state stream.
const std::vector<std::uint32_t> echo = s.pwm();
std::printf("State t=%.3f pos=(%.3f,%.2f,%.2f)  pwm_echo=[", s.elapsed, p[0], p[1],
            p[2]);
for (std::size_t i = 0; i < echo.size(); ++i) {
    std::printf("%s%u", i ? "," : "", echo[i]);
}
The same in Python (examples/python/ex02_hello_control.py)
s = mr.states
x, y, z = s.kin.lin_pos
print(
    f"State t={s.elapsed:.3f} pos=({x:.3f},{y:.2f},{z:.2f}) "
    f"echo={s.actuator.pwm}"
)

Running that prints one line per iteration in which echo holds the pulse widths the robot is currently applying. A wrong sys_id, a command the robot does not implement, and a wrong array length all present identically: the echo does not move.

Rule 2: an ack is a receipt, not a result

What it means. Every srv/* reply is packed the instant the request arrives. The change itself lands in phase 0 of the robot's next physics step, after the reply is already on its way back to you. ok = true therefore means "the request was received", and nothing more.

Why. The service handler runs on the network thread; the change has to run on the physics thread, at a point in the step where applying it is safe. Waiting for that before replying would put a physics frame of latency into every service call.

How you detect the failure. By measuring, because the ack will not tell you. Only srv/skin ever answers ok = false. A wrong rotor count, an unknown frame id, a drive_mode that is not 2 or 4: all acked ok and refused by a simulator log line no client can see. The SDK refuses what it can before sending, which is what every VrError::InvalidArgument from a configuration call is. For the rest, read the state stream back and check the robot behaves differently. That is why the configuration examples measure rather than assert.

Gotcha. A typo in a skin name on a robot that has a skin catalog is acked ok and dropped, so it looks exactly like success. Only a tier refusal produces ok = false. See Skins.

One physics step, in order

Both rules above are consequences of one sequence. This is what happens between two state samples:

sequenceDiagram
  participant C as Your program
  participant Q as Command queue
  participant S as Physics step
  C->>Q: publish on z/cmd, no reply
  Note over Q: last value wins, and waits
  S->>Q: phase 0, drain the queue
  S->>S: phase 0, apply commands and service changes
  S->>S: integrate the dynamics
  S-->>C: publish z/state, actuator echo included

Physics runs at 50 Hz and state is published at 25 Hz, so a loop faster than 25 Hz reads the same snapshot more than once, and a loop faster than 50 Hz sends commands that are overwritten in the queue before anything drains it. Neither is an error. Pacing your loop is where you decide which rate you actually want.

Rule 3: states() never fails

What it means. states() never blocks, never returns a half-written value, and has no error case. An SDK-owned subscriber keeps a snapshot fresh in the background and states() hands you an Arc of the most recent one. If the simulator stops, it keeps returning that last snapshot forever.

Why. A control loop that has to handle an error on every sensor read is a control loop full of error handling. Making the read infallible pushes the one real question, "is this data still arriving?", to the one place that should ask it.

How you detect the failure. With wait_new_state(timeout), which blocks for a sample newer than the current one and returns VrError::Timeout when none comes. A timeout is a status, not a failure: the session is fine and the next call may well succeed. From examples/rust/src/bin/ex09_state_paced_loop.rs:

#![allow(unused)]
fn main() {
Err(VrError::Timeout(detail)) => {
    // Not a broken session: no sample arrived in time. The sim is
    // paused, stopped, or the machine is very busy. states() still
    // returns the last snapshot it had.
    let s = robot.states();
    println!(
        "no new state in {:?} ({detail}); still holding seq={} at t={:.3}",
        TIMEOUT, s.seq, s.elapsed
    );
}
}
The same in C++ (examples/cpp/ex09_state_paced_loop.cpp)
try {
    robot.wait_new_state(TIMEOUT_S);
} catch (const vrsdk::Error& e) {
    if (e.code() != VRSDK_ERR_TIMEOUT) {
        throw;  // a real failure
    }
    // Not a broken session: no sample arrived in time. The sim is
    // paused, stopped, or the machine is very busy. states() still
    // returns the last snapshot it had.
    const vrsdk::State s = robot.states();
    std::printf("no new state in %.1fs; still holding seq=%llu at t=%.3f\n", TIMEOUT_S,
                static_cast<unsigned long long>(s.seq), s.elapsed);
    continue;
}
The same in Python (examples/python/ex09_state_paced_loop.py)
try:
    mr.wait_new_state(TIMEOUT)
except vrsdk.VrError as e:
    if e.code != vrsdk.err.TIMEOUT:
        raise  # a real failure
    # Not a broken session: no sample arrived in time. The sim is
    # paused, stopped, or the machine is very busy. `states` still
    # returns the last snapshot it had.
    s = mr.states
    print(
        f"no new state in {TIMEOUT}s ({e.detail}); "
        f"still holding seq={s.seq} at t={s.elapsed:.3f}"
    )
    continue

Rust distinguishes the timeout by matching the VrError::Timeout variant. C++ and Python have one error type each, so they branch on the numeric code and re-raise anything else: e.code() != VRSDK_ERR_TIMEOUT and e.code != vrsdk.err.TIMEOUT are the same test, and it is the same stable number in both.

That arm prints a line each time the simulator goes quiet and keeps the loop alive. Propagating the timeout out of main with ? instead is what turns a paused simulator into a crashed program. stats() gives the same answer cumulatively: received stops climbing, and seq_gaps counts the samples that went missing while the stream was still up. Stream health covers both.

Rule 4: robots outlive the process

What it means. A VirtualRobot is a handle to something that already existed or that you asked to be created, and dropping it closes your session and nothing else. The robot keeps running, keeps publishing, and keeps its last latched command. Deletion is explicit: only delete() removes a robot.

Why. The simulator is a persistent world, not a library object. A client that crashes mid-flight should not take the scene down with it, and a program that attaches to a scene-authored robot must not be able to delete it by exiting.

How you detect the failure. vrobots topic list shows every robot that is publishing. A scene that accumulates robots across runs is a program creating and not deleting; a robot that keeps moving after your program exits is rule 1 plus rule 4, and the fix is to send a stop command before returning rather than to hope the drop does it. is_deleted() reports whether this handle's robot was deleted, and a spent handle returns an error from commands rather than silently doing nothing.

Rule 5: every vector is in the robot's frame, not yours

What it means. Vectors on the wire are expressed in the frame the publisher names in coord_frame_id, and different robots genuinely disagree. Your outgoing headers carry your own frame (ConnectOptions::coord_frame_id, "unity" by default) and the robot converts before acting, but nothing converts what you read.

Why. There is no single correct convention. Aerospace wants forward-right-down, the renderer works in Unity's left-handed frame, and computer vision wants right-down-forward. Rather than picking one and silently converting, the simulator tags every message with the frame it used.

How you detect the failure. Print coord_frame_id next to every vector you print. The signature is a sign error: an altitude that goes negative as the aircraft climbs, or a distance between two robots that is wrong in one axis. examples/rust/src/bin/ex18_multi_robot.rs holds two robots whose frames differ and labels the computed separation as wrong for exactly this reason. Frames, axes and units is the full treatment.

Two more that are almost rules

Decode errors are counted, not fatal. A payload that fails to decode does not tear down the session or interrupt the loop. It increments stats().decode_errors and is retrievable through last_error(), and the next sample is handled normally. A stream that is producing numbers and also producing decode errors is a version-skew symptom, and it is invisible unless you look at the counters.

State and camera streams are never paired. They are independent streams on independent transports with independent rates, and the SDK does not associate a frame with a snapshot. Both carry t_ns on the same clock, so fusion code compares those timestamps explicitly and decides for itself what counts as simultaneous. Freshness has the mechanics.

Next: Frames, axes and units

See also: Commands latch, Stream health, Robot lifecycle, When nothing happens

Frames, axes and units

Which vector is in which frame, what coord_frame_id decides, and the conventions that bite.

Everything is SI

Metres, seconds, kilograms, newtons, radians, pascals, degrees Celsius. There are no scaled integers anywhere in the state stream: this SDK publishes physical units directly, so a gyroscope reading is rad/s rather than a count to be divided by something.

Three places break the pattern, and each is called out where it appears: mount_euler_deg on a camera and initial_pole_angle_deg on a cart-pole are in degrees because the simulator's inspector shows degrees, and pulse widths are in microseconds because that is what a PWM channel is.

The wire carries f32. The SDK widens every value to f64 on the way in, which is lossless, and narrows to f32 on the way out, which is not. A setpoint you send and read back through the actuator echo can differ in the seventh significant figure. It is never a control problem and it is occasionally an equality-comparison problem.

Axes is an integer, deliberately

The declaration is the argument. From crates/vrobots-sdk/src/state.rs:

#![allow(unused)]
fn main() {
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Axes(pub i32);
}
The same in C++ (crates/vrobots-sdk-capi/include/vrobots_sdk.h)
/**
 * The axis convention a vector is expressed in -- the enum tag beside
 * `coord_frame_id`, which is the authoritative name.
 */
typedef int32_t vrsdk_axes_t;
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
class State:
    @property
    def axis_convention(self) -> int: ...
    @property
    def axis_convention_name(self) -> str: ...

def axes_name(value: int) -> str: ...

The openness survives every binding. C++ gets a typedef over int32_t rather than an enum class, which is the same decision for the same reason, and Python reports it as a plain int with axes_name to turn one into a label. It is the one place in the SDK where C++ does not wrap a C type in something stronger.

That is a type rather than a program, so there is no output to show. Four constants are defined on it:

ConstantC++ValueMeaning
Axes::UNSPECIFIEDVRSDK_AXES_UNSPECIFIED0no convention declared
Axes::UNITYVRSDK_AXES_UNITY1left-handed, X right, Y up, Z forward
Axes::FRDVRSDK_AXES_FRD2aerospace forward-right-down
Axes::CVVRSDK_AXES_CV3computer vision right-down-forward

Python has no constants for these: compare the integer, or read axis_convention_name and compare the string.

It is not a Rust enum, and the reason is that the set is open. A scene can register a coordinate frame at runtime, and such a frame has no constant here. A Rust enum would force every unknown value to collapse into a catch-all on decode, so a frame the SDK has never heard of would round-trip as "unspecified" and the information would be gone. A transparent i32 carries whatever the simulator sent, whether or not this build knows a name for it, which also keeps the type trivially FFI-safe for the C++ and Python bindings.

Axes::name() returns the registry id string for a known constant ("unity", "frd", "cv") and "" for anything else, including a runtime-registered frame.

coord_frame_id is the authoritative one

Every message carries both a string frame id and an Axes tag. The string wins. It is the only thing that can name a runtime-registered frame, and Axes is a convenience tag beside it for the cases where a numeric comparison is easier.

"fru" is a registered id with no Axes constant, and it is the scene default on a fresh launch, which makes this concrete rather than theoretical: a robot can publish a frame whose Axes tag is UNSPECIFIED and whose string is meaningful.

Frames are set at three levels, scene, robot and device, and the most specific wins. scene_frame() reads the scene level; srv/frames sets the robot and device levels. Coordinate frames is the whole picture.

The robots do not agree with each other. The truck publishes "fru", while the multirotor and the Global Hawk publish "frd" (the Global Hawk verified live on 2026-08-09 against simulator v3.0.0), so the third component is up for one and down for the others, and altitude on the multirotor is minus that component.

Gotcha. Two handles in one process is the program where this bites hardest, because a distance between two positions in different frames is silently wrong rather than visibly wrong. Read coord_frame_id on each snapshot and convert before combining. examples/rust/src/bin/ex18_multi_robot.rs prints the tag beside each position and marks its own separation calculation as wrong when the two disagree.

Your frame and the robot's frame are different questions

ConnectOptions::coord_frame_id ("unity" by default) names the convention your outgoing vectors are written in. The robot converts an incoming vector out of that frame into its own before acting on it, so a body force sent in Unity axes does the right thing on a robot publishing frd.

Nothing converts what you read. Incoming state is in the robot's frame, tagged with the robot's coord_frame_id, and that is the asymmetry to keep in mind: commands are converted for you, states are not.

Quaternion order is [x, y, z, w]

The scalar part is last. This matches the wire's Vec4 field order, and it is the opposite of the [w, x, y, z] convention that most textbooks and several quaternion libraries use. Feeding an array in the wrong order into a rotation routine does not error; it produces a rotation that looks almost plausible, which is the worst possible failure mode. Check the order at every boundary where a quaternion enters or leaves the SDK.

The world-pose, body-twist split

Within Kinematics, half the fields are world frame and half are body frame:

FieldUnitsFrame
lin_posmworld
quatn/aworld
lin_velm/sbody
ang_velrad/sbody
lin_accm/s²body
ang_accrad/s²body

This is physics, not a configuration choice, and there is no option that changes it. A position is only meaningful relative to some fixed origin, so pose is world. A velocity or an acceleration is what an onboard instrument measures, and instruments are bolted to the vehicle, so twist is body: ang_vel is what a gyroscope reads, which is body rates rather than Euler angle derivatives.

The same split holds in the estimate block, which carries a Kinematics of the same shape, so a comparison between estimate.kin and kin is field-by-field valid without any frame juggling. Kinematics works through what that means for each field.

Next: Rotation conversions

See also: Coordinate frames, Kinematics, More than one robot

Rotation conversions

Turning an attitude between quaternions, angles and matrices, and re-expressing a whole state in another frame.

cargo run -p vrobots-examples --bin ex36_rotations
./target/cpp-build/ex36_rotations
python examples/python/ex36_rotations.py

vrobots_sdk::rotations talks to nothing: pure math, shared with the C++ namespace vrsdk::rotations and the Python submodule vrsdk.rotations, so "convert this quaternion to roll, pitch and yaw" has one answer. ex36 creates its own truck, drives an arc so there is something non-zero to convert, and deletes it, so it takes no sys_id. Frames, axes and units owns the storage conventions; this page is the arithmetic on top of them.

Storage order is fixed; application order is an argument

Storage never changes. An Euler triple is [about x, about y, about z], reading as [roll, pitch, yaw] in a body frame whatever sequence the rotations are applied in; a quaternion is [x, y, z, w], scalar last; a matrix is row-major 3x3, so r[1][2] is row 1, column 2.

Application is the EulerOrder argument. Zyx applies yaw first, then pitch, then roll, and reads euler[2], euler[1], euler[0] in that sequence. It never reorders the array, so euler_to_quat([roll, pitch, yaw], Zyx) and the same triple under Xyz build different rotations from identical numbers. There is no [yaw, pitch, roll] layout here.

ConstantWire valueAppliedBuilt-in frames reporting in it
Xyz1about x, then the new y, then the newest znone
Xzy2about x, then the new z, then the newest ynone
Yxz3about y, then the new x, then the newest znone
Yzx4about y, then the new z, then the newest xnone
Zxy5about z, then the new x, then the newest y"unity"
Zyx6about z, then the new y, then the newest x"frd", "fru", "cv"

The values are swarmbotix.coordinates.EulerOrder's, so to_wire() is a cast. Zero is EULER_ORDER_UNSPECIFIED, not an order and with no variant here. Python spells the variants EulerOrder.ZYX; Rust and C++ spell them EulerOrder::Zyx.

Gotcha. vrobots_sdk::EulerOrder and rotations::EulerOrder are different types with the same name: the first is the wire tag, a transparent i32 keeping whatever a publisher sent, the second the math parameter, closed because a conversion handed a value that names no order has nothing correct to do. Cross with EulerOrder::from_wire(tag.0) and handle the None.

The six conversions

A quaternion here is Hamilton, unit and body-to-world: rotate_vec3 takes body components and returns world components. Euler angles are intrinsic Tait-Bryan, each rotation about an axis of the frame the previous one produced, matching the simulator.

FromToFunction
Eulerquaternioneuler_to_quat(euler, order)
Eulermatrixeuler_to_rotmat(euler, order)
quaternionEulerquat_to_euler(quat, order)
quaternionmatrixquat_to_rotmat(quat)
matrixEulerrotmat_to_euler(r, order)
matrixquaternionrotmat_to_quat(r)

Around them sit quat_multiply, quat_conjugate, quat_normalize, rotate_vec3, rotmat_multiply, rotmat_transpose, rotmat_det, rotmat_abs and rotmat_apply. quat_multiply(a, b) is "b first, then a", which is why ex35 post-multiplies to bias about a body axis. All are pure and total: no panics, no NaN out of finite input, no allocation, and a zero-length quaternion normalizes to IDENTITY_QUAT rather than erroring.

A frame is nine numbers, and the wire carries them

A convention is one signed-permutation matrix R whose rows are the Unity axes that map onto that frame's axes, so R * v re-expresses a Unity vector in frame components. That is what z/frames publishes, and it is all a peer needs: the inverse is the transpose, the handedness det(R), and north, east and down are R on the Unity world anchor.

Frame idAxesHandedEuler orderAxes constant
"unity"+x right, +y up, +z forwardleftZxyAxes::UNITY
"frd"+x forward, +y right, +z downrightZyxAxes::FRD
"fru"+x forward, +y right, +z upleftZyxnone
"cv"+x right, +y down, +z forwardrightZyxAxes::CV

AxisBasis is that matrix plus the frame's reporting order; FrameTransform is the R_to * R_from^T product between two of them. Both have constructors for the four built-ins, and AxisBasis::from_frame_def builds one from a definition read off the wire, the only route that reaches a frame with no Axes constant. "fru" is that case today, and a scene registering a convention at runtime is the same problem further out.

ex36 reads the definition with frame_def() and turns it into a basis. From examples/rust/src/bin/ex36_rotations.rs:

#![allow(unused)]
fn main() {
    let basis = AxisBasis::from_frame_def(&def).ok_or_else(|| {
        VrError::InvalidArgument(format!(
            "frame {:?} names no euler order and its axis convention has no built-in default, \
             so there is no order to report angles in",
            def.id
        ))
    })?;
}
The same in C++ (examples/cpp/ex36_rotations.cpp)
// Throws when the definition names no order and its axis convention has
// no built-in default: there is then no order to report angles in.
const vrsdk::rotations::AxisBasis basis = vrsdk::rotations::AxisBasis::from_frame_def(def);
The same in Python (examples/python/ex36_rotations.py)
    basis = rotations.AxisBasis.from_frame_def(fdef)
    if basis is None:
        raise SystemExit(
            f"frame {fdef.id!r} names no euler order and its axis convention has no "
            "built-in default, so there is no order to report angles in"
        )

Rust returns Option, Python None and C++ throws, all saying the same thing: a basis with a guessed order extracts angles that look plausible and mean nothing. For the truck's "fru" the call succeeds, and the line under it reads

  det(R)=+1, right-handed=false

Gotcha. det(R) = -1 means right-handed, not left: the determinant is of the map out of left-handed Unity, so a right-handed frame is the one that flips the sign. is_right_handed() exists so nothing has to remember that.

Which rule applies depends on what the vector is

M * v is right for a polar vector and wrong for an axial one. An axial vector is defined by a cross product, so it picks up an extra sign when the basis flips handedness. The simulator splits its conversions the same way, so this is a rule the SDK agrees with rather than one it chose.

CategoryRuleMethodState fields
polarM * vapply_vec3lin_pos, lin_vel, lin_acc, wrench.force, env.gravity, accelerometer, magnetometer, GNSS and optical-flow velocity
axialdet(M) * M * vapply_axial_vec3ang_vel, ang_acc, wrench.torque, gyroscope
diagonal inertiaabs(M) * vapply_inertia_vec3PhysicalParams::moi, a permutation with no sign, because a moment of inertia is positive
orientationM * C * M^Tapply_quat, apply_rotmatquat

Between two frames of the same handedness det(M) = +1 and the first two rules coincide, which is why picking the wrong one survives testing until somebody crosses a flip. ex36 converts the live gyro both ways. From examples/rust/src/bin/ex36_rotations.rs:

#![allow(unused)]
fn main() {
    // axial: the gyro, and the mistake beside it
    let gyro = state.sensors.gyroscope.angular_velocity;
    let gyro_frd = t.apply_axial_vec3(gyro);
    let gyro_wrong = t.apply_vec3(gyro);
}
The same in C++ (examples/cpp/ex36_rotations.cpp)
// axial: the gyro, and the mistake beside it
const vrsdk::Vec3 gyro = gyro_of(state);
const vrsdk::Vec3 gyro_frd = t.apply_axial_vec3(gyro);
const vrsdk::Vec3 gyro_wrong = t.apply_vec3(gyro);
The same in Python (examples/python/ex36_rotations.py)
    # axial: the gyro, and the mistake beside it
    gyro = state.sensors.gyroscope.angular_velocity
    gyro_frd = t.apply_axial_vec3(gyro)
    gyro_wrong = t.apply_vec3(gyro)

The truck reports in "fru" and the target is "frd", so M is diag(1, 1, -1) and det(M) = -1: every component of the axial answer is the negation of the polar one, and a body rate converted with the polar rule sends the robot spinning the other way. A roll rate of [0, 0, 1] in Unity axes converts to [-1, 0, 0] under apply_axial_vec3 and to [+1, 0, 0] under apply_vec3, which is the whole trap in two lines.

For a pair the Axes tags can name, convert_vec3, convert_axial_vec3, convert_inertia_vec3, convert_quat, convert_rotmat and convert_euler do the same in one call, each returning VrResult. Handed the truck's own axis_convention, which is UNSPECIFIED, they fail with InvalidArgument naming the three constants that exist. That is why frame_def() is there: the tag is a convenience and the nine numbers are the fact.

Gimbal lock has an answer, and it is the simulator's

With the middle angle at a quarter turn the two outer rotations act about the same axis and only their difference survives, so no unique triple is left. rotmat_to_euler and quat_to_euler return one anyway: the angle about z is pinned to zero, because heading is the meaningless quantity when the nose is vertical, and the whole determined combination goes into the other outer angle. Where z is itself the middle axis and cannot be pinned, the last angle of the sequence is zeroed instead. That matches CoordFrame.MatrixToEulerDeg, so a locked attitude decodes here to the triple the simulator shows.

ex36's last section builds an attitude from LOCKED_DEG, which is (0, 90, 40) degrees with the pitch exactly at the pole, and asks for the angles back through the same euler_to_quat and quat_to_euler pair the table above lists:

  in    roll=  +0.00 deg  pitch= +90.00 deg  yaw= +40.00 deg
  out   roll= -40.00 deg  pitch= +90.00 deg  yaw=  +0.00 deg

Different numbers, same rotation: the example rebuilds the matrix from the extracted triple and checks it element by element. A naive atan2 pair returns neither answer, because both its terms are rounding noise at the pole, which is how (0, 90, 40) comes back from a hand-inlined formula as (26.6, 90, 90).

Next: Reading state

See also: Frames, axes and units, Coordinate frames, Publishing estimates

Reading state

How one state snapshot is put together, and which page of this chapter describes each part of it.

The snapshot model

The simulator publishes one complete state sample per robot at 25 Hz. The SDK subscribes in the background, decodes each sample into an owned snapshot and stores it. You never poll a queue and you never register a callback: you read the latest snapshot whenever your loop wants it.

That read is the narrowest API in the SDK.

From crates/vrobots-sdk/src/robot.rs:

#![allow(unused)]
fn main() {
pub fn states(&self) -> Arc<State> {
    self.channel.snapshot.load_full()
}
}
The same in C++ (cpp/include/vrobots_sdk.hpp)
[[nodiscard]] State states() const {
    vrsdk_state_t raw{};
    detail::check(vrsdk_robot_states(require(), &raw), "states");
    return State::from_raw(raw);
}
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
class VirtualRobot:
    @property
    def states(self) -> State: ...

The three differ only in how the copy is made and named. Rust hands back a reference-counted clone, so reading is a pointer bump. C++ copies the C struct into a value you can store and pass to another thread, and Python builds a State object. In Python it is a property, so it is mr.states with no parentheses; forgetting that is the most common transcription error when porting a loop from one of the other two.

This is a signature rather than a program: it returns immediately with the most recent decoded sample, hands you a reference-counted clone, and cannot fail. A State is a plain owned struct of fixed-size arrays and Vecs, with no borrows into the receive buffer, so it outlives the sample it came from and crosses to C++ and Python as a memory copy.

Three consequences follow, and the rest of the chapter is mostly their detail:

  • A snapshot is never torn. You either see the previous sample in full or the next one in full, never half of each.
  • A snapshot is never absent. connect blocks for the first sample before it returns, so states() is valid immediately afterwards.
  • A snapshot is never an error. If the simulator stops, states() keeps returning the last sample it had, unchanged, forever. Detecting that requires wait_new_state, not an error check.

What one snapshot contains

Every sample carries a header, then four blocks that differ in what a real robot could know about itself.

flowchart TD
  S["State (one sample)"]
  S --> H["header: t_ns, elapsed, seq, sys_id, coord_frame_id"]
  S --> T["truth: simulator-exact"]
  S --> M["measured: robot-observable"]
  S --> B["believed: the robot's filter"]
  S --> A["actuator: none of the three"]
  T --> K["kin: pose, twist, accel"]
  T --> W["wrench: force, torque"]
  T --> E["env: gravity, air, geo, agl"]
  M --> SN["sensors: accel, gyro, mag, baro, gnss, flow"]
  B --> ES["estimate: kin, valid, timestamp"]
  A --> AC["pwm, normalized, measured"]

The split is the schema's whole purpose. kin, wrench and env are values no physical vehicle could measure. sensors is the noisy view of the same instant. estimate is what the robot's own filter believes. actuator belongs to none of them, because it is the command going out and the realised motion coming back.

The minimal read loop

The smallest useful program takes two fields out of the snapshot and paces itself.

From examples/rust/src/bin/ex01_hello_states.rs:

#![allow(unused)]
fn main() {
loop {
    let s = robot.states(); // immutable latest snapshot, never torn
    let [x, y, z] = s.kin.lin_pos;
    println!("State t={:.3} pos=({x:.3},{y:.2},{z:.2})", s.elapsed);
    robot.rate(HZ); // drift-compensated pacing, Hz
}
}
The same in C++ (examples/cpp/ex01_hello_states.cpp)
for (;;) {
    const vrsdk::State s = robot.states();  // latest snapshot, never torn
    const double* p = s.kin().lin_pos;
    std::printf("State t=%.3f pos=(%.3f,%.2f,%.2f)\n", s.elapsed, p[0], p[1], p[2]);
    robot.rate(HZ);  // drift-compensated pacing, Hz
}
The same in Python (examples/python/ex01_hello_states.py)
while True:
    s = mr.states  # immutable latest snapshot, never torn
    x, y, z = s.kin.lin_pos
    print(f"State t={s.elapsed:.3f} pos=({x:.3f},{y:.2f},{z:.2f})")
    mr.rate(HZ)  # drift-compensated pacing, Hz

Rust and Python destructure the position into three names; C++ takes a pointer to the three-element array, because lin_pos is a plain C double[3] there.

At the example's 50 Hz against a 25 Hz stream, consecutive lines repeat the same sample about half the time, which is correct and costs nothing:

State t=12.480 pos=(0.031,0.85,-1.20)
State t=12.480 pos=(0.031,0.85,-1.20)
State t=12.520 pos=(0.032,0.85,-1.21)

Note. Reading the same snapshot twice is free and is not a bug. If duplicate processing would be a bug, for example when you differentiate or log, pace on the data instead: see Pacing your loop.

The rest of this chapter

PageAnswers
Truth, measured and believedWhich block may a real robot use, and which differences are the experiment
KinematicsPose, twist, acceleration, their frames, and the net wrench
SensorsEvery device, its fields, its units, its own clock
The environment blockThe truth the sensors were noised from
ActuatorsThe command echo, and the only proof a command landed
Timestamps and sequence numbersWhich of the four clocks to use for what
Pacing your loopFree-running against sample-paced loops
Stream healthCounters, last_error, stalls and restarts
A tour of the whole snapshotOne program that prints all of it at once

Next: Truth, measured and believed

See also: Hello states, The shape of a program, Five rules that explain everything

Truth, measured and believed

The three epistemic categories a snapshot keeps apart, and the two differences between them that are worth measuring.

Why the schema separates them

A simulator can tell you exactly where a robot is. A robot cannot know that about itself. If both numbers live in the same struct under similar names, a control loop that accidentally reads the exact one works beautifully in simulation and fails on hardware, and nothing in the code looks wrong.

The message schema prevents that by construction, and the SDK's State mirrors it: truth, measurement and belief sit in separate blocks, and no field appears in two of them. Choosing a block is therefore a deliberate act. If your controller reads kin.lin_pos, you have decided to use ground truth, and that decision is visible in the source.

CategoryBlocksAvailable on a real robotWhat it is
truthkin, wrench, envnosimulator-exact values, the physics engine's own numbers
measuredsensorsyesthe noisy, robot-observable view of the same instant
believedestimateyeswhat the robot's own filter has concluded
neitheractuatoryescommand in, realised motion out

actuator is listed as none of the three on purpose. It is not a measurement of the world and not a belief about it: it is the echo of what you commanded beside what the device did. It has its own page, Actuators.

The two differences are the experiment

Because the blocks are published from the same instant, characterising a sensor or an estimator is a subtraction between two fields of one snapshot. Nothing has to be inferred, and no separate ground-truth log has to be aligned in time.

The SDK source states two of these outright:

DifferenceWhat it is
estimate.kin - kinthe estimator error
sensors.barometer.pressure - env.air_pressurethe barometer's error

The same construction extends to every other device. sensors.gyroscope.angular_velocity - kin.ang_vel is the gyro's noise realisation on that sample, because both are body-frame angular rates in rad/s. sensors.gnss.geo_point against env.geo_point is the receiver's position error.

Two cautions apply to every such diff. Both sides must be in the same frame, which is not automatic when a device overrides the robot's axis_convention (see Sensors). And the measured side must be fresh: a sensor slower than the state stream republishes its previous reading, so a diff taken every sample measures the same noise realisation several times over. Compare timestamps first, as Timestamps and sequence numbers describes.

Reading the believed block

estimate has the same Kinematics shape as the truth block, plus its own clock, its own frame, and a validity flag.

From examples/rust/src/bin/ex10_sensors_tour.rs:

#![allow(unused)]
fn main() {
// -- believed -------------------------------------------------------
let e = &s.estimate;
println!(
    "BELIEVED  estimate  {}  frame={:?}",
    stamp(e.valid, e.timestamp),
    e.coord_frame_id
);
println!(
    "  lin_pos   {} m       (estimate.kin - kin IS the error)",
    v3(e.kin.lin_pos)
);
}
The same in C++ (examples/cpp/ex10_sensors_tour.cpp)
// -- believed -----------------------------------------------------
std::printf("BELIEVED  estimate  ");
stamp(r.estimate.valid, r.estimate.timestamp);
// The fixed C char arrays are NUL-terminated; the precision bounds
// the read even if a future field ever fills the buffer exactly.
std::printf("  frame=\"%.*s\"\n",
            static_cast<int>(sizeof r.estimate.coord_frame_id - 1),
            r.estimate.coord_frame_id);
v3("lin_pos", r.estimate.kin.lin_pos, "m", "(estimate.kin - kin IS the error)");
v3("lin_vel", r.estimate.kin.lin_vel, "m/s", "");
The same in Python (examples/python/ex10_sensors_tour.py)
# -- believed -------------------------------------------------------
e = s.estimate
print(
    f"BELIEVED  estimate  {stamp(e.valid, e.timestamp)}  "
    f"frame={e.coord_frame_id!r}"
)
print(f"  lin_pos   {v3(e.kin.lin_pos)} m       (estimate.kin - kin IS the error)")
print(f"  lin_vel   {v3(e.kin.lin_vel)} m/s")

C++ reaches the block through s.raw, the copied C struct, where the other two have named fields on the snapshot: r.estimate.kin.lin_pos against s.estimate.kin.lin_pos. The fields, the units and the subtraction that gives the estimator error are identical.

Today that always prints an invalid stamp, zeroed vectors and an empty frame id:

BELIEVED  estimate  [INVALID t=0.000]  frame=""
  lin_pos   (  +0.000,  +0.000,  +0.000) m       (estimate.kin - kin IS the error)

It is not a decode failure and not a dropped block: a missing nested table decodes to its Default, which is all zeros, false and empty strings. The simulator runs no estimator and omits the field on purpose, for the same reason this page opens with. An estimate that silently mirrors truth would make estimate.kin - kin a tautology, so the block is absent rather than filled from the truth block.

Publishing the belief yourself

The believed block is therefore yours to supply, and it travels on its own topic rather than in the snapshot. publish_estimate puts a swarmbotix.states.EstimateState on vrobots/{sys_id}/z/estimate, where the fixed wing reads it under FW_EST_OBSERVER. Read sensors off z/state, run your filter, publish the result, and the subtraction against kin measures a real estimator error. Publishing estimates is that loop end to end.

Gotcha. estimate.valid is false until the filter converges, and an unconverged estimate that silently mirrors truth is the classic trap. A filter initialised from the simulator's own state reads as a perfect estimator for as long as nothing disturbs it, and your error metric reads zero because you are subtracting a number from itself. Gate every use of estimate on estimate.valid, and treat an error of exactly zero as evidence of a bug rather than of quality.

Which block should your code read

You are writingReadBecause
a controller you intend to port to hardwaresensors, or estimate when validthese are the only blocks a physical robot has
an estimator or filter under testsensors in, kin only to score itreading kin inside the filter invalidates the test
a sensor characterisationboth sides of one of the diffs abovethe pair is published from the same instant
a plotting or debugging toolanythingthere is no port to fail

Note. Nothing in the SDK stops a controller reading kin. Early on that is often the right choice, because it separates a controller bug from a sensing problem. Make it a decision you can find later, not a default.

Next: Kinematics

See also: Publishing estimates, Frames, axes and units, A tour of the whole snapshot, Sensor noise

Kinematics

Every field of the pose, twist and acceleration block, the frame each one is expressed in, and the net wrench published beside it.

One struct, two uses

Kinematics appears twice in a snapshot: as State::kin, which is simulator truth, and as State::estimate.kin, which is the robot's belief. The fields, units and frames are identical in both, which is what makes estimate.kin - kin a meaningful subtraction. Everything on this page applies to both instances.

The one distinction inside the struct is the frame. Position and attitude are world quantities: they answer "where is the robot in the scene". Velocity, angular velocity and both accelerations are body quantities: they answer "what is the robot doing to itself". That split is physics rather than configuration, and no service changes it.

FieldTypeUnitsFrameDefaultNotes
lin_pos[f64; 3]mworld[0.0; 3]origin at the scene's world origin
quat[f64; 4]world[0.0; 4]unit quaternion, ordered [x, y, z, w]; body attitude relative to world
lin_vel[f64; 3]m/sbody[0.0; 3]
ang_vel[f64; 3]rad/sbody[0.0; 3]body rates, which is what a gyro measures
lin_acc[f64; 3]m/s²body[0.0; 3]coordinate acceleration, not specific force
ang_acc[f64; 3]rad/s²body[0.0; 3]

The wire carries these components as f32. The SDK widens them to f64, which is lossless, so the same struct reads naturally from Rust, C++ and Python.

The three conventions that catch people

Quaternion order is [x, y, z, w]. It matches the wire's Vec4 field order, not the [w, x, y, z] order that several maths libraries use for their constructors. Feeding the array into a library that expects scalar-first produces a rotation that is plausible, continuous and wrong, so it survives casual inspection.

ang_vel is not the derivative of Euler angles. It is the body rate vector, the quantity a rate gyro integrates. The two agree only in the trivial case of a single-axis rotation. If your controller wants roll, pitch and yaw rates in the aerospace sense, ang_vel is already that vector; if it wants the time derivatives of the Euler angles you extracted from quat, you must convert, and the conversion is singular near 90 degrees of pitch.

lin_acc is not what the accelerometer reads. kin.lin_acc is coordinate acceleration in body axes. The accelerometer reports specific force, which includes gravity, so a robot sitting still on the ground publishes lin_acc near zero and an accelerometer reading near 1 g. See Sensors.

Gotcha. A nested table missing from the wire decodes to that struct's Default, and Kinematics::default() sets quat to [0.0, 0.0, 0.0, 0.0], which is not the identity rotation [0, 0, 0, 1]. A zero quaternion is not a unit quaternion, so normalising it divides by zero. Check the norm before you rely on an attitude, particularly when decoding recorded payloads whose producer may not have filled the block.

Which frame the numbers are in

The vectors above are expressed in the convention named by the snapshot's own axis_convention and coord_frame_id, which is the robot's frame and not yours. Different robot types genuinely disagree: the examples report the truck publishing fru and the multirotor frd, so the third component of lin_pos is up for one and down for the other. Read coord_frame_id before you interpret a sign.

Header fieldTypeDefaultNotes
axis_conventionAxesAxes::UNSPECIFIEDtransparent i32; UNITY 1, FRD 2, CV 3
coord_frame_idString""authoritative, and the only way to name a frame registered at runtime

Axes::name() returns the registry id for the three built-in conventions and "" for anything else, so a runtime-registered frame keeps its identity in coord_frame_id while axis_convention carries whatever tag the publisher stamped.

Wrench: the physics engine's bottom line

State::wrench is truth, published beside the kinematics, and it is the net effect of everything acting on the body during that step: rotor thrust, gravity, contacts and drag combined.

FieldTypeUnitsFrameDefaultNotes
force[f64; 3]Nbody[0.0; 3]net force this step
torque[f64; 3]N·mbody[0.0; 3]net torque this step

Its use is diagnostic. Diffing the wrench against the force and torque your commanded actuation should have produced is how you observe unmodelled forces, and it is considerably more direct than inferring them from the acceleration.

Reading the truth block

The tour example prints the whole of kin and the wrench together, labelling each line with its frame.

From examples/rust/src/bin/ex10_sensors_tour.rs:

#![allow(unused)]
fn main() {
// -- truth ----------------------------------------------------------
let k = &s.kin;
println!("TRUTH  kinematics");
println!("  lin_pos   {} m       (world)", v3(k.lin_pos));
println!(
    "  quat      [{:+.3},{:+.3},{:+.3},{:+.3}] (world, xyzw)",
    k.quat[0], k.quat[1], k.quat[2], k.quat[3]
);
println!("  lin_vel   {} m/s     (body)", v3(k.lin_vel));
println!(
    "  ang_vel   {} rad/s   (body -- what a gyro measures)",
    v3(k.ang_vel)
);
println!("  lin_acc   {} m/s^2   (body)", v3(k.lin_acc));
println!("  ang_acc   {} rad/s^2 (body)", v3(k.ang_acc));
println!(
    "  wrench    F={} N  T={} N.m",
    v3(s.wrench.force),
    v3(s.wrench.torque)
);
}
The same in C++ (examples/cpp/ex10_sensors_tour.cpp)
// -- truth --------------------------------------------------------
std::printf("TRUTH  kinematics\n");
v3("lin_pos", r.kin.lin_pos, "m", "(world)");
std::printf("  %-9s [%+.3f,%+.3f,%+.3f,%+.3f] (world, xyzw)\n", "quat", r.kin.quat[0],
            r.kin.quat[1], r.kin.quat[2], r.kin.quat[3]);
v3("lin_vel", r.kin.lin_vel, "m/s", "(body)");
v3("ang_vel", r.kin.ang_vel, "rad/s", "(body -- what a gyro measures)");
v3("lin_acc", r.kin.lin_acc, "m/s^2", "(body)");
v3("ang_acc", r.kin.ang_acc, "rad/s^2", "(body)");
v3("force", r.wrench.force, "N", "(total on the body)");
v3("torque", r.wrench.torque, "N.m", "");
The same in Python (examples/python/ex10_sensors_tour.py)
# -- truth ----------------------------------------------------------
k = s.kin
print("TRUTH  kinematics")
print(f"  lin_pos   {v3(k.lin_pos)} m       (world)")
quat = ",".join(f"{c:+.3f}" for c in k.quat)
print(f"  quat      [{quat}] (world, xyzw)")
print(f"  lin_vel   {v3(k.lin_vel)} m/s     (body)")
print(f"  ang_vel   {v3(k.ang_vel)} rad/s   (body -- what a gyro measures)")
print(f"  lin_acc   {v3(k.lin_acc)} m/s^2   (body)")
print(f"  ang_acc   {v3(k.ang_acc)} rad/s^2 (body)")
print(f"  wrench    F={v3(s.wrench.force)} N  T={v3(s.wrench.torque)} N.m")

Rust and Python read quat as a four-element sequence; C++ indexes the C array. Every field name, frame and unit is the same in all three, and so is the rule that lin_pos and quat are world while the four rate and acceleration vectors are body.

For a stationary robot the twist and acceleration rows sit at zero and the attitude row shows the identity rotation with its scalar part last:

TRUTH  kinematics
  lin_pos   (  +0.031,  +0.852,  -1.204) m       (world)
  quat      [+0.000,+0.000,+0.000,+1.000] (world, xyzw)
  lin_vel   (  +0.000,  +0.000,  +0.000) m/s     (body)
  ang_vel   (  +0.000,  +0.000,  +0.000) rad/s   (body -- what a gyro measures)
  lin_acc   (  +0.000,  +0.000,  +0.000) m/s^2   (body)
  ang_acc   (  +0.000,  +0.000,  +0.000) rad/s^2 (body)
  wrench    F=(  +0.000,  +0.000,  +0.000) N  T=(  +0.000,  +0.000,  +0.000) N.m

Next: Sensors

See also: Frames, axes and units, Mass and inertia, A tour of the whole snapshot

Sensors

Every device in the measured block, its fields, its units, and the two common fields that reveal the rate it actually runs at.

What is in the block, and what is deliberately not

State::sensors is everything the robot can observe about itself. There are six devices: accelerometer, gyroscope, magnetometer, barometer, GNSS receiver and optical flow. Each is a separate struct with its own fields.

Two absences are deliberate. There is no imu grouping, because the accelerometer and the gyroscope are separate devices with separate clocks and separate noise models, and bundling them invites code that assumes they updated together. There is no attitude field anywhere in sensors, because attitude is never measured, only fused: if you want the robot's belief about its orientation, that is estimate, and if you want the simulator's, that is kin.quat.

Every device carries the same two fields, and they matter more than the readings do.

FieldTypeUnitsDefaultNotes
timestampf64s since the unix epoch0.0the sensor's own capture clock, not the snapshot header's
validboolfalsefalse until the device has produced a usable reading

valid = false has three distinct causes that look identical from the client: the device is not mounted on this robot, it is mounted but has not produced a first reading, or it has lost its fix. Treat it as "do not use this number" and check the robot's configured sensor set if you expected otherwise.

The devices

Accelerometer

FieldTypeUnitsFrameDefaultNotes
linear_acceleration[f64; 3]m/s²body[0.0; 3]specific force, so +1 g at rest and 0 in free fall
axis_conventionAxesUNSPECIFIEDmounting convention, when it differs from the robot's
coord_frame_idString""mounting frame id, when it differs from the robot's

Specific force is the field people misread. It is not kin.lin_acc: subtracting gravity is your job, and doing it needs an attitude you do not have from this device alone.

Gyroscope

FieldTypeUnitsFrameDefaultNotes
angular_velocity[f64; 3]rad/sbody[0.0; 3]body rates, directly comparable to kin.ang_vel
axis_conventionAxesUNSPECIFIEDmounting override
coord_frame_idString""mounting override

Magnetometer

FieldTypeUnitsFrameDefaultNotes
magnetic_field[f64; 3]see belowbody[0.0; 3]the sources disagree on the unit
axis_conventionAxesUNSPECIFIEDmounting override
coord_frame_idString""mounting override

Note. The unit of magnetic_field is not documented in state.rs. The tour example labels its printout gauss and says so explicitly; the sensor-noise service documents the magnetometer's noise parameter in tesla. Until this is checked against a running simulator, do not assume the reading and the noise setting share a unit (1 T is 10 000 G).

Barometer

FieldTypeUnitsDefaultNotes
pressuref64Pa0.0static pressure; diff against env.air_pressure for the error
altitudef64m0.0pressure altitude computed against qnh, so it drifts with the weather
qnhf64Pa0.0reference sea-level pressure used for altitude

The barometer is the one device with no axis_convention and no coord_frame_id, because it is a scalar sensor with nothing to orient.

GNSS

FieldTypeUnitsDefaultNotes
geo_pointGeoPointdeg, deg, mzeroedreported geodetic position
velocity[f64; 3]m/s[0.0; 3]reported velocity
ephf64m0.0horizontal position accuracy estimate
epvf64m0.0vertical position accuracy estimate
fix_typeu320fix quality, receiver-defined
axis_conventionAxesUNSPECIFIEDmounting override
coord_frame_idString""mounting override

GeoPoint is latitude and longitude in degrees and altitude in metres, and the same struct appears in env.geo_point as the true position.

Gotcha. The receiver runs at roughly 5 Hz against a 25 Hz state stream, so the same fix is republished in about five consecutive snapshots. Presence is not freshness. To detect an update boundary, compare gnss.timestamp with the value you saw last, and only then treat the reading as new. Code that differentiates GNSS position once per state sample instead of once per fix produces a velocity that is zero four samples out of five and then spikes.

Optical flow

FieldTypeUnitsFrameDefaultNotes
velocity[f64; 3]m/sbody[0.0; 3]estimated velocity from a downward-looking sensor
axis_conventionAxesUNSPECIFIEDmounting override
coord_frame_idString""mounting override

Optical flow is optional and deliberately a poor sensor: valid goes false over featureless ground. Robots mount it only when asked, so valid = false on a default robot usually means the device is not fitted rather than that it failed.

The per-device frame override

Five of the six devices carry their own axis_convention and coord_frame_id. They exist for one reason: a device whose mounting frame differs from the robot's, for example an IMU rotated in its bracket or a receiver quoting velocity in NED while the body publishes frd. When they are set, they win for that device's vectors only.

The practical rule is that a diff between a sensor vector and a truth vector is only meaningful once both are in the same frame. Read the device's coord_frame_id first; when it is empty, the device is in the robot's frame from the snapshot header.

Reading the measured block

The tour example prints each device with its reading, its validity and its own clock side by side, which is what makes the differing rates visible.

From examples/rust/src/bin/ex10_sensors_tour.rs:

#![allow(unused)]
fn main() {
println!(
    "  gnss      lat={:.6} lon={:.6} alt={:.2} m  vel={} m/s (NED)",
    n.gnss.geo_point.latitude,
    n.gnss.geo_point.longitude,
    n.gnss.geo_point.altitude,
    v3(n.gnss.velocity)
);
println!(
    "            fix={} eph={:.2} epv={:.2} m  {}   [slowest device, ~5 Hz]",
    n.gnss.fix_type,
    n.gnss.eph,
    n.gnss.epv,
    stamp(n.gnss.valid, n.gnss.timestamp)
);
}
The same in C++ (examples/cpp/ex10_sensors_tour.cpp)
std::printf("  gnss      lat=%.6f lon=%.6f alt=%.2f m  vel=(%+.3f,%+.3f,%+.3f) m/s (NED)\n",
            n.gnss.geo_point.latitude, n.gnss.geo_point.longitude,
            n.gnss.geo_point.altitude, n.gnss.velocity[0], n.gnss.velocity[1],
            n.gnss.velocity[2]);
std::printf("            fix=%u eph=%.2f epv=%.2f m  ", n.gnss.fix_type, n.gnss.eph,
            n.gnss.epv);
stamp(n.gnss.valid, n.gnss.timestamp);
std::printf("   [slowest device, ~5 Hz]\n");
The same in Python (examples/python/ex10_sensors_tour.py)
g = n.gnss.geo_point
print(
    f"  gnss      lat={g.latitude:.6f} lon={g.longitude:.6f} alt={g.altitude:.2f} m  "
    f"vel={v3(n.gnss.velocity)} m/s (NED)"
)
print(
    f"            fix={n.gnss.fix_type} eph={n.gnss.eph:.2f} epv={n.gnss.epv:.2f} m  "
    f"{stamp(n.gnss.valid, n.gnss.timestamp)}   [slowest device, ~5 Hz]"
)

The device path is the same in all three: sensors.gnss.geo_point.latitude, reached through s.raw.sensors in C++ and s.sensors in the other two. So is the valid and timestamp pair every device carries, which is what the shared stamp helper prints.

Run the tour at 1 Hz and the GNSS stamp advances once per printed block; run it at the state rate and the same stamp repeats:

  gnss      lat=37.400000 lon=-122.100000 alt=12.34 m  vel=(  +0.000,  +0.000,  +0.000) m/s (NED)
            fix=3 eph=1.20 epv=1.80 m  [valid t=1770000000.200]   [slowest device, ~5 Hz]

The helper stamp that formats the bracketed field is two lines long and is quoted in A tour of the whole snapshot.

Next: The environment block

See also: Sensor noise, Coordinate frames, Timestamps and sequence numbers

The environment block

The true world the sensor readings were noised from, field by field, including the one field the sources disagree about.

Truth about the world, not about the robot

State::env is a truth block, like kin and wrench. It holds the atmosphere and the geodetic position the simulator used when it generated that sample's measurements. Its value to you is almost entirely as the reference side of a subtraction: the barometer reading minus env.air_pressure is the barometer's error, and the GNSS position minus env.geo_point is the receiver's.

The block is small and every field is scalar or a fixed-size array, so reading it costs nothing.

FieldTypeUnitsFrameDefaultNotes
gravity[f64; 3]m/s²world[0.0; 3]the acceleration the physics engine applied
air_pressuref64Pa0.0true static pressure; the barometer's reference
air_densityf64kg/m³0.0
temperaturef64°C0.0true air temperature
geo_pointGeoPointdeg, deg, mzeroedthe robot's true geodetic position
aglf64m0.0true height above ground, but see below

gravity is a world-frame vector, not a scalar, so its sign tells you which way the frame's third axis points: positive where that axis counts downwards, negative where it counts upwards. That makes it a cheap runtime check that you have understood the frame the rest of the snapshot is in.

The two diffs this block exists for

DifferenceGives you
sensors.barometer.pressure - env.air_pressurethe barometer's error in Pa on that sample
sensors.gnss.geo_point - env.geo_pointthe receiver's position error

Both are only meaningful when the measured side is fresh. The barometer and the GNSS receiver run at their own rates, so compare their timestamps before differencing. See Sensors.

Note. air_density and temperature are published as truth and are not derived from any sensor in the block: there is no thermometer and no air-data device in sensors. If your model needs density, this is where it comes from, and a real vehicle would have to estimate it.

The agl field, and what the sources say about it

agl is documented in the SDK as the true height above ground, to be compared with the barometer's pressure altitude. The examples say something incompatible with that.

SourceClaim
crates/vrobots-sdk/src/state.rsagl is true height above ground in metres, to be compared with the barometer's pressure altitude
examples/rust/README.md, simulator v3.0.0agl is a hard-coded zero for every robot, because filling it needs a downward raycast the simulator does not run; the substitute is kin.lin_pos[2], negated on a robot publishing frd

The two are not reconcilable by reading the repository, so this book states both and resolves neither. The example header is explicit that the zero is a placeholder rather than a measurement, on the reasoning that env is the truth block and an invented height above ground would be worse than a visibly missing one.

Until it is checked against a running simulator, write code that survives either version: if agl is exactly zero while the robot is demonstrably not on the ground, fall back to the vertical component of kin.lin_pos with the sign that the snapshot's coord_frame_id implies.

Reading the world block

The tour example prints the environment in two lines, and labels the agl line with the example's own claim about it.

From examples/rust/src/bin/ex10_sensors_tour.rs:

#![allow(unused)]
fn main() {
let env = &s.env;
println!("WORLD  environment");
println!(
    "  gravity   {} m/s^2   air {:.1} Pa {:.3} kg/m^3 {:.1} C",
    v3(env.gravity),
    env.air_pressure,
    env.air_density,
    env.temperature
);
println!(
    "  agl       {:.2} m    home lat={:.6} lon={:.6}   [agl is hard-coded 0 in sim v3.0.0 -- use -lin_pos[2]]",
    env.agl, env.geo_point.latitude, env.geo_point.longitude
);
}
The same in C++ (examples/cpp/ex10_sensors_tour.cpp)
std::printf("WORLD  environment\n");
std::printf("  gravity   (%+.3f,%+.3f,%+.3f) m/s^2   air %.1f Pa %.3f kg/m^3 %.1f C\n",
            r.env.gravity[0], r.env.gravity[1], r.env.gravity[2], r.env.air_pressure,
            r.env.air_density, r.env.temperature);
std::printf(
    "  agl       %.2f m    home lat=%.6f lon=%.6f   [agl is hard-coded 0 in sim "
    "v3.0.0 -- use -lin_pos[2]]\n",
    r.env.agl, r.env.geo_point.latitude, r.env.geo_point.longitude);
The same in Python (examples/python/ex10_sensors_tour.py)
env = s.env
print("WORLD  environment")
print(
    f"  gravity   {v3(env.gravity)} m/s^2   air {env.air_pressure:.1f} Pa "
    f"{env.air_density:.3f} kg/m^3 {env.temperature:.1f} C"
)
print(
    f"  agl       {env.agl:.2f} m    home lat={env.geo_point.latitude:.6f} "
    f"lon={env.geo_point.longitude:.6f}   "
    f"[agl is hard-coded 0 in sim v3.0.0 -- use -lin_pos[2]]"
)

All three carry the same warning in the same place, because all three read the same field from the same message: agl is the one entry in this block you cannot use as it stands.

The printed agl value is the first thing to look at when you check this page's conflict, because a non-zero reading on a robot in the air settles it:

WORLD  environment
  gravity   (  +0.000,  +0.000,  +9.807) m/s^2   air 101325.0 Pa 1.225 kg/m^3 15.0 C
  agl       0.00 m    home lat=37.400000 lon=-122.100000   [agl is hard-coded 0 in sim v3.0.0 -- use -lin_pos[2]]

Next: Actuators

See also: Sensors, Known simulator issues, Kinematics

Actuators

The three index-parallel arrays that carry your last command back to you beside what the devices actually did.

Command in, motion out

State::actuator is the only block in a snapshot that is neither truth, measurement nor belief. Two of its arrays are an echo of the command you sent, and the third is the physical response of the devices. It is published on every state sample, at the state rate, whether or not you ever send a command.

The three arrays are index-parallel: entry i is the same device in each of them. There is no name, no id and no length field beyond the arrays' own lengths, so the mapping from index to physical device comes from the robot type, not from the snapshot.

FieldTypeUnitsDefaultNotes
pwmVec<u32>µsemptycommanded pulse widths, your last command echoed back
normalizedVec<f64>emptythe same command mapped to [-1, 1]
measuredVec<f64>varies by deviceemptywhat the device actually did

measured is the array to read carefully, because its unit is a property of the device at that index rather than of the array: a rotor reports rad/s, a wheel reports rad/s, and a servo reports rad. Nothing in the snapshot tells you which, so the robot type decides. Chapter 7 gives the per-robot layouts.

Note. All three arrays default to empty rather than to a fixed length. A robot that has never been commanded, or a nested table missing from the wire, decodes to empty vectors. Index them with get, or check the length, before assuming a rotor count.

The echo is the only proof a command landed

Commands are fire and forget. They latch, they get no reply, and publishing to a topic nobody is subscribed to is not an error in zenoh, so a command sent while the simulator is closed still returns Ok. There is no acknowledgement anywhere in the command path.

That leaves exactly one way to confirm that a robot received what you sent: read actuator.pwm in the next state sample and compare it with what you commanded. The robust-loop example makes the point by printing the echo beside the sequence number in its status line.

From examples/rust/src/bin/ex19_robust_loop.rs:

#![allow(unused)]
fn main() {
let s = robot.states();
if samples.is_multiple_of(REPORT_EVERY) {
    let [x, y, z] = s.kin.lin_pos;
    let stats = robot.stats();
    println!(
        "ok  seq={} t={:.2}s pos=({x:.2},{y:.2},{z:.2}) echo={:?} \
         received={} gaps={} decode_errors={}",
        s.seq,
        s.elapsed,
        s.actuator.pwm,
        stats.received,
        stats.seq_gaps,
        stats.decode_errors
    );
}
}
The same in C++ (examples/cpp/ex19_robust_loop.cpp)
const vrsdk::State s = robot.states();
if (samples % REPORT_EVERY == 0) {
    const double* p = s.kin().lin_pos;
    const vrsdk_state_stats_t st = robot.stats();
    std::printf(
        "ok  seq=%llu t=%.2fs pos=(%.2f,%.2f,%.2f) received=%llu gaps=%llu "
        "decode_errors=%llu\n",
        static_cast<unsigned long long>(s.seq), s.elapsed, p[0], p[1], p[2],
        static_cast<unsigned long long>(st.received),
        static_cast<unsigned long long>(st.seq_gaps),
        static_cast<unsigned long long>(st.decode_errors));
}
The same in Python (examples/python/ex19_robust_loop.py)
s = mr.states
if samples % REPORT_EVERY == 0:
    x, y, z = s.kin.lin_pos
    st = mr.stats
    print(
        f"ok  seq={s.seq} t={s.elapsed:.2f}s pos=({x:.2f},{y:.2f},{z:.2f}) "
        f"echo={s.actuator.pwm} received={st.received} gaps={st.seq_gaps} "
        f"decode_errors={st.decode_errors}"
    )

Rust and Python print actuator.pwm as a list; C++ has it as s.raw.actuator.pwm with a separate pwm_count, and s.pwm() is the convenience that turns the pair into a std::vector<std::uint32_t>. That count matters: the array is fixed-size and only the first pwm_count entries mean anything.

The echo= field is the pwm array verbatim, so a multirotor commanded to 1501 µs on all four rotors reports it back:

ok  seq=125 t=5.00s pos=(0.03,0.85,-1.20) echo=[1501, 1501, 1501, 1501] received=125 gaps=0 decode_errors=0

An echo that does not match what you sent is informative in itself. Values that stay at their previous setting across several samples mean your command did not reach the robot. An empty array means the robot has never been commanded at all. Values that differ from yours but do change when you change yours mean the robot received the command and did something to it, which is a question for the command chapter rather than this one.

Three arrays, three questions

ArrayAnswersUse it when
pwmwhat did I command, in the units I commanded itconfirming a command landed, and comparing against your own setpoint
normalizedwhat fraction of the range was thatlogging or plotting across robots with different pulse-width ranges
measuredwhat did the hardware dodetecting saturation, lag between command and response, and stalled devices

The gap between normalized and measured is where actuator dynamics would show. A step in the command appears in pwm and normalized as soon as the robot has it, because those two are the command, while measured can only follow as fast as the modelled device does. Diffing the two over a step is how you observe that behaviour without instrumenting the simulator.

Where the shape differs: the Global Hawk

The three-array layout is uniform, but what the indices mean is not, and the fixed-wing Global Hawk is the case that breaks a naive reading.

IndexDeviceUnits in measured
0 to 5control panelsdeflection in radians
6enginethrust in newtons

Code that assumes measured is a homogeneous array of rotor speeds produces a thrust figure in the wrong units at index 6 and silently mixes radians with newtons in any aggregate. Read the robot type first.

Gotcha. Index-parallel does not mean equal length in general. Read the length of the array you are about to index rather than the length of pwm, particularly on robot types whose command surface is not a pulse width at all.

Next: Timestamps and sequence numbers

See also: Commands latch, Global Hawk, Rotors and thrust curves

Timestamps and sequence numbers

The four clocks and one counter that appear in a single snapshot, and which of them to use for which question.

Why there is more than one

A snapshot is stamped by the simulator at capture time, not by the SDK at read time, so every time value in it refers to the simulator's clock rather than to your process. Once you have that, the multiplicity is easy to justify: the header answers "when was this sample taken", each sensor answers "when did this device last produce a reading", and the estimate answers "how old is the filter's output". They are different questions and they have different answers on the same sample.

Reach for the right one and most freshness bugs disappear. Reach for the wrong one and you get code that looks correct and measures nothing.

NameWhereTypeUnitsEpochUse it for
t_nssnapshot headeri64nsunixcomparing across streams, including camera frames
elapsedsnapshot headerf64sthis robot's first state sampleprinting, plotting, and reading a log by eye
seqsnapshot headeru64countper topicdetecting dropped samples
<sensor>.timestampeach sensor blockf64sunixdetecting whether that device updated
estimate.timestampestimate blockf64sunixthe age of the filter's output

t_ns is signed, which is deliberate: the useful operation on it is a difference, and a difference between two independent streams can legitimately be negative.

Elapsed, and what it does not do

elapsed is seconds since this robot's first state sample, computed by the decoder as (t_ns - epoch_ns) / 1e9. One epoch is shared by every stream on the robot, so a state elapsed and a camera frame elapsed are directly comparable. It is monotonic for as long as the simulator keeps publishing.

Two properties surprise people. It is not the simulator's run time, because the epoch is fixed at your handle's first sample, not at the simulator's start. And it does not reset when the simulator restarts: it keeps counting through the outage and comes back having jumped forward by however long the simulator was away. The robust-loop example measures exactly that, freezing at 5.84 s for the duration of an outage and resuming at 21.77 s on the first sample of the new run.

Note. State::decode(bytes, epoch_ns) exposes the same arithmetic for offline use. epoch_ns only affects elapsed; pass 0 when decoding a standalone recorded frame, and the frame's elapsed then equals its absolute unix time in seconds.

Sequence numbers

seq is a per-topic counter stamped by the publisher. Consecutive samples differ by one, so a jump means the samples in between never reached your process. That is the only ground truth for drops available to a client: a rate measured over a window cannot distinguish a publisher that slowed down from a network that dropped every third sample.

The sample-paced example computes both the wall gap and the skip count on each wakeup.

From examples/rust/src/bin/ex09_state_paced_loop.rs:

#![allow(unused)]
fn main() {
let s = robot.states();
let dt_ms = if last_t_ns == 0 {
    f64::NAN
} else {
    (s.t_ns - last_t_ns) as f64 / 1e6
};
let skipped = s.seq.saturating_sub(last_seq + 1);
last_seq = s.seq;
last_t_ns = s.t_ns;
}
The same in C++ (examples/cpp/ex09_state_paced_loop.cpp)
// Exactly one new sample is waiting -- read it and do the work.
const vrsdk::State s = robot.states();
const double dt_ms =
    last_t_ns == 0 ? 0.0 : static_cast<double>(s.t_ns - last_t_ns) / 1e6;
const std::uint64_t skipped = s.seq > last_seq + 1 ? s.seq - last_seq - 1 : 0;
last_seq = s.seq;
last_t_ns = s.t_ns;
The same in Python (examples/python/ex09_state_paced_loop.py)
# Exactly one new sample is waiting -- read it and do the work.
s = mr.states
dt_ms = float("nan") if last_t_ns == 0 else (s.t_ns - last_t_ns) / 1e6
skipped = max(0, s.seq - (last_seq + 1))
last_seq, last_t_ns = s.seq, s.t_ns

t_ns is a signed 64-bit integer of nanoseconds and seq an unsigned 64-bit counter in all three, so the arithmetic is the same everywhere. Only the guard against the first iteration differs: Rust and Python use a NaN sentinel for the unknown first dt, C++ prints zero.

At a healthy 25 Hz the interval sits near 40 ms and skipped stays zero; a drop shows as a doubled interval and a non-zero skip on the same line:

seq=310 dt=  40.0 ms pos=(0.031,0.85,-1.20)
seq=311 dt=  40.1 ms pos=(0.031,0.85,-1.20)
seq=313 dt=  80.0 ms pos=(0.032,0.85,-1.21)  <- 1 sample(s) skipped

seq restarting from zero is not a drop. It means the publisher restarted, and the SDK recognises it as such: see Stream health.

Two streams, one clock

State arrives over zenoh and camera frames arrive over iceoryx2. They are independent streams with independent rates, and the SDK never pairs them. There is no combined callback, no synchronised read and no interpolation.

sequenceDiagram
    participant Sim as Simulator
    participant St as State stream (zenoh)
    participant Cam as Camera stream (iceoryx2)
    participant You as Your loop
    Sim->>St: State t_ns=T0
    You->>St: states()
    Sim->>Cam: Frame t_ns=T0+12ms
    Sim->>St: State t_ns=T0+40ms
    You->>Cam: latest()
    Note over You: lag = frame.t_ns - state.t_ns
    You->>You: accept or reject on lag

What makes the pairing possible at all is that both stamps are on the same clock: Frame::t_ns and State::t_ns are both simulator capture times in unix nanoseconds and are directly subtractable, and Frame::elapsed shares the state stream's epoch.

The fusion rule follows from that in one line: compare t_ns explicitly, decide a tolerance, and reject the pair when the lag exceeds it. A frame and a state sample that merely arrived near each other in your process are not simultaneous, because arrival order reflects transport and scheduling rather than capture time. Chapter 5 gives the freshness patterns in full.

Choosing between them

QuestionField
How far apart in time were these two samplest_ns
Did I lose any samplesseq
What do I put on the x axis of a plotelapsed
Is this GNSS fix the same one I already usedsensors.gnss.timestamp
Is the filter output staleestimate.timestamp, and estimate.valid first
Does this camera frame belong with this stateFrame::t_ns minus State::t_ns

Next: Pacing your loop

See also: Freshness, Stream health, Measuring rates

Pacing your loop

The two ways to decide how often your loop body runs, and how to pick between them.

cargo run -p vrobots-examples --bin ex09_state_paced_loop
./target/cpp-build/ex09_state_paced_loop
python examples/python/ex09_state_paced_loop.py

Your clock or the data's clock

main owns the loop, so something in the body has to decide when the next iteration starts. The SDK offers two answers and they are not interchangeable.

rate(hz) sleeps until your next tick, with drift compensation, and then returns. Your clock drives the loop. states() hands back whatever the latest snapshot is at that moment, which at a tick rate above the 25 Hz stream means the same sample twice and at a tick rate below it means samples you never look at.

wait_new_state(timeout) blocks until a snapshot newer than the one you have arrives. The data drives the loop, so the body runs once per published sample: no duplicates, no skips, and no need to guess a rate that divides 25 Hz.

flowchart TB
  subgraph A["rate(hz): your clock"]
    A1["read states()"] --> A2["compute and command"]
    A2 --> A3["rate(hz) sleeps to next tick"]
    A3 --> A1
    A4["may read the same sample twice"] -.-> A1
  end
  subgraph B["wait_new_state(t): the data's clock"]
    B1["wait_new_state(timeout)"] --> B2{"new sample?"}
    B2 -->|Ok| B3["read states(), compute"]
    B2 -->|Timeout| B4["note the stall, hold last state"]
    B3 --> B1
    B4 --> B1
  end

Both signatures come from the same handle.

From crates/vrobots-sdk/src/robot.rs:

#![allow(unused)]
fn main() {
pub fn wait_new_state(&self, timeout: Duration) -> VrResult<()> {
}
The same in C++ (cpp/include/vrobots_sdk.hpp)
void wait_new_state(double timeout_s = 0.2)

void rate(double hz)
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
def wait_new_state(self, timeout: float = 0.2) -> None: ...
def rate(self, hz: float) -> None: ...

Rust takes a Duration; C++ and Python take seconds as a plain double, and both default it to 0.2. Neither returns the sample in any of the three, so states() is still the read.

The call returns Ok(()) once a newer sample is available, at which point you still read it with states(). It does not hand you the sample, which keeps the read path identical in both styles.

A timeout is a status, not an error

VrError::Timeout from wait_new_state means "no new sample arrived in time". The session is healthy, the subscriber is intact, and the next call may well succeed. A paused simulator, a stopped simulator and a very busy machine all announce themselves this way, and none of them is a reason to exit.

Propagating that error out of main with ? is the single most common way to turn a paused simulator into a crashed program. Match on it instead, and let every other variant be fatal.

From examples/rust/src/bin/ex09_state_paced_loop.rs:

#![allow(unused)]
fn main() {
loop {
    match robot.wait_new_state(TIMEOUT) {
        Ok(()) => {
            // Exactly one new sample is waiting -- read it and do the work.
            let s = robot.states();
            let dt_ms = if last_t_ns == 0 {
                f64::NAN
            } else {
                (s.t_ns - last_t_ns) as f64 / 1e6
            };
            let skipped = s.seq.saturating_sub(last_seq + 1);
            last_seq = s.seq;
            last_t_ns = s.t_ns;

            let [x, y, z] = s.kin.lin_pos;
            println!(
                "seq={} dt={dt_ms:6.1} ms pos=({x:.3},{y:.2},{z:.2}){}",
                s.seq,
                if skipped > 0 {
                    format!("  <- {skipped} sample(s) skipped")
                } else {
                    String::new()
                }
            );
        }
        Err(VrError::Timeout(detail)) => {
            // Not a broken session: no sample arrived in time. The sim is
            // paused, stopped, or the machine is very busy. states() still
            // returns the last snapshot it had.
            let s = robot.states();
            println!(
                "no new state in {:?} ({detail}); still holding seq={} at t={:.3}",
                TIMEOUT, s.seq, s.elapsed
            );
        }
        Err(other) => return Err(other), // a real failure
    }
}
}
The same in C++ (examples/cpp/ex09_state_paced_loop.cpp)
for (;;) {
    try {
        robot.wait_new_state(TIMEOUT_S);
    } catch (const vrsdk::Error& e) {
        if (e.code() != VRSDK_ERR_TIMEOUT) {
            throw;  // a real failure
        }
        // Not a broken session: no sample arrived in time. The sim is
        // paused, stopped, or the machine is very busy. states() still
        // returns the last snapshot it had.
        const vrsdk::State s = robot.states();
        std::printf("no new state in %.1fs; still holding seq=%llu at t=%.3f\n", TIMEOUT_S,
                    static_cast<unsigned long long>(s.seq), s.elapsed);
        continue;
    }

    // Exactly one new sample is waiting -- read it and do the work.
    const vrsdk::State s = robot.states();
    const double dt_ms =
        last_t_ns == 0 ? 0.0 : static_cast<double>(s.t_ns - last_t_ns) / 1e6;
    const std::uint64_t skipped = s.seq > last_seq + 1 ? s.seq - last_seq - 1 : 0;
    last_seq = s.seq;
    last_t_ns = s.t_ns;

    const double* p = s.kin().lin_pos;
    std::printf("seq=%llu dt=%6.1f ms pos=(%.3f,%.2f,%.2f)",
                static_cast<unsigned long long>(s.seq), dt_ms, p[0], p[1], p[2]);
    if (skipped > 0) {
        std::printf("  <- %llu sample(s) skipped", static_cast<unsigned long long>(skipped));
    }
    std::printf("\n");
}
The same in Python (examples/python/ex09_state_paced_loop.py)
while True:
    try:
        mr.wait_new_state(TIMEOUT)
    except vrsdk.VrError as e:
        if e.code != vrsdk.err.TIMEOUT:
            raise  # a real failure
        # Not a broken session: no sample arrived in time. The sim is
        # paused, stopped, or the machine is very busy. `states` still
        # returns the last snapshot it had.
        s = mr.states
        print(
            f"no new state in {TIMEOUT}s ({e.detail}); "
            f"still holding seq={s.seq} at t={s.elapsed:.3f}"
        )
        continue

    # Exactly one new sample is waiting -- read it and do the work.
    s = mr.states
    dt_ms = float("nan") if last_t_ns == 0 else (s.t_ns - last_t_ns) / 1e6
    skipped = max(0, s.seq - (last_seq + 1))
    last_seq, last_t_ns = s.seq, s.t_ns

    x, y, z = s.kin.lin_pos
    note = f"  <- {skipped} sample(s) skipped" if skipped else ""
    print(f"seq={s.seq} dt={dt_ms:6.1f} ms pos=({x:.3f},{y:.2f},{z:.2f}){note}")

Rust's match puts the two outcomes side by side; C++ and Python invert it, catching the timeout, re-raising everything else, and falling through to the work. The continue in the timeout arm is what keeps the shape equivalent: a caught timeout must not run the read below it.

Pausing the simulator while this runs switches the output from one line per sample to one line per timeout, and unpausing it switches back without a reconnect:

seq=310 dt=  40.0 ms pos=(0.031,0.85,-1.20)
seq=311 dt=  40.1 ms pos=(0.031,0.85,-1.20)
no new state in 200ms (no new state within 200ms (sys_id 1)); still holding seq=311 at t=12.440
no new state in 200ms (no new state within 200ms (sys_id 1)); still holding seq=311 at t=12.440
seq=312 dt=1240.3 ms pos=(0.031,0.85,-1.20)

The example sets TIMEOUT to 200 ms, five times the 25 Hz period. That is the useful shape for a timeout: long enough that ordinary jitter never trips it, short enough that a stall is noticed within a human's patience.

Gotcha. wait_new_state guarantees that the sample is newer, not that it is the next one. The SDK stores the newest sample received, so if two arrive between wakeups you see the second and seq jumps by two. That is why the example computes skipped from seq rather than assuming one iteration equals one sample.

Choosing

Your loopUseBecause
a controller emitting a setpoint on a schedulerate(hz)the output must be periodic whatever the sensor did
a controller running faster than 25 Hzrate(hz)duplicate reads are free and the schedule is what matters
logging every sample oncewait_new_statea duplicated or missed row is a corrupt log
numerical differentiation or a filterwait_new_stateprocessing a sample twice gives a zero derivative and a wrong state
watching for the simulator to stallwait_new_stateit is the only call that reports the absence of data
a one-shot scriptneitherdo the request, print, exit

The two styles compose. A controller can run on rate(hz) and still call wait_new_state with a short timeout once a second to check that the stream is alive, which is the pattern Stream health builds on.

Note. rate(hz) is drift-compensated: its deadlines are absolute multiples of the period counted from the first call, not now + period, so the time your loop body spends working does not accumulate as a slow drift. An iteration that overruns its budget re-anchors to the present rather than firing a burst of zero-length sleeps to catch up. It is also deliberately infallible, so it needs no ? on the hot path: a non-finite or non-positive hz returns immediately with a warning instead of panicking.

Next: Stream health

See also: Timestamps and sequence numbers, The shape of a program, Measuring rates

Stream health

The counters that say whether your loop is really seeing every sample, and how to survive the simulator going away and coming back.

cargo run -p vrobots-examples --bin ex12_version_info
./target/cpp-build/ex12_version_info
python examples/python/ex12_version_info.py
cargo run -p vrobots-examples --bin ex19_robust_loop
./target/cpp-build/ex19_robust_loop
python examples/python/ex19_robust_loop.py

Why a rate is not enough

Measuring how many samples per second your loop processed answers a weaker question than it appears to. Twenty-five samples a second with three gaps and twenty samples a second with none are different experiments, and a rate alone cannot tell them apart: a publisher that slowed down and a network that dropped every fifth sample produce the same average.

The SDK counts the difference for you, continuously, from the sequence numbers the publisher stamps. stats() returns a copy of those counters.

FieldTypeCountsNotes
receivedu64samples that arrived and decodeddivide by a wall interval for the effective rate
decode_errorsu64samples that arrived and did not decodenever fatal; see last_error
seq_gapsu64how many times seq jumped by more than onethe number of drop events
missed_samplesu64total samples implied missing by those jumpsthe size of those events
last_sequ64seq of the most recently decoded sample

seq_gaps and missed_samples answer different questions. Ten gaps of one sample each is jitter in the transport; one gap of ten samples is something that stopped. Both report missed_samples = 10.

Decode errors are counted, not raised

A malformed payload does not tear down the session, does not stop the subscriber and does not raise anywhere in your loop. One bad sample must not end a flight, so it increments decode_errors and the loop keeps running. The error itself is kept for inspection.

MethodReturnsMeaning
stats()StateStatshow many, and of what kind
last_error()Option<VrError>the error that was counted instead of raised

last_error() returning None means every payload so far decoded. A non-empty value beside a non-zero decode_errors is the actual reason, and it is almost always schema drift between your build and the simulator's.

From examples/rust/src/bin/ex12_version_info.rs:

#![allow(unused)]
fn main() {
let stats = robot.stats();
let last = robot.states();
println!(
    "\nstats after {:.1} s: received={} decode_errors={} seq_gaps={} \
     missed_samples={} last_seq={}",
    elapsed.as_secs_f64(),
    stats.received,
    stats.decode_errors,
    stats.seq_gaps,
    stats.missed_samples,
    stats.last_seq
);
println!(
    "  effective rate {:.1} Hz over the window; sim clock advanced {:.2} s",
    stats.received as f64 / elapsed.as_secs_f64(),
    last.elapsed - first.elapsed
);

// Counted, not raised. This is where a decode failure went.
match robot.last_error() {
    None => println!("  last_error: none -- every payload decoded"),
    Some(e) => println!("  last_error: [{}] {e}", e.code()),
}
}
The same in C++ (examples/cpp/ex12_version_info.cpp)
const vrsdk_state_stats_t st = robot.stats();
const vrsdk::State last = robot.states();
std::printf(
    "\nstats after %.1f s: received=%llu decode_errors=%llu seq_gaps=%llu "
    "missed_samples=%llu last_seq=%llu\n",
    elapsed, static_cast<unsigned long long>(st.received),
    static_cast<unsigned long long>(st.decode_errors),
    static_cast<unsigned long long>(st.seq_gaps),
    static_cast<unsigned long long>(st.missed_samples),
    static_cast<unsigned long long>(st.last_seq));
std::printf("  effective rate %.1f Hz over the window; sim clock advanced %.2f s\n",
            static_cast<double>(st.received) / (elapsed > 0.0 ? elapsed : 1.0),
            last.elapsed - first.elapsed);

// Counted, not thrown. This is where a decode failure went.
if (const std::optional<vrsdk::Error> e = robot.last_error()) {
    std::printf("  last_error: [%d] %s\n", e->code(), e->what());
} else {
    std::printf("  last_error: none -- every payload decoded\n");
}
The same in Python (examples/python/ex12_version_info.py)
st = mr.stats
last = mr.states
print(
    f"\nstats after {elapsed:.1f} s: received={st.received} "
    f"decode_errors={st.decode_errors} seq_gaps={st.seq_gaps} "
    f"missed_samples={st.missed_samples} last_seq={st.last_seq}"
)
print(
    f"  effective rate {st.received / elapsed:.1f} Hz over the window; "
    f"sim clock advanced {last.elapsed - first.elapsed:.2f} s"
)

# Counted, not raised. This is where a decode failure went.
err = mr.last_error
if err is None:
    print("  last_error: none -- every payload decoded")
else:
    print(f"  last_error: [{err.code} {err.kind}] {err.detail}")

The five counters carry the same names everywhere. The absence is spelled three ways for the same reason: it is an optional, not an error. Rust returns Option<VrError>, C++ returns std::optional<vrsdk::Error>, and Python returns None or a VrError without raising it, which is the whole point of the field. In Python both stats and last_error are properties.

On a healthy link the effective rate lands near the stream's 25 Hz, the gap counters stay at zero, and the last line reports nothing:

stats after 2.0 s: received=50 decode_errors=0 seq_gaps=0 missed_samples=0 last_seq=361
  effective rate 25.0 Hz over the window; sim clock advanced 2.00 s
  last_error: none -- every payload decoded

The same example prints the simulator's schema_version beside the SDK's, which is the check worth making before you trust any of the numbers above. A version mismatch does not present as an error; it presents as fields that decode to plausible nonsense. Versions and pins covers that comparison.

Noticing that the simulator stopped

states() cannot tell you. It keeps returning the last snapshot it had, unchanged, forever, and a dead simulator therefore looks exactly like a perfectly stationary robot. That is the observer contract, and the cost of it is that a stall is not detectable from a data read alone.

Two things do detect it: elapsed (or seq) ceasing to advance, and a Timeout from wait_new_state. The second is the deliberate one.

From examples/rust/src/bin/ex19_robust_loop.rs:

#![allow(unused)]
fn main() {
Err(VrError::Timeout(_)) => {
    if healthy {
        println!("\nSTALLED: no new state in {TIMEOUT:?}. Not an error -- holding.");
        healthy = false;
        down_since = Some(Instant::now());
    }
    // states() still answers, with the LAST snapshot. Note that
    // `elapsed` is frozen: that, not an exception, is how a dead sim
    // looks from a data read.
    let s = robot.states();
    let down = down_since.map_or(0.0, |t| t.elapsed().as_secs_f64());
    println!(
        "    down {down:5.1}s -- stale snapshot still readable: seq={} \
         t={:.2}s (frozen)",
        s.seq, s.elapsed
    );

    // Publishing into an empty topic is not an error in zenoh, so
    // this keeps returning Ok. A command has no reply; only the echo
    // in the state stream ever proves anything landed.
    robot.set_mr_pwm([PWM_US; 4])?;
}
}
The same in C++ (examples/cpp/ex19_robust_loop.cpp)
if (timed_out) {
    if (healthy) {
        std::printf("\nSTALLED: no new state in %.1fs. Not an error -- holding.\n",
                    TIMEOUT_S);
        healthy = false;
        down_since = std::chrono::steady_clock::now();
    }
    // states() still answers, with the LAST snapshot. Note that
    // `elapsed` is frozen: that, not an exception, is how a dead
    // sim looks from a data read.
    const vrsdk::State s = robot.states();
    const double down =
        std::chrono::duration<double>(std::chrono::steady_clock::now() - down_since)
            .count();
    std::printf(
        "    down %5.1fs -- stale snapshot still readable: seq=%llu t=%.2fs "
        "(frozen)\n",
        down, static_cast<unsigned long long>(s.seq), s.elapsed);

    // Publishing into an empty topic is not an error in zenoh, so
    // this keeps succeeding. A command has no reply; only the echo
    // in the state stream ever proves anything landed.
    robot.set_mr_pwm(hold);
    continue;
}
The same in Python (examples/python/ex19_robust_loop.py)
if healthy:
    print(f"\nSTALLED: no new state in {TIMEOUT}s. Not an error -- holding.")
    healthy = False
    down_since = time.perf_counter()
# `states` still answers, with the LAST snapshot. Note that
# `elapsed` is frozen: that, not an exception, is how a dead sim
# looks from a data read.
s = mr.states
down = time.perf_counter() - down_since
print(
    f"    down {down:5.1f}s -- stale snapshot still readable: "
    f"seq={s.seq} t={s.elapsed:.2f}s (frozen)"
)
# Publishing into an empty topic is not an error in zenoh, so this
# keeps succeeding. A command has no reply; only the echo in the
# state stream ever proves anything landed.
mr.set_mr_pwm([PWM_US] * 4)
continue

Each surface measures the outage with its own monotonic clock: Instant, steady_clock and time.perf_counter. The SDK does not supply one, because elapsed is the simulator's clock and it is exactly the thing that has stopped.

Close the simulator while that runs and the output changes character without the program exiting, with t frozen at whatever it reached:

STALLED: no new state in 500ms. Not an error -- holding.
    down   0.5s -- stale snapshot still readable: seq=146 t=5.84s (frozen)
    down   1.0s -- stale snapshot still readable: seq=146 t=5.84s (frozen)

Commands sent during the outage keep returning Ok, because publishing to a topic nobody is subscribed to is not an error in zenoh. Nothing in that path can tell you the robot is gone, which is the practical reason the actuator echo is the only proof a command landed.

Surviving a restart

Start the simulator again and samples resume on the same session. There is no reconnect logic in the example, because there is nothing to reconnect: the zenoh session, the subscriber and the command publisher all outlive the simulator's absence, and discovery is zenoh's job.

The restart is visible in two places, and neither is an error.

What you seeWhyWhat not to do
seq restarts from 0it is a new publisherdo not count it as thousands of lost samples; the SDK logs state seq went backwards: the publisher restarted and leaves seq_gaps where it was
elapsed jumps forwardits epoch is fixed at this handle's first sample and never resetsdo not read it as the simulator's run time; reconnect if you need that

Leaving seq_gaps untouched across a restart is a deliberate decision. A counter that jumped by several thousand every time someone left Play mode would be useless for the thing it exists for, which is spotting real drops.

Gotcha. A loop that treats VrError::Timeout as fatal turns every pause of the simulator into a crashed program, and a loop that ignores it entirely spins on stale data without noticing. Handle it as a state change: note the transition once, keep using the last known snapshot, and log the recovery when samples come back.

Next: A tour of the whole snapshot

See also: Pacing your loop, Versions and pins, Appendix C: Error reference

A tour of the whole snapshot

One program that prints truth, measurement and belief side by side once a second, and what to read in the differences between them.

cargo run -p vrobots-examples --bin ex10_sensors_tour
./target/cpp-build/ex10_sensors_tour
python examples/python/ex10_sensors_tour.py

What this example is for

Every other example in the book reads two or three fields. This one walks the entire State, block by block, and prints it at 1 Hz because each iteration is a page of text. It is the capstone of the chapter for a specific reason: the value of the snapshot is not in any single field but in the fact that truth, measurement and belief for the same instant are published together, so characterising anything is a subtraction rather than an inference.

Read the output once with the simulator idle and once with the robot moving, and most of this chapter becomes concrete.

The header, and the two helpers

Each iteration opens with the snapshot's identity: name, id, sequence number, elapsed time, schema version, and the frame every vector below it is expressed in.

From examples/rust/src/bin/ex10_sensors_tour.rs:

#![allow(unused)]
fn main() {
println!(
    "\n=== {} sys_id={} seq={} t={:.3}s schema={} frame={:?} ({}) ===",
    s.name,
    s.sys_id,
    s.seq,
    s.elapsed,
    s.schema_version,
    s.coord_frame_id,
    s.axis_convention.name()
);
}
The same in C++ (examples/cpp/ex10_sensors_tour.cpp)
const vrsdk::State s = robot.states();
const vrsdk_state_t& r = s.raw;

std::printf("\n=== %s sys_id=%u seq=%llu t=%.3fs schema=%u frame=\"%s\" ===\n",
            s.name.c_str(), s.sys_id, static_cast<unsigned long long>(s.seq),
            s.elapsed, r.schema_version, s.coord_frame_id.c_str());
The same in Python (examples/python/ex10_sensors_tour.py)
s = mr.states

print(
    f"\n=== {s.name} sys_id={s.sys_id} seq={s.seq} t={s.elapsed:.3f}s "
    f"schema={s.schema_version} frame={s.coord_frame_id!r} "
    f"({s.axis_convention_name}) ==="
)

The C++ header line omits the convention name, because the C surface exposes axis_convention as a bare int32_t with no name lookup beside it. Rust spells the lookup axis_convention.name() and Python spells it axis_convention_name.

Printing coord_frame_id beside axis_convention.name() is not redundant. The string is authoritative and is the only way to name a frame registered at runtime; the convention is the enum tag beside it, and returns "" for anything outside the three built-in conventions.

Two small helpers do all the formatting, and the second one is the interesting one. Also from examples/rust/src/bin/ex10_sensors_tour.rs:

#![allow(unused)]
fn main() {
/// A 3-vector, aligned so a column of them reads as a column.
fn v3(v: [f64; 3]) -> String {
    format!("({:+8.3},{:+8.3},{:+8.3})", v[0], v[1], v[2])
}

/// A sensor's own validity and clock -- the two fields that reveal its rate.
fn stamp(valid: bool, timestamp: f64) -> String {
    format!(
        "[{} t={timestamp:.3}]",
        if valid { "valid" } else { "INVALID" }
    )
}
}
The same in C++ (examples/cpp/ex10_sensors_tour.cpp)
/// A 3-vector, aligned so a column of them reads as a column.
static void v3(const char* label, const double* v, const char* unit, const char* note) {
    std::printf("  %-9s (%+8.3f,%+8.3f,%+8.3f) %-8s %s\n", label, v[0], v[1], v[2], unit, note);
}

/// A sensor's own validity and clock -- the two fields that reveal its rate.
static void stamp(bool valid, double timestamp) {
    std::printf("[%s t=%.3f]", valid ? "valid" : "INVALID", timestamp);
}
The same in Python (examples/python/ex10_sensors_tour.py)
def v3(v: Sequence[float]) -> str:
    """A 3-vector, aligned so a column of them reads as a column."""
    return "(" + ",".join(f"{c:+8.3f}" for c in v) + ")"


def stamp(valid: bool, timestamp: float) -> str:
    """A sensor's own validity and clock -- the two fields that reveal its rate."""
    return f"[{'valid' if valid else 'INVALID'} t={timestamp:.3f}]"

The C++ helpers print rather than return, because building strings with printf formatting would mean a scratch buffer per call. That is why the C++ output puts the label and unit inside v3 where the other two paste them at the call site.

Neither helper prints anything on its own: v3 renders one vector as ( +0.031, +0.852, -1.204) and stamp renders one device's validity and clock as [valid t=1770000000.960]. stamp is applied to every device, which is what makes the differing sensor rates visible in a static printout: the bracketed time next to the GNSS row sits still for about five iterations of a 25 Hz loop while the header's t advances on every one.

One iteration

The loop body prints five labelled sections in a fixed order: TRUTH, MEASURED, BELIEVED, WORLD and ACTUATORS. Each section's code is quoted on its own page of this chapter, and the whole body is one println! per line with no logic between them.

=== multirotor sys_id=1 seq=725 t=29.000s schema=1 frame="frd" (frd) ===
TRUTH  kinematics
  lin_pos   (  +0.031,  +0.852,  -1.204) m       (world)
  quat      [+0.000,+0.000,+0.000,+1.000] (world, xyzw)
  lin_vel   (  +0.000,  +0.000,  +0.000) m/s     (body)
  ang_vel   (  +0.000,  +0.000,  +0.000) rad/s   (body -- what a gyro measures)
  lin_acc   (  +0.000,  +0.000,  +0.000) m/s^2   (body)
  ang_acc   (  +0.000,  +0.000,  +0.000) rad/s^2 (body)
  wrench    F=(  +0.000,  +0.000,  +0.000) N  T=(  +0.000,  +0.000,  +0.000) N.m
MEASURED  sensors
  accel     (  +0.012,  -0.004,  -9.803) m/s^2  [valid t=1770000000.960]   [specific force: +1 g at rest]
  gyro      (  +0.001,  -0.002,  +0.000) rad/s  [valid t=1770000000.960]
  mag       ( +22.100,  +1.400, +42.300) gauss  [valid t=1770000000.960]
  baro      101318.4 Pa  alt=0.58 m (qnh 101325.0 hPa)  [valid t=1770000000.940]
  gnss      lat=37.400000 lon=-122.100000 alt=12.34 m  vel=(  +0.000,  +0.000,  +0.000) m/s (NED)
            fix=3 eph=1.20 epv=1.80 m  [valid t=1770000000.800]   [slowest device, ~5 Hz]
  flow      (  +0.000,  +0.000,  +0.000) m/s  [INVALID t=0.000]   [optional; mount it via srv/sensors]
BELIEVED  estimate  [INVALID t=0.000]  frame=""
  lin_pos   (  +0.000,  +0.000,  +0.000) m       (estimate.kin - kin IS the error)
  lin_vel   (  +0.000,  +0.000,  +0.000) m/s
WORLD  environment
  gravity   (  +0.000,  +0.000,  +9.807) m/s^2   air 101325.0 Pa 1.225 kg/m^3 15.0 C
  agl       0.00 m    home lat=37.400000 lon=-122.100000   [agl is hard-coded 0 in sim v3.0.0 -- use -lin_pos[2]]
ACTUATORS  command in, motion out
  pwm        [] us      (echo of the last command)
  normalized []
  measured   []   (rotor rad/s -- what the devices did)

Reading the differences

The printout is arranged so that the pairs worth subtracting sit near each other.

CompareAgainstGives
MEASURED gyroTRUTH ang_velthe gyro's noise realisation on that sample
MEASURED baro pressureWORLD air pressurethe barometer's error in Pa
MEASURED gnss positionWORLD home positionthe receiver's position error
BELIEVED lin_posTRUTH lin_posthe estimator error, once valid is true
ACTUATORS measuredACTUATORS normalizedhow far the device is from its command

Four things the printout makes visible that a field table cannot.

The accelerometer disagrees with lin_acc on purpose. It reads specific force, so a robot at rest reports about 1 g and a robot in free fall reports zero, while kin.lin_acc does the opposite. Subtracting gravity is your job and it needs an attitude.

A sensor's stamp moves at the sensor's rate. Watch the GNSS row's bracketed time sit still while the header's t advances. Nothing else in the snapshot reveals that, and code that treats every sample's GNSS reading as new will differentiate a constant four times out of five.

An invalid block is not a failure. The test scene runs no estimator, so estimate arrives valid=false, zero-filled, with an empty frame id. Optical flow is optional and arrives the same way when it is not mounted. That is what "not present" looks like on the wire; a missing nested table decodes to its Default rather than raising.

The frame is on every line for a reason. coord_frame_id is the robot's, not yours. In a scene where the multirotor publishes frd, lin_pos[2] counts downwards and altitude is its negation, which is the substitute the example uses for env.agl.

Gotcha. Everything in TRUTH and WORLD is unavailable on a physical vehicle. The tour is a debugging and characterisation tool, and reading it is the fastest way to understand a robot; taking a control decision from those two sections is how a program that works in simulation stops working anywhere else.

Next: Sending commands

See also: Truth, measured and believed, Sensors, Supported virtual robots

Sending commands

Every actuator in the simulator is driven the same way: you publish a command, nothing replies, and the state stream is the only receipt you get.

The path a command takes

A command is a one-way publication onto a shared bus. Nothing in the diagram below travels back along the arrow it came in on.

sequenceDiagram
    participant You as Your loop
    participant SDK
    participant Cmd as z/cmd
    participant Robot
    participant St as z/state

    You->>SDK: set_mr_pwm([1501.0; 4])
    SDK->>SDK: validate client-side
    SDK->>Cmd: FlatBuffers put (zenoh)
    Note over Cmd: many-to-many: every peer's<br/>traffic to this robot lands here
    SDK-->>You: Ok(()) = published
    Cmd->>Robot: queued
    Note over Robot: next physics step:<br/>drain queue, latch, ack nothing
    Robot->>St: snapshot at 25 Hz
    St->>You: states().actuator echoes it

In words, five steps:

  1. You call a typed method such as set_mr_pwm, or send_cmd for an id the SDK has no method for.
  2. The SDK validates what it can client-side. A pulse width outside the band, a non-finite float or an empty slice returns VrError::InvalidArgument and nothing reaches the wire.
  3. The SDK publishes one FlatBuffers payload to vrobots/{sys_id}/z/cmd over zenoh.
  4. The robot drains its command queue at the start of its next physics step, and acknowledges nothing.
  5. The only observable consequence is the actuator echo in the next state snapshot.

Ok means published, not acted on

Ok(()) from any command method means zenoh accepted the put. It says nothing about whether a robot read the message, understood the id, or did anything with it. The id space is shared across robot types, so a robot receiving an id it does not implement ignores it, and that is correct behaviour rather than a fault.

Gotcha. Wrong command id, wrong sys_id and wrong array length all present identically from outside: the state stream does not change. To tell them apart, print s.actuator.pwm (or s.actuator.measured on the plants that have no pulse widths) every iteration and watch whether it follows what you sent.

Which command drives which robot

Robot typecatalog_key()Drive commandPage
Multirotor"multirotor"SET_MR_PWMDriving a multirotor
Truck"truck"SET_CARDriving the truck
Msd"msd"SET_MSDSingle degree of freedom plants
CartPole"cartpole"SET_INVPENSingle degree of freedom plants
HalfDrone"halfdrone"SET_MR_PWM with exactly 2 valuesDriving a multirotor
GlobalHawk"globalhawk"SET_ANGVEL tracking, or direct surfacesFixed wing control

Only catalog_key() is ever put on the wire. RobotType::from_catalog_key is case-insensitive and accepts synonyms ("car", "cart_pole", "invpen", "mass_spring_damper", "half_drone", "global_hawk", "rq4b"), which matters when a robot type comes from a configuration file rather than from Rust.

Floats narrow on the way out

The API is f64 everywhere, for the same reason the snapshots are: one float type across Rust, C++ and Python. The wire's float_val, float_arr, Vec3 and Vec4 are f32, so every float you send narrows once on the way out. Integer channels such as pulse widths do not: they are i32 on the wire and the SDK rounds into them.

Every vector carries a frame

Commands whose payload is a vector (vec3 or vec4) are stamped with the coord_frame_id and axis_convention from your ConnectOptions, and the robot converts them into its own axes using the physically correct rule for that command. A force and a torque convert differently, because a torque is a pseudovector and carries the handedness sign. Array payloads such as SET_MR_PWM and SET_FW_SURFACES carry no frame at all: they are per-channel numbers, and nothing is re-expressed.

Next: Commands latch

See also: Five rules that explain everything, Actuators, Appendix B: Command reference, Appendix D: Glossary

Commands latch

A command is a setpoint the robot holds, not an event it performs, and understanding that explains most of the surprises in this chapter.

A setpoint, not an impulse

The last command a robot received stays in effect until the next one arrives. Nothing decays it, nothing times it out, and nothing zeroes it when the process that sent it exits. Run a program that latches a pulse width, kill it, and the rotors keep spinning at that pulse width: the echo in the state stream reports the robot's holder, not the sender.

There is no watchdog anywhere in the command path. This is a deliberate simulator property, not an oversight, and it is the same assumption a real ESC makes about the flight controller upstream of it.

Your send rate is your own business

Because the value latches, the publish rate carries no meaning. A 5 Hz sender and a 50 Hz sender produce exactly the same behaviour if they send the same numbers, and physics runs at its own rate regardless. Publish at whatever rate your controller thinks at.

The practical consequence appears in every loop that waits for state. From examples/rust/src/bin/ex29_hello_cartpole.rs, the response to a stalled state stream is to do nothing at all:

#![allow(unused)]
fn main() {
        if let Err(VrError::Timeout(_)) = robot.wait_new_state(SAMPLE_TIMEOUT) {
            println!("no new state -- holding the last force (it latches)");
            continue;
        }
}
The same in C++ (examples/cpp/ex29_hello_cartpole.cpp)
} catch (const vrsdk::Error& e) {
    if (e.code() != VRSDK_ERR_TIMEOUT) {
        throw;
    }
    std::printf("no new state -- holding the last force (it latches)\n");
    continue;
}
The same in Python (examples/python/ex29_hello_cartpole.py)
except vrsdk.VrError as e:
    if e.code != vrsdk.err.TIMEOUT:
        raise
    print("no new state -- holding the last force (it latches)")
    continue

Skipping the iteration is not a lost command. The previous force is still applied, so holding is the correct response to silence rather than a degraded one.

Releasing a command is itself a command

There is no "stop" verb. To stop pushing, send zero. From examples/rust/src/bin/ex28_hello_msd.rs, where the step force has to be explicitly withdrawn before the plant can ring back to equilibrium:

#![allow(unused)]
fn main() {
    // Release. The force LATCHES, so this zero is not optional.
    println!("   release (set_msd_force(0.0)) -- watch it ring back to equilibrium");
    for i in 0..RELEASE_SAMPLES {
        robot.set_msd_force(0.0)?;
}
The same in C++ (examples/cpp/ex28_hello_msd.cpp)
// Release. The force LATCHES, so this zero is not optional.
std::printf("   release (set_msd_force(0.0)) -- watch it ring back to equilibrium\n");
for (int i = 0; i < RELEASE_SAMPLES; ++i) {
    robot.set_msd_force(0.0);
The same in Python (examples/python/ex28_hello_msd.py)
# Release. The force LATCHES, so this zero is not optional.
print("   release (set_msd_force(0.0)) -- watch it ring back to equilibrium")
for i in range(RELEASE_SAMPLES):
    robot.set_msd_force(0.0)

Leaving that call out does not "let the force fade": the mass sits pushed against its spring forever, and the printout looks like a plant that will not settle.

Gotcha. A Ctrl-C never releases anything. Examples that hand a robot back do it on the normal exit path only, so an interrupted run leaves the last force, the last pulse widths or the last deflections applied until someone else overwrites them.

What each command holds

CommandWhat stays latchedHow to release it
SET_MR_PWMone pulse width per rotor, microsecondssend new pulse widths, or reset()
SET_CARsteer, throttle and brake, microsecondssend new channels, or reset()
SET_MSDthe drive force, newtonsset_msd_force(0.0), or reset()
SET_INVPENthe cart force, newtonsset_cartpole_force(0.0), or reset()
SET_FW_SURFACESone deflection per panel, radianssend new deflections, leave direct mode, or reset()
SET_FW_THRUSTengine thrust, newtonsset_fw_thrust_bias(0.0) gives the engine back to airspeed hold in onboard mode
SET_ANGVELthe rate setpoint, rad/ssend a new one; reset() clears this latch rather than re-seeding it

Watching a latch hold

examples/rust/src/bin/ex31_globalhawk_direct.rs demonstrates the property directly by sending one pose and then sending nothing for roughly two seconds:

#![allow(unused)]
fn main() {
    // ===== latching, and no watchdog =====
    println!("\n-- nothing sent for ~2 s --");
    robot.set_fw_surfaces(&[
        DEFLECT_RAD,
        -DEFLECT_RAD,
        DEFLECT_RAD,
        -DEFLECT_RAD,
        0.0,
        0.0,
    ])?;
    for i in 0..HOLD_SAMPLES {
        if i % 25 == 0 {
            print_echo(&robot, "  latched");
        }
        robot.rate(HZ);
    }
    println!("  unchanged. A command is a setpoint; there is no failsafe behind it.");
}
The same in C++ (examples/cpp/ex31_globalhawk_direct.cpp)
// ===== latching, and no watchdog =====
const std::vector<double> latched = {DEFLECT_RAD, -DEFLECT_RAD, DEFLECT_RAD,
                                     -DEFLECT_RAD, 0.0,         0.0};
std::printf("\n-- nothing sent for ~2 s --\n");
robot.set_fw_surfaces(latched);
for (int i = 0; i < HOLD_SAMPLES; ++i) {
    if (i % 25 == 0) {
        print_echo(robot, "  latched");
    }
    robot.rate(HZ);
}
std::printf("  unchanged. A command is a setpoint; there is no failsafe behind it.\n");
The same in Python (examples/python/ex31_globalhawk_direct.py)
# ===== latching, and no watchdog =====
latched = [DEFLECT_RAD, -DEFLECT_RAD, DEFLECT_RAD, -DEFLECT_RAD, 0.0, 0.0]
print("\n-- nothing sent for ~2 s --")
robot.set_fw_surfaces(latched)
for i in range(HOLD_SAMPLES):
    if i % 25 == 0:
        print_echo(robot, "  latched")
    robot.rate(HZ)
print("  unchanged. A command is a setpoint; there is no failsafe behind it.")

Each printed line has the shape below, and the point of the passage is that the panel numbers in successive lines are identical while nothing is being published:

  latched t=<seconds>s panels=[<six deflections, radians>] engine=<newtons> N  rates=(<p>,<q>,<r>) deg/s

What reset does to a latch

reset() re-latches the robot's initial command, the same thing the simulator's own Reset button does. It is a state reset, not a factory reset: configuration sent through the srv/* services (masses, noise models, rotor curves, skins) survives it.

A live publisher wins again one physics step later, so a control loop that keeps running through a reset barely notices: it sees one snapshot of the initial command and then its own values again. A program that resets and then stops sending sees the initial command stand indefinitely.

On the fixed wing, reset() also reverts the control mode and the estimate source, which is a larger change than re-latching a number and has its own section.

Next: Driving a multirotor

See also: Sending commands, Robot lifecycle, Pacing your loop

Driving a multirotor

SET_MR_PWM is the lowest actuation level the simulator offers, so flying a multirotor means writing the flight controller yourself.

cargo run -p vrobots-examples --bin ex02_hello_control
./target/cpp-build/ex02_hello_control
python examples/python/ex02_hello_control.py

The whole program

examples/rust/src/bin/ex02_hello_control.rs is the loop shape from chapter 2 with one command in it:

use vrobots_sdk::{RobotType, VirtualRobot, VrError};

const SYS_ID: u32 = 1; // the multirotor in the test scene
const PWM_US: f64 = 1501.0; // microseconds per rotor, on the 1100-2000 band
const HZ: f64 = 100.0;

fn main() -> Result<(), VrError> {
    // ===== setup =====
    vrobots_sdk::init_logging("info");
    let robot = VirtualRobot::connect(RobotType::Multirotor, Some(SYS_ID))?;

    // ===== loop =====
    loop {
        let s = robot.states();
        let [x, y, z] = s.kin.lin_pos;
        println!(
            "State t={:.3} pos=({x:.3},{y:.2},{z:.2}) echo={:?}",
            s.elapsed, s.actuator.pwm
        );

        // Do some COOL control here and publish -- PID/EKF is user code, NOT the SDK.
        let cool_control_result = [PWM_US; 4];
        robot.set_mr_pwm(cool_control_result)?;

        robot.rate(HZ);
    }
}
The same in C++ (examples/cpp/ex02_hello_control.cpp)
constexpr std::uint32_t SYS_ID = 1;  // the multirotor in the test scene
constexpr double PWM_US = 1501.0;    // microseconds per rotor, 1100-2000 band
constexpr double HZ = 100.0;

int main() {
    try {
        // ===== setup =====
        vrsdk::check_version();
        vrsdk::VirtualRobot robot(vrsdk::RobotType::Multirotor, SYS_ID);
        robot.connect();
        std::printf("connected to sys_id %u\n", robot.sys_id());

        // ===== loop =====
        for (;;) {
            const vrsdk::State s = robot.states();
            const double* p = s.kin().lin_pos;

            // Do some COOL control here and publish -- PID/EKF is user code,
            // NOT the SDK.
            const std::vector<double> cool_control_result = {PWM_US, PWM_US, PWM_US, PWM_US};
            robot.set_mr_pwm(cool_control_result);

            // The echo: what the robot actually latched, from the state stream.
            const std::vector<std::uint32_t> echo = s.pwm();
            std::printf("State t=%.3f pos=(%.3f,%.2f,%.2f)  pwm_echo=[", s.elapsed, p[0], p[1],
                        p[2]);
            for (std::size_t i = 0; i < echo.size(); ++i) {
                std::printf("%s%u", i ? "," : "", echo[i]);
            }
            std::printf("]  rotor0=%.1f rad/s\n",
                        s.actuator().measured_count > 0 ? s.actuator().measured[0] : 0.0);

            robot.rate(HZ);
        }
    } catch (const vrsdk::Error& e) {
        std::fprintf(stderr, "error [%d] %s\n", e.code(), e.what());
        return 1;
    }
}
The same in Python (examples/python/ex02_hello_control.py)
SYS_ID = 1  # the multirotor in the test scene
PWM_US = 1501.0  # microseconds per rotor, on the 1100-2000 band
HZ = 100


def main() -> None:
    # ===== setup =====
    vrsdk.init_logging("info")
    mr = VirtualRobot(RobotType.MULTIROTOR, sys_id=SYS_ID)
    mr.connect()

    # ===== loop =====
    while True:
        s = mr.states
        x, y, z = s.kin.lin_pos
        print(
            f"State t={s.elapsed:.3f} pos=({x:.3f},{y:.2f},{z:.2f}) "
            f"echo={s.actuator.pwm}"
        )

        # Do some COOL control here and publish -- PID/EKF is user code, NOT the
        # SDK. `set_mr_pwm(a, b, c, d)` and `set_mr_pwm([a, b, c, d])` are the
        # same call.
        cool_control_result = [PWM_US] * 4
        mr.set_mr_pwm(cool_control_result)

        mr.rate(HZ)

One line per iteration. The four numbers at the end are actuator.pwm, which is your last command echoed back, so they are the proof the command landed:

State t=<seconds> pos=(<x>,<y>,<z>) echo=[1501, 1501, 1501, 1501]

PWM_US is barely off idle and will not lift the airframe. Raise it to 1700 to watch the drone climb.

Note. Nothing sits between these four pulse widths and the rotor thrust curves: no attitude stabilisation, no rate damping, no mixer. Equal values spin every rotor equally; differential values roll, pitch and yaw the airframe. Hover is wherever total thrust crosses weight for the current mass and curves, so there is no single "hover number".

The two methods

MethodSignatureRotor countNotes
set_mr_pwm(&self, pwm: [f64; 4]) -> VrResult<()>exactly 4fits the simulator's quadrotor; delegates to set_mr_pwm_n
set_mr_pwm_n(&self, pwm: &[f64]) -> VrResult<()>any, one per rotorsix or eight for bigger airframes; exactly 2 for a HalfDrone, as [left_us, right_us]

Both send command id SET_MR_PWM (300) with the values in int_arr. The array length must equal the airframe's rotor count, which is fixed at spawn.

The bindings need only one method each, because neither language has Rust's split between a fixed-size array and a slice.

The same in C++ (cpp/include/vrobots_sdk.hpp)
void set_mr_pwm(const std::vector<double>& pwm)
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
def set_mr_pwm(self, *pwm: Union[float, Sequence[float]]) -> None: ...

C++ takes a std::vector<double> of any length, so {1501, 1501, 1501, 1501} covers the quadrotor and a two-element vector covers a HalfDrone. Python accepts either form: set_mr_pwm(a, b, c, d) and set_mr_pwm([a, b, c, d]) are the same call.

The band

QuantityUnitsMinimumMaximumNotes
Pulse width per rotormicroseconds1100.02000.01100 is idle, so [1100; 4] lets a flying drone fall

The band is checked client-side by a shared check_pwm helper, so a value outside it, or a non-finite value, returns VrError::InvalidArgument before anything is published. Passing a normalised 0.7 by mistake names the channel and the band it missed:

pwm[0] = 0.7 is outside the 1100-2000 us pulse-width band (neutral is 1500; values look like microseconds, not normalised units)

The refusal is deliberate rather than defensive. The simulator's actuators are specified on 1100 to 2000 microseconds, so a value outside it is far more likely to be a unit mistake (a normalised 0.7, a raw newton figure) than an intent.

A wrong rotor count is not an error

The SDK checks each value but not the length, because it does not know the airframe. A slice of the wrong length is published happily, the simulator logs it and drops it, and the previously latched pulse widths stay in effect.

Gotcha. Sending five pulse widths to a quadrotor looks exactly like sending nothing: Ok(()) from the call, no error anywhere, and an echo that keeps reporting the old values. Compare s.actuator.pwm.len() against what you sent before assuming the simulator is asleep.

The one client-side length check is emptiness: an empty slice returns VrError::InvalidArgument with the message "set_mr_pwm needs one pulse width per rotor; got none".

The half drone

A HalfDrone is a two-rotor airframe that takes the same command id through set_mr_pwm_n with exactly two values, [left_us, right_us]. It is scene-authored rather than creatable, so attach to it by sys_id rather than asking the manager to spawn one.

Next: Driving the truck

See also: Commands latch, Actuators, Rotors and thrust curves, Multirotor

Driving the truck

SET_CAR carries three pulse-width channels, on a factory band that is not the multirotor's.

cargo run -p vrobots-examples --bin ex05_hello_car
./target/cpp-build/ex05_hello_car
python examples/python/ex05_hello_car.py

The whole program

examples/rust/src/bin/ex05_hello_car.rs is the same loop shape as the multirotor example with a different actuator in it:

use vrobots_sdk::{RobotType, VirtualRobot, VrError};

const SYS_ID: u32 = 0; // the truck in the test scene
const STEER_US: f64 = 1400.0; // left of centre
const THROTTLE_US: f64 = 1650.0; // light forward
const BRAKE_US: f64 = 1100.0; // released
const HZ: f64 = 50.0;

fn main() -> Result<(), VrError> {
    // ===== setup =====
    vrobots_sdk::init_logging("info");
    let robot = VirtualRobot::connect(RobotType::Truck, Some(SYS_ID))?;

    // ===== loop =====
    loop {
        let s = robot.states();
        let [x, y, z] = s.kin.lin_pos;
        // lin_vel is a BODY-frame vector, so no single component is "the speed";
        // its magnitude is.
        let [vx, vy, vz] = s.kin.lin_vel;
        let speed = (vx * vx + vy * vy + vz * vz).sqrt();
        println!(
            "State t={:.3} pos=({x:.3},{y:.2},{z:.2}) speed={speed:.2} m/s echo={:?}",
            s.elapsed, s.actuator.pwm
        );

        // A gentle left arc: steering left of centre, light forward throttle.
        robot.set_car(STEER_US, THROTTLE_US, Some(BRAKE_US))?;

        robot.rate(HZ);
    }
}
The same in C++ (examples/cpp/ex05_hello_car.cpp)
constexpr std::uint32_t SYS_ID = 0;      // the truck in the test scene
constexpr double STEER_US = 1400.0;      // left of centre
constexpr double THROTTLE_US = 1650.0;   // light forward
constexpr double BRAKE_US = 1100.0;      // released
constexpr double HZ = 50.0;

int main() {
    try {
        // ===== setup =====
        vrsdk::check_version();
        vrsdk::VirtualRobot robot(vrsdk::RobotType::Truck, SYS_ID);
        robot.connect();
        std::printf("connected to sys_id %u\n", robot.sys_id());

        // ===== loop =====
        for (;;) {
            const vrsdk::State s = robot.states();
            const double* p = s.kin().lin_pos;

            // A gentle left arc: steering left of centre, light forward
            // throttle, brake released.
            robot.set_car(STEER_US, THROTTLE_US, BRAKE_US);

            // Speed from the body-frame twist, so it is visible that the truck
            // really is moving rather than that the command was merely accepted.
            const double* v = s.kin().lin_vel;
            const double speed = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);

            const std::vector<std::uint32_t> echo = s.pwm();
            std::printf("State t=%.3f pos=(%.3f,%.2f,%.2f) speed=%.2f m/s  pwm_echo=[", s.elapsed,
                        p[0], p[1], p[2], speed);
            for (std::size_t i = 0; i < echo.size(); ++i) {
                std::printf("%s%u", i ? "," : "", echo[i]);
            }
            std::printf("]\n");

            robot.rate(HZ);
        }
    } catch (const vrsdk::Error& e) {
        std::fprintf(stderr, "error [%d] %s\n", e.code(), e.what());
        return 1;
    }
}
The same in Python (examples/python/ex05_hello_car.py)
SYS_ID = 0  # the truck in the test scene
STEER_US = 1400.0  # left of centre
THROTTLE_US = 1650.0  # light forward
BRAKE_US = 1100.0  # released
HZ = 50


def main() -> None:
    # ===== setup =====
    vrsdk.init_logging("info")
    car = VirtualRobot(RobotType.TRUCK, sys_id=SYS_ID)
    car.connect()

    # ===== loop =====
    while True:
        s = car.states
        x, y, z = s.kin.lin_pos
        # lin_vel is a BODY-frame vector, so no single component is "the speed";
        # its magnitude is.
        speed = math.dist(s.kin.lin_vel, (0.0, 0.0, 0.0))
        print(
            f"State t={s.elapsed:.3f} pos=({x:.3f},{y:.2f},{z:.2f}) "
            f"speed={speed:.2f} m/s echo={s.actuator.pwm}"
        )

        # A gentle left arc: steering left of centre, light forward throttle.
        car.set_car(STEER_US, THROTTLE_US, BRAKE_US)

        car.rate(HZ)

The echo at the end of each line is the three channels coming back in the order they were sent:

State t=<seconds> pos=(<x>,<y>,<z>) speed=<m/s> echo=[1400, 1650, 1100]

The signature

#![allow(unused)]
fn main() {
pub fn set_car(&self, steer: f64, throttle: f64, brake: Option<f64>) -> VrResult<()>
}
The same in C++ (cpp/include/vrobots_sdk.hpp)
void set_car(double steer, double throttle, std::optional<double> brake = std::nullopt)
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
def set_car(
    self, steer: float, throttle: float, brake: Optional[float] = None
) -> None: ...

The optional third argument is optional in the type system of all three, and C++ and Python also default it, so set_car(steer, throttle) is the two-channel form there.

The three channels go on the wire as int_arr, in that order, under command id SET_CAR (304).

The three channels

ChannelUnits110015001900Notes
steermicrosecondsfull leftcentrefull rightsymmetric about 1500
throttlemicrosecondsfull reversestop, idle brakefull forwardsymmetric about 1500
brakemicrosecondsreleasedpart appliedfullbottom-anchored: 1100 is off, not 1500

The truck's factory band is 1100 to 1900, unlike the multirotor's 1100 to 2000. The client-side validator is the shared pulse-width check, which accepts the wider band, so a value between 1900 and 2000 is published rather than refused. What the truck does with one is not documented.

Gotcha. The brake channel is the one people get wrong, because every other channel in the SDK is centred on 1500. Sending 1500 to the brake is not "no brake": it sits part way up a scale whose zero is 1100, and the symptom is a truck that accelerates far more slowly than its throttle suggests.

Passing None

brake: None sends the two-channel form, [steer_us, throttle_us], and brakes nothing. That is a different message from sending Some(1100.0) only in what is on the wire: both leave the truck unbraked.

Use None when your controller has no brake concept, and Some(...) when it does. Pick one form and keep to it: the two-channel message says nothing about the brake, and whether the robot then holds the brake value a previous three-channel message latched is not documented.

Stopping

Stopping is a command like any other. Throttle at 1500 is the idle brake, which is the truck's own resting state, and a brake channel at 1900 adds full braking on top of it. A controller that exits while commanding 1650 microseconds of throttle leaves the truck driving, because that is the value still latched.

Next: Single degree of freedom plants

See also: Commands latch, The truck drivetrain, Truck

Single degree of freedom plants

The mass spring damper and the cart pole each take one number, a force in newtons, and nothing else.

cargo run -p vrobots-examples --bin ex28_hello_msd
./target/cpp-build/ex28_hello_msd
python examples/python/ex28_hello_msd.py
cargo run -p vrobots-examples --bin ex29_hello_cartpole -- <sys_id>
./target/cpp-build/ex29_hello_cartpole <sys_id>
python examples/python/ex29_hello_cartpole.py <sys_id>

The two commands

MethodSignatureCommandUnitsSimulator-side clamp
set_msd_force(&self, newtons: f64) -> VrResult<()>SET_MSD (305)N along the plant's +xmagnitude clamped to the plant's max_force, 100 N by default
set_cartpole_force(&self, newtons: f64) -> VrResult<()>SET_INVPEN (306)N along the rail's +xmagnitude clamped to CartPoleConfig::max_force, 20 N by default

Both send their value in float_val, and both validate only that it is finite. Both clamps are silent: nothing is refused, nothing is reported, and the state stream is the only place that says what force was actually applied.

The mass spring damper

An Msd is in the sandbox catalog, so connect(RobotType::Msd, None) spawns one. It is the only plant in the simulator whose response you can work out on paper first, because the SDK owns every letter of m*x'' + c*x' + k*x = F: m through set_physical_params, k and c through configure_msd, and F through set_msd_force.

From examples/rust/src/bin/ex28_hello_msd.rs, the start of one step-response run:

#![allow(unused)]
fn main() {
    println!("-- {label} --");
    if retune {
        robot.configure_msd(&MsdConfig::default().with_spring_k(k).with_damping_c(c))?;
    }
    // Home, at rest, with the force latch cleared -- otherwise the previous run's
    // step is still pushing.
    robot.set_msd_force(0.0)?;
    robot.reset()?;
}
The same in C++ (examples/cpp/ex28_hello_msd.cpp)
std::printf("-- %s --\n", label);
if (retune) {
    auto config = vrsdk::msd_config();
    config.has_spring_k = true;
    config.spring_k = k;
    config.has_damping_c = true;
    config.damping_c = c;
    robot.configure_msd(config);
}
// Home, at rest, with the force latch cleared -- otherwise the previous run's
// step is still pushing.
robot.set_msd_force(0.0);
robot.reset();
The same in Python (examples/python/ex28_hello_msd.py)
print(f"-- {label} --")
if retune:
    robot.configure_msd(spring_k=k, damping_c=c)
# Home, at rest, with the force latch cleared -- otherwise the previous run's
# step is still pushing.
robot.set_msd_force(0.0)
robot.reset()

The three spell the optional configuration fields differently, and the difference is worth noticing because it is the pattern for every service in chapter 6. Rust chains with_* setters on a default; Python takes keyword arguments and omits what it does not set; C++ builds the plain C struct from vrsdk::msd_config() and sets a has_* flag beside each value. Forgetting the has_* flag in C++ means the field is silently not applied.

The progress lines that follow have this shape, one every 25 samples:

   t=<seconds>s x=<metres> m  x'=<m/s> m/s  disp=<metres> m  net F=<newtons> N

Note the order in that snippet. Clearing the force latch before the reset matters, because a reset with a step force still latched puts the mass back home and immediately starts pushing it again.

Predicting it before you run it

The example prints its own predictions, from the arithmetic in its header:

settles at   F / k          metres
period       2*pi*sqrt(m/k) seconds
damping      c / (2*sqrt(k*m))   -- < 1 rings, ~1 slides home, > 1 crawls

The actuator block carries the plant's own arithmetic rather than an echo of your command:

ChannelMeaningUnits
actuator.measured[0]the total force on the mass, F - k*x - c*x'N
actuator.measured[1]displacement from equilibriumm

Gotcha. measured[0] is not the force you sent. It is what the spring and the damper left of it, which is why it crosses zero at every peak of the oscillation. To check that a clamp did not eat your command, compare against the displacement rather than against this channel.

The cart pole

A cart pole is scene-authored rather than creatable, so it takes a sys_id argument: find the live one with cargo run -p vrobots-sdk --bin vrobots -- topic list. Ids are allocated at scene load and keep incrementing, so no constant in an example could stay true.

One actuator, a force on the cart, and two things to control with it. That is what underactuated means: the pole is unactuated by design, and there is no command anywhere in the SDK that touches it. From examples/rust/src/bin/ex29_hello_cartpole.rs, the whole control output is three lines:

#![allow(unused)]
fn main() {
        let force = (-K_THETA * theta - K_THETA_DOT * theta_dot + K_X * x + K_V * v)
            .clamp(-MAX_FORCE_N, MAX_FORCE_N);
        robot.set_cartpole_force(force)?;
}
The same in C++ (examples/cpp/ex29_hello_cartpole.cpp)
const double raw = -K_THETA * theta - K_THETA_DOT * theta_dot + K_X * x + K_V * v;
const double force = std::fmin(std::fmax(raw, -MAX_FORCE_N), MAX_FORCE_N);
robot.set_cartpole_force(force);
The same in Python (examples/python/ex29_hello_cartpole.py)
force = min(
    max(-K_THETA * theta - K_THETA_DOT * theta_dot + K_X * x + K_V * v, -MAX_FORCE_N),
    MAX_FORCE_N,
)
robot.set_cartpole_force(force)

Only the clamp differs, because only Rust has f64::clamp on the primitive. The control law and the newtons on the wire are identical.

Clamping in the controller as well as trusting the simulator's clamp is worth the line: the simulator's clamp is silent, so a controller that saturates without knowing it will report a force it never applied. Each printed line reports both:

t=<seconds>s  theta=<degrees> deg  theta'=<rad/s> rad/s  rail=<metres> m (world x=<metres>)  x'=<m/s> m/s  F=<commanded N> N  applied=<clamped N> N

The pole rides the actuator channels, which are exactly three:

ChannelMeaningUnits
actuator.measured[0]the force actually applied, after the clampN
actuator.measured[1]pole angle thetaradians
actuator.measured[2]pole rate theta primerad/s

theta is 0 upright and plus or minus pi hanging, wrapped into [-pi, pi], so a fallen pole may print either sign.

Latching is unforgiving here

On a plant this unstable, a controller that stops publishing is not neutral. The last force stays applied, and the pole is on the floor within about a second. Two habits follow: treat a late state sample as a reason to hold the last force rather than to send something new, and release the latch explicitly on the way out:

#![allow(unused)]
fn main() {
    robot.set_cartpole_force(0.0)?;
}
The same in C++ (examples/cpp/ex29_hello_cartpole.cpp)
// ===== hand it back =====
// A command latches: without this the cart keeps pushing forever.
robot.set_cartpole_force(0.0);
The same in Python (examples/python/ex29_hello_cartpole.py)
# ===== hand it back =====
# A command latches: without this the cart keeps pushing forever.
robot.set_cartpole_force(0.0)

A Ctrl-C skips that line and leaves the cart pushing.

Next: Fixed wing control

See also: Commands latch, Mass spring damper and cart pole, Mass spring damper, Cart pole

Fixed wing control

The RQ-4B Global Hawk flies itself unless you take it, and five commands decide who is flying and what the airframe does.

cargo run -p vrobots-examples --bin ex31_globalhawk_direct -- <sys_id>
./target/cpp-build/ex31_globalhawk_direct <sys_id>
python examples/python/ex31_globalhawk_direct.py <sys_id>
cargo run -p vrobots-examples --bin ex33_fw_est_source -- <sys_id>
./target/cpp-build/ex33_fw_est_source <sys_id>
python examples/python/ex33_fw_est_source.py <sys_id>

The Global Hawk is scene-authored and lives in the IMU scene, not the sandbox, so both examples take the live sys_id as an argument. Find it with cargo run -p vrobots-sdk --bin vrobots -- topic list.

Two modes, and what reset does to them

stateDiagram-v2
    [*] --> Onboard
    Onboard: FW_ONBOARD_RATE (0, the default)
    Direct: FW_DIRECT_SURFACE (1)
    Onboard --> Direct: set_fw_ctrl_mode(1)
    Direct --> Onboard: set_fw_ctrl_mode(0)
    Direct --> Onboard: reset()
    Onboard --> Onboard: reset() also puts the estimate source back to truth
    note right of Onboard
        rate PIDs track SET_ANGVEL
        airspeed hold owns the engine
        set_fw_thrust pins it, bias trims it
        estimate source: truth or observer
    end note
    note right of Direct
        panels take SET_FW_SURFACES verbatim
        set_fw_thrust is the thrust, full stop
        thrust bias ignored, estimate source inert
    end note

Left alone the aircraft cruises at 72.8 m/s under the onboard rate loop plus airspeed hold and needs nothing from you. Transitions in both directions are bumpless on the simulator's side, so there is no need to ramp in or out.

The five commands

MethodSignatureCommandUnits and rangeActed on in
set_fw_ctrl_mode(&self, mode: i32) -> VrResult<()>SET_FW_CTRL_MODE (310)0 or 1; anything else is InvalidArgumentboth
set_fw_surfaces(&self, radians: &[f64]) -> VrResult<()>SET_FW_SURFACES (307)radians, one per panel, clamped to the airframe's 20 degree limitdirect only; latched but unused in onboard
set_fw_thrust(&self, newtons: f64) -> VrResult<()>SET_FW_THRUST (308)newtons, clamped to [0, max_thrust], 20 kN on the RQ-4Bboth
set_fw_thrust_bias(&self, newtons: f64) -> VrResult<()>SET_FW_THRUST_BIAS (309)newtons, signed trim; 0.0 is neutralonboard only
set_fw_est_source(&self, source: i32) -> VrResult<()>SET_FW_EST_SOURCE (311)0 or 1; anything else is InvalidArgumentonboard only

The rate setpoint is a sixth command, and it is not a SET_FW_* id: it is SET_ANGVEL (51), shared across the whole id space with the fixed wing as the only type that acts on it. set_angvel(&self, rates: [f64; 3]) -> VrResult<()> is its typed method. The triple is [p, q, r] in rad/s in your header frame, and the robot re-expresses it as an axial vector, so a conversion between two opposite-handed conventions flips its sign where a force's would not.

Six panels, no mixer

Each entry of set_fw_surfaces drives its own panel. Turning roll, pitch and yaw demands into deflections is your job.

IndexPanelWhat the onboard mixer does with it
0left outboard flapaileron, gain +1
1right outboard flapaileron, gain -1
2left inner flapnothing, gain 0 on all three channels
3right inner flapnothing
4rear left ruddervatorelevator -1, rudder +1
5rear right ruddervatorelevator -1, rudder -1

Indices 2 and 3 are how you prove direct mode took: the simulator's own mixer has zero gain there and can never move them, so an inner flap that follows your command could only have come through the per-panel path.

The length must equal the panel count exactly. A wrong-length array makes the simulator drop the whole command, never apply it partially, and the previously latched deflections stay in effect. Client-side the SDK checks only that the slice is non-empty and that every value is finite.

The echo is in radians and newtons

actuator.measured has panels plus one entries:

measured[0..=5]  per-panel deflection, RADIANS
measured[6]      the engine, NEWTONS -- not normalised, not a pulse width

A simulator too old for the per-panel path publishes six entries rather than seven, so the channel count is the version check. examples/rust/src/bin/ex32_fw_rate_controller.rs refuses to run against one:

#![allow(unused)]
fn main() {
    let channels = robot.states().actuator.measured.len();
    if channels != PANELS + 1 {
        return Err(VrError::InvalidArgument(format!(
            "this robot publishes {channels} actuator channels; this mixer is written for \
             {PANELS} panels + an engine. Six channels means a simulator too old for the \
             per-panel path."
        )));
    }
}
The same in C++ (examples/cpp/ex32_fw_rate_controller.cpp)
const std::uint32_t channels = robot.states().actuator().measured_count;
if (channels != PANELS + 1) {
    std::fprintf(stderr,
                 "this robot publishes %u actuator channels; this mixer is written for "
                 "%zu panels + an engine. Six channels means a simulator too old for the "
                 "per-panel path.\n",
                 channels, PANELS);
    return 1;
}
The same in Python (examples/python/ex32_fw_rate_controller.py)
channels = len(robot.states.actuator.measured)
if channels != PANELS + 1:
    raise SystemExit(
        f"this robot publishes {channels} actuator channels; this mixer is "
        f"written for {PANELS} panels + an engine. Six channels means a "
        f"simulator too old for the per-panel path."
    )

The count comes off a length in Rust and Python and off measured_count in C++, which is the same number: the C struct carries a fixed-size array plus its used length.

The check runs once, before the aircraft is touched, so an old simulator produces a named error at startup instead of a mixer that appears to do nothing.

Bumpless is not zeroed

Entering a mode seeds the latches from what the plant is doing now, including whatever thrust the airspeed hold happened to be carrying. Nothing jolts, and nothing is cleared either. A client that wants a particular thrust must send it after every mode entry. That is why the order in examples/rust/src/bin/ex31_globalhawk_direct.rs is mode first, thrust second:

#![allow(unused)]
fn main() {
    robot.set_fw_ctrl_mode(cmd::FW_DIRECT_SURFACE)?;
    robot.set_fw_thrust(CRUISE_N)?;
    println!("\nmode -> DIRECT_SURFACE, thrust -> {CRUISE_N} N\n");
}
The same in C++ (examples/cpp/ex31_globalhawk_direct.cpp)
// -- so the thrust command has to come AFTER the mode, every time.
robot.set_fw_ctrl_mode(vrsdk::FwCtrlMode::DirectSurface);
robot.set_fw_thrust(CRUISE_N);
std::printf("\nmode -> DIRECT_SURFACE, thrust -> %.0f N\n\n", CRUISE_N);
The same in Python (examples/python/ex31_globalhawk_direct.py)
# so the thrust command has to come AFTER the mode, every time.
robot.set_fw_ctrl_mode(cmd.FW_DIRECT_SURFACE)
robot.set_fw_thrust(CRUISE_N)
print(f"\nmode -> DIRECT_SURFACE, thrust -> {CRUISE_N} N\n")

Only the spelling of the mode differs. C++ has a real enum class, vrsdk::FwCtrlMode, so the argument is FwCtrlMode::DirectSurface; Rust and Python pass the integer constant from cmd. The wire value is the same in all three.

mode -> DIRECT_SURFACE, thrust -> 3800 N

Skip the thrust line and the engine keeps whatever the autopilot left, which is usually about 3.8 kN at trim. That is a plausible-looking number, which is what makes the omission hard to spot.

In onboard mode set_fw_thrust means something slightly different: it takes the engine off airspeed hold and pins it, and it clears any thrust bias. The two overrides are last-writer-wins, and set_fw_thrust_bias(0.0) is the release back to plain airspeed hold.

What reset takes away

reset() reverts the control mode to FW_ONBOARD_RATE and the estimate source to FW_EST_TRUTH, and relaunches the aircraft at trim airspeed. This is deliberate: keeping direct control with the surface latches zeroed would relaunch the aircraft unflyable.

Gotcha. After a reset your surface commands are still publishing, still returning Ok(()), and doing nothing at all, because the mixer is flying the panels again. The symptom is the inner flaps sitting at 0 while the same command streams. Re-assert set_fw_ctrl_mode, then re-assert set_fw_thrust, in that order, after every reset.

Feeding the loop your own estimate

set_fw_est_source decides which attitude the onboard rate loop believes. The controller itself never knows which, because the swap happens upstream of it in the robot, which is exactly how a real flight computer gets fooled.

ConstantValueThe onboard loop is fed
cmd::FW_EST_TRUTH0the simulator's true attitude; the default
cmd::FW_EST_OBSERVER1whatever is published on the robot's z/estimate topic

From examples/rust/src/bin/ex33_fw_est_source.rs, selecting the observer with nothing publishing an estimate:

#![allow(unused)]
fn main() {
    robot.set_fw_est_source(cmd::FW_EST_OBSERVER)?;
    println!(
        "\nsource -> OBSERVER. Nothing publishes z/estimate here, so within 0.5 s \
         the estimate is stale and the loop is fed truth again -- the simulator \
         logs a warning saying exactly that."
    );
}
The same in C++ (examples/cpp/ex33_fw_est_source.cpp)
// ===== phase 2: observer, with nobody publishing an estimate =====
robot.set_fw_est_source(vrsdk::FwEstSource::Observer);
std::printf(
    "\nsource -> OBSERVER. Nothing publishes z/estimate here, so within 0.5 s the "
    "estimate is stale and the loop is fed truth again -- the simulator logs a warning "
    "saying exactly that.\n");
The same in Python (examples/python/ex33_fw_est_source.py)
# ===== phase 2: observer, with nobody publishing an estimate =====
robot.set_fw_est_source(cmd.FW_EST_OBSERVER)
print(
    "\nsource -> OBSERVER. Nothing publishes z/estimate here, so within 0.5 s "
    "the estimate is stale and the loop is fed truth again -- the simulator "
    "logs a warning saying exactly that."
)

Again C++ has the typed vrsdk::FwEstSource where the other two pass cmd::FW_EST_OBSERVER and cmd.FW_EST_OBSERVER.

The run ends with one line per phase:

steady yaw-rate tracking, commanded 0.05 rad/s:
  truth (source 0)                   mean r=<rad/s>  mean error=<rad/s>  |error|=<rad/s>
  observer (source 1), no publisher  mean r=<rad/s>  mean error=<rad/s>  |error|=<rad/s>
  after reset                        mean r=<rad/s>  mean error=<rad/s>  |error|=<rad/s>

The example measures yaw-rate tracking error across those three phases, and phase 2 matching phase 1 is the whole result: the loop asked for an estimate, found none fresh, and kept flying on truth.

Gotcha. An estimate older than 0.5 s is treated as stale and the loop silently falls back to truth. From the client there is no field that says which source is live, so an estimator publishing at 1 Hz behaves like one that is not publishing at all, and the only difference you can measure is that the tracking error stops changing.

This page is only the selector. The other half is publish_estimate, which puts a swarmbotix.states.EstimateState on vrobots/{sys_id}/z/estimate for FW_EST_OBSERVER to find, and examples/rust/src/bin/ex35_publish_estimate.rs is the paired publisher: it flies the same aircraft on a truth copy of its own attitude, then on a copy pitched up five degrees, and the airframe trims down to chase a nose position that was never real. Publishing estimates is that side in full, including the half-second staleness clock from the publisher's end.

Next: The generic command

See also: Publishing estimates, Commands latch, Reading someone else's commands, Global Hawk

The generic command

send_cmd reaches the whole command id space, including the ids the SDK has no typed method for.

cargo run -p vrobots-examples --bin ex08_generic_cmd
./target/cpp-build/ex08_generic_cmd
python examples/python/ex08_generic_cmd.py

One message, and cmd_id decides what it means

#![allow(unused)]
fn main() {
pub fn send_cmd(&self, cmd_id: u32, args: &CmdArgs) -> VrResult<()>
}
The same in C++ (cpp/include/vrobots_sdk.hpp)
/// Publish any command by id -- the escape hatch for the whole
/// `VROBOTS_CMDS` space. Fill `args` with `vrsdk_cmd_args_default` first.
void send_cmd(std::uint32_t cmd_id, const vrsdk_cmd_args_t* args = nullptr)
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
def send_cmd(
    self,
    cmd_id: int,
    *,
    int_val: int = 0,
    float_val: float = 0.0,
    int_arr: Optional[Sequence[int]] = None,
    float_arr: Optional[Sequence[float]] = None,
    vec3: Optional[Sequence[float]] = None,
    vec4: Optional[Sequence[float]] = None,
    vec3_arr: Optional[Sequence[Sequence[float]]] = None,
    vec4_arr: Optional[Sequence[Sequence[float]]] = None,
) -> None: ...

Python has no CmdArgs type at all: the eight payload fields are keyword arguments on the call, and anything you omit stays off the wire. C++ passes the plain C struct by pointer, and every field in it is a borrowed pointer, so a null means "absent" and nothing is retained after the call returns.

Command is a union by convention: one message shape, and the id decides which payload fields mean anything. SET_MR_PWM reads int_arr and ignores the rest, SET_BODY_FORCE reads vec3. Fill the fields the target id reads and leave the others alone. Empty vectors and None vectors are omitted from the wire entirely rather than sent as zero length, so the receiver's "field absent" default applies.

Errors are VrError::Deleted if the robot was deleted and VrError::Publish if zenoh refuses the put. There is no validation here, which is the trade: every typed method validates its arguments, and send_cmd publishes whatever you build.

Sending an implemented id and an ignored one, side by side

examples/rust/src/bin/ex08_generic_cmd.rs sends two commands per iteration to the truck, on purpose:

#![allow(unused)]
fn main() {
    loop {
        // (1) An implemented id, built by hand. CmdArgs::ints fills int_arr,
        //     which is the field SET_CAR reads; everything else stays off the
        //     wire.
        let drive = CmdArgs::ints(&[STEER_US, THROTTLE_US, BRAKE_US]);
        robot.send_cmd(cmd::SET_CAR, &drive)?;

        // (2) An id nothing acts on, whose payload rides vec3. Same call, same
        //     Ok(()), no effect. `cmd::name` turns an id back into its schema
        //     name, which is what makes a log line readable.
        let gust = CmdArgs::default().with_vec3(GUST_N);
        robot.send_cmd(cmd::ADD_BODY_FORCE, &gust)?;
}
The same in C++ (examples/cpp/ex08_generic_cmd.cpp)
// ===== loop =====
for (;;) {
    // (1) An implemented id, built by hand. int_arr is the field SET_CAR
    //     reads; everything else stays NULL and off the wire.
    vrsdk_cmd_args_t drive;
    vrsdk_cmd_args_default(&drive);
    drive.int_arr = channels;
    drive.int_arr_len = 3;
    robot.send_cmd(SET_CAR, &drive);

    // (2) An id nothing acts on, whose payload rides vec3. Same call,
    //     no exception, no effect. Every payload field is a borrowed
    //     pointer: NULL means "absent" and nothing is retained after
    //     the call returns, so a stack array is fine.
    vrsdk_cmd_args_t force;
    vrsdk_cmd_args_default(&force);
    force.vec3 = gust;  // exactly 3 doubles
    robot.send_cmd(ADD_BODY_FORCE, &force);
The same in Python (examples/python/ex08_generic_cmd.py)
# ===== loop =====
while True:
    # (1) An implemented id, built by hand. int_arr is the field SET_CAR
    #     reads; everything else stays off the wire.
    car.send_cmd(cmd.SET_CAR, int_arr=[STEER_US, THROTTLE_US, BRAKE_US])

    # (2) An id nothing acts on, whose payload rides vec3. Same call, no
    #     exception, no effect. `cmd.name` turns an id back into its schema
    #     name, which is what makes a log line readable.
    car.send_cmd(cmd.ADD_BODY_FORCE, vec3=GUST_N)

Python is the shortest because the payload is keyword arguments. C++ is the longest because each call needs a vrsdk_cmd_args_default first, and it also declares the two ids as its own constants: the C surface ships no cmd namespace, so SET_CAR = 304 and ADD_BODY_FORCE = 203 are written out from the schema and there is no cmd::name to turn one back into a label.

Both calls return Ok(()). Only one of them changes anything, and the printed lines say which:

sent SET_CAR(304) + ADD_BODY_FORCE(203) -> echo=[1500, 1600, 1100]
      SET_CAR landed (the echo is the receipt); ADD_BODY_FORCE was ignored -- wrench=(<fx>,<fy>,<fz>) N unchanged

cmd::name(cmd_id) -> &'static str maps an id back to its constant name for logging, and returns "" for an id that is not in the schema.

CmdArgs

#![allow(unused)]
fn main() {
#[non_exhaustive]
pub struct CmdArgs {
    pub int_val: i32,
    pub float_val: f64,
    pub int_arr: Vec<i32>,
    pub float_arr: Vec<f64>,
    pub vec3: Option<[f64; 3]>,
    pub vec4: Option<[f64; 4]>,
    pub vec3_arr: Vec<[f64; 3]>,
    pub vec4_arr: Vec<[f64; 4]>,
}
}
The same in C++ (crates/vrobots-sdk-capi/include/vrobots_sdk.h)
typedef struct vrsdk_cmd_args_t {
    int32_t int_val;
    double float_val;
    const int32_t *int_arr;
    size_t int_arr_len;
    const double *float_arr;
    size_t float_arr_len;
    const double *vec3;
    const double *vec4;
    const double *vec3_arr;
    size_t vec3_arr_len;
    const double *vec4_arr;
    size_t vec4_arr_len;
} vrsdk_cmd_args_t;
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
int_val: int = 0
float_val: float = 0.0
int_arr: Optional[Sequence[int]] = None
float_arr: Optional[Sequence[float]] = None
vec3: Optional[Sequence[float]] = None
vec4: Optional[Sequence[float]] = None
vec3_arr: Optional[Sequence[Sequence[float]]] = None
vec4_arr: Optional[Sequence[Sequence[float]]] = None

The eight fields in the table below are the same eight in all three. The C struct pairs every array with an explicit _len, and it flattens vec3_arr and vec4_arr into one double array of three or four values per entry, so vec3_arr_len counts vectors, not doubles. Python's are the keyword arguments of send_cmd rather than a type of their own.

FieldTypeUnitsDefaultNotes
int_vali32per command0mode selectors live here (SET_FW_CTRL_MODE, SET_FW_EST_SOURCE)
float_valf64per command0.0scalar forces (SET_MSD, SET_INVPEN, SET_FW_THRUST); narrows to f32
int_arrVec<i32>microsecondsemptypulse widths (SET_MR_PWM, SET_CAR)
float_arrVec<f64>per commandemptyper-panel radians (SET_FW_SURFACES); narrows to f32
vec3Option<[f64; 3]>per commandNoneframe-tagged and converted by the robot
vec4Option<[f64; 4]>per commandNoneordered [x, y, z, w], matching the wire
vec3_arrVec<[f64; 3]>per commandemptySET_BODY_FT puts the torque half here
vec4_arrVec<[f64; 4]>per commandempty

The struct is #[non_exhaustive], so it cannot be built with a struct literal: a future schema field would otherwise be a breaking change across three languages. Build it with a shorthand or with CmdArgs::default() plus chained setters.

ShorthandSetsThe shape used by
CmdArgs::ints(&[i32])int_arrSET_MR_PWM, SET_CAR
CmdArgs::floats(&[f64])float_arrSET_FW_SURFACES
CmdArgs::vector([f64; 3])vec3SET_BODY_FORCE, SET_ANGVEL

The builders are with_int_val, with_float_val, with_int_arr, with_float_arr, with_vec3, with_vec4, with_vec3_arr and with_vec4_arr. Each consumes self, returns Self and is #[must_use], so they chain and a dropped result is a compiler warning rather than a silent no-op.

Every command id

Live means a robot type acts on the id today. Not yet means it is defined on the wire and no robot type acts on it. Absent means the robot type it belongs to does not exist in the simulator.

ConstantValueStatusActed on by
SET_ACC1not yet
SET_VEL2not yet
SET_POS3not yet
SET_ANGACC50not yet
SET_ANGVEL51liveGlobalHawk onboard rate loop; the in-game IMU panel publishes it at 50 Hz
SET_EULER52not yet
SET_EULER_DOT53not yet
SET_QUAT54not yet
SET_MASS100not yetuse srv/params instead
SET_MOI_3X1101not yetuse srv/params
SET_MOI_3X3102not yetuse srv/params
SET_BODY_FORCE200not yet
SET_BODY_TORQUE201not yet
SET_BODY_FT202not yet
ADD_BODY_FORCE203not yet
ADD_BODY_TORQUE204not yet
ADD_BODY_FT205not yet
SET_MR_PWM300liveMultirotor, HalfDrone
SET_MR_THROTTLE301not yet
SET_OMROVER302absentrobot type not in the simulator
SET_HELI303absentrobot type not in the simulator
SET_CAR304liveTruck
SET_MSD305liveMsd
SET_INVPEN306liveCartPole
SET_FW_SURFACES307liveGlobalHawk
SET_FW_THRUST308liveGlobalHawk
SET_FW_THRUST_BIAS309liveGlobalHawk
SET_FW_CTRL_MODE310liveGlobalHawk
SET_FW_EST_SOURCE311liveGlobalHawk

The mode constants are argument values rather than ids: FW_ONBOARD_RATE (0) and FW_DIRECT_SURFACE (1) for SET_FW_CTRL_MODE, FW_EST_TRUTH (0) and FW_EST_OBSERVER (1) for SET_FW_EST_SOURCE.

When to reach for it

Use send_cmd for ids the SDK has no method for, SET_ANGVEL being the one that comes up in practice. Prefer the typed methods everywhere they exist: they fill the right field for you and they validate, and a pulse width outside 1100 to 2000 microseconds is refused before anything is sent, where send_cmd publishes it.

Next: Commands nothing acts on

See also: Sending commands, Fixed wing control, Appendix B: Command reference

Commands nothing acts on

Several ids are fully defined on the wire and implemented by no robot type, and from the client side they look exactly like a command you got wrong.

cargo run -p vrobots-examples --bin ex06_hello_throttle
./target/cpp-build/ex06_hello_throttle
python examples/python/ex06_hello_throttle.py
cargo run -p vrobots-examples --bin ex07_body_wrench
./target/cpp-build/ex07_body_wrench
python examples/python/ex07_body_wrench.py

The worked example: SET_MR_THROTTLE

SET_MR_THROTTLE is normalised per-rotor throttle: four values on 0 to 1 instead of four pulse widths. It is the command you would reach for to hover without thinking in microseconds, and no robot type acts on it. examples/rust/src/bin/ex06_hello_throttle.rs is therefore a lesson in what that looks like rather than a way to fly:

#![allow(unused)]
fn main() {
    loop {
        // Published exactly like set_mr_pwm: one put on vrobots/<id>/z/cmd, no
        // reply, latched until the next one arrives.
        robot.set_mr_throttle(THROTTLE)?;

        let s = robot.states();
        // The state frame is the robot's, not yours -- "frd" here, so lin_pos[2]
        // is DOWN, and altitude above the start point is its negation.
        let [_, _, down] = s.kin.lin_pos;
        println!(
            "sent {THROTTLE:?} -> alt={:.2} m  pwm={:?} normalized={:?} measured={:?}",
            -down,
            s.actuator.pwm,
            round3(&s.actuator.normalized),
            round3(&s.actuator.measured),
        );

        robot.rate(HZ);
    }
}
The same in C++ (examples/cpp/ex06_hello_throttle.cpp)
// ===== loop =====
for (;;) {
    // Published exactly like set_mr_pwm: one put on vrobots/<id>/z/cmd,
    // no reply, latched until the next one arrives.
    robot.set_mr_throttle(throttle);

    const vrsdk::State s = robot.states();
    // The state frame is the robot's, not yours -- "frd" here, so
    // lin_pos[2] is DOWN and altitude is its negation.
    const double alt = -s.kin().lin_pos[2];
    const vrsdk_actuator_t& a = s.actuator();

    std::printf("sent %.2f x4 -> alt=%.2f m  ", THROTTLE, alt);
    std::printf("pwm=[");
    for (std::uint32_t i = 0; i < a.pwm_count; ++i) {
        std::printf(i ? ",%u" : "%u", a.pwm[i]);
    }
    std::printf("] ");
    print_array("normalized", a.normalized, a.normalized_count);
    print_array("measured", a.measured, a.measured_count);
    std::printf("\n");

    robot.rate(HZ);
}
The same in Python (examples/python/ex06_hello_throttle.py)
# ===== loop =====
while True:
    # Published exactly like set_mr_pwm: one put on vrobots/<id>/z/cmd, no
    # reply, latched until the next one arrives. Both call shapes work:
    # set_mr_throttle(a, b, c, d) and set_mr_throttle([a, b, c, d]).
    mr.set_mr_throttle(THROTTLE)

    s = mr.states
    # The state frame is the robot's, not yours -- "frd" here, so lin_pos[2]
    # is DOWN, and altitude above the start point is its negation.
    down = s.kin.lin_pos[2]
    norm = [round(v, 3) for v in s.actuator.normalized]
    meas = [round(v, 3) for v in s.actuator.measured]
    print(
        f"sent {THROTTLE} -> alt={-down:.2f} m  pwm={s.actuator.pwm} "
        f"normalized={norm} measured={meas}"
    )

    mr.rate(HZ)

The call succeeds in all three and nothing moves in any of them, which is the point of the example: an ignored command is indistinguishable from a delivered one at the call site.

Every line reports the same three actuator channels, and none of them moves in response to what was sent:

sent [0.6, 0.6, 0.6, 0.6] -> alt=<metres> m  pwm=<latched pulse widths> normalized=<latched normalised command> measured=<rotor rad/s>

There is no reply and no error, because the id space is shared across robot types and "not mine" is correct behaviour rather than a fault. Run ex02 alongside it: identical loop, an id the simulator implements, and the echo moves.

Not yet. set_mr_throttle is on the wire and acted on by nothing. Its payload field is the one the schema provides, but unlike set_mr_pwm it has never been confirmed against a consumer, so treat it as unverified until the simulator implements it. Today the way to fly a multirotor is set_mr_pwm.

The body wrench group

The wrench group is the disturbance-injection channel the schema reserves for wind gusts, payload drops and contact pushes. Three typed methods cover it, and today they are in the same category as SET_MR_THROTTLE.

MethodCommandUnitsWire payload
set_body_force(f)SET_BODY_FORCE (200)Nvec3 = force
set_body_torque(t)SET_BODY_TORQUE (201)N·mvec3 = torque
set_body_ft(f, t)SET_BODY_FT (202)N and N·mvec3 = force, vec3_arr[0] = torque

SET_BODY_FT is asymmetric because the schema is. The typed method hides it; building the message by hand with CmdArgs does not.

From examples/rust/src/bin/ex07_body_wrench.rs, one verb per iteration so each printed line names exactly what went out:

#![allow(unused)]
fn main() {
        let sent = match step % 3 {
            0 => {
                robot.set_body_force(GUST_N)?;
                format!("set_body_force({GUST_N:?})")
            }
            1 => {
                robot.set_body_torque(TWIST_NM)?;
                format!("set_body_torque({TWIST_NM:?})")
            }
            _ => {
                robot.set_body_ft(GUST_N, TWIST_NM)?;
                format!("set_body_ft({GUST_N:?}, {TWIST_NM:?})")
            }
        };
}
The same in C++ (examples/cpp/ex07_body_wrench.cpp)
// One verb per iteration, so each printed line names exactly what
// went out on the wire.
const char* sent = nullptr;
switch (step % 3) {
    case 0:
        robot.set_body_force(GUST_N);
        sent = "set_body_force(5, 0, 0)";
        break;
    case 1:
        robot.set_body_torque(TWIST_NM);
        sent = "set_body_torque(0, 0, 0.2)";
        break;
    default:
        robot.set_body_ft(GUST_N, TWIST_NM);
        sent = "set_body_ft((5,0,0), (0,0,0.2))";
        break;
}
The same in Python (examples/python/ex07_body_wrench.py)
# One verb per iteration, so each printed line names exactly what went
# out on the wire. Each takes three scalars or one sequence.
if step % 3 == 0:
    mr.set_body_force(GUST_N)
    sent = f"set_body_force({GUST_N})"
elif step % 3 == 1:
    mr.set_body_torque(TWIST_NM)
    sent = f"set_body_torque({TWIST_NM})"
else:
    mr.set_body_ft(GUST_N, TWIST_NM)
    sent = f"set_body_ft({GUST_N}, {TWIST_NM})"
step += 1

The three verbs are one for one across the surfaces. C++ takes std::array<double, 3> and Python takes either three scalars or one sequence; both hide the same schema asymmetry, in which set_body_ft puts the force in vec3 and the torque in vec3_arr[0].

The state block it prints afterwards is state.wrench, the total force and torque the simulator has on the body. That is where the effect will appear the day the simulator implements these ids. Until then it shows the robot's own actuators and nothing of yours:

set_body_force([5.0, 0.0, 0.0])
    state.wrench force=(<fx>,<fy>,<fz>) N  torque=(<tx>,<ty>,<tz>) N.m  in "<frame>"

The vectors are still frame-tagged on the way out, using the coord_frame_id from your connect options. An untagged vector would be taken at face value and would silently flip sign between opposite-handed conventions, which is why the SDK always tags.

Telling ignored from wrong

You cannot, from outside. There is no acknowledgement to inspect and no error to catch, so every one of these produces the same symptom:

What you observePossible causes
Ok(()), and the state stream does not changethe id is not implemented; the id is implemented by a different robot type; the sys_id is not the robot you meant; the array length does not match the airframe; the value was clamped to what was already latched

The one tool that separates them is the echo. Print actuator.pwm, actuator.normalized and actuator.measured every iteration, and compare their contents and their lengths against what you sent. A channel that never moves while its neighbours do points at the id; an actuator block that never moves at all while elapsed keeps advancing points at the robot, which is either of a type that does not implement the command or not the robot you meant to attach to.

Confirm which robot is which with vrobots topic list before blaming a command.

Next: Reading someone else's commands

See also: The generic command, When nothing happens, Actuators

Reading someone else's commands

A robot's command topic is a shared bus, so you can subscribe to what other publishers are sending it and use that as your own controller's input.

cargo run -p vrobots-examples --bin ex32_fw_rate_controller -- <sys_id>
./target/cpp-build/ex32_fw_rate_controller <sys_id>
python examples/python/ex32_fw_rate_controller.py <sys_id>

The one place the SDK reads z/cmd

Everywhere else a command is write-only, and rightly so: it has no reply and the state stream is the proof. subscribe_setpoint is the exception. Zenoh's vrobots/{sys_id}/z/cmd is many-to-many, so the setpoints the simulator's in-game IMU panel publishes at 50 Hz are readable by anyone who subscribes to the same key.

That enables the experiment the fixed wing was built for. From examples/rust/src/bin/ex32_fw_rate_controller.rs, three streams meet in one program:

z/cmd    ->  SET_ANGVEL from the sim's IMU panel   the setpoint (read, not written)
z/state  ->  kin.ang_vel                           the measurement
z/cmd    <-  SET_FW_SURFACES + SET_FW_THRUST       your output

The operator keeps flying with the stick, the simulator's rate PIDs are bypassed, and your gains are in their place.

The two subscriptions

MethodSignatureNotes
subscribe_setpoint(&self) -> VrResult<SetpointStream>shorthand for subscribe_command(cmd::SET_ANGVEL)
subscribe_command(&self, cmd_id: u32) -> VrResult<SetpointStream>any id; only commands carrying a vec3 yield a Setpoint, and the rest are counted as filtered

Both return VrError::Deleted if the robot was deleted and VrError::Session if zenoh will not declare the subscriber. Subscribe before you take the aircraft, so an input during the handover is not missed:

#![allow(unused)]
fn main() {
    // Subscribe BEFORE taking the aircraft: a stick input during the handover
    // would otherwise be missed, and the loop would start from "no setpoint".
    let setpoints = robot.subscribe_setpoint()?;
    println!(
        "attached to sys_id={} ({:?}); watching {} for {} (id {}), ignoring src_id={own_src_id}",
        robot.sys_id(),
        robot.robot_type(),
        setpoints.key(),
        cmd::name(setpoints.cmd_id()),
        setpoints.cmd_id()
    );
}
The same in C++ (examples/cpp/ex32_fw_rate_controller.cpp)
// Subscribe BEFORE taking the aircraft: a stick input during the
// handover would otherwise be missed, and the loop would start from "no
// setpoint".
vrsdk::SetpointStream setpoints = robot.subscribe_setpoint();
std::printf(
    "attached to sys_id=%u (GlobalHawk); watching %s for SET_ANGVEL (id %u), ignoring "
    "src_id=%u\n",
    robot.sys_id(), setpoints.key().c_str(), setpoints.cmd_id(), OWN_SRC_ID);
The same in Python (examples/python/ex32_fw_rate_controller.py)
own_src_id = robot.options["src_id"]

# Subscribe BEFORE taking the aircraft: a stick input during the handover
# would otherwise be missed, and the loop would start from "no setpoint".
setpoints = robot.subscribe_setpoint()
print(
    f"attached to sys_id={robot.sys_id} ({robot.robot_type!r}); watching "
    f"{setpoints.key} for {cmd.name(setpoints.cmd_id)} (id {setpoints.cmd_id}), "
    f"ignoring src_id={own_src_id}"
)

Knowing your own src_id is where the surfaces diverge, and it matters here because it is how you filter your own traffic off the bus. Rust and Python can read the options back (robot.options["src_id"] in Python); the C++ surface has no accessor for them, so the example sets src_id explicitly in the connect options and keeps its own constant.

attached to sys_id=<id> (GlobalHawk); watching vrobots/<id>/z/cmd for SET_ANGVEL (id 51), ignoring src_id=122

Dropping the stream undeclares the subscription and nothing else. It does not stop anyone publishing, and the robot never learns that you were listening.

What a setpoint carries

FieldTypeUnitsNotes
cmd_idu32the id this stream filtered on
value[f64; 3]rad/s for SET_ANGVELthe vec3 payload unconverted, in the sender's frame
axis_conventionAxesthe convention value is expressed in
coord_frame_idStringthe frame id; authoritative when it and axis_convention disagree
src_idu32who sent it; the in-game IMU panel is 108
sys_idu32which robot it was addressed to
sequ64the sender's per-topic sequence number
t_nsi64ns since the unix epochthe sender's capture time
elapsedf64sthe same clock as State::elapsed, so a setpoint and a state are directly subtractable

The vector is taken exactly as the sender stamped it. The simulator converts its own copy, so a controller that skips the conversion disagrees with the simulator by a permutation and a sign or two. For SET_ANGVEL off the IMU panel the frame is the target robot's own, which for a fixed wing is FRD, so it reads as [p, q, r].

Filtering out your own traffic

Everything anyone sends to the robot arrives on this stream, your own commands included. Compare Setpoint::src_id against ConnectOptions::src_id, which defaults to 122:

#![allow(unused)]
fn main() {
        // --- the setpoint: latched, so read the current one every iteration ---
        let setpoint = setpoints.latest();
        let demand = match &setpoint {
            // Our own traffic comes back on this bus too. It is not a setpoint.
            Some(sp) if sp.src_id == own_src_id => [0.0; 3],
            Some(sp) => {
}
The same in C++ (examples/cpp/ex32_fw_rate_controller.cpp)
// --- the setpoint: latched, so read the current one every iteration
const std::optional<vrsdk::Setpoint> setpoint = setpoints.latest();
double demand[3] = {0.0, 0.0, 0.0};
if (setpoint && setpoint->src_id() != OWN_SRC_ID) {
    // Our own traffic comes back on this bus too. It is not a
    // setpoint.
    const std::array<double, 3> value = setpoint->value();
    demand[0] = value[0];
    demand[1] = value[1];
    demand[2] = value[2];
The same in Python (examples/python/ex32_fw_rate_controller.py)
# --- the setpoint: latched, so read the current one every iteration ---
setpoint = setpoints.latest
if setpoint is None or setpoint.src_id == own_src_id:
    # Nobody has ever published one, or it is our own traffic coming back
    # on this bus. "Hold zero rates" is a decision.
    demand = (0.0, 0.0, 0.0)
else:
    demand = setpoint.value

Two absences collapse into one test in every surface: no setpoint has ever arrived, and the only setpoint is your own echo. Both mean "hold zero rates" here. Note that latest is a property in Python and a method in the other two.

Skipping that arm makes a controller chase its own output, which reads as a loop that will not settle rather than as a bug in the filter.

latest, not fresh

MethodReturnsUse it when
latest()the current setpoint, new or not, None before the first ever arrivesa rate loop: a setpoint latches, so "the current command" is what you want every iteration
fresh()the setpoint only if it is new since the last call, handed out exactly oncesomething that must not act twice on one operator input
wait_new_setpoint(timeout)blocks until a newer one arrivesVrError::Timeout means nobody is publishing, which for a hand-flown panel is most of the time

A None from fresh() does not mean the setpoint went away. The bus latches, and a publisher that stopped sending has not commanded zero, which is why latest() is almost always the right read here.

Gotcha. Before the first setpoint ever arrives, latest() is None. Whatever you substitute is a decision, not a default: ex32 holds zero rates, which is safe on an aircraft that is already trimmed and would not be on every plant.

Stream health

SetpointStats carries received, filtered, decode_errors, seq_gaps and last_seq. filtered climbing fast is normal: it counts every other peer's traffic to the same robot, which is everything that is not the one id you asked for. seq_gaps is only meaningful with a single publisher, since two senders interleaving their own sequences look like gaps. Decode errors are counted rather than returned and never tear the subscription down.

Next: Publishing estimates

See also: Fixed wing control, Stream health, Frames, axes and units

Publishing estimates

Putting your filter's belief on the wire so the fixed wing can fly it instead of the simulator's truth.

cargo run -p vrobots-examples --bin ex35_publish_estimate -- <sys_id>
./target/cpp-build/ex35_publish_estimate <sys_id>
python examples/python/ex35_publish_estimate.py <sys_id>

The Global Hawk is scene-authored and lives in the IMU scene, so the example takes the live sys_id as an argument. Find it with vrobots topic list.

The simulator deliberately leaves the estimate empty

Every snapshot carries an estimate block, and it is always zeroed with valid = false. That is not a gap. The simulator runs no filter of its own and omits the field on purpose, because an estimate mirroring truth would make estimate.kin - kin a tautology instead of an estimator error. Truth, measured and believed is that split from the reading side. The two topics divide along the same line and travel in opposite directions.

TopicDirectionCarries
vrobots/{sys_id}/z/statethe simulator publishesState, with truth in kin and measurements in sensors
vrobots/{sys_id}/z/estimatethe simulator subscribesEstimateState, your belief, published by you

The loop is therefore: read sensors, run your filter, publish the result, repeat.

The two entry points

Both build the same wire message. From crates/vrobots-sdk/src/robot.rs:

#![allow(unused)]
fn main() {
    pub fn publish_estimate(
        &self,
        quat: [f64; 4],
        angular_rates: Option<[f64; 3]>,
        valid: bool,
    ) -> VrResult<()>
}
The same in C++ (cpp/include/vrobots_sdk.hpp)
    void publish_estimate(const Quat& quat, std::optional<Vec3> angular_rates = std::nullopt,
                          bool valid = true)
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
def publish_estimate(
    self,
    quat: Sequence[float],
    angular_rates: Optional[Sequence[float]] = None,
    valid: bool = True,
) -> None: ...

C++ and Python default angular_rates to none and valid to true; Rust asks for all three. publish_estimate_euler(euler, order, angular_rates, valid) is the same call from angles: it runs rotations::euler_to_quat and hands the result to publish_estimate, and everything after the conversion is identical.

ArgumentMeaningNotes
quatthe believed attitude, [x, y, z, w]sent exactly as given; the SDK will not normalise it for you, because a filter drifting off the unit sphere is a bug worth seeing
angular_ratesbelieved body rates, rad/s, or nonenone leaves twist off the wire entirely, which says "my filter does not estimate rates"; zeros claim the body is not rotating. No consumer reads it today
validthe convergence flaga gate, not a hint. See below

InvalidArgument comes back for a non-finite component or a quaternion whose norm is too near zero to be an attitude. There is no reply and no ack beyond that: the confirmation is the simulator's _Est cockpit gauges moving.

Publishing one

ex35 wraps the call in a helper so all four phases publish identically. From examples/rust/src/bin/ex35_publish_estimate.rs:

#![allow(unused)]
fn main() {
    // valid = true throughout. The gyro rates go along for the ride; nothing
    // reads them yet.
    robot.publish_estimate(quat, Some(s.sensors.gyroscope.angular_velocity), true)?;
    Ok(Some(quat))
}
The same in C++ (examples/cpp/ex35_publish_estimate.cpp)
// valid = true throughout. The gyro rates go along for the ride; nothing
// reads them yet.
robot.publish_estimate(quat, gyro_of(s), true);
return quat;
The same in Python (examples/python/ex35_publish_estimate.py)
    # valid=True throughout. The gyro rates go along for the ride; nothing reads
    # them yet.
    robot.publish_estimate(quat, s.sensors.gyroscope.angular_velocity, True)
    return quat

It prints nothing of its own and returns once zenoh has accepted the put, exactly like a command; the only observable difference is what the aircraft does next.

It must keep coming

A command latches: send set_angvel once and the setpoint stands until you change it. An estimate does the opposite. The simulator ages it from arrival, in sim time, and stops trusting it after 0.5 seconds, so a publisher that pauses has handed the aircraft back to truth. Publish it every control iteration, at 20 Hz or better, which is why every loop in ex35 pairs one publish with one rate call:

#![allow(unused)]
fn main() {
    for _ in 0..SETTLE_SAMPLES {
        publish(robot, &robot.states(), estimator)?;
        robot.rate(HZ);
    }
}
The same in C++ (examples/cpp/ex35_publish_estimate.cpp)
for (int i = 0; i < SETTLE_SAMPLES; ++i) {
    publish(robot, robot.states(), estimator);
    robot.rate(HZ);
}
The same in Python (examples/python/ex35_publish_estimate.py)
    for _ in range(SETTLE_SAMPLES):
        publish(robot, robot.states, estimator)
        robot.rate(HZ)

The example runs at 25 Hz, inside the window, and the settle loop prints nothing; the tracking window after it prints one progress line every two seconds.

Gotcha. valid = false is a gate, not a status flag. The simulator drops the message before it reads the quaternion and does not reset the age counter, so a stream of invalid estimates is indistinguishable, sim-side, from publishing nothing at all. Sending it while your filter is unconverged is still the honest thing to do; expect no way to tell it apart from a dead publisher.

The frame comes from the header

The SDK leaves the estimate's own frame pair unset, so it inherits the header's: the coord_frame_id and axis_convention from your ConnectOptions. Your quaternion must therefore be expressed in the frame you connected with, not in the robot's, and the two agree only if you make them. ex35 connects with ConnectOptions::default().with_frame("frd", Axes::FRD) precisely so s.kin.quat can go straight back out without conversion, and it checks states().coord_frame_id against the frame it stamped rather than assuming. Where they differ, convert first: Rotation conversions is the arithmetic, and an attitude is the M * C * M^T case.

Selecting it, and the four phases

Publishing alone changes nothing. set_fw_est_source(cmd::FW_EST_OBSERVER) is what makes the onboard loop read z/estimate instead of truth. Fixed wing control is that selector, this page is the publisher, and neither works without the other. ex35 walks all four combinations, measuring each the same way so the rows compare.

PhaseSourcePublishedWhat happens
1truththe robot's own quaternionnothing changes; the _Est gauges move
2observerthe robot's own quaternionstill nothing: the estimate is right
3observerthe same, pitched up 5 degreesthe nose trims down by about 5 degrees
4observernothing at all0.5 s later the loop is back on truth

Phase 1 is what makes phase 3 mean anything. A truth-copy estimator is the one whose error is exactly zero, so if phases 1 and 2 already differed, the difference would be plumbing rather than estimation. reset() runs before phase 4, because the lie costs tens of metres of altitude and phase 4 is only comparable from a level start; that reset also clears the estimate source and the rate setpoint, so the example re-sends both. The run ends with one row per phase:

what each phase flew on, and what the airframe did about it:
  1 truth source, truth-copy estimate    pitch=<deg>  roll=<deg>  r=<rad/s>  alt=<m> (<m> over 6.0 s)
  2 observer source, truth-copy estimate pitch=<deg>  roll=<deg>  r=<rad/s>  alt=<m> (<m> over 6.0 s)
  3 observer source, +5.0 deg pitch lie  pitch=<deg>  roll=<deg>  r=<rad/s>  alt=<m> (<m> over 6.0 s)
  4 observer source, nothing published   pitch=<deg>  roll=<deg>  r=<rad/s>  alt=<m> (<m> over 6.0 s)

The onboard loop reads roll and pitch out of the believed attitude for two assists, a wings-leveller and an altitude hold that biases the pitch demand. Tell it the nose is five degrees higher than it is and the pitch assist trims five degrees of nose-down to correct an error that does not exist. The rate loop tracks its demand exactly as well as in phase 1: it is being asked for the wrong thing.

Note. Five degrees is deliberately small: the altitude assist is clamped at ten, so a lie inside the clamp settles the aircraft lower instead of departing.

Next: Cameras and images

See also: Fixed wing control, Truth, measured and believed, Rotation conversions

Cameras and images

Camera frames arrive on a second transport, at their own rate, with their own timestamps, and this chapter is how you read them.

Assume a vrobot ships with front_left and front_right mounted, at 720p rgba8. That is the starting assumption throughout this chapter and every camera example in the book: reading images means open_camera on one of that pair, which changes nothing in the simulator. It holds for the multirotor and the truck; robot types that carry no camera at all are the exception, and vrobots topic list settles it in one command. Creating a camera of your own is a separate, mutating operation, covered on Lens and mount pose and used by exactly one example.

Frames never leave the host

Camera frames ride iceoryx2 shared memory, so they are same-host only. This holds even when zenoh is talking to a simulator on another machine: states, commands and services cross the network happily, and the images do not follow. The simulator loans a buffer and your process reads the same physical pages, so there is no socket for a frame to travel over and no machine boundary it can cross.

The failure is quiet rather than loud. An iceoryx2 service that does not exist on your host is not an error at the far end, it is an absence, so mount_camera and open_camera wait for a publisher that is never going to appear and return VrError::Timeout after ConnectOptions::camera_timeout (5 s). A remote simulator, a typo in the format string and a camera nobody mounted all present as the same timeout.

Gotcha. If states work and cameras time out, check where the simulator is running before you check anything else. Run the simulator and your program on one machine to use cameras at all.

The path a frame takes

Six steps, from the render to the Frame in your hand:

  1. Unity renders the camera.
  2. The simulator requests a GPU readback and stamps the capture time, t_ns, at that moment.
  3. It loans one shared-memory sample, a fixed 5760-byte prefix followed by the pixels, and publishes it on the camera's iceoryx2 service, vrobots/{sys_id}/i/cam/{name}/{res}_{fmt}.
  4. The stream's reader thread, one per CameraStream, receives the sample and parses the prefix.
  5. It copies the pixel tail out, flipping rows as it copies, because the wire is bottom-up and Frame::data is top-down. Then it releases the sample: the pixels you are handed never alias shared memory.
  6. Your thread calls fresh() and gets an Arc<Frame>, an owned immutable snapshot that stays valid for as long as you hold it.

The reader thread sleeps about 2 ms when a receive finds nothing, rather than spinning. At 60 fps a frame period is about 16 ms, so the added latency is under 15% of one frame, and the SDK does not burn a core on a user's behalf.

How this differs from the state stream

The two streams share a clock and nothing else.

State streamCamera stream
Transportzenohiceoryx2 shared memory
Reachacross a network, with --routerthe local host only
Rate25 Hzthe render rate, about 60 fps
Read verbstates()fresh(), latest(), wait_new_frame()
Freshnessalways returns the latest snapshot, changed or noteach frame is handed out once
On a stallkeeps returning the last snapshot foreverfresh() returns None, wait_new_frame times out
Existencecreated by connectalready on the robot (front_left, front_right), or created by mount_camera

Frame::t_ns and State::t_ns are the same clock, and Frame::elapsed and State::elapsed count from the same epoch, the robot's first state sample. That is the only relationship the two streams have. The SDK never pairs a frame with a state. Code that fuses them subtracts t_ns explicitly, as page Two cameras at once shows.

The rest of this chapter

PageWhat it answers
Mount, open and unmountWhich of the three verbs changes the simulator, and what each one can undo
Formats and resolutionThe accepted strings, the byte cost of each, and the one setting that is robot-wide
Inside a frameEvery field, plus row order, stride and channel order
Freshnessfresh against latest against wait_new_frame, and the stream's counters
Lens and mount poseWhere the camera sits, what lens it has, and what the simulator substitutes
Two cameras at onceIndependent streams, and pairing frames by timestamp
Saving a frameOne image to disk with no image library
Showing frames in a windowA live OpenCV window, and the one conversion the SDK leaves to you

Everything here runs against a live simulator. If a camera call times out and the simulator is on this machine, vrobots topic list prints the streams that actually exist right now; the [i] lines are the camera services.

Next: Mount, open and unmount

See also: Two transports, one simulator, The topic namespace, The vrobots command

Mount, open and unmount

Three verbs with three different effects on the simulator, only one of which changes anything.

cargo run -p vrobots-examples --bin ex13_open_camera
./target/cpp-build/ex13_open_camera
python examples/python/ex13_open_camera.py
cargo run -p vrobots-examples --bin ex17_camera_pose
./target/cpp-build/ex17_camera_pose
python examples/python/ex17_camera_pose.py

Start by opening what is already there

Every vrobot ships with front_left and front_right mounted, at 720p rgba8. That is the default assumption behind every camera example in this book: to read images you open_camera one of those two, and the simulator is not changed in any way. Mounting is for the case the pair cannot serve -- a camera somewhere else on the robot, pointing somewhere else, through a different lens, or in a different format -- and among the examples only ex17_camera_pose does it.

That is not a limitation of the API, it is an ordering of it: the mutating verb is the one with a cleanup step, a name collision to avoid and a robot-wide resolution knob behind it, and none of that is worth paying for a picture the robot is already publishing.

The three verbs

VerbMutates the simulatorNeeds the camera to exist firstCan it undo itself
mount_camera / mount_camera_withyes, srv/camerasno, it creates the camerayes, with unmount_camera
open_camerano, subscribe onlyyes, exactly this name, resolution and formatnothing to undo
unmount_camerayes, srv/camerasit must be one this handle mountedit is the undo

The signatures, from crates/vrobots-sdk/src/robot.rs:

#![allow(unused)]
fn main() {
fn mount_camera(&self, name: &str, resolution: &str, format: &str) -> VrResult<CameraStream>
fn mount_camera_with(&self, name: &str, resolution: &str, format: &str, options: &CameraOptions) -> VrResult<CameraStream>
fn open_camera(&self, name: &str, resolution: &str, format: &str) -> VrResult<CameraStream>
fn unmount_camera(&self, name: &str) -> VrResult<()>
fn mounted_cameras(&self) -> Vec<CameraSpec>
}
The same in C++ (cpp/include/vrobots_sdk.hpp)
[[nodiscard]] CameraStream mount_camera(const std::string& name,
                                        const std::string& resolution = "720p",
                                        const std::string& format = "rgb8",
                                        const vrsdk_camera_options_t* options = nullptr)
[[nodiscard]] CameraStream open_camera(const std::string& name,
                                       const std::string& resolution = "720p",
                                       const std::string& format = "rgb8")
void unmount_camera(const std::string& name)
[[nodiscard]] std::vector<std::array<std::string, 3>> mounted_cameras() const
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
def mount_camera(
    self,
    name: str,
    resolution: str = "720p",
    format: str = "rgb8",
    *,
    mount_position: Optional[Sequence[float]] = None,
    mount_euler_deg: Optional[Sequence[float]] = None,
    fx: Optional[float] = None,
    fy: Optional[float] = None,
    near_clip: Optional[float] = None,
    far_clip: Optional[float] = None,
) -> CameraStream: ...
def open_camera(
    self, name: str, resolution: str = "720p", format: str = "rgb8"
) -> CameraStream: ...
def unmount_camera(self, name: str) -> None: ...
def mounted_cameras(self) -> list[CameraSpec]: ...

Four verbs in Rust, three in the bindings: mount_camera_with has no counterpart, because C++ takes the options as an optional fourth argument and Python takes them as keyword-only arguments. C++ also has no CameraSpec type, so mounted_cameras gives back {name, resolution, format} as a three-element array of strings. Everything else, including the defaults of "720p" and "rgb8", matches across the three.

The two mount_* calls and unmount_camera reach the robot's srv/cameras; open_camera opens a subscriber, and mounted_cameras never leaves the process.

The lifecycle

A camera is either on the robot or not, and a camera that is on the robot was put there by this handle, by another client, or by the scene. Those three cases behave differently, and the difference is the whole page.

stateDiagram-v2
  state "Mounted by you" as Yours
  state "Mounted by someone else" as Theirs
  state "Theirs, and you are reading it" as Opened
  [*] --> Unmounted
  Unmounted --> Yours: mount_camera
  Yours --> Yours: mount_camera (reconfigure)
  Yours --> Unmounted: unmount_camera
  Unmounted --> Theirs: scene default or another client
  Theirs --> Theirs: unmount_camera refused
  Theirs --> Opened: open_camera
  Opened --> Theirs: stop or drop the stream
  Opened --> Opened: unmount_camera refused

Opened is a state of your subscription, not of the camera: the camera itself does not notice that you attached, and it keeps publishing for everyone when you drop the stream.

Mounting adds exactly one camera

mount_camera is an upsert of one camera. The request names that camera and asks the simulator to add it, or to reconfigure it if the name is already there. Every other camera on the robot is left exactly as it was: the scene's own cameras, another client's cameras, ones you attached with open_camera. Their streams do not blip.

Mounting a name that is already mounted reconfigures it, and if the resolution or format changes then the stream name changes with it, so the old stream ends and a new one begins. Anything still holding the old handle is reading a service that no longer exists.

From examples/rust/src/bin/ex17_camera_pose.rs, the one example that mounts:

#![allow(unused)]
fn main() {
let robot = VirtualRobot::connect(RobotType::Multirotor, Some(SYS_ID))?;

let options = CameraOptions::default()
    .with_mount_position(MOUNT_POSITION)
    .with_mount_euler_deg(MOUNT_EULER_DEG)
    .with_focal_length(FOCAL_PX)
    .with_clip(0.2, 500.0);

// mount_camera_with CREATES the camera on the robot (srv/cameras) and
// subscribes to its iox2 stream in one call.
let cam = robot.mount_camera_with(CAMERA, RESOLUTION, FORMAT, &options)?;
println!("camera stream: {}", cam.service_name());
}
The same in C++ (examples/cpp/ex17_camera_pose.cpp)
vrsdk::VirtualRobot robot(vrsdk::RobotType::Multirotor, SYS_ID);
robot.connect();

vrsdk::CameraStream cam = robot.mount_camera(CAMERA, RESOLUTION, FORMAT, &options);
std::printf("camera stream: %s\n", cam.service_name().c_str());
The same in Python (examples/python/ex17_camera_pose.py)
mr = VirtualRobot(RobotType.MULTIROTOR, sys_id=SYS_ID)
mr.connect()

cam = mr.mount_camera(
    CAMERA,
    RESOLUTION,
    FORMAT,
    mount_position=MOUNT_POSITION,
    mount_euler_deg=MOUNT_EULER_DEG,
    fx=FOCAL_PX,
    fy=FOCAL_PX,
    near_clip=0.2,
    far_clip=500.0,
)
print(f"camera stream: {cam.service_name}")

service_name is a method in Rust and C++ and a property in Python, and it reports the same string in all three.

With SYS_ID = 1, CAMERA = "tilt", RESOLUTION = "720p" and FORMAT = "rgb8", that prints the iceoryx2 service name, which is what vrobots topic list shows for the same stream:

camera stream: vrobots/1/i/cam/tilt/720p_rgb8

The ack from srv/cameras is a receipt, not a result. The confirmation that the camera exists is the stream appearing, which is what mount_camera waits for before it returns.

Opening changes nothing

open_camera opens the iceoryx2 subscriber and touches the simulator not at all. Two processes can open the same stream, neither disturbs the other, and neither has to own the camera. The price is that the name, resolution and format must match the publisher exactly: on iceoryx2 those three strings are the stream identity, and there is no type negotiation behind them.

Every vrobot ships front_left and front_right at 720p rgba8, which is Unity's native readback and not rgb8 -- a detail worth getting right, since rgb8 is the signature default and asking for it here is one of the two ways to earn the timeout below. Those are the cameras every camera example reads.

From examples/rust/src/bin/ex13_open_camera.rs, opening with the failure spelled out:

#![allow(unused)]
fn main() {
let cam = match robot.open_camera(CAMERA, RESOLUTION, FORMAT) {
    Ok(cam) => cam,
    Err(VrError::Timeout(detail)) => {
        // The whole point of the example: nothing is mounted under that
        // exact identity, and there is no way for the SDK to tell you which
        // of the three strings is wrong.
        eprintln!("no publisher for {CAMERA}/{RESOLUTION}_{FORMAT}: {detail}");
        eprintln!(
            "run `vrobots topic list` -- the [i] lines are the streams that \
             do exist. A camera another process mounted then unmounted is gone."
        );
        return Ok(());
    }
    Err(other) => return Err(other),
};
}
The same in C++ (examples/cpp/ex13_open_camera.cpp)
vrsdk::CameraStream cam;
try {
    cam = robot.open_camera(CAMERA, RESOLUTION, FORMAT);
} catch (const vrsdk::Error& e) {
    if (e.code() != VRSDK_ERR_TIMEOUT) {
        throw;
    }
    // The whole point of the example: nothing is mounted under that
    // exact identity, and there is no way for the SDK to tell you which
    // of the three strings is wrong.
    std::printf("no publisher for %s/%s_%s: %s\n", CAMERA, RESOLUTION, FORMAT, e.what());
    std::printf(
        "run `vrobots topic list` -- the [i] lines are the streams that do exist. A "
        "camera another process mounted then unmounted is gone.\n");
    return 0;
}
The same in Python (examples/python/ex13_open_camera.py)
try:
    cam = mr.open_camera(CAMERA, RESOLUTION, FORMAT)
except vrsdk.VrError as e:
    if e.code != vrsdk.err.TIMEOUT:
        raise
    # The whole point of the example: nothing is mounted under that exact
    # identity, and there is no way for the SDK to tell you which of the
    # three strings is wrong.
    print(f"no publisher for {CAMERA}/{RESOLUTION}_{FORMAT}: {e.detail}")
    print(
        "run `vrobots topic list` -- the [i] lines are the streams that do "
        "exist. A camera another process mounted then unmounted is gone."
    )
    return

The missing publisher is a timeout in every surface, so it is caught the same way it is on wait_new_state: branch on the code, re-raise anything else. C++ pays one extra line for it, because CameraStream has to be declared outside the try to outlive it.

On a running simulator it attaches and reports the stream it found:

attached to vrobots/1/i/cam/front_left/720p_rgba8 (nothing in the sim changed)
spec: name=front_left resolution=720p format=rgba8 (3686400 bytes/frame)

Unmounting removes what you mounted

unmount_camera removes exactly the name it is given and stops that stream's reader thread. Every other camera on the robot keeps streaming. It refuses a name this handle did not mount, locally, with VrError::InvalidArgument, and the message lists what this handle did mount. That is what the end of ex13_open_camera demonstrates against the scene's own front_left:

unmount_camera refused, correctly: [2] invalid_argument: camera "front_left" was not mounted by this handle (mounted: []). unmount_camera only removes what mount_camera added -- a camera attached with open_camera belongs to whoever created it

mounted_cameras() returns the specs this handle asked for, in mount order. It is not a read-back: srv/cameras has no get verb, and the robot may well carry cameras this handle knows nothing about. vrobots topic list is the read-back.

Gotcha. A program that mounts must reach its unmount_camera call before exiting, which is why ex17_camera_pose runs for a fixed frame count rather than looping forever. Ctrl-C skips the cleanup and leaves the camera mounted in a simulator that outlives your process; unmount it by mounting the same name again from a short program, or restart the simulator. The examples that only open have no such deadline.

Next: Formats and resolution

See also: Freshness, Lens and mount pose, The vrobots command

Formats and resolution

The resolutions and pixel formats on offer, and the one setting that is robot-wide.

cargo run -p vrobots-examples --bin ex15_camera_formats
./target/cpp-build/ex15_camera_formats
python examples/python/ex15_camera_formats.py

Resolution

VariantAccepted stringsWidth x height
Resolution::P360"360p", "360"640 x 360
Resolution::P720"720p", "720"1280 x 720, the simulator's default
Resolution::P1080"1080p", "1080"1920 x 1080

Bare heights are accepted because the service field is a number while the stream segment is a p-suffixed string. Widths are the 16:9 partners of the height: width() == height * 16 / 9.

Pixel format

VariantAccepted stringBytes per pixelNotes
PixelFormat::Mono8"mono8"18-bit greyscale, the integer BT.601 luma of the render
PixelFormat::Rgb8"rgb8"3R, G, B in that order, not BGR
PixelFormat::Rgba8"rgba8"4R, G, B, A; the renderer's native readback and the cheapest for the simulator to publish

Parsing is case-insensitive. Both strings are baked into the iceoryx2 service name, so a format change is an unmount and remount rather than a field write, and every consumer has to resubscribe under the new name.

What a frame costs

Resolution times format is the whole story, since the pixels are tightly packed and there is no compression anywhere on the path.

resolutionmono8 (1 B)rgb8 (3 B)rgba8 (4 B)
360p230 400691 200921 600
720p921 6002 764 8003 686 400, what every vrobot ships
1080p2 073 6006 220 8008 294 400

CameraSpec::data_size() returns that number for any combination. Sixteen times between the corners is worth having for anything that needs only luminance and geometry: optical flow, fiducials, horizon detection. The stream rides shared memory, so the saving is memory bandwidth rather than network, but at 60 fps it is 200 MB/s against 13 MB/s.

ex15_camera_formats opens front_left and prices the alternatives against the frames it is actually receiving. From examples/rust/src/bin/ex15_camera_formats.rs:

#![allow(unused)]
fn main() {
const CAMERA: &str = "front_left"; // every vrobot ships front_left and front_right
const RESOLUTION: &str = "720p"; // 360p | 720p | 1080p -- robot-wide
const FORMAT: &str = "rgba8"; // mono8 | rgb8 | rgba8 -- per camera
}
The same in C++ (examples/cpp/ex15_camera_formats.cpp)
constexpr const char* CAMERA = "front_left";  // every vrobot ships front_left and front_right
constexpr const char* RESOLUTION = "720p";    // 360p | 720p | 1080p -- robot-wide
constexpr const char* FORMAT = "rgba8";       // mono8 | rgb8 | rgba8 -- per camera
The same in Python (examples/python/ex15_camera_formats.py)
CAMERA = "front_left"  # every vrobot ships front_left and front_right
RESOLUTION = "720p"  # 360p | 720p | 1080p -- robot-wide
FORMAT = "rgba8"  # mono8 | rgb8 | rgba8 -- per camera

The three strings are the stream identity on the wire, so they are literal strings in every surface: there is no enum for either, and a typo in one of them is a stream that does not exist rather than a compile error.

camera stream: vrobots/1/i/cam/front_left/720p_rgba8

frame 1280x720 = 921600 px, rgba8 at 4 B/px, step=5120 B/row, 3686400 B/frame
  mono8:    921600 B/frame
   rgb8:   2764800 B/frame
  rgba8:   3686400 B/frame  <- this stream

Getting the cheap end means creating a camera, since the pair the robot ships is rgba8 and nothing reconfigures a camera you do not own. That is mount_camera, covered on Lens and mount pose and used by ex17_camera_pose -- the one example in the book that mounts.

Resolution is robot-wide

Format is per camera. Resolution is one knob for the whole robot, shared by every camera on it, and it is the only part of a mount request that is not confined to the camera being named. Changing it restarts every stream on that robot under a new service name.

Three consequences, in the order people meet them:

  1. Mounting a second camera at a different resolution from one this handle already holds is refused client-side with VrError::InvalidArgument, before anything reaches the wire. Mount them all at one resolution, or unmount the others first.
  2. The guard can only see this handle's cameras. Mounting a camera at 360p does not unmount the robot's front_left and front_right, but it does move the resolution knob, so they come back at 360p under new names: vrobots/1/i/cam/front_left/360p_rgba8. Whatever was reading the 720p name is reading a service that no longer exists.
  3. Unmounting does not put the knob back. unmount_camera removes one camera; resolution is a separate setting, and nothing in the SDK restores it. The robot streams at 360p until something sets it again or the simulator restarts.

The refusal names the cameras in the way:

invalid_argument: camera "wide" asks for 720p but "mono" is already mounted at 360p -- resolution is one setting for the WHOLE robot, and changing it restarts every stream under a new name. Mount them all at the same resolution, or unmount the others first

Gotcha. If a camera example times out on front_left at 720p, something mounted a camera at 360p and moved the robot with it. Open front_left at "360p" instead, or restart the simulator. ex17_camera_pose is the only example here that can cause it, and it mounts at 720p precisely so that it does not.

What is checked before anything is sent

CameraSpec::parse validates all three strings at the call site, because every one of them fails later as a stream that never arrives rather than as an error.

InputRuleOn failure
namenon-empty, and only letters, digits, _ and -VrError::InvalidArgument, naming the offending character
resolutionone of the accepted strings aboveVrError::InvalidArgument, naming the accepted set
formatone of the accepted strings aboveVrError::InvalidArgument, naming the accepted set

The name has to survive being pasted into an iceoryx2 service name. The simulator does not check it and does not error on a bad one: it mounts a camera whose service can never be opened.

Next: Inside a frame

See also: Mount, open and unmount, Two cameras at once, Appendix C: Error reference

Inside a frame

Row order, stride, channel order and the metadata that rides with every image.

A Frame is an owned, immutable snapshot. The reader thread copies the pixels out of the shared-memory sample and releases the sample immediately, so a Frame you hold stays valid for as long as you keep the Arc, however long that is.

Every field

FieldTypeUnitsNotes
t_nsi64ns since the unix epochcapture time, stamped when the simulator requested the GPU readback. The same clock as State::t_ns, so the two subtract directly
elapsedf64scounts from the robot's first state sample, the same epoch as State::elapsed
sequ64per stream, contiguous by construction: the simulator increments it only on a successful send, so a skipped render leaves no gap and any gap is a genuine shared-memory drop. Restarts at 0 when the stream restarts
widthu32px
heightu32px
formatPixelFormatMono8, Rgb8 or Rgba8
stepu32bytesbytes per row, always width * bytes_per_pixel(); wire padding has been removed
dataVec<u8>height * step bytes, row-major, top-down, tightly packed
sys_idu32the robot this camera is on
camera_nameStringthe camera's name on the robot
camera_idu32the camera's numeric id on the robot
intrinsicsIntrinsicsthe lens this frame was rendered through
mountMountPosewhere the camera was when the frame was taken
axis_conventionAxesthe convention the camera's own frame uses
coord_frame_idStringthe camera's resolved coordinate frame id
schema_versionu32stamped by the simulator

intrinsics and mount ride with every frame, which is the point: a gimballed or re-mounted camera cannot desync from its images, and there is no camera-info topic to join by timestamp. Page Lens and mount pose covers both.

MethodReturns
bytes_per_pixel()u32, 1, 3 or 4
row(n)Option<&[u8]>, one row top-down; None when n >= height
Frame::decode(payload, epoch_ns)VrResult<Frame>, one recorded slice turned back into a frame with no simulator involved

Three facts about the pixels

The SDK normalises geometry and nothing else.

Rows are top-down. Row 0 is the top of the picture. The wire is bottom-up, in Unity's render order, and the SDK flips while copying, which costs nothing: it is the same memcpy per row, in reverse order.

Stride is tight. step == width * bytes_per_pixel(), always, whatever padding the wire carried. data[y * step + x * bpp] is the first byte of pixel (x, y) with no special cases.

Channels are never swapped. rgb8 is R, G, B and rgba8 is R, G, B, A, exactly as the renderer produced them. Converting for the consumers that want BGR would tax the ones that do not, so the conversion happens at the call site that needs it: OpenCV users want cvtColor(..., COLOR_RGB2BGR) once, in their own code.

Gotcha. Brightness is the wrong way to check orientation outdoors. Measured on the test scene, the sky rows run B - R = +98 and the pale desert floor runs -25, so the ground is the brighter of the two and a brightness test reports the picture upside down. Compare blue against red instead.

Reading a row

row(n) is the shortest way to sanity-check orientation and channel order at once. From examples/rust/src/bin/ex03_hello_image.rs:

#![allow(unused)]
fn main() {
/// Mean `blue - red` across one row: strongly positive for sky, negative for
/// most ground. `0.0` for mono8, which has no channels to compare.
fn blueness(frame: &Frame, row: u32) -> f64 {
    let bpp = frame.bytes_per_pixel() as usize;
    if bpp < 3 {
        return 0.0;
    }
    let Some(pixels) = frame.row(row) else {
        return 0.0;
    };
    let mut sum = 0.0;
    let mut count = 0.0;
    // Channel order is R,G,B(,A) -- the SDK normalises rows and stride, never
    // channel order, so this is the renderer's own layout.
    for pixel in pixels.chunks_exact(bpp) {
        sum += f64::from(pixel[2]) - f64::from(pixel[0]);
        count += 1.0;
    }
    if count == 0.0 { 0.0 } else { sum / count }
}
}
The same in C++ (examples/cpp/ex03_hello_image.cpp)
/// Mean `blue - red` across one row: strongly positive for sky, negative for
/// most ground. 0 for mono8, which has no channels to compare.
static double blueness(const vrsdk::Frame& frame, std::uint32_t row) {
    const std::uint32_t bpp = frame.bytes_per_pixel();
    const std::uint8_t* pixels = frame.row(row);
    if (bpp < 3 || pixels == nullptr) {
        return 0.0;
    }
    double sum = 0.0;
    // Channel order is R,G,B(,A) -- the SDK normalises rows and stride, never
    // channel order, so this is the renderer's own layout.
    for (std::uint32_t x = 0; x < frame.width(); ++x) {
        sum += static_cast<double>(pixels[x * bpp + 2]) - static_cast<double>(pixels[x * bpp]);
    }
    return frame.width() > 0 ? sum / frame.width() : 0.0;
}
The same in Python (examples/python/ex03_hello_image.py)
def sky_ness(img: np.ndarray, row: int) -> float:
    """Mean ``blue - red`` across one row.

    Strongly positive for sky, negative for most ground. The way to recognise
    sky is that it is *blue*, not that it is bright: in this scene the desert
    floor is the brighter of the two, so a brightness test reports the picture
    upside down. Returns 0.0 for mono8, which has no channels to compare.
    """
    if img.shape[2] < 3:
        return 0.0
    line = img[row].astype(np.int16)
    return float(np.mean(line[:, 2] - line[:, 0]))

Rust and C++ walk the raw bytes: frame.row(y) hands back one row and bytes_per_pixel gives the stride within it. Python does not walk bytes at all, because cam.image is a numpy (h, w, c) uint8 array, so the same subtraction is one slice. All three index channel 2 minus channel 0, which is blue minus red in the renderer's own RGB order.

Called on row 0 and row height - 1 of a forward-facing camera, it separates sky from ground and therefore confirms both facts at once:

Image front_left t=3.214 size=(1280x720) seq=42 lag_vs_state=8.4 ms
      sky-ness (B-R) top=+98 bottom=-25 (top-down: sky above ground), fov_y=61.9 deg

mono8 has one byte per pixel, so there is no channel order and no RGB against BGR question at all: data[y * step + x] is the intensity. Getting one means mounting a camera of your own, since the pair every vrobot ships is rgba8; ex15_camera_formats prices that trade, and ex17_camera_pose is the example that mounts.

Decoding a frame with no simulator

Frame::decode(payload, epoch_ns) does exactly what the reader thread does, on a slice you supply. That is the offline half of record and replay: vrobots record --camera writes those slices byte for byte, and this turns one back into a Frame. Pass a robot's first state timestamp as epoch_ns to line elapsed up with its states, or 0 to get elapsed as raw unix seconds. It fails with VrError::Decode if the slice is shorter than the 5760-byte prefix, declares more pixel bytes than it carries, or describes a shape that is not 1, 3 or 4 bytes per pixel.

Next: Freshness

See also: Saving a frame, Timestamps and sequence numbers, Recording and testing without the simulator

Freshness

fresh, latest and wait_new_frame, and which one a control loop wants.

cargo run -p vrobots-examples --bin ex13_open_camera
./target/cpp-build/ex13_open_camera
python examples/python/ex13_open_camera.py

Three ways to read one stream

MethodReturnsBlocksConsumes freshness
fresh()Option<Arc<Frame>>, Some only if a frame arrived since the last callnoyes
latest()Option<Arc<Frame>>, the current frame whether or not it is newnono
wait_new_frame(timeout)VrResult<()>, Ok when a newer frame has landedyes, up to timeoutno; call fresh() after it

This is deliberately not how states() behaves. A control loop wants the current state every iteration whether or not it changed, so states() always hands one back. An image pipeline wants to do its work once per frame, so fresh() hands each frame out exactly once. Polling fresh() at 100 Hz against a 60 fps stream returns a frame about 60 times a second and None the rest of the time, which is the shape of the loop in ex03_hello_image:

#![allow(unused)]
fn main() {
if let Some(frame) = cam.fresh() {
    // Some only if new since the last read
    seen += 1;
}
The same in C++ (examples/cpp/ex03_hello_image.cpp)
// A value only if new since the last read.
if (auto frame = cam.fresh()) {
    ++seen;
The same in Python (examples/python/ex03_hello_image.py)
if cam.fresh:
    frame = cam.frame  # metadata for the image we are about to read
    img = cam.image  # numpy (h, w, c) uint8, top-down, RGB(A)
    seen += 1

Python is the one that splits the question from the answer. cam.fresh is a boolean property that asks, and cam.image or cam.read() is what consumes the freshness; a loop that tests cam.fresh and then never reads keeps seeing the same frame as new.

That fragment prints nothing on its own: it is the guard deciding whether the image half of the loop runs at all, and ex03_hello_image prints a state line from the else arm instead.

Each frame is handed out once even when two threads race on the same stream: the claim is a compare-and-exchange, so exactly one caller gets a given frame and the other gets None. Reach for latest() when you want the current picture regardless, for a viewer or a status line, and it will not steal a frame from the thread doing the real work.

Pacing on frames instead of the clock

wait_new_frame blocks until a frame newer than the last stored one arrives, so the loop body runs exactly once per rendered frame with no polling at all. From examples/rust/src/bin/ex13_open_camera.rs:

#![allow(unused)]
fn main() {
let mut seen = 0u64;
while seen < FRAMES {
    if let Err(VrError::Timeout(_)) = cam.wait_new_frame(Duration::from_millis(500)) {
        println!("no frame in 500 ms -- the camera stopped, or the sim is paused");
        continue;
    }
    let Some(frame) = cam.fresh() else { continue };
    seen += 1;
}
The same in C++ (examples/cpp/ex13_open_camera.cpp)
std::uint64_t seen = 0;
while (seen < FRAMES) {
    try {
        cam.wait_new_frame(TIMEOUT_S);
    } catch (const vrsdk::Error& e) {
        if (e.code() != VRSDK_ERR_TIMEOUT) {
            throw;
        }
        std::printf("no frame in %.1fs -- the camera stopped, or the sim is paused\n",
                    TIMEOUT_S);
        continue;
    }
    const std::optional<vrsdk::Frame> frame = cam.fresh();
    if (!frame) {
        continue;
    }
    ++seen;
The same in Python (examples/python/ex13_open_camera.py)
seen = 0
while seen < FRAMES:
    try:
        cam.wait_new_frame(TIMEOUT)
    except vrsdk.VrError as e:
        if e.code != vrsdk.err.TIMEOUT:
            raise
        print(f"no frame in {TIMEOUT}s -- the camera stopped, or the sim is paused")
        continue

    frame = cam.read()  # consumes freshness; None if someone else got it
    if frame is None:
        continue
    seen += 1

wait_new_frame takes seconds as a double in C++ and Python where Rust takes a Duration, and the timeout is the same status rather than a failure in all three. Note the second guard: wait_new_frame returning does not guarantee the read succeeds, because another thread may have taken the frame in between, so the empty case is still handled.

While frames are arriving, nothing is printed by the timeout arm:

frame 1: seq=17 1280x720 3686400 bytes, mount=(+0.00,+0.00,+0.00) m
frame 11: seq=27 1280x720 3686400 bytes, mount=(+0.00,+0.00,+0.00) m

wait_new_frame returns VrError::Timeout when nothing new arrives in time, and the message names the service and the deadline:

timeout: no camera frame on vrobots/1/i/cam/front_left/720p_rgba8 within 500ms

Gotcha. A timeout here never means the stream is broken, and it is also how a stopped camera presents: a paused simulator, a camera someone else unmounted, and a genuinely slow render are the same event. Treat it as a condition to handle, not an error to propagate, which is why the example loop continues rather than returning.

The counters

stats() returns a CameraStats snapshot of the reader thread's counters.

FieldTypeNotes
receivedu64frames received and published to the stream
decode_errorsu64slices that did not decode; counted, never fatal
seq_gapsu64times the sequence number jumped forward by more than one
missed_framesu64total frames missed across all gaps
last_sequ64the last sequence number seen

Because seq is contiguous by construction, a non-zero seq_gaps is a real shared-memory drop rather than a skipped render. A malformed slice is counted and dropped, exactly like a malformed state payload: one bad frame must not end a flight. When decode_errors is non-zero, last_error() returns the most recent failure.

received counts what the thread stored, not what you read. A loop that polls more slowly than the camera renders sees fewer frames than received reports, and that difference is your loop falling behind, not a drop.

read 60 frame(s): received=60 decode_errors=0 seq_gaps=0 missed_frames=0

Stopping is not unmounting

stop() ends the reader thread, and Drop does the same and then joins it. Joining rather than detaching is deliberate: the thread holds an iceoryx2 subscriber, and letting it outlive the handle would leave a port attached to a service nobody can see. The wait is bounded by one poll interval, about 2 ms, plus the frame in flight.

Neither one unmounts the camera. The robot keeps rendering and publishing for everyone else, exactly as dropping a VirtualRobot leaves the robot running. unmount_camera is the verb for that, and it works only on cameras this handle mounted.

is_running() reports whether the reader thread is still alive. It is false after stop(), after unmount_camera removed this camera, or if the thread ended on its own.

Next: Lens and mount pose

See also: Mount, open and unmount, Pacing your loop, Stream health

Lens and mount pose

Where the camera sits, what it sees, and what the simulator substitutes for values it does not like.

cargo run -p vrobots-examples --bin ex17_camera_pose
./target/cpp-build/ex17_camera_pose
python examples/python/ex17_camera_pose.py

This is the page where a camera gets created rather than opened, and ex17_camera_pose is the one example in the book that does it. Everywhere else the assumption holds that every vrobot already ships front_left and front_right at 720p rgba8, and a reader just opens one. You reach for mount_camera when that pair cannot serve: a camera somewhere else on the robot, pointing somewhere else, through a different lens, or in a different format.

mount_camera uses the defaults: at the robot origin, looking along its forward axis, 600 px focal length. mount_camera_with takes a CameraOptions and configures the mount and the lens in the same call. Whatever it creates is yours to remove, and unmount_camera at the end of the run is what keeps the robot's own two cameras the only ones left.

What you can ask for

FieldUnitsDefaultSimulator substitution
mount_positionm, in your header frame[0, 0, 0]re-expressed into the robot's frame
mount_euler_degdegrees, Unity-local[0, 0, 0]taken as written, never re-expressed
fxpx at the current resolution600.0substitutes 600 for anything <= 0
fypx at the current resolution600.0substitutes 600 for anything <= 0
near_clipm0.5substitutes 0.5 for anything <= 0
far_clipm1000.0substitutes 1000 for anything at or below the near plane

The two mount fields are tagged differently on purpose, matching the simulator. Position is a frame-tagged vector, re-expressed from the frame your headers declare into the robot's own. The euler triple is a Unity-local mount convention and is taken as written, in degrees, whatever frame you declared.

The lens is specified as intrinsics, not as a field of view. fx != fy renders anamorphic. A smaller focal length is a wider angle: fov_y = 2 * atan(height / (2 * fy)), which puts the 600 px default at about 61.9 degrees on a 720p frame and 400 px at about 84.0.

BuilderSets
with_mount_position([f64; 3])mount_position
with_mount_euler_deg([f64; 3])mount_euler_deg
with_focal_length(f)fx and fy together, the square-pixel case
with_clip(near, far)near_clip and far_clip

From examples/rust/src/bin/ex17_camera_pose.rs, the whole configuration:

#![allow(unused)]
fn main() {
let options = CameraOptions::default()
    .with_mount_position(MOUNT_POSITION)
    .with_mount_euler_deg(MOUNT_EULER_DEG)
    .with_focal_length(FOCAL_PX)
    .with_clip(0.2, 500.0);
println!("requested: {options:?}");

let cam = robot.mount_camera_with(CAMERA, RESOLUTION, FORMAT, &options)?;
println!("camera stream: {}", cam.service_name());
}
The same in C++ (examples/cpp/ex17_camera_pose.cpp)
// Start from the documented defaults, then override.
vrsdk_camera_options_t options;
vrsdk_camera_options_default(&options);
for (int i = 0; i < 3; ++i) {
    options.mount_position[i] = MOUNT_POSITION[i];
    options.mount_euler_deg[i] = MOUNT_EULER_DEG[i];
}
options.fx = FOCAL_PX;
options.fy = FOCAL_PX;
options.near_clip = 0.2;
options.far_clip = 500.0;

vrsdk::CameraStream cam = robot.mount_camera(CAMERA, RESOLUTION, FORMAT, &options);
std::printf("camera stream: %s\n", cam.service_name().c_str());
The same in Python (examples/python/ex17_camera_pose.py)
cam = mr.mount_camera(
    CAMERA,
    RESOLUTION,
    FORMAT,
    mount_position=MOUNT_POSITION,
    mount_euler_deg=MOUNT_EULER_DEG,
    fx=FOCAL_PX,
    fy=FOCAL_PX,
    near_clip=0.2,
    far_clip=500.0,
)
print(f"camera stream: {cam.service_name}")

Only Rust needs a second entry point. C++ takes an optional fourth argument on mount_camera, defaulting to a null pointer, and Python takes the same settings as keyword-only arguments, so there is no mount_camera_with in either.

Start the C++ struct from vrsdk_camera_options_default, never from {}. A zeroed struct asks for a zero focal length and zero clip planes, which is not the defaults; Rust's CameraOptions::default() and Python's omitted keywords are the equivalents.

With MOUNT_EULER_DEG = [0.0, 0.0, 180.0] the camera is upside down, and the stream name is the usual one:

camera stream: vrobots/1/i/cam/tilt/720p_rgb8

What comes back

Both blocks ride with every frame, so a gimballed or re-mounted camera cannot desync from its images and there is no camera-info topic to join by timestamp.

frame.intrinsics is read back from the live camera rather than echoed from your request, because the simulator round-trips fx and fy through the Unity field of view. The numbers can differ slightly from the ones you sent, and the read-back is the authority on what rendered the frame.

Intrinsics fieldUnitsNotes
fx, fypxread back from the camera, not echoed
cx, cypxalways the image centre
fov_yradiansto_degrees() before printing
near_clip, far_clipm

Rendered images are an ideal pinhole. There are no distortion coefficients, because there is no distortion.

MountPose fieldUnitsNotes
positionmin the robot's frame, not the frame the rest of your header is tagged with
euler_radradiansthe request took degrees; the simulator converts
axis_conventionAxesthe convention position is expressed in
coord_frame_idStringthe frame id position is expressed in

Degrees on the way in, radians on the way out, so the read-back needs converting before it is comparable to what you asked for:

#![allow(unused)]
fn main() {
// Degrees on the way in, radians on the way out: the wire is SI.
let euler_deg = [
    frame.mount.euler_rad[0].to_degrees(),
    frame.mount.euler_rad[1].to_degrees(),
    frame.mount.euler_rad[2].to_degrees(),
];
}
The same in C++ (examples/cpp/ex17_camera_pose.cpp)
const vrsdk_mount_pose_t& m = frame->info.mount;
const vrsdk_intrinsics_t& in = frame->info.intrinsics;

// Degrees on the way in, radians on the way out: the wire is SI.
const double euler_deg[3] = {m.euler_rad[0] * RAD2DEG, m.euler_rad[1] * RAD2DEG,
                             m.euler_rad[2] * RAD2DEG};
The same in Python (examples/python/ex17_camera_pose.py)
m, i = frame.mount, frame.intrinsics

# Degrees on the way in, radians on the way out: the wire is SI.
euler_deg = tuple(round(math.degrees(a), 1) for a in m.euler_rad)

The asymmetry is the wire's, not the binding's: mount_euler_deg goes out in degrees and mount.euler_rad comes back in radians, in all three. Every surface converts at the same place, and the field names carry their units for exactly this reason.

That fragment prints nothing itself; it feeds the pose line ex17_camera_pose prints whenever the mount differs from the previous frame's.

The read-back is not the numbers you sent

You express the mount in your frame; the robot converts it into its frame and reports it back tagged with that frame. Against the test scene, the components move and change sign:

requested  [0.10, 0.20, 0.30] m  "unity"
read back  (-0.20, +0.30, -0.10) m  "frd"    -- permuted and signed

Never assume your triple survives intact. frame.mount says where the camera actually is, and comparing it against your request is the only way to confirm what the simulator did. Read frame.mount.coord_frame_id rather than assuming a convention.

Note. The service acks immediately, but the camera has to be rebuilt and re-rendered before it publishes. For a new camera the stream does not exist until that is done, so its very first frame already carries the requested pose. Re-mounting an existing name is the case to watch: the change ends the old stream and starts a new one, and anything still holding the old handle is reading a dead service.

The last thing ex17_camera_pose prints is a check on what was rendered rather than on what the simulator was told. With the camera rolled 180 degrees, the sky lands in the bottom rows of a buffer whose row 0 is still, always, the top:

sky-ness (B-R) top=-25 bottom=+98 -> sky is at the BOTTOM: the camera really is upside down

Next: Two cameras at once

See also: Inside a frame, Frames, axes and units, Coordinate frames

Two cameras at once

Independent streams, independent freshness, and pairing a frame with a state snapshot.

cargo run -p vrobots-examples --bin ex16_two_cameras
./target/cpp-build/ex16_two_cameras
python examples/python/ex16_two_cameras.py

The stereo pair every vrobot already has: front_left and front_right, both at 720p rgba8. Each open_camera call returns its own CameraStream, and each stream owns its own reader thread, its own sequence numbers and its own freshness.

Opening both

Both streams are at the same resolution, and could not be anything else: resolution is one knob for the whole robot. Were you mounting instead, asking for a second resolution while this handle holds a stream at another is refused with VrError::InvalidArgument rather than restarting the first stream under a new name behind your back. Format is per camera; only resolution is shared.

From examples/rust/src/bin/ex16_two_cameras.rs:

#![allow(unused)]
fn main() {
// Two subscriptions, no mutation: both cameras are already on the robot.
let left = robot.open_camera(LEFT, RESOLUTION, FORMAT)?;
let right = robot.open_camera(RIGHT, RESOLUTION, FORMAT)?;
println!("left : {}", left.service_name());
println!("right: {}", right.service_name());
println!(
    "mounted by this handle: {:?}  <- neither is ours",
    robot.mounted_cameras()
);
}
The same in C++ (examples/cpp/ex16_two_cameras.cpp)
        // Two subscriptions, no mutation: both cameras are already on the robot.
        vrsdk::CameraStream left = robot.open_camera(LEFT, RESOLUTION, FORMAT);
        vrsdk::CameraStream right = robot.open_camera(RIGHT, RESOLUTION, FORMAT);
        std::printf("left : %s\n", left.service_name().c_str());
        std::printf("right: %s\n", right.service_name().c_str());
        std::printf("mounted by this handle: %zu  <- neither is ours\n",
                    robot.mounted_cameras().size());
The same in Python (examples/python/ex16_two_cameras.py)
    # Two subscriptions, no mutation: both cameras are already on the robot.
    left = mr.open_camera(LEFT, RESOLUTION, FORMAT)
    right = mr.open_camera(RIGHT, RESOLUTION, FORMAT)
    print(f"left : {left.service_name}")
    print(f"right: {right.service_name}")
    print(f"mounted by this handle: {mr.mounted_cameras()}  <- neither is ours")

Only the printing differs. service_name is a property in Python and a method in the other two, and C++ has no CameraSpec type, so mounted_cameras() there hands back a std::array<std::string, 3> of name, resolution and format, printed here as a count.

Two service names, and an empty owned list -- which is the point:

left : vrobots/1/i/cam/front_left/720p_rgba8
right: vrobots/1/i/cam/front_right/720p_rgba8
mounted by this handle: []  <- neither is ours

Neither call changed anything in the simulator, so two of these programs can run at once on the same pair without either noticing, and a third can be mounting a camera of its own alongside them.

Two consumers in one loop

There is no combined "wait for both", by design: the cameras are separate iceoryx2 services and they render on their own schedules. Two fresh() calls in one loop are genuinely independent, and neither can consume the other's frame.

#![allow(unused)]
fn main() {
while n_left < FRAMES || n_right < FRAMES {
    // Two consumers, each draining its own stream. Neither call can consume
    // the other's frame.
    if let Some(f) = left.fresh() {
        n_left += 1;
        last_left_ns = f.t_ns;
}
The same in C++ (examples/cpp/ex16_two_cameras.cpp)
        while (n_left < FRAMES || n_right < FRAMES) {
            // Two consumers, each draining its own stream. Neither call can
            // consume the other's frame.
            if (const std::optional<vrsdk::Frame> f = left.fresh()) {
                ++n_left;
                last_left_ns = f->t_ns();
The same in Python (examples/python/ex16_two_cameras.py)
    while n_left < FRAMES or n_right < FRAMES:
        # Two consumers, each draining its own stream. Neither call can consume
        # the other's frame.
        f = left.read()
        if f is not None:
            n_left += 1
            last_left_ns = f.t_ns

Rust and C++ take the frame out of an Option, so the if both tests and binds. Python calls left.read(), which returns the frame or None, and this is the per-stream read that gives each CameraStream its own freshness; t_ns is a method in C++ and a field in the other two.

Neither branch prints on every pass: the example reports every twentieth frame per camera. The loop is paced with robot.rate(HZ) at 100 Hz against streams that render at about 60 fps, so most iterations find one stream fresh and the other not.

Skew between the two

The only honest way to relate two frames is to subtract their capture stamps. Both are on the same clock, so the difference is a real interval.

#![allow(unused)]
fn main() {
let skew_ms = if last_left_ns == 0 {
    f64::NAN
} else {
    (f.t_ns - last_left_ns) as f64 / 1e6
};
}
The same in C++ (examples/cpp/ex16_two_cameras.cpp)
                    const double skew_ms =
                        last_left_ns == 0 ? 0.0
                                          : static_cast<double>(f->t_ns() - last_left_ns) / 1e6;
The same in Python (examples/python/ex16_two_cameras.py)
                skew_ms = (
                    float("nan") if last_left_ns == 0 else (f.t_ns - last_left_ns) / 1e6
                )

The subtraction is the same in all three. Only the "no left frame yet" value differs: Rust and Python report NaN, C++ reports 0.0, so a C++ reader cannot tell that first row from a genuinely zero skew.

Printed every twentieth frame, alongside the sequence numbers each stream keeps for itself:

L frame 1: seq=8 t=2.104
R frame 1: seq=8 t=2.104  skew_vs_last_left=+0.0 ms
L frame 21: seq=28 t=2.437
R frame 21: seq=28 t=2.437  skew_vs_last_left=+0.2 ms

Sequence numbers are per stream and start from that stream's own beginning, so left and right agreeing on a number means nothing. Compare t_ns, never seq.

Pairing a frame with a state

The same rule applies between a camera and the state stream, and it is the more common case. The SDK never pairs them for you. Frames arrive at the render rate, states at 25 Hz, and no frame belongs to any state. What they share is the clock: Frame::t_ns and State::t_ns are both nanoseconds on the simulator's unix clock, and Frame::elapsed and State::elapsed count from the same epoch, the robot's first state sample.

So fusion code subtracts. From examples/rust/src/bin/ex03_hello_image.rs, which reports the age of each frame against the state snapshot taken in the same iteration:

#![allow(unused)]
fn main() {
let s = robot.states();

// Images are a separate stream with their own timestamps -- never assume
// they match the state's. Compare t_ns explicitly when fusing.
if let Some(frame) = cam.fresh() {
}
The same in C++ (examples/cpp/ex03_hello_image.cpp)
            const vrsdk::State s = robot.states();

            // A value only if new since the last read.
            if (auto frame = cam.fresh()) {
The same in Python (examples/python/ex03_hello_image.py)
        s = mr.states

        # Images are a separate stream with their own timestamps -- never assume
        # they match the state's. Compare t_ns explicitly when fusing.
        if cam.fresh:
            frame = cam.frame  # metadata for the image we are about to read

Python splits what the other two do in one move: mr.states and cam.fresh are properties, and the frame comes from cam.frame after the freshness test rather than out of the test itself.

(s.t_ns - frame.t_ns) as f64 / 1e6 is that age in milliseconds. For anything that needs a state at the instant of capture rather than the newest one, keep a short ring of recent snapshots and pick the one whose t_ns is closest to the frame's.

Nothing to clean up

There is no unmount at the end of this example, for either stream. This handle created neither camera, so it has nothing to remove: letting the streams go ends two subscriptions, and both cameras keep rendering and publishing for everyone else.

That is also why unmount_camera(LEFT) here would be refused rather than obeyed -- it removes only what mount_camera added, and page Mount, open and unmount shows the refusal in full. A program that does mount a pair of its own unmounts each by name, in either order, since each call removes exactly the name it is given and cannot undo the other.

Next: Saving a frame

See also: Freshness, Formats and resolution, More than one robot

Saving a frame

Write one image to disk and stop, without pulling in an image library.

cargo run -p vrobots-examples --bin ex14_camera_save
./target/cpp-build/ex14_camera_save
python examples/python/ex14_camera_save.py

The shortest complete camera program there is: open, wait for one frame, write it, exit. There is no loop, which makes it the right place to see what a Frame holds and what it takes to get pixels out of the process. Like every camera example it reads front_left, the camera every vrobot already ships at 720p rgba8, so there is no mount and no cleanup.

One frame, blocking

open_camera has already waited for the stream to exist by the time it returns, but the next frame still has to be rendered. Block for it rather than polling. From examples/rust/src/bin/ex14_camera_save.rs:

#![allow(unused)]
fn main() {
// open_camera already waited for the stream to exist, but the next frame
// still has to be rendered. Block for it rather than polling.
cam.wait_new_frame(TIMEOUT)?;
let frame = cam
    .fresh()
    .expect("wait_new_frame returned Ok, so one is waiting");
}
The same in C++ (examples/cpp/ex14_camera_save.cpp)
// ===== the one frame =====
// open_camera already waited for the stream to exist, but the next
// frame still has to be rendered. Block for it rather than polling.
cam.wait_new_frame(TIMEOUT_S);
const std::optional<vrsdk::Frame> frame = cam.fresh();
if (!frame) {
    std::fprintf(stderr, "wait_new_frame returned but no frame was waiting\n");
    return 1;
}
The same in Python (examples/python/ex14_camera_save.py)
# ===== the one frame =====
# open_camera already waited for the stream to exist, but the next frame
# still has to be rendered. Block for it rather than polling.
cam.wait_new_frame(TIMEOUT)
frame = cam.read()
assert frame is not None, "wait_new_frame returned, so one is waiting"

Each surface asserts the same invariant in its own idiom. None of them has any unwinding to do: nothing was created, so an early return leaves the simulator exactly as it found it.

TIMEOUT is 2 s here, and the ? propagates a VrError::Timeout rather than swallowing it: for a one-shot program, no frame is a failure and not a condition to retry through.

The two lines it prints are the geometry and the lens the frame was rendered through:

frame seq=3 t=1.842s 1280x720 rgba8 (4 B/px, step=5120, 3686400 bytes)
intrinsics fx=600.0 fy=600.0 cx=640.0 cy=360.0 fov_y=61.9 deg  clip 0.50..1000 m

step is width * 4 exactly for rgba8, and data.len() is height * step exactly. Nothing on this path is padded or compressed.

Writing it out

A binary PPM (P6) is a short text header followed by the raw RGB bytes, and that is the entire format. It needs no image library, and every viewer reads it.

#![allow(unused)]
fn main() {
/// Write the frame as a binary PPM (P6) -- the simplest image format there is.
/// Mono8 is expanded to grey RGB; rgba8 drops alpha.
fn write_ppm(frame: &Frame, path: &str) -> std::io::Result<()> {
    let mut out = std::io::BufWriter::new(std::fs::File::create(path)?);
    write!(out, "P6\n{} {}\n255\n", frame.width, frame.height)?;

    let bpp = frame.bytes_per_pixel() as usize;
    let mut rgb = Vec::with_capacity(frame.width as usize * frame.height as usize * 3);
    // Row-major and top-down already, so a straight walk is the right order.
    for pixel in frame.data.chunks_exact(bpp) {
        match bpp {
            1 => rgb.extend_from_slice(&[pixel[0], pixel[0], pixel[0]]),
            _ => rgb.extend_from_slice(&pixel[..3]),
        }
    }
    out.write_all(&rgb)?;
    out.flush()
}
}
The same in C++ (examples/cpp/ex14_camera_save.cpp)
/// Write the frame as a binary PPM (P6) -- the simplest image format there is.
/// Mono8 is expanded to grey RGB; rgba8 drops alpha.
static bool write_ppm(const vrsdk::Frame& frame, const std::string& path) {
    std::FILE* out = std::fopen(path.c_str(), "wb");
    if (out == nullptr) {
        return false;
    }
    std::fprintf(out, "P6\n%u %u\n255\n", frame.width(), frame.height());
    const std::uint32_t bpp = frame.bytes_per_pixel();
    std::vector<std::uint8_t> rgb;
    rgb.reserve(static_cast<std::size_t>(frame.width()) * frame.height() * 3);
    // Row-major and top-down already, so a straight walk is the right order.
    for (std::size_t i = 0; i + bpp <= frame.data.size(); i += bpp) {
        if (bpp == 1) {
            rgb.insert(rgb.end(), {frame.data[i], frame.data[i], frame.data[i]});
        } else {
            rgb.insert(rgb.end(), {frame.data[i], frame.data[i + 1], frame.data[i + 2]});
        }
    }
    const bool ok = std::fwrite(rgb.data(), 1, rgb.size(), out) == rgb.size();
    std::fclose(out);
    return ok;
}
The same in Python (examples/python/ex14_camera_save.py)
def save(img: np.ndarray, path: str) -> str:
    """Write the frame to disk, with or without OpenCV. Returns the path used."""
    if cv2 is not None:
        code = cv2.COLOR_RGBA2BGR if img.shape[2] == 4 else cv2.COLOR_RGB2BGR
        cv2.imwrite(path, img if img.shape[2] == 1 else cv2.cvtColor(img, code))
        return path
    # No OpenCV: a binary PPM needs no image library at all and every viewer
    # reads it. Mono8 is expanded to grey RGB; rgba8 drops alpha.
    path = path.rsplit(".", 1)[0] + ".ppm"
    rgb = img[:, :, :3] if img.shape[2] >= 3 else np.repeat(img, 3, axis=2)
    with open(path, "wb") as f:
        f.write(f"P6\n{rgb.shape[1]} {rgb.shape[0]}\n255\n".encode())
        f.write(rgb.tobytes())
    return path

Python writes a PNG through OpenCV when it is installed and falls back to the same PPM when it is not, which is why its OUTPUT is frame.png where the other two are frame.ppm. The PPM path is identical in all three, and it needs no image library because the frame is already row-major, top-down and tightly packed. What Python must do and the others must not is the RGB to BGR conversion: OpenCV wants BGR and the SDK never swaps channels.

It prints nothing: the caller reports either the path it wrote or the io::Error it got back. Three properties of Frame are doing the work here, and all three are why the loop is a single straight walk over data with no row arithmetic:

  • Rows are top-down, and PPM is top-down, so no flip is needed.
  • Stride is tight, so chunks_exact(bpp) never walks into padding.
  • Channels are R, G, B, A in that order, and PPM wants the first three of them. A format that wanted BGR would convert here, at the call site, and nowhere else.

mono8 is expanded to grey by repeating the single byte three times, and rgba8 drops alpha by taking pixel[..3].

The run ends as soon as the file is on disk, with nothing to put back:

attached to vrobots/1/i/cam/front_left/720p_rgba8
wrote frame.ppm

Ctrl-C during the two-second wait is equally safe here, which it would not be in a program that had mounted a camera of its own.

When one file is not enough

Writing images from a loop makes this the slow part of your program: a 720p rgba8 frame is 3.69 MB, and 60 of those per second is more than most disks want. For capture rather than inspection, record the raw slices instead and decode them later with Frame::decode, which is what vrobots record --camera and the fixture workflow are for. That path is covered in Recording and testing without the simulator, along with why those recordings are the thing that keeps cargo test meaningful with the simulator closed.

Next: Showing frames in a window

See also: Inside a frame, Freshness, The vrobots command

Showing frames in a window

Put the live camera on screen with OpenCV, and convert the one property the SDK deliberately leaves alone.

cargo run -p vrobots-examples --features opencv --bin ex34_camera_view
./target/cpp-build/ex34_camera_view
python examples/python/ex34_camera_view.py

The one example with an outside dependency

Every other example in this book needs nothing but the SDK. This one needs OpenCV, so all three languages keep it opt-in rather than making everyone install it:

LanguageHow it is opted intoWithout OpenCV installed
Rustthe opencv cargo feature, off by defaultcargo build --workspace skips the binary
C++find_package(OpenCV) in examples/cpp/CMakeLists.txtCMake prints skipping ex34_camera_view and builds the rest
Pythonpip install opencv-pythonthe program exits with that line as its message

That is why the Rust command above carries --features opencv and no other command in this book does. Hello image is the version with no dependency at all.

The loop

Setup is the same open_camera on front_left as Hello image, followed by one named_window. Nothing is mounted, so nothing has to be torn down. The loop is where the two lessons of this page live. From examples/rust/src/bin/ex34_camera_view.rs:

#![allow(unused)]
fn main() {
    // ===== loop =====
    // Frame-paced: wait_new_frame blocks until the next render, so imshow runs
    // once per frame rather than redrawing one it has already shown.
    let mut seen = 0u64;
    loop {
        if let Err(VrError::Timeout(_)) = cam.wait_new_frame(TIMEOUT) {
            // A status, not a failure: the sim is paused, or the camera stopped.
            // Still pump the GUI so the window stays responsive.
            if quit_requested()? {
                break;
            }
            continue;
        }
        let Some(frame) = cam.fresh() else { continue };
        seen += 1;

        // The pixels are already row-major, top-down and tightly packed, so this
        // is a straight copy into a Mat of the same shape.
        let mut rgba = Mat::new_rows_cols_with_default(
            frame.height as i32,
            frame.width as i32,
            CV_8UC4,
            Scalar::all(0.0),
        )?;
        rgba.data_bytes_mut()?.copy_from_slice(&frame.data);

        // The SDK never does this for you: RGBA is what Unity rendered, BGR is
        // what OpenCV displays.
        let mut bgr = Mat::default();
        imgproc::cvt_color_def(&rgba, &mut bgr, imgproc::COLOR_RGBA2BGR)?;

        highgui::imshow(WINDOW, &bgr)?;
        if quit_requested()? {
            break;
        }
    }
}
The same in C++ (examples/cpp/ex34_camera_view.cpp)
        // ===== loop =====
        // Frame-paced: wait_new_frame blocks until the next render, so imshow
        // runs once per frame rather than redrawing one it has already shown.
        std::uint64_t seen = 0;
        for (;;) {
            try {
                cam.wait_new_frame(TIMEOUT_S);
            } catch (const vrsdk::Error& e) {
                if (e.code() != VRSDK_ERR_TIMEOUT) {
                    throw;
                }
                // A status, not a failure: the sim is paused, or the camera
                // stopped. Still pump the GUI so the window stays responsive.
                if (quit_requested()) {
                    break;
                }
                continue;
            }

            const std::optional<vrsdk::Frame> frame = cam.fresh();
            if (!frame) {
                continue;
            }
            ++seen;

            // A header over the frame's own bytes -- no copy. Row-major,
            // top-down and tightly packed is exactly what cv::Mat wants.
            const cv::Mat rgba(static_cast<int>(frame->height()), static_cast<int>(frame->width()),
                               CV_8UC4, const_cast<std::uint8_t*>(frame->data.data()),
                               static_cast<std::size_t>(frame->step()));

            // The SDK never does this for you: RGBA is what Unity rendered, BGR
            // is what OpenCV displays.
            cv::Mat bgr;
            cv::cvtColor(rgba, bgr, cv::COLOR_RGBA2BGR);

            cv::imshow(WINDOW, bgr);
            if (quit_requested()) {
                break;
            }
        }
The same in Python (examples/python/ex34_camera_view.py)
    # ===== loop =====
    # Frame-paced: wait_new_frame blocks until the next render, so imshow runs
    # once per frame rather than redrawing one it has already shown.
    seen = 0
    while True:
        try:
            cam.wait_new_frame(TIMEOUT)
        except vrsdk.VrError as e:
            if e.code != vrsdk.err.TIMEOUT:
                raise
            # A status, not a failure: the sim is paused, or the camera stopped.
            # Still pump the GUI so the window stays responsive.
            if quit_requested():
                break
            continue

        frame = cam.read()  # consumes freshness; None if someone else got it
        if frame is None:
            continue
        seen += 1

        # frame.image is (h, w, 4) uint8, top-down, RGBA. The SDK never converts
        # colour for you: RGBA is what Unity rendered, BGR is what OpenCV shows.
        bgr = cv2.cvtColor(frame.image, cv2.COLOR_RGBA2BGR)

        cv2.imshow(WINDOW, bgr)
        if quit_requested():
            break

Two differences worth naming. Rust matches on VrError::Timeout where C++ compares e.code() against VRSDK_ERR_TIMEOUT and Python compares e.code against vrsdk.err.TIMEOUT, which is the same distinction the whole book draws between a timeout and a failure. And the three reach the pixels differently: C++ wraps a cv::Mat header around the frame's own bytes and copies nothing, Rust copies into an owned Mat, and Python passes the numpy view as it stands. The C++ header is valid only while that Frame is alive, which is why it is built inside the loop body and never stored.

RGBA in, BGR out

The SDK normalises geometry and nothing else. Rows arrive top-down, step is width * bytes_per_pixel with no padding, and channels are the renderer's own order, so the pixels map onto a Mat with no rearranging and then need exactly one colour conversion, written at the call site that wants BGR.

Skip that conversion and the picture still appears, with the sky orange and the desert blue. That is the fastest way to recognise the mistake.

The example opens front_left, which is rgba8, so the type is fixed. The other two formats differ only here:

FormatMat typeConversion for display
rgba8CV_8UC4COLOR_RGBA2BGR, what this example uses
rgb8CV_8UC3COLOR_RGB2BGR
mono8CV_8UC1none, one channel has no order

One imshow per rendered frame

wait_new_frame blocks until the next render, so the window redraws exactly once per frame instead of re-showing a picture it has already drawn. A timeout is a status rather than an error, exactly as in Freshness, and the one thing that must still happen on that path is the GUI pump: a window that never gets wait_key stops repainting and the desktop reports the program as not responding.

That pump is also how the keypress is read, which is why quit_requested in all three files does both in one call. imshow on its own queues an image and paints nothing, and the 1 ms argument is a maximum rather than a delay: the call returns as soon as the window has been serviced.

Quitting

The run prints one line on the way in and one on the way out:

showing vrobots/1/i/cam/front_left/720p_rgba8 -- press q or Esc to quit
showed 412 frame(s), received=412 decode_errors=0 seq_gaps=0

Pressing q or Esc breaks the loop, and the only thing left to close is the window:

#![allow(unused)]
fn main() {
    // ===== cleanup =====
    // Only ours: the window. Dropping the stream ends this subscription and
    // nothing else -- front_left keeps rendering for everyone.
    highgui::destroy_all_windows()?;
}

Ctrl-C is just as safe: this example never mounted anything, so there is no camera left behind on a robot that outlives the process. An example that mounts one -- ex17_camera_pose is the only one here -- does have that deadline, and page Mount, open and unmount spells it out.

showed counts what reached the window and received counts what the reader thread got, so a gap between them means frames arrived while the loop was inside cvtColor or imshow. seq_gaps is the different and more serious number: it counts frames the publisher sent that never arrived at all.

Next: Services and configuration

See also: Inside a frame, Freshness, Saving a frame

Services and configuration

One-shot requests that change the robot rather than drive it, and why the reply is not the result.

What a service is

A service is a zenoh query on vrobots/{sys_id}/z/srv/{segment}: you send one request, the robot sends one reply, and the exchange is over. That is the opposite shape from a command, which is a publication nobody answers (see Commands latch). Services carry configuration, which is quasi-static: mass, noise models, rotor geometry, frames, the skin. Commands carry setpoints, which change every loop iteration.

Two services live outside a robot's namespace. vrobots/manager/z/srv/delete belongs to the scene manager, and vrobots/scene/z/srv/frame answers for the scene as a whole. Everything else is per robot, keyed by sys_id.

The ack is a receipt, not a result

Every reply is an SrvAck, and the simulator packs it the instant the query lands. The change itself is applied in phase 0 of the robot's next physics step, one step later.

sequenceDiagram
    participant P as Your program
    participant S as Simulator
    participant F as Physics step
    P->>S: GET srv/params
    S-->>P: SrvAck ok=true
    Note over S: queued, nothing changed yet
    S->>F: phase 0 of the next step
    Note over F: applied, or dropped with a log line
    F-->>P: state stream, one sample later

So Ok(()) means the robot heard you. It does not mean the robot agreed with you.

Only srv/skin ever says no

srv/skin is the single service in this API that can answer ok = false, and it does so only for a tier refusal. Everything else acks ok whatever you sent. A rotor list of the wrong length, an unknown frame id, a drive_mode that is not 2 or 4, a noise block for a sensor the robot does not carry: each is acked ok and then refused by a log line inside the simulator that no client can read.

The SDK closes part of that gap by refusing what it can before anything reaches the wire. The "Refused client-side" column of the table below is the whole of that protection; past it you are on your own.

Gotcha. A silently dropped request and a successful one are byte-for-byte identical from outside the simulator. Detect the difference by measuring the robot, never by reading the return value.

The state stream is the confirmation

Because of that, the examples in this chapter measure rather than assert. ex27 makes the point deliberately by sending a rotor list one entry short, with a thrust curve that would drop the aircraft out of the sky.

From examples/rust/src/bin/ex27_rotor_config.rs:

#![allow(unused)]
fn main() {
    robot.configure_rotors(&short)?;
    println!("... returned Ok. That is a receipt, and the request was dropped:");
    let dropped = climb(&robot, "after the short list", &collective)?;
}
The same in C++ (examples/cpp/ex27_rotor_config.cpp)
robot.configure_rotors(shortlist);
std::printf("... returned without throwing. That is a receipt, and the request was "
            "dropped:\n");
const Run dropped = climb(robot, "after the short list", collective);
The same in Python (examples/python/ex27_rotor_config.py)
robot.configure_rotors(short)
print("... returned without raising. That is a receipt, and the request was dropped:")
dropped = climb(robot, "after the short list", collective)

Each surface reports failure differently, and that is exactly what makes the point here: the Rust ?, the C++ catch and the Python except all stay quiet, because a dropped request is acked ok on the wire and none of them has anything to raise.

The ? never fires, and the aircraft climbs exactly as it did before, which is the proof that nothing was applied:

configure_rotors with <n-1> entries for <n> rotors ...
... returned Ok. That is a receipt, and the request was dropped:
-- after the short list --
   t=<seconds>s alt=<metres> m  climb=<rate> m/s  measured=[...]

The service map

Every live robot serves the same seven keys, whatever its type:

SegmentMethodPage
activate(inside connect, no public method)Robot lifecycle
resetresetRobot lifecycle
paramsset_physical_paramsMass and inertia
sensorsconfigure_sensorsSensor noise
framesset_framesCoordinate frames
camerasmount_camera, unmount_cameraMount, open and unmount
skinset_skinSkins

One more service belongs to each of four robot types, and to nothing else:

SegmentMethodRobotPage
driveconfigure_driveTruckThe truck drivetrain
rotorsconfigure_rotorsMultirotorRotors and thrust curves
msdconfigure_msdMsdMass spring damper and cart pole
cartpoleconfigure_cartpoleCartPoleMass spring damper and cart pole

HalfDrone and GlobalHawk add nothing: their entire control surface is command-level.

Signatures, with what each refuses before publishing:

MethodSignatureTopicRefused client-sideRobot
reset() -> VrResult<()>srv/resetnothingall
set_physical_params(&PhysicalParams) -> VrResult<()>srv/paramsempty request, mass not positive-finite, any MOI axis not positive-finiteall
set_skin(&str) -> VrResult<()>srv/skinan empty nameall
configure_sensors(&SensorConfig) -> VrResult<()>srv/sensorsempty request, any non-finite valueall
set_frames(Option<&str>, &[DeviceFrame]) -> VrResult<()>srv/framesboth halves empty, an entry with an empty device or frame idall
scene_frame() -> VrResult<SceneFrame>vrobots/scene/z/srv/framenothingscene scope
configure_drive(&DriveConfig) -> VrResult<()>srv/driveempty request, drive_mode not 2 or 4, any non-finite valueTruck
configure_rotors(&[RotorSpec]) -> VrResult<()>srv/rotorsan empty slice, any non-finite valueMultirotor
configure_msd(&MsdConfig) -> VrResult<()>srv/msdempty request, a negative or non-finite valueMsd
configure_cartpole(&CartPoleConfig) -> VrResult<()>srv/cartpoleempty request, a non-positive mass, length or force, any non-finite valueCartPole
delete() -> VrResult<()>manager/z/srv/deletealready deletedall
is_deleted() -> boollocal flagall

Asking for a service the robot does not serve

A robot registers a queryable only for its own type's service, so configure_drive on a multirotor is a query nobody answers. After ConnectOptions::service_timeout (8 seconds by default) it returns VrError::NoResponder.

That is not a failure mode to defend against, it is a capability probe: it is how ex30_hello_halfdrone establishes that a HalfDrone serves the common seven and nothing more. It is also indistinguishable from a simulator that is not running, so confirm with vrobots topic list before drawing a conclusion.

Next: Robot lifecycle

See also: Five rules that explain everything, The topic namespace, Appendix C: Error reference

Robot lifecycle

Create, attach, activate, reset and delete, and what each is confirmed by.

cargo run -p vrobots-examples --bin ex04_hello_service
./target/cpp-build/ex04_hello_service
python examples/python/ex04_hello_service.py
cargo run -p vrobots-examples --bin ex21_reset -- 1
./target/cpp-build/ex21_reset 1
python examples/python/ex21_reset.py 1
cargo run -p vrobots-examples --bin ex21_reset
./target/cpp-build/ex21_reset
python examples/python/ex21_reset.py

ex21_reset takes an optional sys_id. With it, the example attaches to the scene's own multirotor; without it, the example creates one. Prefer the argument: a client-created multirotor does not integrate physics in simulator v3.0.0, which is a known issue.

Five verbs, five different confirmations

VerbHow you issue itConfirmed by
createconnect(type, None)the new robot's state topic starting to publish
attachconnect(type, Some(id))the first state snapshot, which connect blocks for
activateConnectOptions::activate_after_create, inside connectthe ack, and then the state topic
resetreset()the position in the state stream, one step later
deletedelete()the state topic going silent for one second

There is no public activate() method. Activation happens as step 3 of the create sequence when activate_after_create is true, which it is by default. Attaching never activates, because the scene already did.

Create and delete

connect(type, None) asks the manager to spawn a robot and the reply carries its sys_id. Create is the only non-idempotent service in the system, so the SDK sends it exactly once and never retries: a retry that lands spawns a second robot.

From examples/rust/src/bin/ex04_hello_service.rs:

#![allow(unused)]
fn main() {
    // Create a NEW robot in the sim (no sys_id -> manager create; reply carries the id).
    let robot = VirtualRobot::connect(ROBOT_TYPE, None)?;
    let sys_id = robot.sys_id();
    println!("created sys_id = {sys_id}");
}
The same in C++ (examples/cpp/ex04_hello_service.cpp)
// Create a NEW robot in the sim: `create` means "no sys_id", so the
// manager assigns one and the reply carries it. (A constructor would be
// ambiguous with the attach form -- see the header.)
vrsdk::VirtualRobot robot = vrsdk::VirtualRobot::create(vrsdk::RobotType::Multirotor);
robot.connect();
const std::uint32_t sys_id = robot.sys_id();
std::printf("created sys_id = %u\n", sys_id);
The same in Python (examples/python/ex04_hello_service.py)
# Create a NEW robot in the sim (no sys_id -> manager create; the reply
# carries the assigned id).
robot = VirtualRobot(ROBOT_TYPE)
robot.connect()
sys_id = robot.sys_id
print(f"created sys_id = {sys_id}")

C++ and Python build the handle first and call connect() on it, where Rust's VirtualRobot::connect does both in one call. C++ spells the create form VirtualRobot::create(type) because a one-argument constructor would be ambiguous with the attach form, and Python reads sys_id as a property rather than a method.

By the time connect returns, the robot's state topic has published at least once, so the snapshot the next lines read is real data rather than a placeholder:

created sys_id = <id>
first state: t=<seconds> seq=<n> name=<robot name>
its state topic: vrobots/<id>/z/state

Deletion is explicit and never implicit. Dropping a VirtualRobot closes the session and leaves the robot running, which is the point of the fourth rule: robots outlive the process.

#![allow(unused)]
fn main() {
    // Deletion is explicit and never implicit. delete() waits for the state topic
    // to fall silent: the manager's ack is only a receipt, absence is the proof.
    robot.delete()?;
    println!(
        "deleted sys_id = {sys_id} (is_deleted={})",
        robot.is_deleted()
    );
}
The same in C++ (examples/cpp/ex04_hello_service.cpp)
// Deletion is explicit and never implicit. The manager's ack is only a
// receipt, so remove() also waits for the robot's state topic to fall
// silent -- that is the real confirmation.
robot.remove();
std::printf("deleted sys_id = %u (removed=%s)\n", sys_id,
            robot.removed() ? "true" : "false");
The same in Python (examples/python/ex04_hello_service.py)
# Deletion is explicit and never implicit. delete() waits for the state topic
# to fall silent: the manager's ack is only a receipt, absence is the proof.
robot.delete()
print(f"deleted sys_id = {sys_id} (is_deleted={robot.is_deleted})")

Only the names differ. C++ spells the pair remove() and removed() because delete is a keyword, and Python's is_deleted is a property where Rust's is a method.

The call returns once the state topic has been quiet for one second, which at 25 Hz is 25 missing samples:

deleted sys_id = <id> (is_deleted=true)

Note. delete() is the one service the SDK deliberately does not retry. A re-send after a delete the manager already applied comes back as ok = false for an unknown sys_id, which would turn a successful delete into a reported failure.

After that the handle is spent. Every command and every service on it fails with VrError::Deleted rather than doing nothing quietly.

#![allow(unused)]
fn main() {
    match robot.set_mr_pwm([1500.0; 4]) {
        Ok(()) => println!("unexpected: a deleted robot accepted a command"),
        Err(e) => println!("the handle is spent, as expected: [{}] {e}", e.code()),
    }
}
The same in C++ (examples/cpp/ex04_hello_service.cpp)
try {
    robot.set_mr_pwm({1500.0, 1500.0, 1500.0, 1500.0});
    std::printf("unexpected: a deleted robot accepted a command\n");
} catch (const vrsdk::Error& e) {
    std::printf("the handle is spent, as expected: [%d] %s\n", e.code(), e.what());
}
The same in Python (examples/python/ex04_hello_service.py)
try:
    robot.set_mr_pwm(1500, 1500, 1500, 1500)
    print("unexpected: a deleted robot accepted a command")
except vrsdk.VrError as e:
    print(f"the handle is spent, as expected: [{e.code} {e.kind}] {e.detail}")

Rust returns the refusal as a Result you match on, while C++ throws vrsdk::Error and Python raises vrsdk.VrError, so both need the call inside a try. Python also accepts the four pulse widths as separate arguments rather than one array.

the handle is spent, as expected: [<code>] <message>

Reset

reset() teleports the robot to the pose captured at its first physics step, zeroes linear and angular velocity, rests the actuators and re-latches the robot's initial command. It is what the simulator's own Reset button does.

From examples/rust/src/bin/ex21_reset.rs:

#![allow(unused)]
fn main() {
    let before = robot.states();
    println!("\n-- reset() (a bare GET) --");
    robot.reset()?;
    println!(
        "acked. That is a RECEIPT: the teleport lands in phase 0 of the next \
         physics step, and the state stream is the proof."
    );
}
The same in C++ (examples/cpp/ex21_reset.cpp)
const vrsdk::State before = robot.states();
std::printf("\n-- reset() (a bare GET) --\n");
robot.reset();
std::printf(
    "acked. That is a RECEIPT: the teleport lands in phase 0 of the next physics step, "
    "and the state stream is the proof.\n");
The same in Python (examples/python/ex21_reset.py)
before = robot.states
print("\n-- reset() (a bare GET) --")
robot.reset()
print(
    "acked. That is a RECEIPT: the teleport lands in phase 0 of the next "
    "physics step, and the state stream is the proof."
)

reset() takes no arguments and returns nothing in any of the three, so the only difference is the snapshot beside it: robot.states is a property in Python where Rust and C++ call states().

The example stops commanding across the reset, so the effect is visible: with nothing sent, the actuator echo falls from the climb pulse back to the robot's initial 1100 us idle, and the distance from home collapses to roughly zero.

-- reset() (a bare GET) --
acked. That is a RECEIPT: the teleport lands in phase 0 of the next physics step, and the state stream is the proof.
home? seq=<n> t=<seconds>s pos=(...) [frd] alt=<metres> |v|=<speed> echo=[...]  d(home)=<metres> m

A live publisher wins one step later. A control loop that keeps sending 1700 us climbs straight back out of the reset and barely registers that it happened, which is exactly what you want when the loop under test is the thing you are resetting around.

Gotcha. srv/reset is the one service key where a payload-less GET performs the action instead of probing it. The simulator's vendored C# zenoh client cannot attach a payload, so an empty query had to mean something. Probe every other key freely; never probe this one. Because twice home is still home, reset() is idempotent and its discovery-retry loop is safe.

Home is not where you found it

Home is the pose at the robot's first physics step, not the pose you attached at. A scene robot that has been flying since the scene loaded can be a long way from it: measured live, one attach found a multirotor 27 m from its home, and a program that treated the attach position as home reported the reset as having moved the robot away from where it belonged.

Nothing reads the home pose out, so the only honest way to learn it is to go there.

#![allow(unused)]
fn main() {
fn learn_home(robot: &VirtualRobot, created: bool) -> Result<Arc<State>, VrError> {
    if created {
        return Ok(robot.states());
    }

    println!("attached: resetting once to find out where home actually is");
    robot.reset()?;
    for _ in 0..SETTLE_SAMPLES {
        robot.rate(HZ);
    }
    Ok(robot.states())
}
}
The same in C++ (examples/cpp/ex21_reset.cpp)
vrsdk::State learn_home(vrsdk::VirtualRobot& robot, bool created) {
    if (created) {
        return robot.states();
    }
    std::printf("attached: resetting once to find out where home actually is\n");
    robot.reset();
    for (int i = 0; i < SETTLE_SAMPLES; ++i) {
        robot.rate(HZ);
    }
    return robot.states();
}
The same in Python (examples/python/ex21_reset.py)
def learn_home(robot: VirtualRobot, created: bool):
    if created:
        return robot.states

    print("attached: resetting once to find out where home actually is")
    robot.reset()
    for _ in range(SETTLE_SAMPLES):
        robot.rate(HZ)
    return robot.states

The snapshot each one hands back differs in ownership, not in content: Rust returns an Arc<State>, C++ returns a vrsdk::State by value, and Python returns whatever the property yields.

On the create path this is unnecessary, because nothing has happened to the robot yet and the first sample already is home. The same trick finds a cart pole's rail centre, which is the one number that plant needs and does not publish.

What survives a reset

Set bySurvives a reset?
set_physical_params (mass, inertia)yes
configure_sensors (noise models)yes
configure_rotors, configure_drive, configure_msd, configure_cartpoleyes
set_frames, set_skinyes
position, orientation, velocityno, teleported home
the latched command and the actuator echono, re-latched to the initial command
fixed-wing control mode and estimate sourceno, reverted to onboard and truth
seq and elapseduntouched, the clock never restarts

A frozen elapsed means the simulator stopped, never that something reset.

Configuration surviving is what makes attaching to a scene robot a one-way door. There is no getter for mass, inertia or rotor geometry, so the SDK cannot read the old value and put it back, and reset() will not do it for you. Whatever you configure on a shared robot stays configured for every other client until the scene is reloaded.

Next: Mass and inertia

See also: What connect actually does, System ids, and the two kinds of robot, Known simulator issues

Mass and inertia

Change what the robot weighs, and confirm it by watching the robot move.

cargo run -p vrobots-examples --bin ex22_physical_params -- 1
./target/cpp-build/ex22_physical_params 1
python examples/python/ex22_physical_params.py 1
cargo run -p vrobots-examples --bin ex22_physical_params
./target/cpp-build/ex22_physical_params
python examples/python/ex22_physical_params.py

ex22_physical_params takes an optional sys_id. With it, the example attaches to the scene's own multirotor; without it, the example creates one and both climb runs read 0.00 m/s, because a client-created multirotor does not integrate physics in simulator v3.0.0 (known issue). Prefer the argument.

The request

srv/params carries two numbers and is the only channel for either.

FieldTypeUnitsDefaultNotes
massOption<f64>kgNone, meaning untouchedframe-invariant; must be positive and finite
moiOption<[f64;3]>kg·m²None, meaning untouchedprincipal moments, read in your header frame and permuted into the robot's; all three axes must be positive and finite

Build one with PhysicalParams::default() and the with_mass / with_moi setters; is_empty() reports whether the request would change nothing. Moments of inertia are positive quantities, so the frame conversion reorders the triple and never flips a sign.

The service works mid-flight, which is the reason it exists: changing the mass under a running loop is the standard way to test a controller against a payload it was not tuned for.

The confirmation is behavioural

Neither figure appears in the state message. Mass properties are quasi-static configuration, not state, so there is nothing to read back and nothing to diff. The same pulse width has to produce a different acceleration, or the change did not land.

ex22 therefore flies a fixed 1800 us collective twice, at two different masses, and compares the climb rate.

From examples/rust/src/bin/ex22_physical_params.rs:

#![allow(unused)]
fn main() {
    // ===== run 1: light =====
    robot.set_physical_params(&PhysicalParams::default().with_mass(LIGHT_KG).with_moi(MOI))?;
    let light = climb_run(&robot, LIGHT_KG)?;

    // ===== run 2: heavy, same command =====
    robot.set_physical_params(&PhysicalParams::default().with_mass(HEAVY_KG))?;
    let heavy = climb_run(&robot, HEAVY_KG)?;
}
The same in C++ (examples/cpp/ex22_physical_params.cpp)
// ===== run 1: light =====
{
    auto params = vrsdk::physical_params();
    params.has_mass = true;
    params.mass = LIGHT_KG;
    params.has_moi = true;
    params.moi[0] = MOI[0];
    params.moi[1] = MOI[1];
    params.moi[2] = MOI[2];
    robot.set_physical_params(params);
}
const double light = climb_run(robot, LIGHT_KG);

// ===== run 2: heavy, same command =====
{
    auto params = vrsdk::physical_params();
    params.has_mass = true;
    params.mass = HEAVY_KG;
    robot.set_physical_params(params);
}
const double heavy = climb_run(robot, HEAVY_KG);
The same in Python (examples/python/ex22_physical_params.py)
# ===== run 1: light =====
robot.set_physical_params(mass=LIGHT_KG, moi=MOI)
light = climb_run(robot, LIGHT_KG)

# ===== run 2: heavy, same command =====
robot.set_physical_params(mass=HEAVY_KG)
heavy = climb_run(robot, HEAVY_KG)

The three spell "leave this field alone" differently, and it is the pattern for every service in this chapter. Rust chains with_* setters on a default; Python takes keyword arguments and omits what it does not set; C++ builds the plain C struct from vrsdk::physical_params() and sets a has_* flag beside each value. Never write {} in C++ and never forget the has_* flag: the field is then silently not applied.

Same command, same rotors, same air: the heavier aircraft climbs slower, and the gap between the two numbers is the entire receipt.

1800 us on every rotor, 2.0 s of climb, twice:
  1 kg -> <rate> m/s
  2 kg -> <rate> m/s
The difference IS the receipt -- there is no mass field in the state message to read back.

Each run starts with reset(), so the two are comparable. That does not undo the mass: configuration survives a state reset.

Positive only, and refused before it is sent

The simulator does not validate either field. It keeps the prefab's value and acks ok, so a bad request is indistinguishable from a good one from outside.

You sendThe simulator doesWhat you would see
mass <= 0keeps the body's current massnothing
a moment of inertia not strictly positive on all three axeskeeps Unity's collider-derived tensornothing

set_physical_params refuses both cases itself, as VrError::InvalidArgument naming the field, before anything reaches the wire.

#![allow(unused)]
fn main() {
    show_refusal(
        "mass = 0.0",
        robot.set_physical_params(&PhysicalParams::default().with_mass(0.0)),
    );
    show_refusal(
        "moi = [0.02, 0.0, 0.04] (one axis left at zero)",
        robot.set_physical_params(&PhysicalParams::default().with_moi([0.02, 0.0, 0.04])),
    );
    show_refusal(
        "nothing set at all",
        robot.set_physical_params(&PhysicalParams::default()),
    );
}
The same in C++ (examples/cpp/ex22_physical_params.cpp)
show_refusal("mass = 0.0", [&] {
    auto params = vrsdk::physical_params();
    params.has_mass = true;
    params.mass = 0.0;
    robot.set_physical_params(params);
});
show_refusal("moi = [0.02, 0.0, 0.04] (one axis left at zero)", [&] {
    auto params = vrsdk::physical_params();
    params.has_moi = true;
    params.moi[0] = 0.02;
    params.moi[1] = 0.0;
    params.moi[2] = 0.04;
    robot.set_physical_params(params);
});
show_refusal("nothing set at all",
             [&] { robot.set_physical_params(vrsdk::physical_params()); });
The same in Python (examples/python/ex22_physical_params.py)
show_refusal("mass = 0.0", lambda: robot.set_physical_params(mass=0.0))
show_refusal(
    "moi = [0.02, 0.0, 0.04] (one axis left at zero)",
    lambda: robot.set_physical_params(moi=(0.02, 0.0, 0.04)),
)
show_refusal("nothing set at all", lambda: robot.set_physical_params())

The helper takes a callable in C++ and Python because the refusal arrives as a thrown exception, where Rust's takes the returned Result directly. The third case reads differently for the same reason the first two do: an empty request is a bare vrsdk::physical_params() in C++ and a no-argument call in Python.

Each prints the error code and the SDK's explanation instead of a receipt:

-- what the SDK refuses before anything reaches the wire --
  mass = 0.0                                       [<code>] <message>
  moi = [0.02, 0.0, 0.04] (one axis left at zero)  [<code>] <message>
  nothing set at all                               [<code>] <message>
All three are acked `ok` by the simulator and silently ignored, which is why they are caught here instead.

Gotcha. The half-filled inertia triple is the one that catches people: two axes set, the third left at zero. It reads like a partial update and is not. The simulator needs all three strictly positive or it keeps the tensor it already had, so a partial triple changes nothing at all.

The cart pole exception

A CartPole re-stamps its cart's mass from CartPoleConfig::cart_mass on every parameter apply, so a mass sent to srv/params is overwritten a step later on that one robot type. Set it with configure_cartpole instead. Inertia is unaffected.

The defaults are not documented

The prefab mass and inertia a multirotor or a truck spawns with are not recorded anywhere in this repository, and the simulator does not publish them. The only documented behaviour is that a non-positive value leaves them alone.

That matters when you attach rather than create. There is no getter, so the SDK cannot read the old value first and put it back, and reset() will not either. Write down what you started with, or reload the scene when you are done.

Next: Sensor noise

See also: Robot lifecycle, Kinematics, Mass spring damper and cart pole

Sensor noise

The noise model the simulator applies, and the eight blocks you can set independently.

cargo run -p vrobots-examples --bin ex24_sensor_config
./target/cpp-build/ex24_sensor_config
python examples/python/ex24_sensor_config.py

The model

Every IMU channel is corrupted the same way:

measured = scale_factor * true + bias(t) + white(t)
bias(t)  = Gauss-Markov process + random walk

The Gauss-Markov part has a steady-state standard deviation (bias_instability) and a correlation time (bias_tau_s); the random walk adds an unbounded drift on top of it, and a fixed offset is drawn once at power-on (turn_on_bias_std). This is the one service whose effect you can see directly in the state stream: degrade the gyro and the measured rates roughen while the truth in kin stays perfectly smooth.

ImuNoise

The same block serves the accelerometer, the gyroscope and the magnetometer.

FieldTypeUnitsDefault (ideal())Notes
scale_factor[f64;3]gain[1.0; 3]1.0 is ideal; 0.0 is a dead channel that reads 0.000 forever
white_std[f64;3]sensor units[0.0; 3]
bias_instability[f64;3]sensor units[0.0; 3]steady-state standard deviation of the Gauss-Markov bias
bias_tau_sf64s0.0<= 0 keeps the current value; the simulator's own default is 100
random_walk_std[f64;3]sensor units per root-second[0.0; 3]
turn_on_bias_std[f64;3]sensor units[0.0; 3]fixed bias drawn at power-on

Sensor units are m/s² for the accelerometer, rad/s for the gyroscope and tesla for the magnetometer. The vectors are per body axis in your header's convention and are permuted into the sensor's own axes, which may differ from the robot's.

Start from ideal(), not from zero

SensorConfig gates each block independently, so None means the simulator does not touch that setting. Inside an ImuNoise block there are no flags at all. The schema has none and the simulator writes the whole block unconditionally, so a field you leave at zero is not "keep the current value", it is zero.

ImuNoise::default() is therefore ImuNoise::ideal(), a working sensor with unit gain and no noise, rather than an all-zero struct. From the examples/rust/src/bin/ex24_sensor_config.rs header:

ImuNoise::ideal().with_white_std([0.02; 3])            // a realistic gyro
ImuNoise::ideal().with_scale_factor([0.0; 3])          // a DEAD channel

Start from a whole sensor and spoil exactly what you mean to spoil. bias_tau_s is the block's one exception, which is why ideal() leaves it at 0: that is the keep-current value.

The eight gated blocks

FieldTypeUnitsDefaultNotes
accel_noiseOption<ImuNoise>m/s²None
gyro_noiseOption<ImuNoise>rad/sNone
mag_noiseOption<ImuNoise>teslaNone
gps_qualityOption<GpsQuality>Nonereported values, not a model
gps_noiseOption<GpsNoise>Nonethe error actually applied
baro_pressure_noise_stdOption<f64>PaNone
optical_flow_noise_stdOption<[f64;3]>m/s, body axesNone
optical_flow_mountedOption<bool>Noneidempotent: asking for the state it is already in does nothing

Configuring the gyro leaves the accelerometer exactly as it was. configure_sensors refuses a config where every block is None, and refuses any non-finite value.

Reported quality is not applied noise

The two GNSS blocks are independent, and the split is deliberate.

GpsQuality fieldTypeUnitsDefaultNotes
ephf64m1.5reported horizontal accuracy
epvf64m3.0reported vertical accuracy
fix_typeu3230 none, 1 dead reckoning, 2 two-dimensional, 3 three-dimensional, 4 RTK
GpsNoise fieldTypeUnitsDefaultNotes
position_std[f64;3]m[0.0; 3]NED, frame-invariant
velocity_std[f64;3]m/s[0.0; 3]NED, frame-invariant

GpsQuality is what the receiver claims. The simulator always has a perfect fix, so setting fix_type to 0 does not invalidate the GNSS and raising eph does not scatter the position: it feeds your filter's covariance and nothing else. GpsNoise is the error actually applied, and unlike the IMU blocks it is not re-expressed from your header frame, because a geodetic receiver's error ellipsoid is not a body quantity.

One call, seven blocks

ex24 rests the robot on the ground and commands nothing, so truth is constant and the spread of each reading is that sensor's noise realisation. It measures a window, configures, and measures again.

From examples/rust/src/bin/ex24_sensor_config.rs:

#![allow(unused)]
fn main() {
    let config = SensorConfig::default()
        .with_gyro_noise(
            ImuNoise::ideal()
                .with_white_std(GYRO_WHITE)
                .with_bias_instability([0.002; 3])
                .with_bias_tau_s(60.0),
        )
        .with_accel_noise(ImuNoise::ideal().with_white_std(ACCEL_WHITE))
        .with_baro_pressure_noise_std(BARO_WHITE_PA)
        .with_gps_quality(GpsQuality::default().with_eph(REPORTED_EPH).with_epv(9.0))
        .with_gps_noise(
            GpsNoise::default()
                .with_position_std([2.0, 2.0, 3.0]) // NED metres
                .with_velocity_std([0.2, 0.2, 0.3]), // NED m/s
        )
        .with_optical_flow_mounted(true)
        .with_optical_flow_noise_std([0.05; 3]);
    robot.configure_sensors(&config)?;
}
The same in C++ (examples/cpp/ex24_sensor_config.cpp)
auto config = vrsdk::sensor_config();

config.has_gyro_noise = true;
config.gyro_noise = vrsdk::imu_noise();
for (int i = 0; i < 3; ++i) {
    config.gyro_noise.white_std[i] = GYRO_WHITE;
    config.gyro_noise.bias_instability[i] = 0.002;
}
config.gyro_noise.bias_tau_s = 60.0;

config.has_accel_noise = true;
config.accel_noise = vrsdk::imu_noise();
for (int i = 0; i < 3; ++i) {
    config.accel_noise.white_std[i] = ACCEL_WHITE;
}

config.has_baro_pressure_noise_std = true;
config.baro_pressure_noise_std = BARO_WHITE_PA;

config.has_gps_quality = true;
config.gps_quality = vrsdk::gps_quality();
config.gps_quality.eph = REPORTED_EPH;
config.gps_quality.epv = 9.0;

config.has_gps_noise = true;
config.gps_noise = vrsdk::gps_noise();
config.gps_noise.position_std[0] = 2.0;  // NED metres
config.gps_noise.position_std[1] = 2.0;
config.gps_noise.position_std[2] = 3.0;
config.gps_noise.velocity_std[0] = 0.2;  // NED m/s
config.gps_noise.velocity_std[1] = 0.2;
config.gps_noise.velocity_std[2] = 0.3;

config.has_optical_flow_mounted = true;
config.optical_flow_mounted = true;
config.has_optical_flow_noise_std = true;
for (int i = 0; i < 3; ++i) {
    config.optical_flow_noise_std[i] = 0.05;
}

robot.configure_sensors(config);
The same in Python (examples/python/ex24_sensor_config.py)
robot.configure_sensors(
    gyro_noise=vrsdk.ImuNoise(
        white_std=GYRO_WHITE,
        bias_instability=(0.002,) * 3,
        bias_tau_s=60.0,
    ),
    accel_noise=vrsdk.ImuNoise(white_std=ACCEL_WHITE),
    baro_pressure_noise_std=BARO_WHITE_PA,
    gps_quality=vrsdk.GpsQuality(eph=REPORTED_EPH, epv=9.0),
    gps_noise=vrsdk.GpsNoise(
        position_std=(2.0, 2.0, 3.0),  # NED metres
        velocity_std=(0.2, 0.2, 0.3),  # NED m/s
    ),
    optical_flow_mounted=True,
    optical_flow_noise_std=(0.05,) * 3,
)

Only C++ gates the blocks by hand: it builds the plain C struct from vrsdk::sensor_config() and sets a has_* flag beside each one, where Rust chains with_* setters and Python passes keyword arguments. The "start from ideal(), not from zero" rule is vrsdk::imu_noise() in C++ and a bare vrsdk.ImuNoise() in Python, and writing {} in C++ instead is what produces a dead channel.

The change is live from the next sensor sample. The measured standard deviations move toward what was asked for, the reported eph echoes back exactly, and the optical flow announces itself:

as spawned  gyro sigma=[...] rad/s  accel sigma=[...] m/s^2  baro sigma=<Pa>  eph=1.50 m  flow_valid=false
configured  gyro sigma=[...] rad/s  accel sigma=[...] m/s^2  baro sigma=<Pa>  eph=4.50 m  flow_valid=true

Note. Mounting the optical flow is the one part of this service that reports itself: sensors.optical_flow.valid goes from false to true. Everything else you have to measure.

What this service does not cover

GNSS home coordinates, the magnetic field vector and sea-level pressure are not here, and the omission is deliberate: they are scene truths rather than per-robot configuration, and two robots in one world must not disagree about them.

A block naming a sensor the robot does not carry is skipped with a log line inside the simulator and acked ok like every other silent refusal. Nothing in the repository enumerates which sensors each robot type carries, so the only way to find out is to read sensors.<name>.valid on a live robot.

Next: Coordinate frames

See also: Sensors, Truth, measured and believed, The environment block

Coordinate frames

Three levels of frame override, where the most specific one wins.

cargo run -p vrobots-examples --bin ex25_frames
./target/cpp-build/ex25_frames
python examples/python/ex25_frames.py

Frames are presentation, never physics

Changing a frame does not move the robot one millimetre differently. The numbers describing it are permuted, and depending on the pair one of them changes sign. Registered ids are unity, frd, fru and cv, plus anything the scene registers at runtime, which is why coord_frame_id (a string) is authoritative and axis_convention (an enum tag) is the convenience beside it.

Three levels

From the examples/rust/src/bin/ex25_frames.rs header:

device override   (srv/frames, per device)      <- most specific
robot override    (srv/frames, robot_frame_id)
robot default     (truck: fru, multirotor: frd, globalhawk: frd -- regardless of the scene)
scene frame       (scene_frame(); every launch starts at fru)

The three robot defaults in that row were measured live against simulator v3.0.0. The remaining robot types are unconfirmed, and no per-robot default exists anywhere in the SDK source, so read State::coord_frame_id off the snapshot rather than assuming a default for them.

srv/frames writes the top two levels. The bottom one is read-only from the SDK.

Reading the scene level

scene_frame() is a payload-less GET that reads and changes nothing. It is scene scope rather than robot scope: the answer is the same for every robot loaded, and the method uses this robot's session only because that is where the wire is.

#![allow(unused)]
fn main() {
    let scene = robot.scene_frame()?;
    println!(
        "scene frame: {:?} (axis_convention {}, {:?})",
        scene.coord_frame_id,
        scene.axis_convention.0,
        scene.axis_convention.name()
    );
}
The same in C++ (examples/cpp/ex25_frames.cpp)
const vrsdk::SceneFrame scene = robot.scene_frame();
std::printf("scene frame: \"%s\" (axis_convention %d)\n", scene.coord_frame_id.c_str(),
            scene.axis_convention);
The same in Python (examples/python/ex25_frames.py)
scene = robot.scene_frame()
print(
    f"scene frame: {scene.coord_frame_id!r} "
    f"(axis_convention {scene.axis_convention}, {scene.axis_convention_name!r})"
)

The query is the same everywhere; what you can print of the answer is not. Rust reads the enum tag's name through Axes::name() and Python through axis_convention_name, while in C++ axis_convention is a plain integer with no name beside it, so the C++ line prints the number alone.

It returns SceneFrame { coord_frame_id: String, axis_convention: Axes }. A frame the scene registered at runtime has no enum value, so axis_convention comes back UNSPECIFIED and the string is the only thing that identifies it.

scene frame: "fru" (axis_convention <n>, <name>)

Note. Nothing persists between launches. The scene frame starts at fru every time the simulator is started, whatever the last session left it at.

Setting the robot and device levels

set_frames(robot_frame_id: Option<&str>, devices: &[DeviceFrame]) takes two independent halves and writes whichever you fill in.

ValueMeaning
None for robot_frame_idleave the robot's level alone
a registered idoverride that level
INHERIT_FRAMEclear the override, so the level below wins again
""untouched; the simulator skips it

INHERIT_FRAME is the string "inherit". The distinction between it and an empty string is the whole of the API here: one erases an override, the other declines to say anything. The SDK refuses an empty frame id in a DeviceFrame entry outright, precisely because the simulator would skip it and the ack would still say ok.

Device names are matched exactly, case included, and live in the device module:

ConstantString
device::ACCELEROMETERaccelerometer
device::GYROSCOPEgyroscope
device::MAGNETOMETERmagnetometer
device::BAROMETERbarometer
device::GPSgps
device::OPTICAL_FLOWoptical_flow
device::camera(name)camera/<name>

Gotcha. The device the frames service matches is gps, while the block it moves is called gnss in the state message. Use the constants rather than a literal.

From examples/rust/src/bin/ex25_frames.rs:

#![allow(unused)]
fn main() {
    robot.set_frames(
        Some("frd"),
        &[
            // Keep the gyro reading the way it was, while the robot moves to frd.
            DeviceFrame::new(device::GYROSCOPE, "fru"),
            // Clear any override this device had: fall back to the robot's level.
            DeviceFrame::new(device::GPS, INHERIT_FRAME),
            // Deliberate miss: this truck has no camera called "front". The entry
            // is skipped with a log line and an `ok` ack; the others still apply.
            DeviceFrame::new(device::camera("front"), "cv"),
        ],
    )?;
}
The same in C++ (examples/cpp/ex25_frames.cpp)
robot.set_frames("frd",
                 {
                     // Keep the gyro reading the way it was, while the
                     // robot moves to frd.
                     {vrsdk::device::GYROSCOPE, "fru"},
                     // Clear any override this device had: fall back to
                     // the robot's level.
                     {vrsdk::device::GPS, vrsdk::INHERIT_FRAME},
                     // Deliberate miss: this truck has no camera called
                     // "front". The entry is skipped with a log line and
                     // an `ok` ack; the others still apply.
                     {vrsdk::device::camera("front"), "cv"},
                 });
The same in Python (examples/python/ex25_frames.py)
robot.set_frames(
    "frd",
    [
        # Keep the gyro reading the way it was, while the robot moves to frd.
        DeviceFrame(device.GYROSCOPE, "fru"),
        # Clear any override this device had: fall back to the robot's level.
        DeviceFrame(device.GPS, vrsdk.INHERIT_FRAME),
        # Deliberate miss: this truck has no camera called "front". The entry
        # is skipped with a log line and an `ok` ack; the others still apply.
        DeviceFrame(device.camera("front"), "cv"),
    ],
)

The device names and INHERIT_FRAME are spelled the same in all three. Only the entry differs: C++ brace-initialises each pair inline, Python constructs a DeviceFrame, and Rust calls DeviceFrame::new. The robot half is Some("frd") in Rust and a plain "frd" in the other two.

The robot's header frame changes on the next state sample, the gyro keeps its own, and the missing camera entry is dropped without disturbing the other two:

default    robot=<default> (...) pos=(...)  gyro frame=<default>  gnss frame=<default>
overridden robot="frd"     (frd) pos=(...)  gyro frame="fru"      gnss frame="frd"

Unknown ids are skipped entry by entry, not request by request. That is different from the rotor list, where a single bad entry drops everything, and it is why a typo in one device name is invisible: the other entries land, the ack says ok, and the only symptom is one block still reporting in the old frame.

Clearing an override

Passing INHERIT_FRAME at both levels puts everything back where it started.

#![allow(unused)]
fn main() {
    robot.set_frames(
        Some(INHERIT_FRAME),
        &[DeviceFrame::new(device::GYROSCOPE, INHERIT_FRAME)],
    )?;
}
The same in C++ (examples/cpp/ex25_frames.cpp)
robot.set_frames(vrsdk::INHERIT_FRAME,
                 {{vrsdk::device::GYROSCOPE, vrsdk::INHERIT_FRAME}});
The same in Python (examples/python/ex25_frames.py)
robot.set_frames(
    vrsdk.INHERIT_FRAME,
    [DeviceFrame(device.GYROSCOPE, vrsdk.INHERIT_FRAME)],
)

INHERIT_FRAME clears an override on every surface. What differs is how each says "leave the robot's level alone": None in Rust and Python, an empty string in C++, which the binding turns into the same absent field. C++ also offers a devices-only overload for that case.

The robot falls back to its own default, and the gyro falls back to the robot:

cleared    robot=<default> (...) pos=(...)  gyro frame=<default>  gnss frame=<default>

If the truck's own default and the scene frame happen to be the same id, that one run cannot tell you which level answered. The robot default outranks the scene either way.

What the SDK refuses

Three requests never reach the wire: nothing set at all, an entry with an empty device name, and an entry with an empty frame id. Each returns VrError::InvalidArgument naming what was wrong.

-- refused before anything reaches the wire --
  nothing set                      [<code>] <message>
  an entry with an empty device    [<code>] <message>
  an entry with an empty frame id  [<code>] <message>
(use INHERIT_FRAME to clear an override; "" would be skipped sim-side)

Where the confirmation is

Two places, and neither is the ack: the coord_frame_id stamped on every subsequent state header, and the robot's z/frames topic, which republishes the full definition of each frame, basis matrix included, on change and then at 1 Hz.

Next: The truck drivetrain

See also: Frames, axes and units, Sensor noise, Appendix A: Topic reference

The truck drivetrain

Steering limits, motor torque, brake torque and the pulse band, all clamped silently.

cargo run -p vrobots-examples --bin ex26_drive_config
./target/cpp-build/ex26_drive_config
python examples/python/ex26_drive_config.py

Truck only

srv/drive is the truck's own service. Ask any other robot for it and the query finds no responder: configure_drive returns VrError::NoResponder after service_timeout. That is a capability probe rather than a fault, and it is indistinguishable from a simulator that is not running, so confirm with vrobots topic list before concluding anything from it.

Every value is read live by the steering servo, the drive motor and the dynamics, so a change bites from the next physics step with no rebuild and no dropout.

DriveConfig

FieldTypeUnitsDefaultNotes
drive_modeOption<u32>None, untouched2 rear axle, 4 all wheels; anything else is ignored by the simulator, so the SDK refuses it first
max_steer_degOption<f64>degNone, untouchedwheel angle at full stick; hard-clamped to 0 to 60
steer_rate_dpsOption<f64>deg/sNone, untouchedservo sweep rate; 0 is an ideal, instantaneous servo
max_motor_torque_nmOption<f64>N·mNone, untouchedpeak torque per driven wheel
no_load_wheel_rpmOption<f64>rpmNone, untouchedwheel speed at full throttle with no load, so this is what sets top speed; <= 0 becomes 200
idle_brake_torque_nmOption<f64>N·mNone, untouchedper wheel, while the throttle sits in the deadband
max_brake_torque_nmOption<f64>N·mNone, untouchedper wheel, at a full brake command
pwm_bandOption<PwmBand>None, untouchedall four numbers move as one group

configure_drive refuses an empty config, a drive_mode that is not 2 or 4, and any non-finite value. Nothing else is checked, because nothing else can be: the clamps in the Notes column happen inside the simulator and never reach the ack.

PwmBand

FieldTypeUnitsFactory valueNotes
min_usu32µs1100full reverse, full left
neutral_usu32µs1500centre stick
max_usu32µs1900full forward, full right
deadband_usu32µshalf-width of the neutral deadband; inside it the throttle is idle and the idle brake torque holds the truck

If min_us < neutral_us < max_us does not hold, the simulator replaces the whole band with 1100 / 1500 / 1900. A partially sensible band is not something you can ask for.

Gotcha. The truck's factory band is 1100 / 1500 / 1900, while set_car validates against the wider 1100 to 2000 the actuators are specified on. So 1950 is accepted by the SDK and is past full throttle for this truck. There is no way to move the neutral point alone.

Measuring instead of reading back

There is nothing to read back, so ex26 drives the same full-left circle four times and compares the steady turn radius, speed / yaw_rate. A steering limit that halves must roughly double the radius, or the request did not land.

From examples/rust/src/bin/ex26_drive_config.rs:

#![allow(unused)]
fn main() {
    // ===== run 2: half the steering =====
    robot.configure_drive(&DriveConfig::default().with_max_steer_deg(15.0))?;
    let narrow = circle(&robot, "max_steer_deg = 15")?;

    // ===== run 3: more than the simulator allows =====
    robot.configure_drive(&DriveConfig::default().with_max_steer_deg(90.0))?;
    let clamped = circle(&robot, "max_steer_deg = 90 -> clamped to 60")?;
}
The same in C++ (examples/cpp/ex26_drive_config.cpp)
// ===== run 2: half the steering =====
{
    auto config = vrsdk::drive_config();
    config.has_max_steer_deg = true;
    config.max_steer_deg = 15.0;
    robot.configure_drive(config);
}
const Circle narrow = circle(robot, "max_steer_deg = 15");

// ===== run 3: more than the simulator allows =====
{
    auto config = vrsdk::drive_config();
    config.has_max_steer_deg = true;
    config.max_steer_deg = 90.0;
    robot.configure_drive(config);
}
const Circle clamped = circle(robot, "max_steer_deg = 90 -> clamped to 60");
The same in Python (examples/python/ex26_drive_config.py)
# ===== run 2: half the steering =====
robot.configure_drive(max_steer_deg=15.0)
narrow = circle(robot, "max_steer_deg = 15")

# ===== run 3: more than the simulator allows =====
robot.configure_drive(max_steer_deg=90.0)
clamped = circle(robot, "max_steer_deg = 90 -> clamped to 60")

One field costs one call in Rust and Python and three lines in C++, because the C++ surface is a plain struct: take an empty request from vrsdk::drive_config(), then set the value and its has_* flag together. The flag is the load-bearing half. A value written without it goes out as if the field had never been touched, and the ack looks the same either way.

Run 2 widens the circle. Run 3 asks for 90 degrees, is acked ok, and drives the circle of a truck limited to 60:

steady turn radius (speed / yaw rate), same command every time:
  as spawned                             r=<m>   speed=<m/s>  yaw=<rad/s>  servo=<value>
  max_steer_deg = 15                     r=<m>   speed=<m/s>  yaw=<rad/s>  servo=<value>
  max_steer_deg = 90 -> clamped to 60    r=<m>   speed=<m/s>  yaw=<rad/s>  servo=<value>
  drive_mode = 2, 40 N.m, 30 deg         r=<m>   speed=<m/s>  yaw=<rad/s>  servo=<value>

The 90 that came back as 60 is indistinguishable from a 60 that was asked for. The circle is the only witness.

Setting the whole drivetrain at once

Run 4 fills in every field, including the factory band restated explicitly.

#![allow(unused)]
fn main() {
    robot.configure_drive(
        &DriveConfig::default()
            .with_drive_mode(2) // rear axle only (4 = all wheels)
            .with_max_steer_deg(30.0)
            .with_steer_rate_dps(120.0) // 0 would be an ideal, instant servo
            .with_max_motor_torque_nm(40.0)
            .with_no_load_wheel_rpm(200.0)
            .with_idle_brake_torque_nm(5.0)
            .with_max_brake_torque_nm(150.0)
            // All four numbers move together, and this IS the factory band.
            .with_pwm_band(PwmBand::new(1100, 1500, 1900, 30)),
    )?;
}
The same in C++ (examples/cpp/ex26_drive_config.cpp)
// ===== run 4: rear-wheel drive, softer motor, factory band restated ====
{
    auto config = vrsdk::drive_config();
    config.has_drive_mode = true;
    config.drive_mode = 2;  // rear axle only (4 = all wheels)
    config.has_max_steer_deg = true;
    config.max_steer_deg = 30.0;
    config.has_steer_rate_dps = true;
    config.steer_rate_dps = 120.0;  // 0 would be an ideal, instant servo
    config.has_max_motor_torque_nm = true;
    config.max_motor_torque_nm = 40.0;
    config.has_no_load_wheel_rpm = true;
    config.no_load_wheel_rpm = 200.0;
    config.has_idle_brake_torque_nm = true;
    config.idle_brake_torque_nm = 5.0;
    config.has_max_brake_torque_nm = true;
    config.max_brake_torque_nm = 150.0;
    // All four numbers move together, and this IS the factory band.
    config.has_pwm_band = true;
    config.pwm_band = vrsdk_pwm_band_t{1100, 1500, 1900, 30};
    robot.configure_drive(config);
}
The same in Python (examples/python/ex26_drive_config.py)
# ===== run 4: rear-wheel drive, softer motor, factory band restated =====
robot.configure_drive(
    drive_mode=2,  # rear axle only (4 = all wheels)
    max_steer_deg=30.0,
    steer_rate_dps=120.0,  # 0 would be an ideal, instant servo
    max_motor_torque_nm=40.0,
    no_load_wheel_rpm=200.0,
    idle_brake_torque_nm=5.0,
    max_brake_torque_nm=150.0,
    # All four numbers move together, and this IS the factory band.
    pwm_band=PwmBand(1100, 1500, 1900, 30),
)

The pulse-width band is the one member that is written whole on every surface, because all four numbers travel as a group: PwmBand::new(...) in Rust, PwmBand(...) in Python, and a braced vrsdk_pwm_band_t in C++. There is no keep-current for one number inside it, so the C++ flag has_pwm_band decides only whether the whole block is sent.

The truck keeps driving through the change: no dropout, no re-spawn, and one line of echo at the start of the next circle.

Reading the drivetrain in the state stream

ChannelMeaning
actuator.pwmthe three channels you sent: steer, throttle, brake
actuator.measured[0..3]the four wheel speeds in rad/s, FL, FR, RL, RR
actuator.measured[4]the steering servo, which is the channel that answers max_steer_deg

An undriven wheel still reports, because the road turns it. So drive_mode 2 versus 4 shows up as which wheels lead under power, not as two silent channels.

Note. There is also a selector form of this service (?mode=2|4) for clients that cannot attach a payload. The SDK can, so it does not use it.

What the SDK refuses

-- refused before anything reaches the wire --
  drive_mode = 3   [<code>] <message>
  nothing set      [<code>] <message>

Next: Rotors and thrust curves

See also: Driving the truck, Truck, Actuators

Rotors and thrust curves

Rotor geometry and thrust polynomials, with no mixing matrix anywhere.

cargo run -p vrobots-examples --bin ex27_rotor_config -- 1
./target/cpp-build/ex27_rotor_config 1
python examples/python/ex27_rotor_config.py 1
cargo run -p vrobots-examples --bin ex27_rotor_config
./target/cpp-build/ex27_rotor_config
python examples/python/ex27_rotor_config.py

ex27_rotor_config takes an optional sys_id. With it, the example attaches to the scene's own multirotor; without it, the example creates one and every climb run reads 0.00 m/s, because a client-created multirotor does not integrate physics in simulator v3.0.0 (known issue). Prefer the argument, and read the warning about attaching below before you do.

Multirotor only

srv/rotors is the multirotor's own service. Anything else returns VrError::NoResponder.

The three curves

From the examples/rust/src/bin/ex27_rotor_config.rs header, with pwm the commanded pulse width in microseconds and g the scene's gravity magnitude:

thrust = (thrust_a*pwm^2 + thrust_b*pwm + thrust_c) * g            [N]
torque = spin_dir * (torque_a*pwm^2 + torque_b*pwm + torque_c) * g [N.m]
omega  = ang_vel_slope*pwm + ang_vel_intercept                     [rad/s]

Roll, pitch and yaw are not in that list because there is no mixing matrix anywhere in the simulator. Moments fall out of the rotor positions, so moving a rotor really does change the airframe's response, and an asymmetric aircraft is a different rotor list rather than a different mixer.

RotorSpec

RotorSpec::default() is the simulator's own reference rotor, and it is the base to build on: there are no per-field flags inside an entry, so a zero is a zero coefficient and not "leave it alone".

FieldTypeUnitsDefaultNotes
position[f64;3]m[0.0, 0.0, 0.0]hub position from the robot origin, not the centre of mass; read in your header frame
spin_dirf640.0sign of the yaw reaction torque: +1 clockwise, -1 counter-clockwise, 0 lets the simulator alternate by index with even indices clockwise
thrust_af647.5e-7quadratic term
thrust_bf64-0.001325linear term
thrust_cf640.55constant term
torque_af647.5e-8quadratic term
torque_bf64-0.0001325linear term
torque_cf640.055constant term
ang_vel_slopef64rad/s per µs1.33slope of the reported propeller speed
ang_vel_interceptf64rad/s-1466.67intercept of the same line
pwm_min_usu32µs1100bottom of this rotor's band
pwm_max_usu32µs2000top of the band; the simulator substitutes 1100 to 2000 if it is not above pwm_min_us

Gotcha. A bare RotorSpec::default() puts the rotor at [0, 0, 0]. A list of those is an aircraft with thrust and no control authority at all, because every moment arm is zero. Whatever you send is the airframe now.

Positions are from the origin

The simulator subtracts the centre-of-mass offset itself, so a position measured from the centre of mass gets it subtracted twice. Measure from the robot's origin.

Positions are read in your header frame, which is unity by default: +x right, +y up, +z forward, so a flat rotor ring lives in the x-z plane at y = 0. That is how ex27 lays one out.

From examples/rust/src/bin/ex27_rotor_config.rs:

#![allow(unused)]
fn main() {
fn ring(n: usize) -> Vec<RotorSpec> {
    (0..n)
        .map(|i| {
            let angle = std::f64::consts::FRAC_PI_4
                + (i as f64) * std::f64::consts::TAU / (n.max(1) as f64);
            RotorSpec::default().with_position([ARM_M * angle.sin(), 0.0, ARM_M * angle.cos()])
        })
        .collect()
}
}
The same in C++ (examples/cpp/ex27_rotor_config.cpp)
std::vector<vrsdk_rotor_spec_t> ring(std::size_t n) {
    std::vector<vrsdk_rotor_spec_t> out;
    out.reserve(n);
    for (std::size_t i = 0; i < n; ++i) {
        const double angle =
            PI / 4.0 + static_cast<double>(i) * 2.0 * PI / static_cast<double>(n > 0 ? n : 1);
        vrsdk_rotor_spec_t rotor = vrsdk::rotor_spec();
        rotor.position[0] = ARM_M * std::sin(angle);
        rotor.position[1] = 0.0;
        rotor.position[2] = ARM_M * std::cos(angle);
        out.push_back(rotor);
    }
    return out;
}
The same in Python (examples/python/ex27_rotor_config.py)
def ring(n: int) -> list[RotorSpec]:
    """``n`` reference rotors laid out on a flat ring of radius ``ARM_M``.

    In the default ``"unity"`` header frame the horizontal plane is x-z and +y is
    up, so the ring sits at y = 0. ``spin_dir`` is left at 0 so the simulator
    alternates clockwise/counter-clockwise by index, which is what keeps the yaw
    torques cancelling.
    """
    out = []
    for i in range(n):
        angle = math.pi / 4.0 + i * math.tau / max(n, 1)
        out.append(
            RotorSpec(position=(ARM_M * math.sin(angle), 0.0, ARM_M * math.cos(angle)))
        )
    return out

Each entry starts from the simulator's own reference rotor on every surface, RotorSpec::default(), vrsdk::rotor_spec() and RotorSpec(), because an entry carries no has_* flags: a field left at zero is a zero coefficient rather than an untouched one. C++ writes the three position components into a fixed array, where Rust and Python pass the whole triple.

spin_dir is left at 0 so the simulator alternates clockwise and counter-clockwise by index, which is what keeps the yaw torques cancelling.

The list replaces the list

One verb, no upsert: the slice you send becomes the whole rotor list. Two consequences follow.

It must describe every rotor, in index order. The rotor count is fixed when the airframe spawns and is actuator.pwm.len() in the state stream. Read it rather than assuming four.

A wrong-length slice drops the entire request. Never a partial apply, and acked ok anyway. ex27 proves it by sending one entry too few, with a curve that would put the aircraft on the ground:

#![allow(unused)]
fn main() {
    let short: Vec<RotorSpec> = ring(rotors.saturating_sub(1))
        .into_iter()
        .map(|r| r.with_thrust_curve(0.0, 0.0, 0.02))
        .collect();
    println!(
        "configure_rotors with {} entries for {rotors} rotors ...",
        short.len()
    );
    robot.configure_rotors(&short)?;
}
The same in C++ (examples/cpp/ex27_rotor_config.cpp)
std::vector<vrsdk_rotor_spec_t> shortlist = ring(rotors > 0 ? rotors - 1 : 0);
for (vrsdk_rotor_spec_t& r : shortlist) {
    r.thrust_a = 0.0;
    r.thrust_b = 0.0;
    r.thrust_c = 0.02;
}
std::printf("configure_rotors with %zu entries for %zu rotors ...\n", shortlist.size(),
            rotors);
robot.configure_rotors(shortlist);
The same in Python (examples/python/ex27_rotor_config.py)
short = [
    RotorSpec(position=r.position, thrust_a=0.0, thrust_b=0.0, thrust_c=0.02)
    for r in ring(max(rotors - 1, 0))
]
print(f"configure_rotors with {len(short)} entries for {rotors} rotors ...")
robot.configure_rotors(short)

Rust sets all three coefficients through one with_thrust_curve call, C++ assigns them on each struct in place, and Python rebuilds each entry around the position the ring produced. The wire result is identical, and so is the outcome: a list one entry short is dropped whole.

The aircraft climbs exactly as it did before, which is the proof that nothing was applied:

configure_rotors with <n-1> entries for <n> rotors ...
... returned Ok. That is a receipt, and the request was dropped:

The one length the SDK does refuse is zero, which returns VrError::InvalidArgument.

What the state stream will not tell you

actuator.measured is rotor speed in rad/s, computed from ang_vel_slope and ang_vel_intercept and the commanded pulse width, and nothing else. It is a reported line, not a measurement. Run 3 of ex27 cuts the thrust curve to 70% and leaves that line alone:

#![allow(unused)]
fn main() {
    let weak: Vec<RotorSpec> = ring(rotors)
        .into_iter()
        .map(|r| {
            let d = RotorSpec::default();
            r.with_thrust_curve(
                d.thrust_a * THRUST_SCALE,
                d.thrust_b * THRUST_SCALE,
                d.thrust_c * THRUST_SCALE,
            )
        })
        .collect();
    robot.configure_rotors(&weak)?;
}
The same in C++ (examples/cpp/ex27_rotor_config.cpp)
std::vector<vrsdk_rotor_spec_t> weak = ring(rotors);
const vrsdk_rotor_spec_t reference = vrsdk::rotor_spec();
for (vrsdk_rotor_spec_t& r : weak) {
    r.thrust_a = reference.thrust_a * THRUST_SCALE;
    r.thrust_b = reference.thrust_b * THRUST_SCALE;
    r.thrust_c = reference.thrust_c * THRUST_SCALE;
}
robot.configure_rotors(weak);
The same in Python (examples/python/ex27_rotor_config.py)
reference = RotorSpec()
weak = [
    RotorSpec(
        position=r.position,
        thrust_a=reference.thrust_a * THRUST_SCALE,
        thrust_b=reference.thrust_b * THRUST_SCALE,
        thrust_c=reference.thrust_c * THRUST_SCALE,
    )
    for r in ring(rotors)
]
robot.configure_rotors(weak)

All three read the reference coefficients back off a fresh default rotor rather than repeating the numbers from the table above, so 70% stays 70% of whatever the SDK's reference rotor is. The list is full length here, so this one applies.

The aircraft stops climbing while the rotor-speed echo does not move a digit:

1800 us on every rotor, three times:
  as spawned               climb=<rate> m/s   rotor speed echo=[...]
  after the short list     climb=<rate> m/s   rotor speed echo=[...]
  70% thrust curve         climb=<rate> m/s   rotor speed echo=[...]

Height is the evidence about thrust. The echo is not.

Attaching is a one-way door

This service replaces the rotor list and there is no read-back. Run it against the scene's multirotor and that aircraft flies whatever geometry and curves you sent, for every other client, until the scene is reloaded. reset() will not undo it, and the SDK cannot restore what it was never able to read.

Next: Mass spring damper and cart pole

See also: Driving a multirotor, Multirotor, Mass and inertia

Mass spring damper and cart pole

Two plants whose entire physics is a handful of numbers you can set.

cargo run -p vrobots-examples --bin ex28_hello_msd
./target/cpp-build/ex28_hello_msd
python examples/python/ex28_hello_msd.py
cargo run -p vrobots-examples --bin ex29_hello_cartpole -- <sys_id>
./target/cpp-build/ex29_hello_cartpole <sys_id>
python examples/python/ex29_hello_cartpole.py <sys_id>

ex29_hello_cartpole requires a sys_id, because a cart pole is scene-authored and is not in the spawn catalog. System ids are allocated at scene load and keep incrementing, so no constant could stay true; find the live one with vrobots topic list.

The mass spring damper

A mass spring damper is one mass on one axis:

m*x'' + c*x' + k*x = F

and the SDK owns every letter of it. m is set_physical_params, k and c are configure_msd, and F is set_msd_force. That makes it the one robot whose answer you can predict before you run it.

MsdConfig fieldTypeUnitsDefaultNotes
spring_kOption<f64>N/mNone, untouchednegatives are committed as zero by the simulator, so the SDK refuses them first
damping_cOption<f64>N·s/mNone, untouchedsame

From examples/rust/src/bin/ex28_hello_msd.rs, retuning the plant between runs:

#![allow(unused)]
fn main() {
    if retune {
        robot.configure_msd(&MsdConfig::default().with_spring_k(k).with_damping_c(c))?;
    }
    // Home, at rest, with the force latch cleared -- otherwise the previous run's
    // step is still pushing.
    robot.set_msd_force(0.0)?;
    robot.reset()?;
}
The same in C++ (examples/cpp/ex28_hello_msd.cpp)
if (retune) {
    auto config = vrsdk::msd_config();
    config.has_spring_k = true;
    config.spring_k = k;
    config.has_damping_c = true;
    config.damping_c = c;
    robot.configure_msd(config);
}
// Home, at rest, with the force latch cleared -- otherwise the previous run's
// step is still pushing.
robot.set_msd_force(0.0);
robot.reset();
The same in Python (examples/python/ex28_hello_msd.py)
if retune:
    robot.configure_msd(spring_k=k, damping_c=c)
# Home, at rest, with the force latch cleared -- otherwise the previous run's
# step is still pushing.
robot.set_msd_force(0.0)
robot.reset()

Two optional fields, three spellings: Rust chains with_spring_k and with_damping_c onto a default, Python names them as keyword arguments, and C++ writes each value beside its own has_* flag on a struct from vrsdk::msd_config(). In C++ the flag is what separates "set this value" from "leave it alone", so a number assigned without its flag never reaches the simulator.

The confirmation is arithmetic. A 20 N step settles at F / k, rings with period 2*pi*sqrt(m/k), and its damping ratio is c / (2*sqrt(k*m)). The example prints the measured value beside the predicted one for three plants:

20 N step, 1 kg, three plants:
                           x_final       F/k    period  2pi*sqrt    zeta
  as spawned (k=20, c=1)   <value>   <value>   <value>   <value> <value>
  k=80, c=1                <value>   <value>   <value>   <value> <value>
  k=80, c=16               <value>   <value>   <value>   <value> <value>

Gotcha. A negative k or c is not an error in the simulator, it is committed as zero: a spring that quietly vanished. configure_msd refuses negatives before they are sent, and that is the only protection there is. For any value it does accept, the committed number may still differ from the one you asked for, and the state stream is the only place that says which.

The last thing ex28 does is ask for a spring that pulls the wrong way, and read the refusal:

#![allow(unused)]
fn main() {
    match robot.configure_msd(&MsdConfig::default().with_spring_k(-5.0)) {
        Ok(()) => println!("\nUNEXPECTED: a negative spring constant was accepted"),
        Err(e) => println!("\nspring_k = -5.0 -> [{}] {}", e.code(), e.detail()),
    }
}
The same in C++ (examples/cpp/ex28_hello_msd.cpp)
try {
    auto config = vrsdk::msd_config();
    config.has_spring_k = true;
    config.spring_k = -5.0;
    robot.configure_msd(config);
    std::printf("\nUNEXPECTED: a negative spring constant was accepted\n");
} catch (const vrsdk::Error& e) {
    std::printf("\nspring_k = -5.0 -> [%d] %s\n", e.code(), e.what());
}
The same in Python (examples/python/ex28_hello_msd.py)
try:
    robot.configure_msd(spring_k=-5.0)
    print("\nUNEXPECTED: a negative spring constant was accepted")
except vrsdk.VrError as e:
    print(f"\nspring_k = -5.0 -> [{e.code} {e.kind}] {e.detail}")

The refusal is client-side on all three, so nothing reaches the wire. Rust returns it as a Result you match on, while C++ throws vrsdk::Error and Python raises vrsdk.VrError, which is why the two of them wrap the call in a try that the Rust version does not need.

spring_k = -5.0 -> [<code>] <message>
(the simulator would commit that as 0 and ack `ok`: no spring, no error)

The cart pole

srv/cartpole owns the whole plant, cart mass included.

FieldTypeUnitsDefaultNotes
cart_massOption<f64>kg1.0owned here, not by srv/params
travel_half_rangeOption<f64>m4.0rail travel each side of the spawn point; the cart dead-stops at the end of it
pole_rod_massOption<f64>kg0.1uniform rod between hinge and bob
bob_massOption<f64>kg0.2point mass at the tip
pole_lengthOption<f64>m1.2hinge to bob; the pole has no collider, so this one number is the whole pendulum geometry
pole_angular_dampingOption<f64>dimensionlessnot documented0 is a frictionless pivot
max_forceOption<f64>N20the clamp on set_cartpole_force
initial_pole_angle_degOption<f64>degreesnot documented0 upright, ±180 hanging, wrapped into [-180, 180]

Out-of-range values are not refused by the simulator, they are silently replaced by the defaults above and acked ok. configure_cartpole therefore refuses non-positive masses, lengths and forces itself.

From examples/rust/src/bin/ex29_hello_cartpole.rs:

#![allow(unused)]
fn main() {
    robot.configure_cartpole(
        &CartPoleConfig::default()
            .with_cart_mass(CART_MASS_KG)
            .with_travel_half_range(4.0)
            .with_pole_rod_mass(ROD_MASS_KG)
            .with_bob_mass(BOB_MASS_KG)
            .with_pole_length(POLE_LENGTH_M)
            .with_pole_angular_damping(0.01)
            .with_max_force(MAX_FORCE_N)
            .with_initial_pole_angle_deg(SEED_DEG),
    )?;
}
The same in C++ (examples/cpp/ex29_hello_cartpole.cpp)
{
    auto config = vrsdk::cartpole_config();
    config.has_cart_mass = true;
    config.cart_mass = CART_MASS_KG;
    config.has_travel_half_range = true;
    config.travel_half_range = 4.0;
    config.has_pole_rod_mass = true;
    config.pole_rod_mass = ROD_MASS_KG;
    config.has_bob_mass = true;
    config.bob_mass = BOB_MASS_KG;
    config.has_pole_length = true;
    config.pole_length = POLE_LENGTH_M;
    config.has_pole_angular_damping = true;
    config.pole_angular_damping = 0.01;
    config.has_max_force = true;
    config.max_force = MAX_FORCE_N;
    config.has_initial_pole_angle_deg = true;
    config.initial_pole_angle_deg = SEED_DEG;
    robot.configure_cartpole(config);
}
The same in Python (examples/python/ex29_hello_cartpole.py)
robot.configure_cartpole(
    cart_mass=CART_MASS_KG,
    travel_half_range=4.0,
    pole_rod_mass=ROD_MASS_KG,
    bob_mass=BOB_MASS_KG,
    pole_length=POLE_LENGTH_M,
    pole_angular_damping=0.01,
    max_force=MAX_FORCE_N,
    initial_pole_angle_deg=SEED_DEG,
)

Eight fields is where the C++ shape costs the most: sixteen assignments against eight setters or eight keyword arguments. A missed has_* flag among them leaves that one field at the plant's current value, and the ack is ok either way, so the pole standing up is the only receipt.

The pole is standing at the seed angle before the next line prints, which is the trap this page exists for:

plant set; the pole is re-seated at -3 deg AT REST, immediately -- not at the next reset

The degrees and radians trap

initial_pole_angle_deg is the only field in this API expressed in degrees, and the same angle comes back on the state topic in radians as actuator.measured[1]. A number that looks sensible in one unit is nonsense in the other, and nothing warns you.

It also behaves unlike every other configuration field. Changing it re-seats the pole at rest immediately, not at the next reset(). It is the episode's initial condition, so sending it mid-swing stops the swing dead. Re-sending the value it already has does nothing.

That makes it useful rather than dangerous once you know: ex29 uses it to stand the pole up before the balance loop starts, because that loop has no swing-up in it and cannot catch the pole from the simulator's own home angle of -45 degrees. Simulated offline against the linearised plant, its gains recover from about 15 degrees and no more.

Note. reset() does both halves of an episode restart at once: the cart returns to the rail centre and the pole is re-hung at rest at whatever home angle was last configured.

Mass belongs to this service, not to srv/params

A cart pole re-stamps its body mass from cart_mass on every parameter apply, so a mass sent to set_physical_params is overwritten a step later. Use configure_cartpole.

Find the rail centre before you balance

The cart slides along world x, but the rail is centred wherever the scene parked the rig, and travel_half_range is measured from that rather than from the world origin. Measured live, one scene's cart pole sits at x = -14.9. The simulator knows the difference internally and does not publish it, so a controller that regulates lin_pos[0] toward zero is ordering the cart metres away, past a dead stop it cannot cross.

Capture the origin yourself, the same way ex21 learns a multirotor's home: reset, settle, read.

#![allow(unused)]
fn main() {
    robot.reset()?;
    settle(&robot);
    let rail_centre = robot.states().kin.lin_pos[0];
}
The same in C++ (examples/cpp/ex29_hello_cartpole.cpp)
robot.reset();
settle(robot);
const double rail_centre = robot.states().kin().lin_pos[0];
The same in Python (examples/python/ex29_hello_cartpole.py)
robot.reset()
settle(robot)
rail_centre = robot.states.kin.lin_pos[0]

Only the path to the field differs. C++ reaches the kinematics block through the kin() accessor over the raw C state, and Python exposes states as a property rather than a call.

Every position term after that is relative to a number you measured rather than one you assumed:

rail centre measured at x = <metres> m (world). Every position term below is relative to THAT, not to 0.

Next: Skins

See also: Single degree of freedom plants, Mass spring damper, Cart pole

Skins

The only service that ever tells you no, and the one where a wrong name looks like success.

cargo run -p vrobots-examples --bin ex23_skins
./target/cpp-build/ex23_skins
python examples/python/ex23_skins.py

The catalogs

set_skin(&str) takes a catalog key and dresses the robot in it. The catalogs belong to the robot type and are matched case-insensitively.

RobotKeys
Multirotorblue, desert, gold, green, mono, pink, snow, white
Truckblack, blue, camouflage, gray, red
everything elsenone, so every request is a no-op

An empty or whitespace-only name is refused client-side with VrError::InvalidArgument, because on the wire an empty payload is a read-back probe rather than a skin.

The one refusal in the whole API

Skins are tier-gated inside the simulator. A tier refusal comes back as an honest ok = false with a reason, which the SDK surfaces as VrError::Service carrying the simulator's own message. That is the single place in this API surface where a service says no.

RequestReplyWhat actually happened
a key your tier allowsokthe skin changed
any key, tier too lowok = false plus a reasonVrError::Service; do not retry
gold on a truck (a multirotor key)oknothing, logged inside the simulator
chartreuse (in no catalog)oknothing, logged inside the simulator

Do not retry a VrError::Service from this service. It is tier-gated rather than transient, so the answer will not change. ex23 treats it as final and stops walking the list rather than asking four more times.

From examples/rust/src/bin/ex23_skins.rs:

#![allow(unused)]
fn main() {
    match robot.set_skin(skin) {
        Ok(()) => println!("set_skin({skin:?}) -> ok"),
        Err(VrError::Service(reason)) => {
            // The sim's own words. This is the ONLY service that ever gets here.
            println!("set_skin({skin:?}) -> REFUSED by the sim: {reason}");
            return Ok(false);
        }
        Err(other) => return Err(other),
    }
}
The same in C++ (examples/cpp/ex23_skins.cpp)
try {
    robot.set_skin(skin);
    std::printf("set_skin(\"%s\") -> ok\n", skin.c_str());
} catch (const vrsdk::Error& e) {
    // The sim's own words. This is the ONLY service that ever gets here, and
    // only for a tier refusal -- anything else is a real failure.
    if (e.code() != VRSDK_ERR_SERVICE) {
        throw;
    }
    std::printf("set_skin(\"%s\") -> REFUSED by the sim: %s\n", skin.c_str(), e.what());
    return false;
}
The same in Python (examples/python/ex23_skins.py)
try:
    robot.set_skin(skin)
    print(f"set_skin({skin!r}) -> ok")
except vrsdk.VrError as e:
    # The sim's own words. This is the ONLY service that ever gets here, and
    # only for a tier refusal -- anything else is a real failure.
    if e.code != vrsdk.err.SERVICE:
        raise
    print(f"set_skin({skin!r}) -> REFUSED by the sim: {e.detail}")
    return False

Rust matches the VrError::Service variant and hands every other variant back to the caller. C++ and Python have one error type each, so they catch it, compare the code against VRSDK_ERR_SERVICE or vrsdk.err.SERVICE, and rethrow anything that is not a tier refusal.

A permitted key prints one line and the truck changes colour. A refused one prints the simulator's own explanation and the run stops:

set_skin("black") -> ok
set_skin("blue") -> REFUSED by the sim: <the simulator's message>

Stopping: the tier gate does not open on a retry.

Note. Every other refusal in this chapter is silent. This one is the exception, and it is worth knowing precisely because of what the rest do instead.

A typo looks like success

An unknown key on a robot that has a catalog is acked ok and dropped with a log line no client can see. So is a key from another robot's catalog. ex23 demonstrates both, after walking the five real truck keys:

#![allow(unused)]
fn main() {
    println!("\n-- keys that are acked `ok` and dropped inside the simulator --");
    wear(&robot, WRONG_TYPE_SKIN)?; // a multirotor key, on a truck
    wear(&robot, UNKNOWN_SKIN)?; // no catalog has it
}
The same in C++ (examples/cpp/ex23_skins.cpp)
std::printf("\n-- keys that are acked `ok` and dropped inside the simulator --\n");
wear(robot, WRONG_TYPE_SKIN);  // a multirotor key, on a truck
wear(robot, UNKNOWN_SKIN);     // no catalog has it
The same in Python (examples/python/ex23_skins.py)
print("\n-- keys that are acked `ok` and dropped inside the simulator --")
wear(robot, WRONG_TYPE_SKIN)  # a multirotor key, on a truck
wear(robot, UNKNOWN_SKIN)  # no catalog has it

Rust's wear returns a Result, so these two calls still carry ? to propagate a genuine error even though the bool is dropped. The C++ and Python helpers return a plain bool and let an unexpected error unwind on its own. Neither key raises anything here: both come back ok.

Both return Ok(()), and the truck is still wearing the last key that worked:

-- keys that are acked `ok` and dropped inside the simulator --
set_skin("gold") -> ok
set_skin("chartreuse") -> ok
Both returned Ok. The truck is still wearing "red" -- the ack was a receipt for a request the robot then refused with a log line no client can see.

The confirmation is the robot in front of you. There is no read-back and no state field carrying the current skin.

On a truck a skin is not only cosmetic

The wheel colliders travel with the skin prefab, so a swap rebinds the physics wheels. ex23 keeps the truck rolling across every change so that shows up on the wire.

ChannelMeaning
actuator.measured[0..3]the four wheel speeds, FL, FR, RL, RR, in rad/s
actuator.measured[4]the steering servo
#![allow(unused)]
fn main() {
    for i in 0..HOLD_SAMPLES {
        robot.set_car(STEER_US, THROTTLE_US, Some(BRAKE_US))?;
        if i % 15 == 0 {
            let s = robot.states();
            let [vx, vy, vz] = s.kin.lin_vel;
            println!(
                "    t={:6.2}s speed={:5.2} m/s wheels={:?} steer_servo={:?}",
                s.elapsed,
                (vx * vx + vy * vy + vz * vz).sqrt(),
                // 0..3 are FL, FR, RL, RR in rad/s; they must keep turning
                // across the swap, because the colliders were just rebound.
                &s.actuator.measured[..s.actuator.measured.len().min(4)],
                s.actuator.measured.get(4)
            );
        }
        robot.rate(HZ);
    }
}
The same in C++ (examples/cpp/ex23_skins.cpp)
for (int i = 0; i < HOLD_SAMPLES; ++i) {
    robot.set_car(STEER_US, THROTTLE_US, BRAKE_US);
    if (i % 15 == 0) {
        const vrsdk::State s = robot.states();
        const double* v = s.kin().lin_vel;
        const double speed = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
        // 0..3 are FL, FR, RL, RR in rad/s; they must keep turning across
        // the swap, because the colliders were just rebound.
        std::printf("    t=%6.2fs speed=%5.2f m/s wheels=%s steer_servo=%s\n", s.elapsed,
                    speed, channels(s.actuator(), 0, 4).c_str(),
                    channels(s.actuator(), 4, 5).c_str());
    }
    robot.rate(HZ);
}
The same in Python (examples/python/ex23_skins.py)
for i in range(HOLD_SAMPLES):
    robot.set_car(STEER_US, THROTTLE_US, BRAKE_US)
    if i % 15 == 0:
        s = robot.states
        speed = math.dist(s.kin.lin_vel, (0.0, 0.0, 0.0))
        m = s.actuator.measured
        # 0..3 are FL, FR, RL, RR in rad/s; they must keep turning across
        # the swap, because the colliders were just rebound.
        wheels = [round(v, 3) for v in m[:4]]
        servo = [round(v, 3) for v in m[4:5]]
        print(
            f"    t={s.elapsed:6.2f}s speed={speed:5.2f} m/s "
            f"wheels={wheels} steer_servo={servo}"
        )
    robot.rate(HZ)

The brake is an Option in Rust and a plain number in the other two. Reading the wheel channels also differs: Rust and Python slice a growable list, while C++ has a fixed array with a separate measured_count, which is why ex23 gives its channels helper a first and last index to stay inside it.

The four wheel channels must keep turning across each swap. A wheel that flatlines is a rebind that did not take:

set_skin("camouflage") -> ok
    t=<seconds>s speed=<m/s> wheels=[<w0>, <w1>, <w2>, <w3>] steer_servo=Some(<value>)

That is also why a skin swap is worth doing while the truck is stationary if you care about repeatability: it is a change to the physics rig, not a texture swap.

The empty key

The name is trimmed before it is checked, so whitespace does not sneak past.

#![allow(unused)]
fn main() {
    match robot.set_skin("   ") {
        Ok(()) => println!("\nUNEXPECTED: an empty key was accepted"),
        Err(e) => println!("\nempty key -> [{}] {}", e.code(), e.detail()),
    }
}
The same in C++ (examples/cpp/ex23_skins.cpp)
try {
    robot.set_skin("   ");
    std::printf("\nUNEXPECTED: an empty key was accepted\n");
} catch (const vrsdk::Error& e) {
    std::printf("\nempty key -> [%d] %s\n", e.code(), e.what());
}
The same in Python (examples/python/ex23_skins.py)
try:
    robot.set_skin("   ")
    print("\nUNEXPECTED: an empty key was accepted")
except vrsdk.VrError as e:
    print(f"\nempty key -> [{e.code} {e.kind}] {e.detail}")

The error carries the same code and message everywhere, but you read it differently: e.code() with e.detail() in Rust, e.code() with e.what() in C++, and the attributes e.code, e.kind and e.detail in Python.

empty key -> [<code>] <message>

Next: Supported virtual robots

See also: Truck, Actuators, Appendix C: Error reference

Supported virtual robots

The roster: which robots exist, which you can create, and what each one accepts.

Six robot types are reachable from the SDK. Three of them you can ask the simulator to spawn; three exist only where a scene author placed them, and you attach to those by sys_id. This page is the index. The six pages after it use one fixed template, so you can compare any two robots section by section.

The roster

RobotRobotTypeCatalog keyCreatableDrive commandType-specific service
MultirotorMultirotormultirotoryesSET_MR_PWMsrv/rotors
TruckTrucktruckyesSET_CARsrv/drive
Mass spring damperMsdmsdyesSET_MSDsrv/msd
Cart poleCartPolecartpoleno, scene-authoredSET_INVPENsrv/cartpole
Half droneHalfDronehalfdroneno, scene-authoredSET_MR_PWM, exactly two valuesnone
Global HawkGlobalHawkglobalhawkno, scene-authoredSET_ANGVEL tracking, or direct surfacesnone

RobotType::from_catalog_key is case-insensitive and accepts synonyms: car for the truck, cart_pole and invpen for the cart pole, mass_spring_damper for the mass spring damper, half_drone for the half drone, global_hawk and rq4b for the Global Hawk. Only catalog_key() ever reaches the wire.

Creatable and scene-authored

The create catalog belongs to the scene, not to the SDK. The sandbox scene registers multirotor, truck and msd; the Global Hawk lives in the IMU scene instead. Asking srv/create for anything else is refused with a message naming the keys that scene does know, which is also the only live way to enumerate a catalog.

flowchart TD
  R[Virtual robots]
  R --> C["Creatable: connect(type, None)"]
  R --> S["Scene-authored: connect(type, Some(id))"]
  C --> C1[Multirotor]
  C --> C2[Truck]
  C --> C3[Mass spring damper]
  S --> S1[Cart pole]
  S --> S2[Half drone]
  S --> S3["Global Hawk (IMU scene)"]

A creatable robot can also be attached to: connect(RobotType::Multirotor, Some(1)) never touches srv/create and works for any robot the scene contains, whatever the catalog says. The arrow runs one way only, so a scene-authored type has exactly one route in.

System ids are allocated at scene load and keep incrementing across loads, so no constant in an example is a contract. Read the live ids with vrobots topic list.

cargo run -p vrobots-sdk --bin vrobots -- topic list

What every live robot serves

Seven services are common to every robot that is publishing, on vrobots/{sys_id}/z/srv/{segment}:

SegmentSDK methodWhat it does
activatepart of connectbrings a created robot online
resetreset()teleports home, zeroes velocity, re-latches the initial command
paramsset_physical_paramsmass and principal moments of inertia
skinset_skinappearance, from a per-type catalog
camerasmount_camera, unmount_cameracamera upsert and remove
sensorsconfigure_sensorsthe sensor noise model
framesset_framesrobot-level and device-level coordinate frames

Each robot type then adds at most one service of its own, as tabulated above. Asking a robot for a service its type does not serve returns VrError::NoResponder after the service timeout, and that is the only capability probe this API has: nothing in a state message names the robot's type.

Gotcha. srv/reset is the one service key where a payload-less GET performs the action instead of probing it. Probe any other key freely; never probe this one.

How to read the six pages

Every robot page carries the same eight sections in the same order: Identity, Physical model, Commands accepted, Services, Frame and units, Cameras, Known quirks, Example. A section with nothing to report says so rather than disappearing, so a blank is a fact and not an omission.

Two kinds of fact are deliberately missing throughout the chapter, because the repository does not contain them: default prefab masses and inertias, and a per-robot list of which sensors the airframe actually carries. Both are marked where they would have gone.

Next: Multirotor

See also: System ids, and the two kinds of robot, Sending commands, Services and configuration, Known simulator issues

Multirotor

Four rotors, direct pulse width control, and configurable thrust curves.

Identity

PropertyValue
RobotTypeMultirotor
Catalog keymultirotor
Synonymsnone
Creatableyes, in the sandbox catalog
Scene-authoredyes, the sandbox scene ships one
Type-specific servicesrv/rotors

On a fresh boot straight into the Flatworld scene the scene multirotor is sys_id 1, with the truck at 0. Ids are allocated at scene load and keep incrementing, so confirm with vrobots topic list rather than relying on that.

Physical model

A rigid body with one rotor per PWM channel. Each rotor turns its pulse width into a force and a moment through three curves the simulator evaluates every physics step:

thrust = (thrust_a * pwm^2 + thrust_b * pwm + thrust_c) * g      [N]
torque = spin_dir * (torque_a * pwm^2 + torque_b * pwm + torque_c) * g   [N·m]
omega  = ang_vel_slope * pwm + ang_vel_intercept                  [rad/s]

There is no mixing matrix anywhere in the simulator. Roll, pitch and yaw fall out of the rotor positions alone, so moving a rotor genuinely changes the airframe's response and an asymmetric aircraft is nothing more than a different rotor list. The rotor count is fixed when the airframe spawns and equals actuator.pwm.len() in the state stream: read it, do not assume four.

Nothing sits between your pulse widths and those curves. There is no attitude stabilisation and no rate damping, so you are the flight controller.

The prefab's default mass and inertia are not documented in the SDK; confirm against a live simulator. The only documented behaviour is that a mass of zero or less leaves the prefab's value in place, and that an inertia triple which is not strictly positive on all three axes leaves Unity's collider-derived tensor in place.

Commands accepted

CommandMethodUnits and rangeStatus
SET_MR_PWM (300)set_mr_pwm([f64;4])µs, 1100 to 2000live
SET_MR_PWM (300)set_mr_pwm_n(&[f64])µs, 1100 to 2000, one per rotorlive
SET_MR_THROTTLE (301)set_mr_throttle([f64;4])normalisedon the wire, nothing acts on it

[1100; 4] is idle and a flying aircraft falls. Hover is wherever total thrust crosses weight for the current mass and curves, so it moves when you change either. set_mr_pwm delegates to set_mr_pwm_n, which rejects an empty slice and checks every value is finite and inside the band.

Commands latch. The last pulse widths received stay in effect until the next ones arrive, there is no watchdog, and no command is ever acknowledged: proof that one landed is actuator.pwm echoing it back in the state stream.

Not yet. SET_MR_THROTTLE exists on the wire and no robot type acts on it. Sending it returns Ok(()) and changes nothing.

Services

The common seven plus srv/rotors, which carries the whole rotor list. The list is replaced rather than merged, so the slice you send must describe every rotor in index order; a wrong-length slice makes the simulator drop the entire request and acknowledge ok anyway. There is no read-back and no per-field flags inside an entry, so RotorSpec::default(), the simulator's own reference rotor, is the base you build on.

FieldUnitsDefault
positionm, from the robot origin, not the centre of mass[0, 0, 0]
spin_dir0.0, meaning alternate by index with even indices clockwise; +1 clockwise, -1 counter-clockwise
thrust_a7.5e-7
thrust_b-0.001325
thrust_c0.55
torque_a7.5e-8
torque_b-0.0001325
torque_c0.055
ang_vel_slope1.33
ang_vel_intercept-1466.67
pwm_min_usµs1100
pwm_max_usµs2000

The simulator substitutes 1100 to 2000 if the band is not strictly increasing. A bare default() puts every rotor at [0, 0, 0], which is an aircraft with thrust and no control authority.

Skins are available: blue, desert, gold, green, mono, pink, snow, white. srv/skin is the only service that ever answers ok = false, and it does so because the catalog is tier-gated, which is why a refusal must not be retried.

Frame and units

Everything is SI. Within Kinematics, pose (lin_pos, quat) is world frame while twist and acceleration (lin_vel, ang_vel, lin_acc, ang_acc) are body frame, in both the truth block and the estimate block. Quaternions are ordered [x, y, z, w].

Rotor positions and moments of inertia are read in your header frame, the one you set through ConnectOptions::coord_frame_id (default "unity"), and permuted into the robot's own. Read State::coord_frame_id for the frame the robot publishes in; it is authoritative.

The multirotor publishes frd, measured live. No per-robot-type native coord_frame_id exists in the SDK source, so the tag on the snapshot is the authority: read State::coord_frame_id in code.

Which sensors this airframe carries is not documented in the SDK; confirm against a live simulator. SensorConfig only guarantees that naming a sensor the robot does not carry is skipped with a simulator log line and still acknowledged ok.

Cameras

Nothing is camera-specific to this robot type. Like every vrobot it ships front_left and front_right at 720p rgba8, which is what open_camera attaches to; mount more with mount_camera when that pair cannot serve. Per-robot camera intrinsics are not documented in the SDK; intrinsics are whatever CameraOptions requested, read back through Frame::intrinsics. See Cameras and images.

Known quirks

Sim bug. A multirotor created through srv/create publishes and serves normally and its actuator echo is live, but its rigidbody never integrates: it does not fall under gravity, does not climb under thrust, and srv/reset teleports it and then it freezes again. Simulator v3.0.0, open, issues/created-multirotor-frozen-dynamics.md. Scene multirotors are unaffected, so the workaround is to attach to one by sys_id. The full account, including what attaching costs you, is on Known simulator issues.

Two more worth knowing before you configure anything:

  • Rotor positions are measured from the robot's origin, not its centre of mass. The simulator subtracts the centre of mass itself, so a centre-of-mass-relative value gets subtracted twice.
  • Neither mass nor inertia appears in the state message. They are quasi-static configuration, so the only confirmation that set_physical_params landed is behavioural: the same pulse width has to produce a different acceleration.

Example

Fly it open loop with ex02:

cargo run -p vrobots-examples --bin ex02_hello_control
./target/cpp-build/ex02_hello_control
python examples/python/ex02_hello_control.py

Rebuild the airframe under itself with ex27, and change the mass under a running controller with ex22. Both take an optional sys_id: pass one to attach to the scene multirotor, omit it to create a robot instead.

cargo run -p vrobots-examples --bin ex27_rotor_config -- 1
./target/cpp-build/ex27_rotor_config 1
python examples/python/ex27_rotor_config.py 1
cargo run -p vrobots-examples --bin ex22_physical_params -- 1
./target/cpp-build/ex22_physical_params 1
python examples/python/ex22_physical_params.py 1

Because of the frozen-dynamics bug above, pass the argument.

Next: Truck

See also: Driving a multirotor, Rotors and thrust curves, Mass and inertia

Truck

A four-wheeled ground vehicle with a configurable drivetrain.

Identity

PropertyValue
RobotTypeTruck
Catalog keytruck
Synonymscar
Creatableyes, in the sandbox catalog
Scene-authoredyes, the sandbox scene ships one
Type-specific servicesrv/drive

On a fresh boot straight into the Flatworld scene the scene truck is sys_id 0, with the multirotor at 1. Confirm with vrobots topic list, because ids keep incrementing across scene loads.

Physical model

A rigid body on four wheel colliders, driven by a steering servo and a drive motor whose parameters are all live: a change bites from the next physics step with no rebuild and no dropout. Steering is limited in both angle and rate; drive is a torque per driven wheel with a no-load wheel speed that sets top speed; braking is split into an idle brake, applied at neutral throttle, and a commanded brake.

Drive mode selects which wheels the motor torque reaches: 2 for rear wheel drive, 4 for all wheel drive.

The prefab's default mass and inertia are not documented in the SDK; confirm against a live simulator. As on every robot, a mass of zero or less leaves the prefab's value in place and an inertia triple that is not strictly positive on all three axes leaves Unity's collider-derived tensor in place.

There is nothing in the state message to read a drivetrain setting back from, so the way to confirm a change is to measure: drive a steady full-lock circle and compare the turn radius, speed / yaw_rate. A steering limit that halves must roughly double the radius.

Commands accepted

CommandMethodUnits and rangeStatus
SET_CAR (304)set_car(steer, throttle, brake)µs per channellive

Every channel is a pulse width, and each has its own meaning at the ends of the band:

Channel110015001900
steerfull leftcentrefull right
throttlefull reversestop, idle brakefull forward
brakereleasedfull

The brake channel is bottom-anchored: 1100 is released, not 1500. Passing brake = None sends the two-channel form of the command, which brakes nothing. The SDK checks every channel is finite and inside 1100 to 2000 microseconds before publishing.

Commands latch and are never acknowledged. The last SET_CAR stays in effect until the next one arrives, so a truck left by a stopped controller keeps its last steer and throttle.

Gotcha. The truck's factory pulse band is 1100 / 1500 / 1900, which is not the multirotor's 1100 to 2000. The SDK validates against the wider band because it does not know the airframe, so 1950 is accepted here and treated as full scale there.

Services

The common seven plus srv/drive. Every field is optional, and the simulator substitutes silently for anything out of range while still acknowledging ok.

FieldTypeUnitsSimulator behaviour
drive_modeOption<u32>must be 2 or 4, else ignored; the SDK refuses anything else first
max_steer_degOption<f64>deghard-clamped to 0 to 60
steer_rate_dpsOption<f64>deg/s0 means an ideal instantaneous servo
max_motor_torque_nmOption<f64>N·mper driven wheel
no_load_wheel_rpmOption<f64>rpmat full throttle with no load, so it sets top speed; zero or less becomes 200
idle_brake_torque_nmOption<f64>N·mper wheel
max_brake_torque_nmOption<f64>N·mper wheel
pwm_bandOption<PwmBand>µsfactory 1100 / 1500 / 1900

PwmBand { min_us, neutral_us, max_us, deadband_us } is four u32 microsecond values. If min < neutral < max does not hold, the simulator replaces the whole band with the factory values.

Skins are available: black, blue, camouflage, gray, red. A key from another robot's catalog, gold for instance, is acknowledged ok and dropped with a log line, so a typo looks like success.

Frame and units

Everything is SI. Pose (lin_pos, quat) is world frame and twist and acceleration (lin_vel, ang_vel, lin_acc, ang_acc) are body frame. Quaternions are ordered [x, y, z, w]. Moments of inertia sent through srv/params are read in your header frame and permuted into the robot's own.

The truck publishes fru, where the third component is up, while the multirotor publishes frd, where it is down. Both were measured live. No per-robot-type native coord_frame_id exists in the SDK source, so the tag on the snapshot is the authority: in code read State::coord_frame_id.

Which sensors this vehicle carries is not documented in the SDK; confirm against a live simulator. Naming a sensor the robot does not carry is skipped with a simulator log line and still acknowledged ok.

Cameras

Nothing is camera-specific to this robot type. Like every vrobot it ships front_left and front_right at 720p rgba8, which is what open_camera attaches to; mount more with mount_camera when that pair cannot serve. Per-robot camera intrinsics are not documented in the SDK; they are whatever CameraOptions requested, read back through Frame::intrinsics. See Cameras and images.

Known quirks

Gotcha. A skin swap on the truck is not purely cosmetic. The wheel colliders travel with the skin prefab, so changing the skin rebinds the physics wheels. Detect it by re-measuring a turn radius after the swap rather than assuming the drivetrain is untouched.

Two smaller ones:

  • max_steer_deg is clamped rather than refused. Asking for 90 degrees gets 60, and the acknowledgement is ok either way; the circle the truck drives is the only evidence.
  • Created trucks have real physics, confirmed live. The frozen-dynamics bug that affects created multirotors does not affect this type.

Example

Drive it with ex05:

cargo run -p vrobots-examples --bin ex05_hello_car
./target/cpp-build/ex05_hello_car
python examples/python/ex05_hello_car.py

Retune the drivetrain under a moving vehicle and measure the result with ex26, and try the skin catalog with ex23:

cargo run -p vrobots-examples --bin ex26_drive_config
./target/cpp-build/ex26_drive_config
python examples/python/ex26_drive_config.py
cargo run -p vrobots-examples --bin ex23_skins
./target/cpp-build/ex23_skins
python examples/python/ex23_skins.py

Neither takes an argument: both create the robot they need.

Next: Mass spring damper

See also: Driving the truck, The truck drivetrain, Skins

Mass spring damper

One mass on one axis: the only plant in the simulator you can predict on paper.

Identity

PropertyValue
RobotTypeMsd
Catalog keymsd
Synonymsmass_spring_damper
Creatableyes, in the sandbox catalog
Scene-authorednot required; create one when you need it
Type-specific servicesrv/msd

Because it is creatable, connect(RobotType::Msd, None) spawns one and the reply carries a fresh sys_id. Created mass spring dampers have real physics, confirmed live.

Physical model

One mass sliding on one axis against a spring and a viscous damper:

m * x'' + c * x' + k * x = F

The SDK owns every letter of it. m is set_physical_params, k and c are configure_msd, and F is set_msd_force. That is what makes this the right first target for control work: the closed-form answers are arithmetic, so a run that disagrees with them is telling you something about the wire rather than about the plant.

settles at   F / k              metres
period       2*pi*sqrt(m/k)     seconds
damping      c / (2*sqrt(k*m))  below 1 rings, near 1 slides home, above 1 crawls

A freshly created plant spawns with k = 20 N/m and c = 1 N·s/m. Its mass comes from set_physical_params like any other robot's, and the default is not documented in the SDK; confirm against a live simulator, or pin it yourself before predicting anything.

Commands accepted

CommandMethodUnits and rangeStatus
SET_MSD (305)set_msd_force(f64)N along the plant's +xlive

The SDK checks the value is finite. The simulator then clamps it silently to the plant's max_force, 100 N by default, so a request for 500 N is acknowledged in exactly the same way as one for 50.

The force latches like every other command. A step force stays applied until the next command replaces it, which is why releasing it takes an explicit set_msd_force(0.0) rather than merely stopping the loop.

Services

The common seven plus srv/msd, which carries two optional fields:

FieldTypeUnitsNotes
spring_kOption<f64>N/mthe k above
damping_cOption<f64>N·s/mthe c above

The simulator commits a negative value as zero and acknowledges ok: a spring that quietly vanished, not an error. configure_msd therefore refuses negatives and non-finite values client-side, as VrError::InvalidArgument, before anything is published. For everything it does send, the committed value may still differ from the asked-for one, and the state stream is the only place that says which.

The mass belongs to set_physical_params, not to this service. That is the opposite of the cart pole, where the type-specific service owns the mass.

srv/skin is served, as it is on every robot, but this type ships no skin catalog, so every skin request is a no-op.

Frame and units

Everything is SI. The plant publishes in the identity "unity" frame, so kin.lin_pos[0] is the position you watch in the editor and kin.lin_vel[0] is x'. The other two components never move.

The actuator block carries the plant's own arithmetic rather than a pulse width:

ChannelMeaning
actuator.measured[0]the total force on the mass, F - k*x - c*x', in newtons
actuator.measured[1]displacement from equilibrium, in metres

measured[0] is not the force you sent. It is what the spring and the damper left of it, which is why it crosses zero at every peak.

Which sensors a mass spring damper carries is not documented in the SDK; confirm against a live simulator. As on every robot, naming a sensor it does not carry is skipped with a simulator log line and still acknowledged ok.

Cameras

Nothing is camera-specific to this robot type. Where a robot carries the default pair, front_left and front_right at 720p rgba8, open_camera attaches to either without changing anything; this one may carry none, and a robot you created yourself carries whatever its prefab does, so mount_camera may be the only way to get pixels off it. vrobots topic list is the authority. See Cameras and images.

Known quirks

Gotcha. configure_msd and set_physical_params have no getters, so a plant you retuned stays retuned. reset() is a state reset: it returns the mass to its home position and zeroes velocity, and leaves k, c and the mass exactly as you last set them.

Two smaller ones:

  • The clamp on set_msd_force is invisible from the client. Compare the force you sent with actuator.measured[0] at the instant x and x' are near zero if you need to know whether you hit it.
  • A negative k or c is refused by the SDK, not by the simulator. Sending one through send_cmd or another client would leave you with a spring-free plant and an ok acknowledgement.

Example

ex28 tunes the plant three ways and prints the measured settling point, period and damping ratio beside the closed-form predictions:

cargo run -p vrobots-examples --bin ex28_hello_msd
./target/cpp-build/ex28_hello_msd
python examples/python/ex28_hello_msd.py

It takes no argument because it creates the robot it uses, and it deletes it on the way out.

Next: Cart pole

See also: Single degree of freedom plants, Mass spring damper and cart pole, Mass and inertia

Cart pole

A cart on a rail with an unactuated pole, and a service that owns the whole plant.

Identity

PropertyValue
RobotTypeCartPole
Catalog keycartpole
Synonymscart_pole, invpen
Creatableno, scene-authored only
Scenesandbox
Type-specific servicesrv/cartpole

connect(RobotType::CartPole, None) is refused by srv/create with a message naming the keys the sandbox scene does know, which are multirotor, truck and msd. The only route in is connect(RobotType::CartPole, Some(sys_id)), and the id comes from vrobots topic list.

Physical model

A cart on a rail with a pole hinged on top. One actuator, a force on the cart, and two things to control with it: that is what underactuated means, and why it is the textbook problem. The pole is unactuated by design.

The simulator pins the cart's rotation to identity and freezes y, z and every axis of rotation, so the rail really is the world x axis. srv/cartpole owns the entire plant, cart mass included:

FieldUnitsDefault
cart_masskg1.0
travel_half_rangem, each side of spawn4.0
pole_rod_masskg0.1
bob_masskg, a tip point mass0.2
pole_lengthm, hinge to bob1.2
pole_angular_dampingdimensionless, 0 is frictionlessnot documented
max_forceN, the clamp on set_cartpole_force20
initial_pole_angle_degdegreesnot documented

The two missing defaults are not documented in the SDK; confirm against a live simulator.

There is no collider on the pole, so pole_length is the whole pendulum geometry. Out-of-range values are not refused: the simulator silently replaces them with the defaults above and acknowledges ok, which is why configure_cartpole refuses non-positive masses, lengths and forces itself.

Commands accepted

CommandMethodUnits and rangeStatus
SET_INVPEN (306)set_cartpole_force(f64)N along the rail's +xlive

This is the only actuator on the robot. The SDK checks the value is finite; the simulator clamps it to CartPoleConfig::max_force, 20 N by default.

The force latches, and that matters more here than anywhere else: a controller that stalls for a second has not commanded zero, it has left its last force applied, and the pole is on the floor.

Services

The common seven plus srv/cartpole. The division of labour is unusual and worth stating plainly: srv/params cannot set the cart's mass, because the robot re-stamps it from cart_mass on every parameter apply, so a mass sent there is overwritten a step later. Use configure_cartpole.

Gotcha. initial_pole_angle_deg is in degrees while the state stream reports the pole angle in radians, and setting it re-seats the pole at rest immediately rather than at the next reset. It is the episode's initial condition, so sending it mid-swing stops the swing dead. The value is wrapped to [-180, 180], where 0 is upright and plus or minus 180 is hanging.

reset() does both halves of a restart at once: the cart returns to the rail centre and the pole is re-hung at rest at whatever home angle was last configured. This type ships no skin catalog, so every skin request is a no-op.

Frame and units

Everything is SI. The plant publishes in the identity "unity" frame, and the published kinematics is the cart's. The pole rides the actuator channels, of which there are exactly three:

ChannelMeaning
kin.lin_pos[0]cart position along the rail, m, in world coordinates
kin.lin_vel[0]cart speed along the rail, m/s
actuator.measured[0]the force actually applied, N, after the clamp
actuator.measured[1]pole angle theta, radians
actuator.measured[2]pole rate theta prime, rad/s

Theta is the signed angle from world +y to the hinge-to-bob direction, right-handed about +z, wrapped into [-pi, pi]. Zero is balanced upright and plus or minus pi is hanging, both, because that is the wrap seam, so a fallen pole may print either sign. A positive theta leans the bob toward -x.

Note the asymmetry between the two halves of kin. The position is world and needs the rail centre subtracted; the velocity does not, because a twist is a body quantity and the cart's body is pinned to identity.

Which sensors a cart pole carries is not documented in the SDK; confirm against a live simulator.

Cameras

Nothing is camera-specific to this robot type. Where a robot carries the default pair, front_left and front_right at 720p rgba8, open_camera attaches to either without changing anything; this one may carry none, in which case mount_camera is the only way to get pixels off it. vrobots topic list is the authority. See Cameras and images.

Known quirks

Gotcha. The rail centre is not the world origin and it is not on the wire. The travel limits are measured from wherever the scene parked the rig, not from the origin: measured live, one scene's cart pole sits at x = -14.9. A controller that regulates lin_pos[0] toward zero is ordering the cart fifteen metres to the world origin, past a dead stop it cannot cross, and nothing in its printout says why.

Capture the origin yourself before you try to balance. reset() teleports the cart to the pose captured at its first physics step, which is the rail centre, so one reset, one settle and one read of lin_pos[0] gives every later position term a number you measured rather than one you assumed.

Two more:

  • The simulator's own home angle is minus 45 degrees. A balance loop with no swing-up in it cannot catch the pole from there; stand the pole up with initial_pole_angle_deg first.
  • Simulated offline against the linearised plant, a textbook set of gains recovers from about 15 degrees of perturbation and no more.

Example

ex29 stands the pole up, finds the rail centre, and balances. The sys_id argument is required:

cargo run -p vrobots-examples --bin ex29_hello_cartpole -- 4
./target/cpp-build/ex29_hello_cartpole 4
python examples/python/ex29_hello_cartpole.py 4

Take the id from vrobots topic list; ids are allocated at scene load and keep incrementing, so no constant stays true. The example never deletes the robot, because it did not create it, and it releases the force latch on the way out. A Ctrl-C does not, and leaves the last force applied.

Next: Half drone

See also: Single degree of freedom plants, Mass spring damper and cart pole, System ids, and the two kinds of robot

Half drone

A bar on a pivot with a rotor at each end, controlled by exactly two pulse widths.

Identity

PropertyValue
RobotTypeHalfDrone
Catalog keyhalfdrone
Synonymshalf_drone
Creatableno, scene-authored only
Scenesandbox
Type-specific servicenone

Attach with connect(RobotType::HalfDrone, Some(sys_id)). The id comes from vrobots topic list and moves between sessions.

Physical model

A multirotor cut in half: a bar on a pivot with a rotor at each end, free to roll about the FRD forward axis with the two arms lying along the left-right axis, and constrained in everything else. It exists so that attitude control can be taught without six degrees of freedom arguing back.

I * theta'' = L2 * F2 - L1 * F1 + g * cos(theta) * m * (L1 - L2) - c * theta'

The only input is the difference between the two pulse widths. The bar dead-stops at a mechanical travel limit of 70 degrees each side by default, so a large enough difference parks it against the stop and nothing more.

IndexRotorEffect
0rotor1, the FRD left armdriving it high rolls FRD roll positive
1rotor2, the FRD right armdriving it high rolls it back

The airframe's mass, inertia, arm lengths and damping are not documented in the SDK; confirm against a live simulator. Mass and inertia are settable through set_physical_params like any other robot's, and the arm lengths and damping have no service at all.

Commands accepted

CommandMethodUnits and rangeStatus
SET_MR_PWM (300)set_mr_pwm_n(&[left_us, right_us])µs, exactly two entrieslive

That is the entire control surface. SET_MR_PWM carries one entry per rotor and the robot refuses any other count with a warning no client can see, so set_mr_pwm, whose fixed [f64; 4] is the quad's shape, is published happily and dropped there. A wrong count leaves the previous command latched.

Gotcha. This airframe's band tops out at 1900 microseconds, not the stock rotor's 2000. The SDK validates against the wider 1100 to 2000 because it does not know the airframe, so 1950 is accepted here and clamped there. Detect it by comparing what you sent with actuator.pwm in the state stream.

A fresh spawn, and every reset, latches [1100, 1100].

Services

The common seven and nothing else. Two rotors and a hinge need no configuration service, so the half drone is the canonical example of a capability probe: asking it for srv/rotors is a GET to a key nobody serves, and after the service timeout it answers VrError::NoResponder.

That timeout is the type discovery. Nothing in a state message names the robot's type, and a command for the wrong type is silently ignored rather than refused, so if you need to know what you are attached to, ask for a service only that type serves and time the answer. The cost is real, since a probe is a wait for an answer that is not coming: budget it with ConnectOptions::service_timeout rather than taking the eight second default. A probe is also indistinguishable from a simulator that is not running, which vrobots topic list tells apart.

This type ships no skin catalog, so every skin request is a no-op.

Frame and units

Everything is SI, and the interesting quantities are in FRD. There are no Euler angles on the state wire, so every consumer derives them the same way from kin.quat, ordered [x, y, z, w]:

roll = atan2(2*(w*x + y*z), 1 - 2*(x*x + y*y))     rad, FRD
rate = kin.ang_vel[0]                              rad/s, FRD roll rate

Pose is world frame and twist is body frame, as on every robot, and State::coord_frame_id is the authoritative name for the frame this robot publishes in. No per-robot-type native coord_frame_id is defined in the SDK source, so which frame the half drone comes up in is not documented in the SDK; confirm against a live simulator.

Which sensors it carries is not documented in the SDK; confirm against a live simulator.

Cameras

Nothing is camera-specific to this robot type. Where a robot carries the default pair, front_left and front_right at 720p rgba8, open_camera attaches to either without changing anything; this one may carry none, in which case mount_camera is the only way to get pixels off it. vrobots topic list is the authority. See Cameras and images.

Known quirks

Gotcha. set_mr_pwm returns Ok(()) on this robot and does nothing. The SDK cannot know the rotor count, the simulator drops the wrong-length command with a log line, and the previously latched pulse widths stay in effect, so the failure looks exactly like a robot that is not responding. Read actuator.pwm.len() to learn the count, and use set_mr_pwm_n.

Two more:

  • Because there is no type-specific service, there is no way to change the travel limit, the arm lengths or the hinge damping from the SDK.
  • The pulse widths latch. A program that exits without idling both rotors leaves the bar driven against whatever it was last commanded.

Example

ex30 runs the capability probe, shows a four-entry command being dropped, then sweeps the differential and prints the roll angle it produces. The sys_id argument is required:

cargo run -p vrobots-examples --bin ex30_hello_halfdrone -- 4
./target/cpp-build/ex30_hello_halfdrone 4
python examples/python/ex30_hello_halfdrone.py 4

Take the id from vrobots topic list. The example shortens the service timeout to three seconds for the probe, never deletes the robot, and idles both rotors on the way out.

Next: Global Hawk

See also: Driving a multirotor, Robot lifecycle, Actuators

Global Hawk

A fixed wing aircraft with six control panels, an onboard rate loop, and thrust in newtons.

Identity

PropertyValue
RobotTypeGlobalHawk
Catalog keyglobalhawk
Synonymsglobal_hawk, rq4b
Creatableno, scene-authored only
SceneIMU scene, not the sandbox
Type-specific servicenone

Attach with connect(RobotType::GlobalHawk, Some(sys_id)). The id comes from vrobots topic list. This is the only robot in the roster that lives outside the sandbox scene, so a sandbox session will not show one at all.

Physical model

An RQ-4B airframe with six aerodynamic panels and one engine. It is the first robot in this book that flies itself: onboard rate PIDs and an airspeed hold close the loop against whatever SET_ANGVEL setpoint arrives, and left alone it cruises at 72.8 m/s and needs nothing from you.

QuantityValue
Panels6
Surface deflection limit20 degrees, default
Maximum thrust20 kN
Trim cruise72.8 m/s under the onboard loop
Thrust at trimabout 3.8 kN

Two control modes, selected by SET_FW_CTRL_MODE:

ConstantValueBehaviour
FW_ONBOARD_RATE0default; the simulator flies via a rate loop tracking SET_ANGVEL
FW_DIRECT_SURFACE1the rate loop is bypassed and the six panels take your radians verbatim

Mass, inertia and wing geometry are not documented in the SDK; confirm against a live simulator. Mass and inertia are settable through set_physical_params like any other robot's.

Commands accepted

CommandMethodUnits and rangeStatus
SET_ANGVEL (51)send_cmd(cmd::SET_ANGVEL, ...) with a vec3rad/s, body rateslive, tracked by the onboard loop
SET_FW_SURFACES (307)set_fw_surfaces(&[f64])radians, one per panellive in FW_DIRECT_SURFACE
SET_FW_THRUST (308)set_fw_thrust(f64)N, clamped to [0, 20000]live
SET_FW_THRUST_BIAS (309)set_fw_thrust_bias(f64)N, signed trimlive in FW_ONBOARD_RATE only
SET_FW_CTRL_MODE (310)set_fw_ctrl_mode(i32)0 or 1live
SET_FW_EST_SOURCE (311)set_fw_est_source(i32)0 or 1live in FW_ONBOARD_RATE only

SET_ANGVEL (51) has the typed wrapper set_angvel(vec3); the generic send_cmd path spells the same bytes, and ex33 keeps using it as the escape-hatch demonstration. It carries a vec3, which means it is re-expressed from your header frame into the robot's as an axial vector; connect with coord_frame_id set to "frd" and [0, 0, r] means what it looks like.

There is no mixer in FW_DIRECT_SURFACE. Each entry drives its own panel, in this order, and the length must equal the panel count exactly: a wrong-length array makes the simulator drop the whole command rather than apply it partially.

IndexPanelWhat the onboard mixer does with it
0left outboard flapaileron, gain +1
1right outboard flapaileron, gain -1
2left inner flapnothing, gain 0 on all three channels
3right inner flapnothing
4rear left ruddervatorelevator -1, rudder +1
5rear right ruddervatorelevator -1, rudder -1

Indices 2 and 3 are the proof that the per-panel path is real: the simulator's own mixer has zero gain there and can never move them, so an inner flap that follows your command came through SET_FW_SURFACES.

set_fw_thrust in FW_ONBOARD_RATE pins the engine off airspeed hold and clears any thrust bias, last writer wins; send set_fw_thrust_bias(0.0) to release it back to airspeed hold. In FW_DIRECT_SURFACE the bias is ignored entirely.

Services

The common seven and nothing else. The Global Hawk adds no type-specific service: its control surface is entirely command-level, so a configure_* call meant for another type answers VrError::NoResponder.

reset() reverts both the control mode, to FW_ONBOARD_RATE, and the estimate source, to FW_EST_TRUTH. That is deliberate: keeping direct control with the surface latches zeroed would relaunch the aircraft unflyable. A direct-surface client must re-assert both after every reset. A reset also clears the SET_ANGVEL setpoint, unlike every other latch on this aircraft.

This type ships no skin catalog, so every skin request is a no-op.

Frame and units

Everything is SI: radians for surfaces, newtons for thrust, rad/s for body rates. Pose is world frame and twist is body frame, as on every robot.

The actuator echo is shaped differently from every other robot's, and this is the fact to carry away from the page. actuator.measured has panels plus one entries:

measured[0..=5]  per-panel deflection, RADIANS
measured[6]      the engine, NEWTONS, not normalised and not a pulse width

That echo is the only receipt for anything on this aircraft, including set_fw_thrust.

The aircraft publishes "frd", with axis_convention reading Axes::FRD, verified live on 2026-08-09 against simulator v3.0.0. The setpoint on z/cmd arrives in the sender's frame, unconverted, while the in-game IMU panel stamps the aircraft's own frd, so it reads [p, q, r] in rad/s and is directly subtractable from kin.ang_vel. No per-robot-type native coord_frame_id is defined in the SDK source, so the tag on the snapshot is the authority: in code, read State::coord_frame_id.

Which sensors it carries is not documented in the SDK; confirm against a live simulator.

Cameras

Nothing is camera-specific to this robot type. Where a robot carries the default pair, front_left and front_right at 720p rgba8, open_camera attaches to either without changing anything; the IMU scene's own defaults are not documented in the SDK and may be none, in which case mount_camera is the only way to get pixels off it. vrobots topic list is the authority. See Cameras and images.

Known quirks

Gotcha. Bumpless is not zeroed. Entering FW_DIRECT_SURFACE seeds the surface and thrust latches from what the plant is doing now, including whatever thrust the airspeed hold happened to be holding, so nothing jolts. A client that wants a particular thrust must therefore send set_fw_thrust after every mode entry and after every reset. Skip it and the engine keeps the autopilot's value, usually about 3.8 kN at trim.

Three more:

  • Everything latches and there is no watchdog. Stop sending and the aircraft keeps flying your last deflections forever, exactly like a dead PWM client on a multirotor.
  • An estimate older than 0.5 s is treated as stale, and the onboard loop silently falls back to truth until a fresh one arrives. The SDK does not publish z/estimate yet, which bounds what FW_EST_OBSERVER can be used for today.
  • Version skew is documented rather than fixed. An old SDK against a new simulator is unaffected, since the aircraft still defaults to onboard rate. A new SDK against an old simulator sees unknown command ids ignored silently, so set_fw_surfaces appears to succeed while nothing moves. Detect it from the actuator echo length: an old simulator publishes six entries rather than seven.

Example

ex31 takes the surfaces off the autopilot and demonstrates the latch, the reset and the bumpless entry. ex32 closes an external rate loop around the operator's stick, and ex33 switches the estimate source. All three require a sys_id argument:

cargo run -p vrobots-examples --bin ex31_globalhawk_direct -- 15
./target/cpp-build/ex31_globalhawk_direct 15
python examples/python/ex31_globalhawk_direct.py 15
cargo run -p vrobots-examples --bin ex32_fw_rate_controller -- 15
./target/cpp-build/ex32_fw_rate_controller 15
python examples/python/ex32_fw_rate_controller.py 15
cargo run -p vrobots-examples --bin ex33_fw_est_source -- 15
./target/cpp-build/ex33_fw_est_source 15
python examples/python/ex33_fw_est_source.py 15

Take the id from vrobots topic list, against the IMU scene. None of them deletes the aircraft, and each hands it back to its autopilot on the way out; a Ctrl-C does not, and leaves it flying the last deflections.

Next: Known simulator issues

See also: Fixed wing control, Reading someone else's commands, The generic command

Known simulator issues

Simulator-side defects and licensing behaviour that change what the SDK can do.

Everything on this page is a property of the simulator, not of the SDK, so none of it can be fixed by upgrading the crate. Each entry says how to detect the symptom, because in a system where an acknowledgement is a receipt rather than a result, most of these present as nothing happening.

Created multirotors do not integrate physics

Status: open, simulator side, v3.0.0. Tracked in issues/created-multirotor-frozen-dynamics.md.

A multirotor created through vrobots/manager/z/srv/create comes up publishing and serving normally, and its actuator echo is live, but its rigidbody never integrates. The command path, the actuator model and the state publisher all run; only physics integration is dead.

StimulusScene multirotorCreated multirotor
SET_MR_PWM 1800 µs on four rotors, 3 sclimbs 19.7 mzero motion
gravity alonefalls, then restshangs at spawn altitude forever
set_body_force, 100 N up, 3 szero motion
configure_rotors then 1800 µszero motion
set_physical_params mass 1 kg, then 1800 µszero motion
srv/reset then 1800 µsteleports home, then freezes again
actuator echo and rotor speed modellivelive

The fault is type-specific. Created trucks show real measured turn radii and created mass spring dampers show real oscillation physics, so only the multirotor spawn path is affected, and scene-authored multirotors are fine.

The workaround shipped in the examples is an optional sys_id argument on ex21_reset, ex22_physical_params and ex27_rotor_config: pass one and the example attaches to the scene multirotor instead of creating a robot.

cargo run -p vrobots-examples --bin ex22_physical_params -- 1
./target/cpp-build/ex22_physical_params 1
python examples/python/ex22_physical_params.py 1

Gotcha. Attaching has a price. Anything ex22 or ex27 configures stays configured on that shared robot until the scene reloads, because reset() is a state reset and neither srv/params nor srv/rotors has a getter to restore the old value from. A later program attaching to the same sys_id inherits whatever the last one left.

HeliModel exists and serves nothing

The simulator contains a HeliModel, and a Zenoh GET probe of every srv/* key finds none of the seven common services registered against it. It is a display model, not an IPC robot: it has no sys_id you can attach to and no state topic.

The command id space agrees. SET_HELI (303) and SET_OMROVER (302) both exist in vrobots_sdk::cmd, and neither robot type is in the simulator, so both are absent rather than merely not yet acted on.

Tier gating decides how many robots serve at once

Both behaviours were verified live on 2026-08-05.

TierWhat serves
Proall scene robots publish and serve simultaneously
Guestonly the robot selected in the simulator's SYS-ID dropdown has its ports open

Under Guest, a robot that is present in the scene and not selected in the dropdown is indistinguishable from a robot that does not exist: no state topic, and every service GET times out. If vrobots topic list shows one robot where you expect four, check the tier before suspecting the SDK.

Skins are tier-gated separately. srv/skin is the only service in the whole API that ever answers ok = false, and it does so for a tier refusal, carrying the simulator's own reason. That surfaces as VrError::Service and must not be retried, because the answer will not change.

Global Hawk version skew

The fixed wing command ids were added to both sides at once, so an SDK and a simulator can disagree about them. The skew is documented rather than prevented.

CombinationBehaviour
Old SDK, new simulatorunaffected; the aircraft still defaults to FW_ONBOARD_RATE and tracks SET_ANGVEL
New SDK, old simulatorthe old robot ignores unknown command ids silently, so set_fw_surfaces appears to succeed and nothing moves

The second case is detectable, and the check costs one state read. A simulator too old for this work publishes an actuator echo with six entries; a current one publishes seven, because index 6 is the engine in newtons.

Note. Ignoring an unknown command id is correct behaviour, not a bug: the id space is shared across every robot type and no robot implements all of it. That is also why a command sent to the wrong robot type is silently dropped rather than refused.

Service coverage is closed

issues/service-coverage.md was closed on 2026-08-05. Every service and every robot type in the roster now has a Rust core implementation, a C API, C++ and Python bindings, and a live-verified example, ex21 through ex33. The one piece of unfinished business moved to the frozen-dynamics issue at the top of this page.

The issue is kept rather than deleted because its method is the reference for the next drift check between the simulator and the SDK: probe every vrobots/{sys_id}/z/srv/{segment} key with a payload-less Zenoh GET, since an acknowledgement means the service is registered and a timeout means it is not.

Gotcha. That probe is safe on every key except srv/reset, where a bare GET performs an actual reset rather than testing for a responder.

One related issue also closed on 2026-08-05: issues/srv-cameras-add-remove.md. Mounting or unmounting a single camera is confirmed live not to disturb the scene default front_left and front_right streams, so the older warning that mounting a camera wipes the robot's defaults no longer applies.

Next: Tooling and diagnostics

See also: Multirotor, Global Hawk, The vrobots command, Skins

Tooling and diagnostics

This chapter gives you a four-rung ladder to climb when a program misbehaves, and names the page that answers each rung.

Most of the time you arrive here because something produced no output, no motion, or numbers that look wrong. The fastest route out is not to read your own code. It is to establish, in order, what the wire is actually doing.

The ladder

RungQuestionCommandLibrary callPage
1Is anything publishing at all, and under which ids?vrobots topic listlist_topicsThe vrobots command, Discovery from code
2At what rate, and how steadily?vrobots topic hz <TOPIC>measure_rateThe vrobots command, Measuring rates
3Do the two ends speak the same schema?vrobots --versionversion_infoVersions and pins
4What is the SDK doing internally?RUST_LOG=vrobots_sdk=debug ...init_loggingLogging

Climb it in order

Rung 1 first, always. Every rung below it assumes traffic exists, and the single most common cause of a silent program is a simulator that is not in Play mode. An empty listing is an answer, not a failure.

Rung 2 next, because a topic that is present is not the same as a topic that is healthy. A control loop that stutters usually has a perfectly good average rate and a maximum interval five times the mean, and only the distribution shows that.

Rung 3 comes before rung 4 whenever rung 1 said nothing. A version mismatch does not report itself as an error: iceoryx2 compares the full version triple on every shared-memory open and silently delivers nothing when it disagrees, which presents as absence rather than as a fault. Checking a version takes one command and rules out a class of bug that logs will never explain.

Rung 4 last. tracing events describe what the SDK did on your behalf, which is the right level of detail once you know the wire is alive and compatible, and far too much detail before that.

Note. The vrobots binary and the library calls answer the same questions from the same code. The CLI is the version you can run without writing a program; list_topics and measure_rate are the version whose result is data you can branch on.

Two pages that are not rungs

More than one robot covers holding two handles in one process, which is where frame disagreements between robot types stop being theoretical.

Recording and testing without the simulator covers capturing real wire bytes once and replaying them in unit tests, which is what makes cargo test meaningful with Unity closed.

Where else to look

This chapter is organised by tool. If you would rather work from a symptom, start at When nothing happens. If you have an error value in hand and want to know what it means, go to Appendix C: Error reference.

Next: The vrobots command

See also: When nothing happens, Two transports, one simulator

The vrobots command

Every subcommand and flag, and how to read the output column by column.

cargo run -p vrobots-sdk --bin vrobots -- topic list

The SDK builds a binary called vrobots. It needs no dev tooling, no configuration and no robot handle, and it is the first thing to run when a program is silent.

Global flags

Both are global, so they are accepted before or after a subcommand.

FlagDefaultNotes
-V, --versionPrints the build's version block and exits 0. Nothing touches the wire.
--log <FILTER>warntracing filter, e.g. debug or vrobots_sdk=debug,zenoh=info. RUST_LOG overrides it.

topic list

Answers "is the simulator publishing at all, and under which ids".

FlagDefaultNotes
-t, --timeout <SECS>1.5zenoh observation window. The iceoryx2 registry lookup ignores it.
-k, --keyword <STR>Case-insensitive substring filter on the topic name.
--router <ENDPOINT>An explicit zenoh router, e.g. tcp/192.168.1.10:7447.

A run against a live simulator prints one row per topic, then a footer:

wire       Hz     bytes  topic
[i]        -         -  vrobots/1/i/cam/front_left/720p_rgba8
[i]        -         -  vrobots/1/i/cam/front_right/720p_rgba8  (stale: no process attached)
[z]      1.3      1760  vrobots/1/z/frames
[z]     25.3     45600  vrobots/1/z/state

4 topic(s); zenoh observed over 1.5s
[z] zenoh, measured by listening.  [i] iceoryx2, read from the registry:
    it exists, but Hz/bytes were not measured -- same host only.

Column by column:

ColumnMeaning
wire[z] zenoh, [i] iceoryx2. It repeats the transport segment in the topic name, so you can also read it off the key.
HzSamples divided by the window. A measurement for [z], and - for [i] because nothing was watched.
bytesTotal payload bytes seen during the window, not bytes per sample. - for [i].
topicThe full key. This is the exact string topic hz, measure_rate and CameraStream::service_name all use.

The rows are sorted by system id first and key second, which is why the two camera streams for sys_id 1 come before its zenoh topics.

Two things in that listing carry information beyond their numbers. 1.3 Hz on a topic that publishes at 1 Hz is the window being short, not the publisher being fast: three samples over 1.5 s of wall clock reads high because the window includes zenoh's discovery latency at one end and a partial period at the other. And (stale: no process attached) marks an iceoryx2 service record whose owning process is gone. The stream is dead rather than idle, and the footer counts them separately.

Gotcha. An empty list exits 0, because "nothing is publishing" is a legitimate answer to "what is publishing". Only topic hz treats silence as a failure.

With nothing running, the command says so and tells you what to try:

(no topics)

Nothing published on `vrobots/**` in 1.5s, and no iceoryx2 camera
stream is registered. Usually one of:
  - the simulator is not in Play mode
  - it is on another machine (pass --router tcp/<host>:7447)
  - the window was too short for zenoh discovery (try -t 5)

topic hz

Answers "how fast, how steadily, and am I losing samples". One key, no wildcards: a single rate across several interleaved topics is not a rate, and the SDK refuses it with InvalidArgument before opening a session.

cargo run -p vrobots-sdk --bin vrobots -- topic hz vrobots/1/z/state -w 5
ArgumentDefaultNotes
<TOPIC>requiredThe exact key topic list printed. The transport is read off the name: an /i/ segment is iceoryx2, anything else is zenoh.
-w, --window <SECS>5.0How long to watch. Longer is more accurate.
--router <ENDPOINT>Ignored for iceoryx2 topics, which are always same-host.

A healthy state topic looks like this:

vrobots/1/z/state  [z]  watched 5.0 s

  rate          25.00 Hz      126 samples over a 5.000 s span
  interval   mean 40.00 ms    min 40.00   max 40.00   jitter 0.00 (sd)
  latency    mean 1.20 ms    min 0.80   max 2.10   publish stamp -> here
  seq        1 -> 126      0 gap(s), 0 missed
  payload        1200 B avg   30.2 kB/s over the window

Line by line:

LineWhat it says
rate(samples - 1) / span, where span is first arrival to last arrival. Not samples divided by the window, which would count zenoh's discovery latency as dead air.
intervalThe gap distribution between arrivals, in ms. jitter is the population standard deviation of those gaps.
latencyArrival wall clock minus the publisher's header.timestamp_ns. Printed only when the payloads carry a header.
seqFirst and last header.seq, how many separate forward jumps occurred, and how many samples those jumps imply were lost.
payloadMean payload size, and throughput computed over the window rather than the span.

max on the interval line and the gap count are the two numbers that explain a stuttering control loop. A mean of 40.00 ms says nothing about the 200 ms stall that broke it; the maximum interval is the worst stall the loop actually saw, and a non-zero gap count says the samples covering that stall were dropped rather than delayed. Read those two before the rate.

The block grows extra lines when there is something to say: a decode line when payloads failed to parse, a paragraph explaining that latency across machines measures clock offset and can go negative, a paragraph when gaps occurred, and a paragraph when seq went backwards. See Measuring rates for what each of those means.

record

Captures raw wire payloads to files, for tests that run with the simulator closed. Recording and testing without the simulator covers the workflow; the flags are here.

FlagDefaultNotes
--sys-id <U32>0The robot to record from.
--camera <NAME>Record raw iceoryx2 camera slices instead of zenoh state payloads.
--resolution <STR>360pUsed with --camera.
--format <STR>mono8Used with --camera.
--mountoffMount the camera first and unmount it afterwards. Requires --camera. Mutates the simulator.
-n, --count <USIZE>5Frames to capture.
-t, --timeout <SECS>10.0Give up after this long.
-o, --out <PATH>crates/vrobots-sdk/tests/fixturesWhere <prefix>_NNN.bin is written.
--prefix <STR>stateFile name prefix.
--router <ENDPOINT>zenoh only. Camera slices are shared memory, so a router does not apply.

Exit codes

CodeMeaning
0Success, including --help and --version, and including topic list finding nothing.
1The command ran and failed. topic hz with no samples lands here, as does running vrobots with no subcommand.
2The arguments did not parse.

cli::run(args) returns that code rather than calling process::exit, so an embedder such as the Python wheel's console script keeps control of the process.

Next: Discovery from code

See also: Measuring rates, The topic namespace, Appendix A: Topic reference

Discovery from code

You get the same answer vrobots topic list gives, as data your program can branch on.

cargo run -p vrobots-examples --bin ex11_topic_discovery
./target/cpp-build/ex11_topic_discovery
python examples/python/ex11_topic_discovery.py

Discovery needs no robot and no connect. It answers "is the simulator publishing, and under which ids" before you have a handle to ask with, which is the order those two questions actually occur in.

The calls

Two listing calls and one capability question. From crates/vrobots-sdk/src/discovery.rs:

#![allow(unused)]
fn main() {
pub fn list_topics(timeout: Duration) -> VrResult<Vec<TopicInfo>>
pub fn list_topics_with(timeout: Duration, options: &ConnectOptions) -> VrResult<Vec<TopicInfo>>
pub fn discovery_covers_all_transports() -> bool
}
The same in C++ (cpp/include/vrobots_sdk.hpp)
inline std::vector<TopicInfo> list_topics(double timeout_s = 1.5,
                                          const vrsdk_connect_options_t* options = nullptr)
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
def list_topics(
    timeout: float = 1.5, router: Optional[str] = None
) -> list[TopicInfo]: ...

Rust splits the plain and the options-taking call in two; C++ and Python fold both into one function with defaulted trailing arguments, and Python narrows the options to the one field that matters here, router. Neither wrapper exposes discovery_covers_all_transports: it exists only as vrsdk_discovery_covers_all_transports in crates/vrobots-sdk-capi/include/vrobots_sdk.h.

The listing calls block for timeout and print nothing; what they return is the vector described below.

The result is sorted by (sys_id, key), so it is stable run to run. list_topics_with takes a ConnectOptions for the one field that matters here, router_endpoint, which reaches a simulator on another host. discovery_covers_all_transports returns true in this build and exists so a future build without shared memory can say that camera streams are missing rather than let an absent stream read as a broken camera.

An empty vector is a legitimate result, not an error. VrError::Session is what you get when zenoh will not open or the iceoryx2 registry cannot be read.

TopicInfo

FieldTypeNotes
keyStringThe full key expression, e.g. vrobots/1/z/state. For iceoryx2 it is also the service name.
transportTransportZenoh or Iceoryx2. transport.tag() gives "z" or "i".
sys_idOption<u32>The owning robot, or None for the manager and scene keys.
observedbooltrue when the entry came from watching traffic, false when it came from a registry.
liveboolWhether anything currently holds the topic open. Always true for zenoh.
samplesu64Payloads seen during the window. 0 when observed is false.
bytesu64Total payload bytes during the window. 0 when observed is false.
hzf64samples / window. 0.0 when observed is false.

Observed versus registered

observed is the field the rest of the struct depends on, and it exists because the two transports answer "what topics are there" by completely different means.

flowchart TB
  Q["list_topics(window)"] --> Z["zenoh: subscribe to vrobots/** for the window"]
  Q --> I["iceoryx2: read the service registry"]
  Z --> ZO["observed = true<br/>it published during the window<br/>hz, samples, bytes are measurements"]
  I --> IR["observed = false<br/>the service is defined<br/>counters are 0, nothing was measured"]
  IR --> L{"live?"}
  L -->|true| A["a process holds it open"]
  L -->|false| D["stale record, owning process is gone"]

Zenoh has no registry. The only honest way to enumerate it is to subscribe to vrobots/** for a window and report what arrived, so a topic appears only if it published during your window, and its counters are real measurements. The consequence is a false negative on slow topics: vrobots/*/z/frames publishes at 1 Hz, so a 0.5 s window loses it entirely. State runs at 25 Hz, so about a second is enough for it.

iceoryx2 does have a registry. An entry means the service is defined, which is not the same as the service producing frames: it may be streaming, or it may be a dead leftover from a process that exited. live is what separates those two. Nothing was watched either way, so samples, bytes and hz are all zero and mean "not measured" rather than "zero traffic".

Gotcha. A router endpoint makes the result asymmetric. Zenoh topics come back from wherever the simulator is, while the iceoryx2 half only ever sees this host, so a remote simulator lists states and services and no camera streams. That is what shared memory means, not a discovery failure.

Reading the flag

From examples/rust/src/bin/ex11_topic_discovery.rs, the print loop is a three-way branch on observed and live rather than a two-way one:

#![allow(unused)]
fn main() {
    println!("\n{:<4} {:>7} {:>9}  topic", "wire", "Hz", "bytes");
    for t in &topics {
        // `observed` decides whether the numbers mean anything at all.
        let (hz, bytes) = if t.observed {
            (format!("{:.1}", t.hz), t.bytes.to_string())
        } else if t.live {
            ("-".to_string(), "-".to_string())
        } else {
            ("stale".to_string(), "-".to_string())
        };
        println!("[{}] {hz:>7} {bytes:>9}  {}", t.transport.tag(), t.key);
    }
}
The same in C++ (examples/cpp/ex11_topic_discovery.cpp)
        std::printf("\n%-4s %7s %9s  topic\n", "wire", "Hz", "bytes");
        for (const vrsdk::TopicInfo& t : topics) {
            // `observed` decides whether the numbers mean anything at all.
            if (t.observed) {
                std::printf("[%s] %7.1f %9llu  %s\n", t.transport, t.hz,
                            static_cast<unsigned long long>(t.bytes), t.key.c_str());
            } else {
                std::printf("[%s] %7s %9s  %s\n", t.transport, t.live ? "-" : "stale", "-",
                            t.key.c_str());
            }
        }
The same in Python (examples/python/ex11_topic_discovery.py)
    print(f"\n{'wire':<4} {'Hz':>7} {'bytes':>9}  topic")
    for t in topics:
        # `observed` decides whether the numbers mean anything at all.
        if t.observed:
            hz, nbytes = f"{t.hz:.1f}", str(t.bytes)
        elif t.live:
            hz, nbytes = "-", "-"
        else:
            hz, nbytes = "stale", "-"
        print(f"[{t.transport}] {hz:>7} {nbytes:>9}  {t.key}")

Rust reaches the wire tag through a method, t.transport.tag(), because Transport is an enum; C++ and Python both hand you t.transport already as the string "z" or "i".

Printing 0.0 for a registry entry would be reporting a measurement nobody took. - says the number does not exist, and stale says the entry does not either.

Grouping by robot

The reason to do this in code rather than at the command line is that the result is data. Grouping by sys_id is how a program answers "which robots exist, and does the one I want have a camera".

#![allow(unused)]
fn main() {
    let mut by_robot: BTreeMap<Option<u32>, Vec<&str>> = BTreeMap::new();
    for t in &topics {
        by_robot.entry(t.sys_id).or_default().push(&t.key);
    }
}
The same in C++ (examples/cpp/ex11_topic_discovery.cpp)
        std::map<std::optional<std::uint32_t>, std::vector<std::string>> by_robot;
        for (const vrsdk::TopicInfo& t : topics) {
            by_robot[t.sys_id].push_back(t.key);
        }
The same in Python (examples/python/ex11_topic_discovery.py)
    by_robot: dict[int | None, list[str]] = defaultdict(list)
    for t in topics:
        by_robot[t.sys_id].append(t.key)

The absent id is Option<u32> in Rust, std::optional<std::uint32_t> in C++ and None in Python, and all three sort or group on it directly. Rust and C++ get the ordering for free from BTreeMap and std::map; Python's dict does not order, so the example sorts the keys itself before printing.

manager and scene sit where an id would in the key, so they can never collide with a robot, and they parse as None.

The whole example prints the table and then the grouping:

listening for 1.5s ...

wire      Hz     bytes  topic
[i]       -         -  vrobots/1/i/cam/front_left/720p_rgba8
[i]       -         -  vrobots/1/i/cam/front_right/720p_rgba8
[z]     1.3      1760  vrobots/1/z/frames
[z]    25.3     45600  vrobots/1/z/state

by robot:
  sys_id 1: 4 topic(s)
      vrobots/1/i/cam/front_left/720p_rgba8
      vrobots/1/i/cam/front_right/720p_rgba8
      vrobots/1/z/frames
      vrobots/1/z/state

Next: Versions and pins

See also: The vrobots command, Two transports, one simulator, The topic namespace

Versions and pins

You print what this build speaks, compare it with what the simulator speaks, and learn why an exact pin is not pedantry.

cargo run -p vrobots-sdk --bin vrobots -- --version
cargo run -p vrobots-examples --bin ex12_version_info
./target/cpp-build/ex12_version_info
python examples/python/ex12_version_info.py

This is the first thing to compare when fields decode as garbage, and the second thing to compare when a topic looks absent. A version mismatch never arrives as an error, so nothing will tell you about it unless you ask.

What the binary says

vrobots --version prints the Display of VersionInfo, which is the same block version_info() returns to a program:

vrobots-sdk 0.1.4
  vrobots_msgs  v2.0.2-31-gac335c0 (schema_version 3)
  flatbuffers   25.12.19
  zenoh         1.9.0
  iceoryx2      0.9.3
  src_id        122
FieldTypeNotes
sdk_version&'static strThis crate's version.
msgs_commit&'static strgit describe --tags --always --dirty of the vrobots_msgs submodule the generated FlatBuffers code was compiled from, or "unknown" when built without git, from a source tarball for instance.
schema_versionu32The schema_version this SDK stamps on outbound headers.
flatbuffers&'static strThe flatbuffers pin, from ipc_versions.json at build time.
zenoh&'static strThe zenoh pin.
iceoryx2&'static strThe iceoryx2 pin.
src_idu32The src_id this build stamps by default.

These strings are stamped into the binary by build.rs, not read from a file at run time. A binary you copied to another machine reports what it was actually built against rather than what happens to be checked out beside it.

What the simulator says

The other half of the comparison rides on every state snapshot. From examples/rust/src/bin/ex12_version_info.rs:

#![allow(unused)]
fn main() {
    let robot = VirtualRobot::connect(RobotType::Multirotor, Some(SYS_ID))?;
    let first = robot.states();
    println!(
        "\nsim says: schema_version={} (ours {}), frame={:?} axes={:?}, \
         its header src_id={} (ours {})",
        first.schema_version,
        v.schema_version,
        first.coord_frame_id,
        first.axis_convention.name(),
        first.src_id,
        v.src_id
    );
    if first.schema_version != v.schema_version {
        println!(
            "  MISMATCH -- fields may decode as garbage. Rebuild the SDK against \
             the sim's vrobots_msgs commit."
        );
    }
}
The same in C++ (examples/cpp/ex12_version_info.cpp)
        vrsdk::VirtualRobot robot(vrsdk::RobotType::Multirotor, SYS_ID);
        robot.connect();
        const vrsdk::State first = robot.states();
        std::printf(
            "\nsim says: schema_version=%u (ours %u), frame=\"%s\", its header src_id=%u "
            "(ours %u)\n",
            first.raw.schema_version, v.schema_version, first.coord_frame_id.c_str(),
            first.raw.src_id, v.src_id);
        if (first.raw.schema_version != v.schema_version) {
            std::printf(
                "  MISMATCH -- fields may decode as garbage. Rebuild the SDK against the sim's "
                "vrobots_msgs commit.\n");
        }
The same in Python (examples/python/ex12_version_info.py)
    mr = VirtualRobot(RobotType.MULTIROTOR, sys_id=SYS_ID)
    mr.connect()
    first = mr.states
    print(
        f"\nsim says: schema_version={first.schema_version} (ours {v['schema_version']}), "
        f"frame={first.coord_frame_id!r} axes={first.axis_convention_name!r}, "
        f"its header src_id={first.src_id} (ours {v['src_id']})"
    )
    if first.schema_version != v["schema_version"]:
        print(
            "  MISMATCH -- fields may decode as garbage. Reinstall the wheel built "
            "against the sim's vrobots_msgs commit."
        )

version_info() returns a struct in Rust and C++ (v.schema_version) but a dict in Python (v['schema_version']). C++ reaches the header fields through first.raw, and it has one check the other two do not: check_version() asserts that this header and the linked library are the same release, because the snapshot structs are shared between them by layout.

sim says: schema_version=3 (ours 3), frame="frd" axes="frd", its header src_id=0 (ours 122)

src_id 0 is the simulator's, reserved for it; 122 is this build's default. They are supposed to differ. schema_version is not: a difference there means the two sides were generated from different schema commits, and the decode that follows produces plausible-looking wrong numbers rather than an error.

Gotcha. A schema mismatch does not raise. FlatBuffers decodes a missing nested table to its Default, which is all zeroes, so an incompatible field arrives as 0.0 and not as VrError::Decode. A block of suspiciously round zeroes in the snapshot is the symptom to recognise.

Why the pins are exact

ipc_versions.json at the repository root is the source of truth, and the workspace Cargo.toml mirrors it as =X.Y.Z rather than ^X.Y.Z.

PackagePin
flatbuffers25.12.19
iceoryx20.9.3
zenoh1.9.0

The exactness is load-bearing for one specific reason. iceoryx2 compares major.minor.patch on every shared-memory open. A caret pin that resolves one patch release away from the simulator's vendored C# drop does not error and does not warn. It silently delivers nothing. What you see is a camera stream that never produces a frame, and what that reads like is "the simulator is not publishing", which sends you looking at Play mode, at the topic list and at your own camera code, none of which are wrong.

What enforces them

Three independent mechanisms, so the drift is caught before it ships.

MechanismWhen it firesWhat it checks
crates/vrobots-sdk/build.rsevery compileThe workspace Cargo.toml pins each package as exactly ="X.Y.Z" from ipc_versions.json, and panics with the offending line when it does not.
scripts/check_versions.ps1CIThe same pin rule without needing a toolchain, plus the one SDK version mirrored across the workspace manifest, the member crates, the wheel metadata, the C++ header and the README.
The release workflowa pushed tagRuns check_versions.ps1 -Tag <tag> as a guard job and refuses to build anything when the tag, the manifests and ipc_versions.json disagree.

build.rs also fails when the vrobots_msgs submodule is missing, with the git submodule update --init --recursive fix in the message rather than 45 include! errors.

The order to check things in

  1. vrobots --version on the machine running your program.
  2. The simulator's schema_version, from any state snapshot, against the schema_version in that block.
  3. The simulator's vendored iceoryx2 version against the iceoryx2 line, if camera frames are the thing that is missing.

Paste all three into a bug report. They are the difference between a reproducible report and a description of a symptom.

Next: Logging

See also: Two transports, one simulator, Timestamps and sequence numbers, When nothing happens

Logging

You turn on the SDK's tracing events, choose which targets are loud, and learn which failures are returned to you and which are only ever logged.

cargo run -p vrobots-examples --bin ex20_logging_tour
./target/cpp-build/ex20_logging_tour
python examples/python/ex20_logging_tour.py
RUST_LOG=vrobots_sdk=debug cargo run -p vrobots-examples --bin ex20_logging_tour

If a connect hangs, RUST_LOG=vrobots_sdk=debug is the answer. The rest of this page is why that works and what else the setting is good for.

RUST_LOG reaches the Rust core, so the other two surfaces turn the volume up in their own idiom instead. Python routes the same events into the standard logging module, so vrsdk.init_logging("debug") (or raising the vrobots_sdk logger's level yourself) is the equivalent. C++ registers a handler with vrsdk::set_log_callback and then calls vrsdk::set_log_level(vrsdk::LogLevel::Debug).

Turning it on

One function, and the only thing about it worth memorising is that it can decline to act. From crates/vrobots-sdk/src/lib.rs:

#![allow(unused)]
fn main() {
pub fn init_logging(filter: &str)
}
The same in C++ (cpp/include/vrobots_sdk.hpp)
using LogHandler = void (*)(LogLevel level, const char* target, const char* message);

inline void set_log_callback(LogHandler handler)

inline void set_log_level(LogLevel level)
The same in Python (crates/vrobots-sdk-py/python/vrsdk/__init__.py)
def init_logging(
    level: _Union[str, int] = "info",
    *,
    format: str = _LOG_FORMAT,  # noqa: A002 - mirrors logging.basicConfig
) -> None:

This is where the three surfaces diverge most. Rust installs a tracing subscriber. Python has no subscriber to install: importing vrsdk already bridges the core's events into the standard logging module, so init_logging is logging.basicConfig plus raising the vrobots_sdk logger's level, and an application that configures logging itself can skip it entirely. C++ has no logging framework to plug into, so you register a function pointer with set_log_callback and set the floor with set_log_level; registration is process-wide and nullptr unregisters.

It returns nothing and cannot fail. It installs a tracing_subscriber fmt layer with an EnvFilter, and does nothing if a subscriber is already installed. That matters twice: a library must never force a global subscriber on its consumer, and calling it a second time from somewhere else in your program cannot fight the first call.

From examples/rust/src/bin/ex20_logging_tour.rs:

#![allow(unused)]
fn main() {
    vrobots_sdk::init_logging(FILTER);
    println!("log filter: {FILTER:?} (RUST_LOG overrides it)\n");

    // Calling it twice is harmless: the second call finds a subscriber already
    // installed and returns. Same reason it is safe to call from a library
    // consumer's main.
    vrobots_sdk::init_logging("error");
}
The same in C++ (examples/cpp/ex20_logging_tour.cpp)
        // Register BEFORE connecting: connect is the noisiest and most
        // diagnostic moment, and a hang with no log is the hardest thing to
        // debug.
        vrsdk::set_log_callback(on_log);
        vrsdk::set_log_level(vrsdk::LogLevel::Debug);
        std::printf("log callback registered at level %s\n\n",
                    vrsdk::to_string(vrsdk::LogLevel::Debug));
The same in Python (examples/python/ex20_logging_tour.py)
    for name in QUIET:
        logging.getLogger(name).setLevel(logging.WARNING)

    # (2) Then the one-liner. It is logging.basicConfig(level=...) plus raising
    #     the vrobots_sdk logger (the part people forget) plus dropping the
    #     extension's cache of what each Python logger accepts. Calling it
    #     *after* the levels above is what makes them take effect immediately.
    vrsdk.init_logging(LEVEL)
    print(f"init_logging({LEVEL!r}) -- SDK records now reach the root handler\n")

Only Rust's call is idempotent by declining to act. C++ and Python are both last-write-wins: a second set_log_callback replaces the handler, and a second init_logging re-raises the level. Both examples configure logging before connect, which is the noisiest and most diagnostic moment.

The second call has no effect at all, and the filter stays at the first one.

Three ways to decide where events go:

WayWhen to use it
init_logging("info")Binaries and examples. One line, and it yields to anything already installed.
RUST_LOG in the environmentChanging the volume of a program you do not want to edit. It overrides the argument entirely.
Your own subscriber, installed before the SDK is usedProduction. Skip init_logging and the events flow into whatever you built: JSON, OpenTelemetry, a file.

The filters worth knowing

The filter syntax is RUST_LOG's, per target, comma separated.

SettingUse
vrobots_sdk=debugA connect that hangs. Shows the session opening, the probe and the wait for the first sample.
vrobots_sdk=debug,zenoh=infoAdds zenoh's own view of discovery and peering. zenoh=debug is a firehose.
vrobots_sdk=traceEvery publish, one line per command.
offSilence.
warnThe vrobots CLI's default.

iceoryx2 does not go through tracing

iceoryx2 has its own logger, it writes to stderr, and no RUST_LOG setting touches it. The SDK turns it down to Error when it creates its node, because iceoryx2 is chatty about conditions the SDK already handles: a service that is not published yet, a stale registry record.

IOX2_LOG_LEVEL is the environment variable that turns it back up, and the SDK honours it rather than overriding it. Set it when a camera stream will not pair and you need to see the version and QoS negotiation that failed.

Gotcha. RUST_LOG=trace produces nothing at all from iceoryx2, and IOX2_LOG_LEVEL produces nothing from the SDK. They are two separate systems writing to two separate places. Set both when debugging a camera.

Two channels, and they mean different things

The distinction the SDK draws is between what you asked for and what it did on your behalf.

ChannelCarriesHow you see it
Returned errorsThings you did or asked for that could not be done: a pulse width outside the band, a camera that does not exist, a service with no responder.A Result<_, VrError> value. Never only in a log.
tracing eventsThings the SDK did for you: opening a session, waiting for a first sample, retrying a service, dropping a malformed payload, reconfiguring a camera that was already mounted.Log lines, when a subscriber is installed.

Nothing in the SDK waits, retries or drops silently, and the events are where that shows up. Ignore them and a hang has no explanation.

The example demonstrates the first channel with an argument that is refused rather than clamped:

#![allow(unused)]
fn main() {
    match robot.set_mr_pwm([0.7; 4]) {
        // 0.7 is a normalised throttle, not a pulse width. The SDK refuses it
        // before publishing rather than clamping, because a clamped 0.7 would
        // look like a valid idle command.
        Ok(()) => println!("   unexpected: 0.7 us was accepted"),
        Err(e) => println!("   [{}] {} -- {}", e.code(), e.kind(), e.detail()),
    }
}
The same in C++ (examples/cpp/ex20_logging_tour.cpp)
        std::printf("\n-- an error is thrown, not logged:\n");
        try {
            // 0.7 is a normalised throttle, not a pulse width. The SDK refuses
            // it before publishing rather than clamping, because a clamped 0.7
            // would look like a valid idle command.
            robot.set_mr_pwm({0.7, 0.7, 0.7, 0.7});
            std::printf("   unexpected: 0.7 us was accepted\n");
        } catch (const vrsdk::Error& e) {
            std::printf("   [%d] %s -- %s\n", e.code(), e.name(), e.what());
        }
The same in Python (examples/python/ex20_logging_tour.py)
    print("\n-- an error is raised, not logged:")
    try:
        # 0.7 is a normalised throttle, not a pulse width. The SDK refuses it
        # before publishing rather than clamping, because a clamped 0.7 would
        # look like a valid idle command.
        mr.set_mr_pwm(0.7, 0.7, 0.7, 0.7)
        print("   unexpected: 0.7 us was accepted")
    except vrsdk.VrError as e:
        print(f"   [{e.code} {e.kind}] {e.detail}")
        print(f"   err.name({e.code}) == {vrsdk.err.name(e.code)!r}")

The channel is the same; the delivery is not. Rust returns the refusal as a Result you match on, while C++ throws vrsdk::Error and Python raises vrsdk.VrError, so both wrap the call in a try. The four pulse widths are an array in Rust and C++ and four positional arguments in Python.

-- an error is returned, not logged:
   [2] invalid_argument -- pwm[0] = 0.7 is outside the 1100-2000 us pulse-width band (neutral is 1500; values look like microseconds, not normalised units)

Nothing about that appears in the log at any filter level. It is a value, and the only place a value can go is your match.

The third case: counted, never raised

A malformed state payload is neither of the two. It is logged as a warning, counted in stats(), stored in last_error(), and not returned from states(), because one bad frame must not end a flight.

#![allow(unused)]
fn main() {
    let stats = robot.stats();
    println!(
        "\n-- counted rather than raised: received={} decode_errors={} last_error={}",
        stats.received,
        stats.decode_errors,
        match robot.last_error() {
            Some(e) => format!("[{}] {e}", e.code()),
            None => "none".to_string(),
        }
    );
}
The same in C++ (examples/cpp/ex20_logging_tour.cpp)
        const vrsdk_state_stats_t st = robot.stats();
        const std::optional<vrsdk::Error> err = robot.last_error();
        std::printf("\n-- counted rather than thrown: received=%llu decode_errors=%llu ",
                    static_cast<unsigned long long>(st.received),
                    static_cast<unsigned long long>(st.decode_errors));
        std::printf("last_error=%s\n", err ? err->what() : "none");
The same in Python (examples/python/ex20_logging_tour.py)
    st = mr.stats
    err = mr.last_error
    print(
        f"\n-- counted rather than raised: received={st.received} "
        f"decode_errors={st.decode_errors} last_error="
        + ("none" if err is None else f"[{err.code}] {err.detail}")
    )

stats and last_error are methods in Rust and C++ and properties in Python. The absent error is Option in Rust, std::optional<vrsdk::Error> in C++ and None in Python, and in none of the three does reading it raise.

-- counted rather than raised: received=10 decode_errors=0 last_error=none

A non-zero decode_errors beside a non-empty last_error() is almost always schema drift between this build and the simulator's, which is Versions and pins.

Next: Measuring rates

See also: Stream health, Appendix C: Error reference

Measuring rates

You watch one topic for a window and get its whole arrival distribution, which is the part an average hides.

cargo run -p vrobots-sdk --bin vrobots -- topic hz vrobots/1/z/state -w 5

When a control loop stutters, the mean rate is almost always fine. The two numbers that explain it are the maximum interval and the gap count, and this page is about reading them.

The calls

Both take one exact key and one window, and both block for the whole window before returning. From crates/vrobots-sdk/src/hz.rs:

#![allow(unused)]
fn main() {
pub fn measure_rate(key: &str, window: Duration) -> VrResult<RateReport>
pub fn measure_rate_with(key: &str, window: Duration, options: &ConnectOptions) -> VrResult<RateReport>
}

All three surfaces carry it: Python as vrsdk.measure_rate(key, window=5.0) returning a RateReport, C++ as vrsdk::measure_rate(key, window_s = 5.0) returning vrsdk::RateReport, both mirroring the CLI's 5-second default window. vrobots topic hz remains the shell spelling, and Python can still run that CLI in-process through vrsdk.cli_main. Do not confuse these calls with robot.rate(hz), which paces your own loop and also exists on all three.

Neither prints anything: the result is the RateReport below, and formatting it is what vrobots topic hz does with it.

The transport is auto-detected from the key: an /i/ segment in the third position means iceoryx2, and everything else is treated as zenoh. That default is deliberate, because a zenoh subscribe on a key nobody publishes reports zero samples while an iceoryx2 open on a service that does not exist blocks until it times out.

A wildcard key and a zero window are both InvalidArgument, refused before a session opens. A wildcard would interleave several seq streams and report a gap for every sample.

Only the zenoh half honours ConnectOptions::router_endpoint. Camera streams are shared memory, so measuring one on a remote simulator is not a thing that can work.

RateReport

Plain owned scalars with no Options, because the C and Python bindings mirror the struct field for field. "Absent" is spelled as a have_* flag beside a zeroed value.

FieldTypeUnitsNotes
keyStringThe key that was watched.
transportTransportDetected from the key.
window_sf64sHow long the subscriber stayed open.
samplesu64Payloads received.
span_sf64sFirst arrival to last arrival. 0.0 with fewer than 2 samples.
hzf64Hz(samples - 1) / span_s. 0.0 with fewer than 2 samples.
mean_interval_msf64msMean gap between arrivals. 0.0 with fewer than 2 samples.
min_interval_msf64msShortest gap.
max_interval_msf64msLongest gap. The worst stall the loop actually saw.
jitter_msf64msPopulation standard deviation of the gaps.
bytesu64BTotal payload bytes received.
have_seqboolWhether any payload carried a header.seq.
first_seq, last_sequ64First and last seq seen. 0 when have_seq is false.
seq_gapsu64How many times seq jumped forward by more than one, i.e. how many separate drop events.
missedu64Total samples missed across all gaps.
seq_restartsu64How many times seq went backwards or repeated.
have_latencyboolWhether any payload carried a header.timestamp_ns.
mean_latency_msf64msPublish stamp to arrival.
min_latency_msf64msNegative means the clocks disagree, not that a message arrived before it was sent.
max_latency_msf64ms
undecodableu64Payloads that did not parse at all. Counted, never fatal.

Two methods: mean_bytes() is bytes / samples, and bytes_per_second() divides by window_s rather than span_s, because a link budget is per wall second.

The rate is over the span, not the window

hz is (samples - 1) / span_s, where the span runs from the first arrival to the last. It is not samples / window.

Both parts of that matter. Dividing by the window would count zenoh's discovery latency, several hundred milliseconds after a fresh session opens during which nothing can arrive, as time the publisher was silent. And n samples give n - 1 intervals, so a single arrival has no rate at all: reporting "1 sample in 5 s = 0.2 Hz" would be inventing a period from one event, and the report says "too few to measure an interval" instead.

The window is still reported beside the span, because the difference between them is information. A publisher that dies one second into a five second window has a perfect 25 Hz over a 1.000 s span, and only the window tells you the other four seconds were silence.

Note. TopicInfo::hz from Discovery from code is the other formula, samples / window, across every key at once. That is a presence check, not a measurement. Use measure_rate when the number has to be right.

Read the distribution, not the average

A control loop at 25 Hz wants a 40 ms period. These two reports have the same mean:

HealthyStuttering
hz25.0024.80
mean_interval_ms40.0040.32
min_interval_ms40.002.10
max_interval_ms40.00213.00
jitter_ms0.0031.40
seq_gaps03
missed011

The right-hand column is a loop that froze for 213 ms, five times its period, then received a burst of queued samples 2 ms apart. The mean absorbed both. max_interval is the number that would have broken a controller, and seq_gaps with missed says those 11 samples were dropped rather than delayed: zenoh's default reliability is best effort, and iceoryx2 drops when the subscriber queue is full, so a loop that reads slower than the publisher writes produces exactly this.

seq_restarts means one of two things

The counter increments when seq goes backwards or repeats, which is never a drop.

  • On a camera stream it means the stream restarted, which happens when the resolution or the pixel format changed mid-window. See Mount, open and unmount.
  • On a zenoh topic it means two publishers are sharing one topic, which is the bug this counter exists to catch. Two processes writing the same sys_id interleave their sequence numbers, and everything downstream of that is wrong.

Restarts are counted separately from gaps on purpose. A restart that read as thousands of missed samples would make seq_gaps useless for what it is for.

Latency is only sometimes a latency

The three latency fields are arrival wall clock minus the publisher's header.timestamp_ns, which subtracts two different clocks.

Same host, that difference is a real transport-plus-decode delay. Across machines it is dominated by the offset between the two clocks, it can be tens of milliseconds with no transport involved at all, and it can be negative when the publisher's clock runs ahead. A negative min_latency_ms is clock skew being reported honestly, not a message that arrived before it was sent.

The interval statistics do not have this problem. They are measured with a monotonic Instant, so an NTP step in the middle of a window cannot corrupt them.

Next: More than one robot

See also: The vrobots command, Pacing your loop, Stream health

More than one robot

You hold two handles in one process, and you find out why coord_frame_id is on every snapshot.

cargo run -p vrobots-examples --bin ex18_multi_robot
./target/cpp-build/ex18_multi_robot
python examples/python/ex18_multi_robot.py

One program is one embedded system bound to one robot is the shape the SDK is built around, and nothing enforces it. A VirtualRobot is a handle. Construct as many as you like.

Two connects, two sessions

Each connect opens its own zenoh session, its own subscriber thread and its own snapshot, and each blocks until its own robot's first sample arrives. From examples/rust/src/bin/ex18_multi_robot.rs:

#![allow(unused)]
fn main() {
    let truck = VirtualRobot::connect(RobotType::Truck, Some(TRUCK_ID))?;
    let drone = VirtualRobot::connect(RobotType::Multirotor, Some(DRONE_ID))?;
    println!(
        "truck sys_id={} ({:?}), drone sys_id={} ({:?})",
        truck.sys_id(),
        truck.robot_type(),
        drone.sys_id(),
        drone.robot_type()
    );
}
The same in C++ (examples/cpp/ex18_multi_robot.cpp)
        // Two connects, two sessions. Each blocks until *its* robot's first
        // state snapshot arrives, so both are live by the time the loop starts.
        vrsdk::VirtualRobot truck(vrsdk::RobotType::Truck, TRUCK_ID);
        truck.connect();
        vrsdk::VirtualRobot drone(vrsdk::RobotType::Multirotor, DRONE_ID);
        drone.connect();
        std::printf("truck sys_id=%u, drone sys_id=%u\n", truck.sys_id(), drone.sys_id());
The same in Python (examples/python/ex18_multi_robot.py)
    truck = VirtualRobot(RobotType.TRUCK, sys_id=TRUCK_ID)
    truck.connect()
    drone = VirtualRobot(RobotType.MULTIROTOR, sys_id=DRONE_ID)
    drone.connect()
    print(
        f"truck sys_id={truck.sys_id} ({truck.robot_type.key}), "
        f"drone sys_id={drone.sys_id} ({drone.robot_type.key})"
    )

Rust's connect constructs and connects in one call; C++ and Python construct the handle first and then call connect() on it, which is two statements per robot. sys_id and the robot type are methods in Rust (robot_type()) and C++ (type()) and properties in Python, and the C++ example prints only the ids because its RobotType is a plain enum with no string form.

truck sys_id=0 (Truck), drone sys_id=1 (Multirotor)

Both handles are live by the time the loop starts, so the first states() on either is valid.

PropertyConsequence
Each robot listens only on its own cmd topicThe sys_id in the topic is the routing. A command addressed to the wrong id produces silence, never a different robot moving.
rate() paces the calling loopCall it on exactly one handle. Calling it on both sleeps twice per iteration and halves the loop rate.
t_ns is sim capture time for bothDirectly comparable between robots.
elapsed counts from each robot's own first sampleNot comparable. The two differ by however far apart the two connect calls were.

Two handles is also two sessions and two subscriber threads. That is fine for a handful of robots. A swarm of fifty wants one subscriber on vrobots/*/z/state, which is a different program.

One commanded, one observed

The loop drives the truck and reads the multirotor. Nothing pairs the two snapshots: each is whatever its own subscriber last received.

#![allow(unused)]
fn main() {
        // One robot commanded ...
        truck.set_car(STEER_US, THROTTLE_US, Some(1100.0))?;
        let t = truck.states();

        // ... the other only observed. Nothing pairs the two snapshots: they are
        // whatever each subscriber last received.
        let d = drone.states();
}
The same in C++ (examples/cpp/ex18_multi_robot.cpp)
            // One robot commanded ...
            truck.set_car(STEER_US, THROTTLE_US, 1100.0);
            const vrsdk::State t = truck.states();

            // ... the other only observed. Nothing pairs the two snapshots:
            // they are whatever each subscriber last received.
            const vrsdk::State d = drone.states();
The same in Python (examples/python/ex18_multi_robot.py)
        # One robot commanded ...
        truck.set_car(STEER_US, THROTTLE_US, 1100.0)
        t = truck.states

        # ... the other only observed. Nothing pairs the two snapshots: they are
        # whatever each subscriber last received.
        d = drone.states

The brake argument is optional in Rust, so it is Some(1100.0); C++ and Python take the plain number. states() is a method in Rust and C++ and a property in Python, and in all three it is a non-blocking read of whatever that robot's subscriber last received.

Pacing happens once, at the bottom, on one handle:

#![allow(unused)]
fn main() {
        // Paced once, on one handle.
        truck.rate(HZ);
}
The same in C++ (examples/cpp/ex18_multi_robot.cpp)
            // Paced once, on one handle.
            truck.rate(HZ);
The same in Python (examples/python/ex18_multi_robot.py)
        # Paced once, on one handle.
        truck.rate(HZ)

rate is identical across the three, and so is the rule: it sleeps the calling thread, so calling it on both handles halves the loop rate.

Their frames disagree

This is the trap, and it is the practical reason coord_frame_id rides on every snapshot instead of being something you configure once and assume.

Measured live in the test scene, the truck publishes fru and the multirotor publishes frd. Same third component, opposite sign: up for one, down for the other. A program that mixes the two positions without converting has a sign error nothing will report.

#![allow(unused)]
fn main() {
        // Each snapshot names its own frame, and here they differ: the truck is
        // "fru" (third component UP) and the drone is "frd" (third component
        // DOWN). Print the tag beside every position rather than assuming one.
        println!(
            "truck[{}] pos=({tx:.2},{ty:.2},{tz:.2}) [{:?}] echo={:?}  |  \
             drone[{}] pos=({dx:.2},{dy:.2},{dz:.2}) [{:?}] alt={:.2} m",
            t.sys_id,
            t.coord_frame_id,
            t.actuator.pwm,
            d.sys_id,
            d.coord_frame_id,
            -dz // "frd": altitude is minus the down component
        );
}
The same in C++ (examples/cpp/ex18_multi_robot.cpp)
            // Each snapshot names its own frame, and here they differ: the
            // truck is "fru" (third component UP) and the drone is "frd" (third
            // component DOWN). Print the tag beside every position.
            std::printf(
                "truck[%u] pos=(%.2f,%.2f,%.2f) [%s]  |  drone[%u] pos=(%.2f,%.2f,%.2f) [%s] "
                "alt=%.2f m\n",
                t.sys_id, tp[0], tp[1], tp[2], t.coord_frame_id.c_str(), d.sys_id, dp[0], dp[1],
                dp[2], d.coord_frame_id.c_str(),
                -dp[2]);  // "frd": altitude is minus the down component
The same in Python (examples/python/ex18_multi_robot.py)
        # Each snapshot names its own frame, and here they differ: the truck is
        # "fru" (third component UP) and the drone is "frd" (third component
        # DOWN). Print the tag beside every position rather than assuming one.
        print(
            f"truck[{t.sys_id}] pos=({tx:.2f},{ty:.2f},{tz:.2f}) "
            f"[{t.coord_frame_id!r}] echo={t.actuator.pwm}  |  "
            f"drone[{d.sys_id}] pos=({dx:.2f},{dy:.2f},{dz:.2f}) "
            f"[{d.coord_frame_id!r}] alt={-dz:.2f} m"
        )

The frame tag is a plain string in all three, read off the snapshot rather than assumed. C++ reaches the position through t.kin().lin_pos, a fixed-size array it indexes, where Rust and Python unpack the three components into named variables; the C++ example also omits the PWM echo the other two print.

truck[0] pos=(1.42,-0.30,0.11) ["fru"] echo=[1500, 1650, 1100]  |  drone[1] pos=(0.00,0.00,-2.50) ["frd"] alt=2.50 m
    naive separation=3.15 m (WRONG: mixed frames, convert first)  snapshot skew=+12.3 ms  (elapsed: truck 4.21s vs drone 4.19s -- different epochs)

The example computes the separation anyway and labels it WRONG, because the point of the page is that the arithmetic runs happily and produces a number. The check that catches it is one comparison:

#![allow(unused)]
fn main() {
            if t.coord_frame_id == d.coord_frame_id {
                ""
            } else {
                " (WRONG: mixed frames, convert first)"
            },
}
The same in C++ (examples/cpp/ex18_multi_robot.cpp)
                t.coord_frame_id == d.coord_frame_id ? "" : " (WRONG: mixed frames, convert first)",
The same in Python (examples/python/ex18_multi_robot.py)
        warn = "" if t.coord_frame_id == d.coord_frame_id else " (WRONG: mixed frames, convert first)"

One string comparison in every surface. The check costs nothing and is the only thing standing between you and a sign error that no error path reports.

Gotcha. The frames a robot type publishes are its own, not yours. A program that holds one robot can get away with assuming; a program that holds two cannot. Read coord_frame_id from the snapshot and branch on it.

The skew line uses t_ns, which is the shared clock, so that difference is real. elapsed appears beside it only to show that it is not: the two robots count from different epochs.

Next: Recording and testing without the simulator

See also: Frames, axes and units, System ids, and the two kinds of robot, Timestamps and sequence numbers

Recording and testing without the simulator

You capture real wire bytes once, check them in, and let cargo test decode them with Unity closed.

cargo run -p vrobots-sdk --bin vrobots -- record --sys-id 1 -n 5 --prefix state_multirotor

That writes state_multirotor_000.bin through state_multirotor_004.bin into crates/vrobots-sdk/tests/fixtures, and those files are what makes the SDK's test suite mean something on a machine with no simulator on it.

Why the bytes have to come from the simulator

A fixture the SDK built itself would test the SDK's own builder against the SDK's own decoder, which proves the crate is self-consistent and nothing else. It cannot catch the failure that actually happens.

Recorded frames are C#-produced golden payloads: the exact bytes the simulator's publisher put on the wire, captured once from a live simulator and checked in, then decoded by the SDK's own decoder in unit tests. A schema change on the simulator side breaks a unit test instead of surfacing months later as garbage fields at runtime. That is the whole reason the recorder exists.

Recording is deliberately not a decode. What is written is the payload byte for byte as it arrived, in raw .bin rather than base64 in text, because a fixture is only useful if it is exact and a binary file cannot be reformatted or line-ending converted on checkout.

The command

FlagDefaultNotes
--sys-id <U32>0Which robot. Without --camera this records vrobots/<sys_id>/z/state.
--camera <NAME>Record raw iceoryx2 camera slices instead.
--resolution <STR>360pWith --camera.
--format <STR>mono8With --camera.
--mountoffMount the camera first and unmount it afterwards. Requires --camera. Mutates the simulator.
-n, --count <USIZE>5Frames to capture.
-t, --timeout <SECS>10.0Give up after this long.
-o, --out <PATH>crates/vrobots-sdk/tests/fixturesOutput directory, created if missing.
--prefix <STR>stateFile name prefix.
--router <ENDPOINT>zenoh only.
crates/vrobots-sdk/tests/fixtures/state_multirotor_000.bin (1200 bytes)
crates/vrobots-sdk/tests/fixtures/state_multirotor_001.bin (1200 bytes)
crates/vrobots-sdk/tests/fixtures/state_multirotor_002.bin (1200 bytes)
crates/vrobots-sdk/tests/fixtures/state_multirotor_003.bin (1200 bytes)
crates/vrobots-sdk/tests/fixtures/state_multirotor_004.bin (1200 bytes)
5 frame(s) from vrobots/1/z/state

Gotcha. --mount mutates the simulator. It adds the named camera to that robot and removes it again at the end, leaving other cameras alone, but resolution is robot-wide: --resolution 360p against a robot whose cameras run at 720p restarts their streams under new names, and unmounting does not put them back. Without --mount the camera must already exist, because the recorder only subscribes.

The calls behind it

From crates/vrobots-sdk/src/record.rs:

#![allow(unused)]
fn main() {
pub struct RecordedFrame { pub key: String, pub bytes: Vec<u8> }

pub fn record_frames(key: &str, count: usize, timeout: Duration, options: &ConnectOptions) -> VrResult<Vec<RecordedFrame>>
pub fn record_camera_frames(sys_id: u32, camera: &str, resolution: &str, format: &str, count: usize, timeout: Duration) -> VrResult<Vec<RecordedFrame>>
pub fn write_fixtures(frames: &[RecordedFrame], dir: &Path, prefix: &str) -> VrResult<Vec<PathBuf>>
}

These three are Rust-only. RecordedFrame, record_frames, record_camera_frames and write_fixtures appear in none of crates/vrobots-sdk-capi/include/vrobots_sdk.h, cpp/include/vrobots_sdk.hpp or _vrsdk.pyi, so there is nothing to show for the other two surfaces. That is deliberate: the recorder exists to produce fixtures for this crate's own test suite, and vrobots record is the interface every surface uses.

They return values rather than printing; the CLI prints what they return.

CallWhat it does
record_framesA raw zenoh subscribe on one key, capturing each payload with no decode. VrError::Timeout when nothing arrives at all, with "is the sim in Play mode?" in the message.
record_camera_framesThe iceoryx2 counterpart, capturing shared-memory slices as [5760-byte prefix][pixels]. The camera must already be mounted; this only subscribes. VrError::Timeout when no publisher appears or no frame arrives.
write_fixturesWrites <prefix>_NNN.bin as raw binary, creating the directory if needed, and returns the paths. VrError::Config when a write fails.

RecordedFrame carries the key it arrived on beside the bytes, so a capture across a wildcard still says which topic each payload came from.

What the tests do with them

crates/vrobots-sdk/tests/replay_decode.rs decodes the state fixtures and camera_replay.rs decodes the camera one. Because the bytes came from the other language, those tests assert things nobody would bother asserting about their own output: src_id == 0, which is reserved for the simulator, the schema version, a unit quaternion, an accelerometer reading about 1 g at rest.

Each state set is consecutive samples off one subscriber, so header.seq increments by exactly one across the set. replay_decode.rs asserts that too, which makes the set a sequence-continuity fixture and not only a decode fixture.

crates/vrobots-sdk/src/hz.rs uses the same fixture from the other direction: its generic header peek has to agree with the full decoder on the same bytes, or topic hz would invent gaps.

Refreshing them

Only when the schema genuinely moves. A fixture that gets regenerated whenever a test fails is not a fixture. The exact commands live in crates/vrobots-sdk/tests/fixtures/README.md, along with the reason to keep the camera recording at 360p mono8: it is the smallest stream the simulator can produce, where the same frame at 720p rgba8 would be 3.6 MB of checked-in binary.

Several assertions in replay_decode.rs encode the state the robot was in when captured, at rest with idle PWM. Recording a flying drone fails them for a good reason. Capture at rest, or change the assertions on purpose.

Next: Appendix A: Topic reference

See also: Inside a frame, Versions and pins, The vrobots command

Appendix A: Topic reference

Every topic pattern in one table: key, transport, direction, payload and rate.

Every wire name in the system has the shape vrobots/<sys_id>/<transport>/<subject...>, where the segment after the id names the transport, z for zenoh and i for iceoryx2. The words manager and scene sit where a sys_id would, so a scope key can never collide with a robot key. Both ends of a wire must match byte for byte and a mismatch is silent: a subscriber on a slightly wrong key never fires, and a GET to an unopened key returns NoResponder. vrobots topic list prints what is really there, tagged [z] or [i].

Every topic

Key patternWireDirectionContentsRate
vrobots/{sys_id}/z/state[z]the simulator publishesswarmbotix.states.State, the full snapshot: kinematics, wrench, actuator, sensors, environment, estimate25 Hz
vrobots/{sys_id}/z/cmd[z]the simulator subscribesswarmbotix.commands.Command, one command id plus its CmdArgs. Many-to-many, so clients can read it tooset by the sender; the in-game IMU panel publishes SET_ANGVEL at 50 Hz
vrobots/{sys_id}/z/frames[z]the simulator publishesswarmbotix.coordinates.CoordFrameDef, the resulting frame definitions, one message per distinct frame the robot and its devices use. Distinct from the srv/frames service, which sets them. Read with frame_defon change, plus a slow keepalive
vrobots/{sys_id}/z/estimate[z]the simulator subscribesswarmbotix.states.EstimateState, an attitude estimate you publish with publish_estimate or publish_estimate_euler. Read by the fixed wing under FW_EST_OBSERVER; nothing else consumes it, and the simulator never echoes it back into State.estimateset by the sender; aged from arrival and stale after 0.5 s, so publish at 20 Hz or better
vrobots/{sys_id}/z/srv/{segment}[z]request and responseper-robot services; see the segment tables belowon demand
vrobots/{sys_id}/i/cam/{name}/{res}_{fmt}[i]the simulator publishesraw camera frames, shared memory, same host onlynot documented; measure with vrobots topic hz
vrobots/manager/z/srv/create[z]request and responseSrvVRobotCreate to SrvVRobotCreated; the reply carries the assigned sys_id. The one non-idempotent service in the systemon demand
vrobots/manager/z/srv/delete[z]request and responseSrvVRobotDelete to SrvAck. Answers ok = false for an unknown sys_idon demand
vrobots/scene/z/srv/frame[z]request and responsepayload-less GET to SrvAck whose message is the scene's active coordinate frame id. Scene scope: one answer however many robots are loadedon demand

Two wildcards the SDK subscribes with rather than publishes on: vrobots/**, which backs topic discovery, and vrobots/*/z/state, which is the whole-swarm state subscribe.

Common service segments

Every live robot serves all seven of these on vrobots/{sys_id}/z/srv/{segment}.

SegmentRequest to replyPurposeSDK entry point
activatepayload-less GET to SrvAckreleases a dormant robot's dynamics hold; an already-active robot acks and no-opsactivate, and connect when activate_after_create is set
resetSrvReset, or a payload-less GET, to SrvAckteleports to the first-physics-step pose, zeroes velocity, rests the actuators, re-latches the initial commandreset
paramsSrvVRobotPhysicalProperty to SrvAckmass and principal moments of inertiaset_physical_params
skinSrvVRobotSkin to SrvAckappearance; the only service that ever answers ok = falseset_skin
sensorsSrvSensorConfig to SrvAcknoise models and GPS quality, gated block by blockconfigure_sensors
framesSrvVRobotFrame to SrvAckwhich coordinate frame the robot and each device report inset_frames
camerasSrvCameraConfig to SrvAckupsert, remove or replace the camera listmount_camera, mount_camera_with, unmount_camera

Gotcha. reset and activate are the two keys where a payload-less GET performs the action instead of probing for a receipt. The simulator enqueues the empty request because the in-game Reset button cannot attach a payload. Probing srv/reset for reachability resets the robot; probing srv/activate releases a dormant robot's hold, which is idempotent and so harmless on one already running.

Type-specific service segments

SegmentRequest to replyRobotSDK entry point
driveSrvUgvDriveConfig to SrvAckTruckconfigure_drive
rotorsSrvMultirotorRotorConfig to SrvAckMultirotorconfigure_rotors
msdSrvMsdConfig to SrvAckMsdconfigure_msd
cartpoleSrvCartPoleConfig to SrvAckCartPoleconfigure_cartpole

HalfDrone and GlobalHawk add nothing to the common seven. Querying a segment the robot's type does not serve is not an error on the wire, only a GET nobody answers, which the SDK reports as NoResponder and which is exactly how a capability probe works.

One key, decomposed

vrobots/1/i/cam/front_left/720p_rgba8

SegmentValueMeaning
1vrobotsthe root every key in the system shares
21the sys_id; manager or scene here means a scope rather than a robot
3iiceoryx2, so shared memory and same host only; z would be zenoh
4camthe subject
5front_leftthe camera name, from CameraSpec::name
6720p_rgba8{res}_{fmt}, from CameraSpec::stream_segment(): 1280 by 720, four bytes per pixel

The format segment is part of the key, so changing a camera's resolution or pixel format renames its stream. A reader opened on the old name goes quiet rather than failing. CameraStream::service_name() returns the key that vrobots topic list prints, character for character, which is the fastest way to check the two agree.

Next: Appendix B: Command reference

See also: The topic namespace, Discovery from code

Appendix B: Command reference

Every command id, its arguments, the robots that act on it, and its status.

Command ids share one numbering space across every robot type, so an id is only ever implemented by some of them, and sending one to a robot that does not implement it is ignored exactly like sending an id that does not exist. Nothing on this wire is acknowledged: the state stream's actuator echo is the only receipt. In the Status column, live means a robot type acts on the id today, not yet means it is on the wire and no robot acts on it, and absent means the robot type it belongs to is not in the simulator. The constants live in vrobots_sdk::cmd, and cmd::name(id) maps a value back to its constant name for logging, returning "" for an unknown id.

Command ids

ConstantValueCmdArgs fieldUnitsSDK methodActed on byStatus
SET_ACC1not documentedm/s²send_cmdnothingnot yet
SET_VEL2not documentedm/ssend_cmdnothingnot yet
SET_POS3not documentedmsend_cmdnothingnot yet
SET_ANGACC50not documentedrad/s²send_cmdnothingnot yet
SET_ANGVEL51vec3rad/s, body rates in your header frame; converted as an axial vector, so it carries the handedness signset_angvel; read back with subscribe_setpointGlobalHawk onboard rate looplive
SET_EULER52not documentedradsend_cmdnothingnot yet
SET_EULER_DOT53not documentedrad/ssend_cmdnothingnot yet
SET_QUAT54not documentedunit quaternion, [x, y, z, w]send_cmdnothingnot yet
SET_MASS100not documentedkgsend_cmd; use set_physical_params insteadnothingnot yet
SET_MOI_3X1101not documentedkg·m²send_cmd; use set_physical_paramsnothingnot yet
SET_MOI_3X3102not documentedkg·m²send_cmd; use set_physical_paramsnothingnot yet
SET_BODY_FORCE200vec3N, header frameset_body_forcenothingnot yet
SET_BODY_TORQUE201vec3N·m, header frameset_body_torquenothingnot yet
SET_BODY_FT202vec3 force, vec3_arr[0] torqueN and N·mset_body_ftnothingnot yet
ADD_BODY_FORCE203not documentedNsend_cmdnothingnot yet
ADD_BODY_TORQUE204not documentedN·msend_cmdnothingnot yet
ADD_BODY_FT205not documentedN and N·msend_cmdnothingnot yet
SET_MR_PWM300int_arr, one per rotorµs, 1100 to 2000, checked client-sideset_mr_pwm, set_mr_pwm_nMultirotor (any rotor count), HalfDrone (exactly 2, [left, right])live
SET_MR_THROTTLE301float_arrnormalisedset_mr_throttlenothingnot yet
SET_OMROVER302not documentednot documentedsend_cmdomnidirectional roverabsent
SET_HELI303not documentednot documentedsend_cmdhelicopterabsent
SET_CAR304int_arr, [steer, throttle] or [steer, throttle, brake]µs, 1100 to 2000 checked client-side; the truck's factory band is 1100/1500/1900set_carTrucklive
SET_MSD305float_valN, clamped simulator-side to max_force (100 N by default)set_msd_forceMsdlive
SET_INVPEN306float_valN along the rail's +x, clamped to CartPoleConfig::max_force (20 N by default)set_cartpole_forceCartPolelive
SET_FW_SURFACES307float_arr, one per panelrad, clamped simulator-side to the airframe limit (20 degrees by default)set_fw_surfacesGlobalHawklive
SET_FW_THRUST308float_valN, clamped to [0, max_thrust] (20 kN on the RQ-4B)set_fw_thrustGlobalHawklive
SET_FW_THRUST_BIAS309float_valN, signed trim; ignored in FW_DIRECT_SURFACEset_fw_thrust_biasGlobalHawklive
SET_FW_CTRL_MODE310int_valFW_ONBOARD_RATE or FW_DIRECT_SURFACEset_fw_ctrl_modeGlobalHawklive
SET_FW_EST_SOURCE311int_valFW_EST_TRUTH or FW_EST_OBSERVERset_fw_est_sourceGlobalHawklive

Where the CmdArgs field says "not documented", the SDK ships no typed method for that id and the source does not name the field it reads, so the payload shape has to come from the simulator before you build on it. The units given for those ids follow from the SDK's everything-is-SI rule and the constant's name rather than from a statement in the source.

The CmdArgs field for SET_ANGVEL is vec3, verifiable from both ends: set_angvel builds CmdArgs::vector(rates), and on the way back subscribe_command yields a Setpoint only for commands carrying a vec3, with subscribe_setpoint as shorthand for subscribe_command(cmd::SET_ANGVEL).

Not yet. Seventeen ids above are carried by the schema and acted on by nothing. They publish without error and the state stream does not change, which is indistinguishable from a wrong sys_id or a wrong array length. Commands nothing acts on shows what that looks like from the outside.

Fixed wing mode constants

These are i32 values carried in CmdArgs::int_val, not command ids. They select the behaviour of SET_FW_CTRL_MODE and SET_FW_EST_SOURCE.

ConstantValueBelongs toMeaning
FW_ONBOARD_RATE0SET_FW_CTRL_MODEthe default; the aircraft flies itself on an onboard rate loop tracking SET_ANGVEL, with airspeed hold
FW_DIRECT_SURFACE1SET_FW_CTRL_MODEthe rate loop is bypassed and the panels take SET_FW_SURFACES verbatim
FW_EST_TRUTH0SET_FW_EST_SOURCEthe default; the onboard loop uses the simulator's true attitude
FW_EST_OBSERVER1SET_FW_EST_SOURCEthe onboard loop uses the attitude published on the robot's z/estimate topic, by publish_estimate or any other peer

reset() returns both settings to their defaults, deliberately: direct surface control with zeroed latches would relaunch the aircraft unflyable. A direct-surface client re-asserts both after every reset.

The CmdArgs shape

Every command carries the same argument struct, and each id reads the one or two fields it cares about.

#![allow(unused)]
fn main() {
#[non_exhaustive]
pub struct CmdArgs {
    pub int_val: i32,
    pub float_val: f64,
    pub int_arr: Vec<i32>,
    pub float_arr: Vec<f64>,
    pub vec3: Option<[f64; 3]>,
    pub vec4: Option<[f64; 4]>,   // [x, y, z, w]
    pub vec3_arr: Vec<[f64; 3]>,
    pub vec4_arr: Vec<[f64; 4]>,
}
}
The same in C++ (crates/vrobots-sdk-capi/include/vrobots_sdk.h)
typedef struct vrsdk_cmd_args_t {
    int32_t int_val;
    double float_val;
    const int32_t *int_arr;
    size_t int_arr_len;
    const double *float_arr;
    size_t float_arr_len;
    const double *vec3;
    const double *vec4;
    const double *vec3_arr;
    size_t vec3_arr_len;
    const double *vec4_arr;
    size_t vec4_arr_len;
} vrsdk_cmd_args_t;
The same in Python (crates/vrobots-sdk-py/python/vrsdk/_vrsdk.pyi)
int_val: int = 0
float_val: float = 0.0
int_arr: Optional[Sequence[int]] = None
float_arr: Optional[Sequence[float]] = None
vec3: Optional[Sequence[float]] = None
vec4: Optional[Sequence[float]] = None
vec3_arr: Optional[Sequence[Sequence[float]]] = None
vec4_arr: Optional[Sequence[Sequence[float]]] = None

The same eight fields carry the same meanings in all three. C++ uses the C struct directly, so every array is a pointer with an explicit _len, and vec3_arr and vec4_arr are flat double arrays whose length counts vectors rather than doubles. Python has no argument type at all: the eight are keyword arguments of send_cmd.

The struct is #[non_exhaustive], so it cannot be built with a struct literal. Build it from CmdArgs::default() plus chained setters, or from a shorthand.

BuilderSetsTypical use
CmdArgs::ints(&[i32])int_arrSET_MR_PWM, SET_CAR
CmdArgs::floats(&[f64])float_arrSET_FW_SURFACES
CmdArgs::vector([f64;3])vec3SET_BODY_FORCE
with_int_val, with_float_valint_val, float_valSET_FW_CTRL_MODE, SET_MSD
with_int_arr, with_float_arrint_arr, float_arr
with_vec3, with_vec4vec3, vec4
with_vec3_arr, with_vec4_arrvec3_arr, vec4_arrSET_BODY_FT's torque half

Each with_* setter consumes self and is #[must_use], so they chain. Empty vectors and None are omitted from the wire entirely rather than sent at zero length. Floats narrow: f64 in the API, f32 on the wire.

VirtualRobot::send_cmd(&self, cmd_id: u32, args: &CmdArgs) -> VrResult<()> is the escape hatch for an id with no typed method. It returns Deleted if the robot has been deleted and Publish if zenoh refuses the put, and nothing else: it cannot tell you whether any robot acted on the id.

Next: Appendix C: Error reference

See also: The generic command, Commands latch

Appendix C: Error reference

Every VrError variant: what it means, what usually causes it, and what to do.

The SDK has one error type, VrError, and VrResult<T> is Result<T, VrError>. Each variant carries a detail string naming the topic, service or field involved, and each has a stable numeric code from VrError::code(): the code says what class of thing went wrong, the string says which one. Those codes are part of the C and Python contract, so a code is never reused and never renumbered, even if a variant is removed. Code 0 always means success and is therefore never returned. VrError::kind() gives the short machine-friendly name, VrError::detail() the string without the prefix that Display adds, and Display prints kind: detail. The enum is #[non_exhaustive], so match on it with a catch-all arm.

Variants

VariantCodekind()MeansUsual causeWhat to do
Session1sessionThe zenoh session could not be opened, or has gone away.Nothing listening at the router endpoint, a network path that closed, or the simulator's host unreachable.Check the endpoint you passed as router_endpoint, then confirm the simulator is in Play mode with vrobots topic list.
InvalidArgument2invalid_argumentBad input from the caller. Nothing was sent.A wrong array length, an empty camera name, a pulse width outside 1100 to 2000, a non-finite value, a rate of zero, a wildcard key where an exact one is required, a second camera at a different resolution.Fix the call. The detail string names the field. This is the SDK refusing before the wire, so no simulator state changed.
Timeout3timeoutA wait ran out of time.No first state sample, no reply to a service query, or no new snapshot before the deadline.Depends entirely on the caller: see the note below.
Decode4decodeA payload arrived and did not decode as the message it should be.Schema drift between the SDK and the simulator, or a truncated payload.Compare vrobots --version against the simulator build. Decode failures on the state stream never reach you as an Err; they are counted in stats() and readable through last_error(), and the loop keeps running.
Publish5publishPublishing to zenoh failed.The session died between connect and the put.Treat it as a lost link, not a rejected command. Nothing in the simulator validates a command by refusing the put.
Service6serviceA service answered, and the answer was "no".In practice only srv/skin, which is the one service that ever replies ok = false, plus manager/z/srv/delete on an unknown sys_id.Do not retry a skin refusal: it is tier-gated, not transient. Remember that ok = true is a receipt, not a result, so the state stream is still the confirmation.
NoResponder7no_responderNobody is serving that key expression.Asking a robot for a service its type does not serve, a wrong sys_id, or a robot that is not loaded.See the note below: on a type-specific service this is an answer, not a fault.
Deleted8deletedThe robot was deleted by VirtualRobot::delete; the handle is spent.Continuing to use a handle after deleting its robot.Connect again. is_deleted() tests the local flag without touching the wire.
Config9configThe SDK could not be configured as asked.A malformed router endpoint, or an unusable zenoh config.Fix the ConnectOptions. The detail string names the setting; endpoints look like tcp/192.168.1.10:7447.

The two that are routinely misread

Timeout from wait_new_state and wait_new_frame is a status, not a failure. Both calls exist to answer "has anything arrived since I last looked", and a timeout is the honest "no": the simulator paused, the robot was deleted, or the camera stopped. states() itself never fails and never blocks, and if the simulator stops it keeps returning the last snapshot forever, so a timeout on the wait is how you detect a stall at all. Handle it as a branch in the loop rather than propagating it with ?. A timeout from connect or from a service query is a genuine failure and reads the other way.

NoResponder is how a capability probe reports that a robot type does not serve a service. A zenoh GET to a key nobody has opened is indistinguishable from a timeout, so the SDK reports it as its own variant: configure_drive on anything that is not a truck answers this, as does configure_rotors on anything that is not a multirotor. Asking and catching the variant is the supported way to discover what a robot can do, and is what ex30_hello_halfdrone is built on. The same code with an unexpected sys_id in the detail string means something else, that no robot with that id is loaded.

The same codes everywhere

SurfaceHow the code reaches you
RustVrError::code() on the variant
Cthe vrsdk_err_t return value
C++vrsdk::Error::code() on the thrown exception
Pythonan exception keyed off the same code
CLIexit code 1 for a command that ran and failed, 2 for arguments that did not parse, 0 for success

The CLI's exit codes are a separate scheme from VrError::code() and do not correspond to it. run(args) never calls process::exit.

Next: Appendix D: Glossary

See also: When nothing happens, Stream health

Appendix D: Glossary

One line each for the terms this book uses precisely.

The book uses these words in one sense and one sense only. Where a term looks like ordinary English but is not, the third column names the page that pins it down. Terms are listed alphabetically, ignoring backticks and capitalisation.

TermMeaningExplained in
ackA service reply. It says the request arrived and was packed, not that the change took effect; only srv/skin ever answers ok = false.Robot lifecycle
actuator echoThe actuator block in the state stream, carrying your last command back as pwm, normalized and measured. The only proof a command landed.Actuators
attachConnecting to a robot that already exists by passing Some(sys_id). Never touches srv/create and works for any robot the scene contains.System ids, and the two kinds of robot
axis conventionThe Axes tag beside every vector: UNSPECIFIED 0, UNITY 1, FRD 2, CV 3. A tag, not the authority; coord_frame_id is.Frames, axes and units
capability probeAsking a robot for a type-specific service and reading NoResponder as "this type does not serve it".Appendix C: Error reference
catalog keyThe wire name of a robot type, such as "multirotor". The catalog belongs to the scene, not the SDK, and only catalog_key() is ever put on the wire.Robot lifecycle
coord_frame_idThe authoritative string naming the frame a vector is expressed in. The only way to name a frame registered at runtime.Coordinate frames
created robotA robot the SDK spawned through manager/z/srv/create by calling connect with None. The reply carries its fresh id.System ids, and the two kinds of robot
elapsedSeconds since this robot's first state sample, monotonic, on one epoch shared by all of its streams including cameras. For printing and plotting.Timestamps and sequence numbers
FlatBuffersThe wire format for every message on both transports. Decoding verifies the buffer before reading any field.Two transports, one simulator
header frameThe frame you stamp on what you send, set by ConnectOptions::coord_frame_id, default "unity". Distinct from the robot's own frame.Frames, axes and units
iceoryx2The shared-memory transport that carries camera frames. Same host only, and it has a registry, so an entry can exist with no live publisher.Two transports, one simulator
latchWhat every command does: the last one received stays in effect until the next arrives. There is no watchdog and no expiry.Commands latch
observed versus registeredWhy a topic appears in discovery. Zenoh entries are observed, meaning they published during your window and the counters are real. iceoryx2 entries are registered, and live tells a streaming one from a dead leftover.Discovery from code
phase 0The point in the robot's next physics step at which a service change is applied, which is why an ack precedes the effect.Services and configuration
physics stepOne simulator integration tick. Commands are drained from the queue at its start; service changes land in its phase 0.Five rules that explain everything
scene-authored robotA robot the scene placed, rather than one the SDK created. Attach to it by id; cartpole, halfdrone and globalhawk exist only this way.System ids, and the two kinds of robot
seqA per-topic sequence number. A jump of more than one means dropped samples, counted in stats() as seq_gaps and missed_samples.Timestamps and sequence numbers
setpointA command read back off the z/cmd bus as a Setpoint, with its vec3 payload unconverted and in the sender's frame.Reading someone else's commands
snapshotThe State value states() returns: the latest complete sample, never torn, never blocking, never failing.Reading state
src_idWho published a message. Yours defaults to 122, must be non-zero, and 0 is reserved for the simulator; the in-game IMU panel publishes as 108.What connect actually does
sys_idWhich robot a message belongs to, and the segment after vrobots/ in every per-robot key. Allocated at scene load and incrementing across loads.System ids, and the two kinds of robot
t_nsCapture time in nanoseconds since the unix epoch, signed. The one clock shared by state snapshots and camera frames, so the two are directly subtractable.Timestamps and sequence numbers
truth / measured / believedThe three views the schema keeps apart: kin, wrench and env are simulator-exact, sensors are noisy and robot-observable, estimate is the robot's own filter belief.Truth, measured and believed
upsertThe verb mount_camera uses: add the named camera or reconfigure it, leaving every other camera on the robot alone.Mount, open and unmount
zenohThe pub/sub and query transport carrying states, commands and services. Works across a network, and has no registry, so a topic appears in discovery only if it published during your window.Two transports, one simulator

Next: The VRobots SDK Book

See also: Appendix A: Topic reference, Five rules that explain everything