Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6836fab
Take RTDE data types from the robot's setup acknowledgement
urrsk Aug 28, 2026
13b7c35
Fix leftover handshake state after a failed RTDE setup.
urrsk Aug 28, 2026
497a212
Cover RTDE handshake refusals and reconnect giving up.
urrsk Aug 28, 2026
161a4a8
Fix macOS and Alpine failures in the RTDE allocation tests.
urrsk Aug 28, 2026
b9f52d8
Remove the unused string alternative from the RTDE field variant so a…
urrsk Aug 28, 2026
d24b3c7
Check documented RTDE outputs against the controller and keep the all…
urrsk Aug 28, 2026
c1c9dd3
Handle batched RTDE packages in the fake server so a pause after a wr…
urrsk Aug 31, 2026
5f33f82
Let the bitset getData reuse the generic lookup instead of duplicatin…
urrsk Sep 1, 2026
fccce1d
Make the RTDE send and receive paths constant-cost per cycle.
urrsk Sep 2, 2026
9156cd9
Make setRecipeTypes() public on the RTDE parser and writer.
urrsk Sep 2, 2026
a07bfaf
Accept a partly typed RTDE package on send without walking field names.
urrsk Sep 3, 2026
436dfc1
Type a pre-allocated RTDE data package in place instead of replacing it.
urrsk Sep 4, 2026
b873a4a
Remove unused DataPackage::layoutHashFor().
urrsk Sep 4, 2026
029a871
Join the fake RTDE server's worker before destroying the mutexes it l…
urrsk Sep 4, 2026
ea69eb9
Restore parseWith's protocol-aware payload and keep setTypes transact…
urrsk Sep 4, 2026
de0b6cf
Apply batched suggestions from code review
urrsk Sep 4, 2026
49846f4
Make RTDE client state atomic and document getDataType as the stored …
urrsk Sep 4, 2026
f1505c3
Apply batched suggestions from code review
urrsk Sep 4, 2026
8cc7354
Reject RTDE writer setup mutators while the send thread is running.
urrsk Sep 5, 2026
42904c4
Document why the fake RTDE server pops one setup-outputs reply per re…
urrsk Sep 5, 2026
d5c4b65
Close leftover RTDE type-from-ack holes and upload robot-free coverage.
urrsk Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,40 @@ jobs:
retention-days: 5
archive: false

unit_coverage:
name: unit_coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install build-tools
run: sudo apt-get update && sudo apt-get install -y build-essential cmake gcovr
- name: configure
run: >
mkdir build &&
cd build &&
cmake ..
-DBUILDING_TESTS=1
-DINTEGRATION_TESTS=0
-DCMAKE_COMPILE_WARNING_AS_ERROR=ON
env:
CXXFLAGS: -g -O2 -fprofile-arcs -ftest-coverage
CFLAGS: -g -O2 -fprofile-arcs -ftest-coverage
LDFLAGS: -fprofile-arcs -ftest-coverage
- name: build
run: cmake --build build --config Debug
- name: test
run: cd build && ctest --output-on-failure --output-junit junit.xml
- name: gcovr
run: cd build && gcovr -r .. --xml coverage.xml --gcov-ignore-parse-errors negative_hits.warn_once_per_file --exclude "../3rdparty"
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v7
with:
fail_ci_if_error: true
files: build/coverage.xml
flags: unit
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

run_tests:
timeout-minutes: 60
runs-on: ubuntu-latest
Expand Down
82 changes: 79 additions & 3 deletions doc/architecture/rtde_client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,20 @@ the :ref:`rtde_client_example` for an example of the blocking read method.
{
if (my_client.getDataPackage(data_pkg, READ_TIMEOUT))
{
std::cout << data_pkg->toString() << std::endl;
std::cout << data_pkg.toString() << std::endl;
}
}

.. note::
Constructing the ``DataPackage`` is where its memory is allocated, so create it before entering
your control loop and reuse it: ``getDataPackage()`` and ``getDataPackageBlocking()`` don't
allocate.

A recipe only lists field names. The data types belonging to them are reported by the robot when
it acknowledges the recipe, and the first read applies them to your ``DataPackage``, which costs
no memory. Until that has happened ``getData()`` on the package fails. See `Field data types`_
for how to ask a package what type it gave a field.

Upon construction, two recipe files have to be given, one for the RTDE inputs, one for the RTDE
outputs. Please refer to the `RTDE
guide <https://www.universal-robots.com/articles/ur-articles/real-time-data-exchange-rtde-guide/>`_
Expand Down Expand Up @@ -69,6 +79,56 @@ After calling ``my_client.start()``, data can be read from the
Remember that, when not using a background thread, data has to be polled regularly, as the robot
will shutdown RTDE communication if the receiving side doesn't empty its buffer.

Both methods deliver their data into a ``DataPackage`` that the caller owns, which is what keeps the
read path free of memory allocations: ``getDataPackage()`` copies the background reader's latest
package into it, ``getDataPackageBlocking()`` parses the next package straight into it. The
deprecated ``getDataPackage(timeout)`` overload, which returns a new package instead, allocates on
every call by design and is therefore not suited for real-time use.

Field data types
~~~~~~~~~~~~~~~~

``getData()`` has to be given a variable of the field's own type, and returns ``false`` if it isn't.
Rather than hardcoding which type a field has, ask the package: ``getDataType()`` reports the
``DataType`` a field currently holds. After acknowledgement that is the type the robot reported;
on an input package written with ``setData()`` before then, it is the type of that write. An
untouched field has no type. This is useful for code that has to handle whatever recipe it is
configured with, such as a bridge to another middleware:

.. code-block:: c++

const std::optional<rtde_interface::DataType> type = data_pkg.getDataType(field_name);
if (!type)
{
// Not part of the recipe, or the field has no type yet
return;
}

// For "actual_q" this prints "VECTOR6D", the same spelling the RTDE guide uses
std::cout << field_name << " is a " << rtde_interface::toString(*type) << std::endl;

switch (*type)
{
case rtde_interface::DataType::DOUBLE:
{
double value;
data_pkg.getData(field_name, value);
break;
}
case rtde_interface::DataType::VECTOR6D:
{
vector6d_t value;
data_pkg.getData(field_name, value);
break;
}
// ... remaining types
}

``DataType`` covers the complete set the protocol defines: ``BOOL``, ``UINT8``, ``UINT32``,
``UINT64``, ``INT32``, ``DOUBLE``, ``VECTOR3D``, ``VECTOR6D``, ``VECTOR6INT32`` and
``VECTOR6UINT32``. Switching over it exhaustively means the compiler will point out any case a
future protocol addition leaves unhandled.

Writing data
------------

Expand Down Expand Up @@ -105,11 +165,11 @@ an empty input recipe, like this:
// Alternatively, pass an empty filename when using recipe files
// rtde_interface::RTDEClient my_client(ROBOT_IP, notifier, OUTPUT_RECIPE_FILE, "");
my_client.init();
auto data_pkg = std::make_unique<rtde_interface::DataPackage>(my_client->getOutputRecipe());
auto data_pkg = std::make_unique<rtde_interface::DataPackage>(my_client.getOutputRecipe());
my_client.start();
while (true)
{
if (my_client.getDataPackage(data_package, READ_TIMEOUT))
if (my_client.getDataPackage(data_pkg, READ_TIMEOUT))
{
std::cout << data_pkg->toString() << std::endl;
}
Expand All @@ -125,6 +185,22 @@ The class offers specific methods for every RTDE input possible to write.

Data is sent asynchronously to the RTDE interface.

To write several fields at once, ask the client for a package that already carries the data types
the robot reported for the input recipe, fill the fields you care about and pass it to
``sendPackage()``. Fields you leave alone are sent as zeros. Because the package is already typed,
``setData()`` reports a value written with the wrong type immediately:

.. code-block:: c++

rtde_interface::DataPackage input_pkg = my_client.createInputDataPackage();
input_pkg.setData("speed_slider_mask", uint32_t{ 1 });
input_pkg.setData("speed_slider_fraction", 0.5);
my_client.getWriter().sendPackage(input_pkg);

A package constructed from ``getInputRecipe()`` still works. Its types are taken from the values
written to it and are checked when the package is sent. See the :ref:`rtde_writer_example` for a
complete example.

.. note::

The ``RTDEWriter`` will return ``false`` on any writing attempts for fields that have not been
Expand Down
1 change: 1 addition & 0 deletions doc/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ may be running forever until manually stopped.
examples/primary_pipeline
examples/primary_pipeline_calibration
examples/rtde_client
examples/rtde_writer
examples/external_fts_through_rtde
examples/script_command_interface
examples/script_sender
Expand Down
9 changes: 6 additions & 3 deletions doc/examples/rtde_client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,18 @@ fetch data synchronously. Hence, we pass ``false`` to the ``start()`` method.
:start-at: auto data_pkg = std::make_unique<rtde_interface::DataPackage>(my_client.getOutputRecipe());
:end-before: // Change the speed slider

Creating the package we read into is the last allocation the read path makes; the loop below reuses
the same package. The recipe only names the fields, so the first read is also what tells this one
what type each of its fields has, which needs no further memory.

In our main loop, we wait for a new data package to arrive using the blocking read method. Once
received, data from the received package can be accessed using the ``getData()`` method of the
``DataPackage`` object. This method takes the key of the data to be accessed as a parameter and
returns the corresponding value.

.. note:: The key used to access data has to be part of the output recipe used to initialize the RTDE
client. Passing a string literal, e.g. ``"actual_q"``, is possible but not recommended as it is
converted to an ``std::string`` automatically, causing heap allocations which should be avoided
in Real-Time contexts.
client. ``getData()`` returns ``false`` for an unknown key, and also if the type of the passed
variable doesn't match the type the robot reported for that field.

Writing Data to the RTDE client
-------------------------------
Expand Down
183 changes: 183 additions & 0 deletions doc/examples/rtde_writer.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
:github_url: https://github.com/UniversalRobots/Universal_Robots_Client_Library/blob/master/doc/examples/rtde_writer.rst

.. _rtde_writer_example:

RTDE writer example
===================

This example shows how to write several `Real-Time Data Exchange (RTDE)
<https://www.universal-robots.com/articles/ur/interface-communication/real-time-data-exchange-rtde-guide/>`_
inputs to the robot in a single package, at the robot's maximum frequency, and how to prove that
the robot processed them.

The one-field ``send...()`` helpers on ``RTDEWriter`` each produce a package of their own. When
several general purpose registers have to change together, ``sendPackage()`` is the method that
puts them on the wire in one RTDE package.

The example's source code can be found in `rtde_writer.cpp
<https://github.com/UniversalRobots/Universal_Robots_Client_Library/blob/master/examples/rtde_writer.cpp>`_.

.. note:: The robot has to be powered on and, on an e-Series, in *remote control mode* for the
register-processing program to be accepted.

Recipes as argument lists
-------------------------

``RTDEClient`` takes the input and output recipes as two lists of field names. Recipe files work
as well; see :ref:`rtde_client_example`. ``timestamp`` is part of the output recipe either way,
because the client adds it if it is missing.

The general purpose register ranges reserved for external RTDE clients are bit registers
``64..127`` and integer and double registers ``24..47``.

.. literalinclude:: ../../examples/rtde_writer.cpp
:language: c++
:caption: examples/rtde_writer.cpp
:linenos:
:lineno-match:
:start-at: const std::vector<std::string> INPUT_RECIPE
:end-at: const std::string OUTPUT_DOUBLE_REGISTER

.. note:: Register fields, unlike the digital and analog outputs and the speed slider, need no
companion ``_mask`` key in the input recipe.

Processing the registers on the robot
-------------------------------------

Input registers cannot be written from URScript, and output registers cannot be written through
RTDE. Getting values back therefore requires a program on the robot.

The program does not copy the values. RTDE also exposes the input registers as outputs, so a
plain echo would be indistinguishable from that read-back. Instead the program inverts the bit,
adds one to the integer and negates the double. A value that satisfies those relations can only
have been produced by this program. ``sync()`` runs the loop once per control cycle.

``sendScript()`` is used rather than ``sendScriptBlocking()``, because the latter would wait until
the program stops, and this one loops forever.

.. literalinclude:: ../../examples/rtde_writer.cpp
:language: c++
:caption: examples/rtde_writer.cpp
:linenos:
:lineno-match:
:start-at: const std::string MIRROR_PROGRAM
:end-at: end)";

.. literalinclude:: ../../examples/rtde_writer.cpp
:language: c++
:caption: examples/rtde_writer.cpp
:linenos:
:lineno-match:
:start-at: // Start the robot program that processes the registers
:end-at: // The program keeps running until we stop it later.

An input package with the robot's field types
---------------------------------------------

The data types of the input recipe belong to the robot and arrive with the handshake, so the
package has to be created after ``init()``. ``createInputDataPackage()`` returns a zeroed package
that already carries those types: ``setData()`` then rejects a wrong type immediately, and
copying the package into the send buffer is a single memcpy.

A package constructed from ``getInputRecipe()`` still works. Its types are taken from the values
written to it and are only checked when the package is sent.

.. literalinclude:: ../../examples/rtde_writer.cpp
:language: c++
:caption: examples/rtde_writer.cpp
:linenos:
:lineno-match:
:start-at: // RTDE client at the robot's maximum frequency
:end-at: my_client.start(false);

``target_frequency = 0.0`` (the default) requests the robot's maximum: 125 Hz on CB3, 500 Hz on
e-Series. See :ref:`real time setup` and :ref:`rtde_client`.

Both ``DataPackage`` objects are allocated before the loop, so the loop itself is allocation-free.
The output package is built from ``getOutputRecipe()`` and is therefore still untyped; the first
read applies the robot's types to it in place, which needs no memory.

Letting the robot pace the loop
-------------------------------

``start(false)`` leaves the background read thread off. ``getDataPackageBlocking()`` returns once
per RTDE cycle and is this loop's time base. The input package is produced immediately after the
read so it reaches the robot in time to be acted on in the next cycle. Printing is throttled to
about once per second, so it stays out of the hot path.

.. literalinclude:: ../../examples/rtde_writer.cpp
:language: c++
:caption: examples/rtde_writer.cpp
:linenos:
:lineno-match:
:start-at: // The blocking read is this loop's clock
:end-at: URCL_LOG_ERROR("Could not get a fresh data package from the robot.");

Writing several inputs in one package
-------------------------------------

Unwritten fields of the package are sent as zeros. One ``sendPackage()`` produces exactly one
RTDE package; the ``send...()`` helpers would produce one package per field. The call only queues
the values for the writer thread, so the loop stays aligned to the robot.

.. literalinclude:: ../../examples/rtde_writer.cpp
:language: c++
:caption: examples/rtde_writer.cpp
:linenos:
:lineno-match:
:start-at: // Writing several general purpose inputs in one package
:end-at: URCL_LOG_ERROR("Sending RTDE data failed.");

Verifying that the robot processed the data
-------------------------------------------

All three values sent in a cycle are derived from the cycle counter, so the integer the robot
returns identifies which cycle an answer belongs to. ``echoed_int - 1`` is that counter. The
expected bit is its inversion and the expected double is the negated sine. The robot's double
register is a 64-bit value, so the negated sine comes back bit for bit and is compared exactly.
Together with the inverted bit, that is what makes an answer attributable to this program rather
than to RTDE's own read-back of the input registers.

Against URSim the lag is one cycle: the values written after the read of cycle N are processed by
the robot and observed in the read of cycle N+1. ``getData()`` needs a variable of the field's own
type; ``getDataType()`` reports that type if the recipe is not known in advance.

.. literalinclude:: ../../examples/rtde_writer.cpp
:language: c++
:caption: examples/rtde_writer.cpp
:linenos:
:lineno-match:
:start-at: // Reading what the robot made of the previous package
:end-at: ++mismatches;

Cleanup
-------

The input registers are reset and the robot program is stopped. A failed stop is only logged,
because CI runs the example for one second and still requires exit code 0.

.. literalinclude:: ../../examples/rtde_writer.cpp
:language: c++
:caption: examples/rtde_writer.cpp
:linenos:
:lineno-match:
:start-at: // Reset the input registers before leaving
:end-at: return 0;

Example output
--------------

The following shows a run against URSim 5.25.1 asking for 500 Hz. The echoed integer trails the
sent integer by one cycle, and ``verified=1`` means the bit and the double match the
transformations the robot program applies to that cycle.

.. code::

[INFO] RTDE target frequency: 500.000000 Hz
sent: bit=1 int=484 double=-0.991869 | robot: bit=0 int=483 double=0.994216 | verified=1 lag_cycles=1 freq=483.063 Hz playing=1
sent: bit=0 int=967 double=-0.242772 | robot: bit=1 int=966 double=0.223323 | verified=1 lag_cycles=1 freq=482.826 Hz playing=1
sent: bit=1 int=1450 double=0.934895 | robot: bit=0 int=1449 double=-0.941806 | verified=1 lag_cycles=1 freq=482.669 Hz playing=1
[INFO] Cycles: 1931, average frequency: 482.628400 Hz, verified: 1929, mismatches: 0, last lag: 1 cycles

A simulator shares the host's CPU, so the measured frequency stays somewhat below the requested
one; on a real controller it tracks the target closely.
Loading
Loading