§Rust implementation of Tango controls
Théo Rozier, November 8th, 2026
rsTango 0.4.0 has just been published! The source code is on GitLab, the crate is on crates.io and its documentation is on docs.rs.
Early in 2025, I joined a synchrotron facility, namely the ESRF in Grenoble (France), basically for working on the control system of the electron accelerator, but also for working on the library behind it: Tango. This library originated from ESRF, but is now used all around the world, mostly in scientific facilities and their experiments.
Tango is a distributed control system, and is best defined by its C++ implementation, cppTango. But I discovered that the API provided by this implementation is really easy to... misuse... or even to use without its code generator: Pogo. The simple fact that a code generator is needed (and it generates a lot of code!) made me think that something was missing from the implementation!
Something I liked however, was the Python binding PyTango, which already provides a way simpler API for using Tango, however it is still a binding onto the C++ implementation, which make it leak through the cool Python API... Anyway, all this experience made me think, roughly one year after joining this position, and with my past knowledge of Rust, that a Rust implementation would be a perfect fit for Tango...
With its type safety, its ownership, borrowing and trait models, I'll show you in this article how I used Rust to provide a safe and sound Tango API.
§Short introduction to Tango
Tango is both simple and complex at the same time, in the sense that there are many, I mean a lot of hidden details and behaviors, but it is also simple to understand the basics.
To start with, everything is centered on the concept of device, which is an object
of a given Tango environment that provides an interface consisting of commands and
attributes. A client is then able to connect, execute commands, read and write
attributes (given that it knows how to talk to the said Tango environment). Each device
is named following the pattern domain/family/member, and can be reached by clients with
this name only!
Each device is hosted in a device server, which is literally a process running somewhere, this device server has an instance name. But remember, Tango is a distributed control system, which means that those servers are possibly running on many physical or virtual machines, given that the clients wants to reach it only through its device name, Tango needs a broker...
The database is the broker of the Tango control system, it's at the same time a... database, but also a Tango device, so it provides commands that allows resolving the socket address (IP + TCP port) given a device name. The server also stores device properties, which are static information kept between restarts of a device.
One more information before going into details, Tango has both a pull model, where the client is responsible for sending requests (commands and attribute read/write) to the devices, and a pull model, where the server send events to clients that subscribed for given attributes and event types.
§Designing the client interface
When first sketching the idea, I started with the types and the interface I wanted for the users. I also paid attention to roughly keeping the same naming conventions as the existing libraries, and so I'll show you comparison with the other languages!
§Tango Resource Locators
A device is reached through a Tango Resource Locator (TRL), which is at its
simplest the device name, but can also give the database to use, or target an attribute:
tango://host:10000/sys/tg_test/1/double_scalar. In cppTango and PyTango, a TRL is just
a string, parsed again by every function that receives one:
1 2 3 4 | |
1 2 3 4 | |
In rstango, the TRL is a type of its own, and functions that need one accept anything that can become one, through a trait:
1 2 3 4 5 6 7 8 | |
So the simple case stays as simple as in the other languages, but a TRL that has already been parsed (and checked!) can be passed around and reused without being parsed again:
1 2 3 4 5 6 7 8 | |
§Attribute and command values
The values of attributes and commands can be of many types: booleans, integers, floats,
strings, states... and as scalar, spectrum (1D) or image (2D). In cppTango, values go
through Tango::DeviceAttribute and Tango::DeviceData containers, which are filled
and emptied with the << and >> operators, the type being checked only at runtime:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
PyTango hides all of this, the value being of whatever type the device sends:
1 2 3 4 5 6 | |
With rstango, we follow the same design style as PyTango, the values are directly given
and returned by the proxy functions, but with static type safety! Every value is a
Value enum, and the types it converts to and from are given by two traits, which are
then used by the proxy functions:
1 2 3 4 5 6 7 8 9 10 11 12 | |
The caller just says which type it expects, and gets an error if the device sent something else. The same goes for attributes, where a tuple of names reads all of them in a single request, each one with its own type:
1 2 3 4 5 6 7 8 | |
§Supporting both sync and async
Every call of rstango is blocking, up to the timeout of the proxy, so there is no runtime
to install for the simple cases. But each one also has an asynchronous *_async form,
which runs on any executor, and the concurrent requests are multiplexed over the
connection shared with the device:
1 2 | |
So a single thread can wait on many requests, or many devices, at once:
1 2 3 4 5 6 7 8 9 | |
Note however that the async functions have no timeout: the timeout of the proxy only bounds the blocking functions. With async, it's up to the caller to bound its own wait, using the timeout of whatever executor it's running on, and to drop the future when it's no longer interested in the answer, which cancels the request.
This might look like a detail, but async is really useful when it comes to events, as you'll see just below!
§Events
In cppTango, events are received through a callback object, which is called from another thread of the library each time an event comes, and the subscription has to be undone manually with its identifier:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
PyTango follows the same model, with any callable as the callback:
1 2 3 4 5 6 7 8 9 10 | |
The problem with callbacks is that the code receiving the events is cut from the code that subscribed, so any state shared between the two needs to be synchronized, and waiting for an event, along with other things, requires building your own channels.
In rstango, a subscription is instead a stream of events, typed just like attribute reads, which are simply awaited where they are needed, and the subscription is undone when the stream is dropped:
1 2 3 4 5 6 7 8 9 | |
Waiting on many events is then just a matter of waiting on many streams, each one keeping its own type, and all from a single thread:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
And, of course, there is a blocking form, waiting for the next event up to a timeout:
1 | |
§Tango errors
You may have noticed that every function is returning a Result, with the error type
of rstango. Errors in Tango have a specific format, the one of CORBA DevFailed
exceptions, which are a stack of errors, each one with a reason, a description, an
origin and a severity. The rstango Error type follows this format, so errors coming
from devices are given as is, and errors can be chained to others as parents:
1 2 3 4 5 6 7 | |
The reason is exposed as an ErrorKind, which can be matched on, for the standard
reasons of Tango as well as the custom ones sent by devices:
1 2 3 4 5 6 7 | |
§Designing the server interface
Something important to notice first is that the server interface is built on top of
the client one: a device server is also a client of the Tango database, which it reaches
through a DatabaseProxy, the same one a client would use, for registering its devices
and getting their properties. It also uses the same Value, FromValue and IntoValue
types for the values of its attributes and commands.
On the server side, cppTango requires a device class to be written by inheriting a few base classes and overriding their virtual methods, which is where Pogo comes in, by generating most of this code. PyTango's high-level API is way simpler, a device being a class whose attributes and commands are defined with decorators:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
§Devices as plain structs
In rstango, a device is a plain Rust struct, its model, and its attributes and commands
are defined from its own functions, with no trait to implement, no macro and no code
generator. The types of the attributes and commands are directly taken from the
signatures of these functions, through the same FromValue and IntoValue traits as
the client side:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | |
Even if this is already quite simple, we're still thinking of making a separate crate of procedural macros, for creating servers even more simply, closer to what the PyTango decorators allow, but built on top of this API, which would stay macro-free.
§Dynamic devices
You may have noticed that in the example above, the attribute and the command are added to the device during its initialization, this is because devices are dynamic by nature in rstango. Contrary to cppTango and PyTango, where the interface of a device is mostly fixed by its class, and where dynamic attributes are a special case, here the class only gives what every one of its devices has, and each device can then add or remove attributes and commands of its own, depending on its properties for example:
1 2 3 4 5 6 7 8 9 10 11 | |
§Shared and exclusive access
The interesting part is the receiver of each function: every read, write and command
can be defined either from a function taking &self (read, write, exec), or
from one taking &mut self (read_exclusive, write_exclusive, exec_exclusive).
Just like Rust's borrowing rules, many shared accesses can run on the model at the same
time, but an exclusive one is alone on it, and the server takes care of the lock. So
the concurrency model of the device is just written in the signatures of its functions,
and checked by the compiler!
§Errors in device functions
Every function of the device returns a Result, with the same Error type we saw on
the client side, so returning an error from a command is as simple as:
1 2 3 4 5 6 7 8 9 10 | |
And the client receiving it gets the very same error, with the same reason and description.
§Backend abstraction
Tango, as it exists today, relies on two protocols: CORBA for the requests (commands,
attributes, and everything else), and ZeroMQ for publishing the events. Without going
into details, CORBA is a standard for calling methods on remote objects, which I actually
like, especially how well it's designed for interoperability between implementations.
What I don't like, however, is how it leaks everywhere into cppTango, which relies on
the omniORB implementation: CORBA types are found all over its API, the DeviceData
and DeviceAttribute containers are basically wrapping CORBA values, and so on.
In rstango, I wanted none of this to leak into the API, the protocol actually used to reach a device should only be a detail of the library.
§Backend traits
Everything goes through backend traits, where a Backend connects to devices (and
serves them), and gives back a BackendDev, which is the connection to one device
(simplified here):
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Notice that every function is returning a future, the backend is async-only, and the
blocking functions of DevProxy are just waiting on those futures. An event
subscription is also simply a stream of events, so the ZMQ part of Tango is hidden
behind the same trait.
§Values and readings
The types used in this abstraction are the same for every backend:
Value, the enum of every value Tango can exchange, of any type and format (scalar, spectrum or image), it's the dynamic counterpart of theFromValueandIntoValueconversions we saw earlier;AttrRead<T>, an attribute reading, with its value, its write value, its quality and its timestamp, the backend givesAttrRead<Value>andDevProxyconverts it to theAttrRead<T>asked by the user;Named<T>andNamedResult<T>, a value or result associated to the name of the attribute it is for, when many attributes are read or written at once.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
§Buffers
All the values are finally held in buffers, of a single type Buf<T, DIM>, which is a
typed array of DIM dimensions, scalar, spectrum and image being just aliases of it:
1 2 3 4 5 6 7 8 | |
The storage of a buffer is shared and reference counted, so cloning or slicing a buffer never copies the values, and casting one to another type of values only reinterprets its octets (as long as they are aligned, and in the right byte order). This is what allows the octets of a received message to go all the way up to the user without being copied:
1 2 3 | |
So reading a spectrum of a million points as a Spectrum<f64> copies nothing, the
reading keeps pointing to the octets of the message it was decoded from (asking for a
Vec<f64> instead is still possible, but then it's copied):
1 | |
A buffer can also be modified, by turning it into a BufMut, which is statically the
only reference to its storage, the values being copied only if the storage was shared
(copy-on-write), and turned back into a Buf to be shared again:
1 2 | |
Finally, strings also live in the same buffers, as Buf<str, DIM>, the strings
pointing into the octets of the storage, and scalars of numbers are directly held in
place, without any storage being allocated.
§CORBA, ZMQ and local backends
This abstraction is what allows rstango to have its own CORBA and ZMQ backend, the CORBA part being written from scratch (no omniORB linked in!) and the ZMQ part using the C library, but also a local backend: when a client is in the same process as the device server serving the device, requests are directly given to the device, without going through any protocol!
§Conclusion
rsTango is still young, so expect rough edges, missing features and some API changes in the next versions... But it's already usable, and the best way to make it better is to have people trying it on their own devices!
If you're using Tango and you're curious about Rust (or the opposite!), give it a try, and feel free to open issues or merge requests on GitLab. You can also come and discuss it on the Matrix channel, whether it's for questions, ideas, or just to say what you're doing with it.
<