Get started with Rust¶
Follow this guide to get started with the Astarte device SDK for the Rust programming language. We will guide you through setting up a very basic Rust application creating a device, connecting it to a local Astarte instance and transmitting some data.
Before you begin¶
There are a few setup steps and requirements that are needed before start working on the example.
Local Astarte instance¶
This get started will focus on creating a device and connecting it to an Astarte instance. If you don’t have access to an Astarte instance you can easily set up one following our Astarte quick instance guide.
From here on we will assume you have access to an Astarte instance, remote or on a local machine
connected to the same LAN where your device will be connected. Furthermore, we will assume you have
access to the Astarte dashboard for a realm. The next steps will install the required interfaces and
register a new device on Astarte using the dashboard. The same operations could be performed using
astartectl
and the access token generated in the Astarte quick instance guide.
Registering the device¶
Devices should be pre-registered to Astarte before their first connection. With the Astarte device SDK for Rust this can be achieved in two ways:
By registering the device on Astarte manually, obtaining a credentials secret and transferring it on the device.
By using the included registration utilities provided by the SDK. Those utilities can make use of a registration JWT issued by Astarte and register the device automatically before the first connection.
To keep this guide as simple as possible we will use the first method, as a device can be registered using the Astarte dashboard with a couple of clicks.
To install a new device start by opening the dashboard and navigate to the devices tab. Click on
register a new device, there you can input your own device ID or generate a random one. For example
you could use the device ID 2TBn-jNESuuHamE2Zo1anA
. Click on register device, this will register
the device and give you a credentials secret. The credentials secret will be used by the device to
authenticate itself with Astarte. Copy it somewhere safe as it will be used in the next steps.
Creating a Rust project¶
First, make sure you have the proper Rust toolchain installed on your machine. We recommend using rustup, otherwise make sure you are using one supported by the MSRV of the Astarte device Rust SDK
Then, create a new project:
cargo new astarte-rust-project
Then, we need to add the following dependencies in the Cargo.toml file:
astarte-device-sdk
, to properly use the Astarte SDKtokio
, a runtime for writing asynchronous applicationsserde
andserde_json
, for serializing and deserializing Rust data structures. They are useful since we want to retrieve some device configuration stored in a json file
We also suggest you to add the following dependencies
tracing
andtracing_subscriber
for printing and showing the logs of the SDKeyre
to convert and report the error into a single trait object
You can run the following cargo add command:
cargo add astarte-device-sdk --features=derive
cargo add tokio --features=full
cargo add serde serde-json --features=serde/derive
cargo add tracing tracing-subscriber
cargo add color-eyre
System dependencies¶
The device SDK uses an SQLite as an in-process database to store Astarte
properties on disk. In order to compile the application, you need to provide a compatible sqlite3
library.
To use your system SQLite library, you need to have a C toolchain, libsqlite3
and pkg-config
installed. This way you can link it with your Rust executable. For example, on a Debian/Ubuntu
system you install them through apt
:
apt install build-essential pkg-config libsqlite3-dev
You can find more information on the rusqlite GitHub page.
Configuration¶
To easily load the Astarte configuration information, such as the realm name, the astarte instance
endpoint, the device id and the pairing url, you could set some environment variables or store them
in a config.json
file, like the following:
{
"realm": "Realm name",
"device_id": "Device ID",
"credentials_secret": "Credentials secret",
"pairing_url": "Pairing URL"
}
We previously added three interfaces to our Astarte instance. We also need to save the three
interfaces in JSON files, for instance in a interfaces
folder in your working directory. These
will be retrieved, parsed and then used during the device connection
Instantiating and connecting a device¶
Now we can start writing the source code of our device application. We will first create a new device using the device ID and credentials secret we obtained in the previous steps. Then, the device will need to be polled regularly to ensure the processing of MQTT messages. To this extent, we can spawn a tokio task (the equivalent of an OS thread but managed by the Tokio runtime) to poll connection messages. Ideally, two separate tasks should be used for both polling and transmission.
use astarte_device_sdk::{
builder::DeviceBuilder, prelude::*, store::SqliteStore, DeviceClient,
transport::mqtt::{Mqtt, MqttConfig}, DeviceConnection,
};
use color_eyre::eyre;
use serde::Deserialize;
use tracing::{info, error};
use tracing_subscriber;
use tokio::task::JoinSet;
/// structure used to deserialize the content of the config.json file containing the
/// astarte device connection information
#[derive(Deserialize)]
struct Config {
realm: String,
device_id: String,
credentials_secret: String,
pairing_url: String,
}
/// Load connection configuration and connect a device to Astarte.
async fn init() -> eyre::Result<(DeviceClient<SqliteStore>, DeviceConnection<SqliteStore, Mqtt<SqliteStore>>)> {
// Load the device configuration
let file = tokio::fs::read_to_string("config.json").await?;
let cfg: Config = serde_json::from_str(&file)?;
let mut mqtt_config = MqttConfig::with_credential_secret(
&cfg.realm,
&cfg.device_id,
&cfg.credentials_secret,
&cfg.pairing_url,
);
mqtt_config.ignore_ssl_errors();
// connect to a db in the current working directory
// if it doesn't exist, the method will create it
let store = SqliteStore::connect_db("./store.db").await?;
let (client, connection) = DeviceBuilder::new()
.store(store)
// NOTE: here we are not defining any Astarte interface, thus the device will not be able to
// send or receive data to/from Astarte
.connect(mqtt_config)
.await?
.build()
.await;
Ok((client, connection))
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
color_eyre::install()?;
tracing_subscriber::fmt::init();
let (client, connection) = init().await?;
info!("Connection to Astarte established.");
// define a set of tasks to be spawned
let mut tasks = JoinSet::<eyre::Result<()>>::new();
// task to poll updates from the connection
tasks.spawn(async move {
connection.handle_events().await?;
Ok(())
});
// ...
// here we will insert other pieces of code to handle receiving and sending data to Astarte
// ...
// handle tasks termination
while let Some(res) = tasks.join_next().await {
match res {
Ok(Ok(())) => {}
Ok(Err(err)) => {
error!(error = %err, "Task returned an error");
return Err(err);
}
Err(err) if err.is_cancelled() => {}
Err(err) => {
error!(error = %err, "Task panicked");
return Err(err.into());
}
}
}
// disconnect the device once finished processing all the tasks
client.disconnect().await?;
info!("Device disconnected from Astarte");
Ok(())
}
You can run the application with cargo run
and see in the Astarte Dashboard that the device
appears as connected. You could also set the RUST_LOG
env variable to the desired log level in
order to show some logs during the program execution.
Installing the required interfaces¶
Up to now we have connected a device to Astarte, but we haven’t installed any interface the device must use to send and/or receive data to/from Astarte. Since we want to show how to stream individual and aggregated data as well as how to set and unset properties, we first need to install the required interfaces.
The following is the definition of the individually aggregated device-owned interface, used by the device to send data to Astarte:
{
"interface_name": "org.astarte-platform.rust.get-started.IndividualDevice",
"version_major": 0,
"version_minor": 1,
"type": "datastream",
"ownership": "device",
"description": "Individual device-owned interface for the get-started of the Astarte device SDK for the Rust programming language.",
"mappings": [
{
"endpoint": "/double_endpoint",
"type": "double",
"explicit_timestamp": false
}
]
}
The following is the definition of the individually aggregated server-owned interface, used by the device to receive data from Astarte:
{
"interface_name": "org.astarte-platform.rust.get-started.IndividualServer",
"version_major": 0,
"version_minor": 1,
"type": "datastream",
"ownership": "server",
"description": "Individual server-owned interface for the get-started of the Astarte device SDK for the Rust programming language.",
"mappings": [
{
"endpoint": "/%{id}/data",
"type": "double",
"explicit_timestamp": true
}
]
}
Next is the definition of an object aggregated interface:
{
"interface_name": "org.astarte-platform.rust.get-started.Aggregated",
"version_major": 0,
"version_minor": 1,
"type": "datastream",
"aggregation": "object",
"ownership": "device",
"description": "Aggregated interface for the get-started of the Astarte device SDK for the Rust programming language.",
"mappings": [
{
"endpoint": "/group_data/double_endpoint",
"type": "double",
"explicit_timestamp": false
},
{
"endpoint": "/group_data/string_endpoint",
"type": "string",
"explicit_timestamp": false
}
]
}
And finally the definition of the property interface:
{
"interface_name": "org.astarte-platform.rust.get-started.Property",
"version_major": 0,
"version_minor": 1,
"type": "properties",
"ownership": "device",
"description": "Property interface for the get-started of the Astarte device SDK for the Rust programming language.",
"mappings": [
{
"endpoint": "/double_endpoint",
"type": "double",
"allow_unset": true
}
]
}
These must also be saved as JSON
files (in the format <INTERFACE_NAME>.json
) in a directory
which will then be used when building th SDK. Thus:
Create an
interface
directorymkdir interfaces
Save the previously shown interfaces in JSON files or download them using the
curl
command as follows:curl --output-dir interfaces --fail --remote-name-all \ 'https://raw.githubusercontent.com/astarte-platform/astarte-device-sdk-rust/refs/heads/master/docs/interfaces/org.astarte-platform.rust.get-started.IndividualDevice.json' \ 'https://raw.githubusercontent.com/astarte-platform/astarte-device-sdk-rust/refs/heads/master/docs/interfaces/org.astarte-platform.rust.get-started.IndividualServer.json' \ 'https://raw.githubusercontent.com/astarte-platform/astarte-device-sdk-rust/refs/heads/master/docs/interfaces/org.astarte-platform.rust.get-started.Aggregated.json' \ 'https://raw.githubusercontent.com/astarte-platform/astarte-device-sdk-rust/refs/heads/master/docs/interfaces/org.astarte-platform.rust.get-started.Property.json'
To install them in the Astarte instance, you could use one of the following methodologies:
Open the Astarte dashboard, navigate to the interfaces tab and click on install new interface. Then copy and paste the JSON files for each interface in the right box overwriting the default template.
Use the
astartectl
tool as follows:astartectl realm-management interfaces sync -u <ASTARTE_URL> -r <REALM_NAME> \ -k <REALM_PRIV_KEY> <INTRERFACE_DIR/*json>
Receiving device events¶
We can now spawn a task to receive data from Astarte.
NOTE: remember to tell the DeviceBuilder
the directory from where to take the Astarte interfaces
// ... imports, structs definition ...
# prelude::*,
#[tracing::instrument(skip_all)]
async fn receive_data(client: DeviceClient<SqliteStore>) -> eyre::Result<()> {
loop {
match client.recv().await {
Ok(data) => {
if let astarte_device_sdk::Value::Individual(var) = data.data {
// we want to analyze a mapping similar to "/id/data" so we split by '/' and use the
// parts of interest
let mut iter = data.path.splitn(3, '/').skip(1);
let id = iter
.next()
.to_owned()
.map(|s| s.to_string())
.ok_or(eyre::eyre!("Incorrect error received"))?;
match iter.next() {
Some("data") => {
let value: f64 = var.try_into()?;
info!(
"Received new data datastream for LED {}. LED data is now {}",
id, value
);
}
item => {
error!("unrecognized {item:?}")
}
}
}
}
Err(RecvError::Disconnected) => return Ok(()),
Err(err) => error!(%err),
}
}
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
// ... configure networking, instantiate the mqtt connection information ...
// Modify the init() function by adding the astarte interfaces directory
/*
let (client, connection) = DeviceBuilder::new()
.store(store)
.interface_directory("interfaces")?
.connect(mqtt_config)
.await?
.build()
.await;
*/
// Spawn a task to receive data from Astarte
let client_cl = client.clone();
tasks.spawn(receive_data(client_cl));
// ... handle tasks termination and client disconnection ...
}
You can simulate sending data from Astarte on a server-owned interface by using the
publish-datastream
option of the astartectl
tool:
astartectl appengine
--appengine-url '<ASTARTE_APPENGINE_URL>' \
--realm-management-url '<ASTARTE_REALM_MANAGEMENT_URL>' \
--realm-key '<REALM>_private.pem' \
--realm-name '<REALM>' \
devices publish-datastream '<DEVICE_ID>' '<SARVER_OWNED_INTERFACE_NAME>' '<ENDPOINT>' '<VALUE>'
Where <ASTARTE_APPENGINE_URL>
and <ASTARTE_REALM_MANAGEMENT_URL>
are the appengine and
realm-management respective endpoints, <REALM>
is your realm name, <DEVICE_ID>
is the device ID
to send the data to, <ENDPOINT>
is the endpoint to send data to, which in this example should be
composed by a LED id and the data
endpoint, and <VALUE>
is the value to send.
For instance, if you are using a local Astarte instance, created a realm named test
and registered
the device 2TBn-jNESuuHamE2Zo1anA
, you could send data as follows:
astartectl appengine \
--appengine-url 'http://api.astarte.localhost/appengine' \
--realm-management-url 'http://api.astarte.localhost/realmmanagement' \
--realm-key 'test_private.pem' \
--realm-name 'test' \
devices publish-datastream '2TBn-jNESuuHamE2Zo1anA' 'org.astarte-platform.rust.get-started.IndividualServer' '/id_123/data' '12.34'
Streaming data¶
Streaming of data could be performed for device owned interfaces of individual
or object
aggregation type.
Streaming individual data¶
In Astarte interfaces with individual
aggregation, each mapping is treated as an independent value
and is managed individually.
The snippet below shows how to send a value that will be inserted into the "/double_endpoint"
datastream, that is part of the "org.astarte-platform.rust.get-started.Individual"
datastream
interface.
// ... imports, structs definition ...
#[tracing::instrument(skip_all)]
async fn send_individual(client: DeviceClient<SqliteStore>) -> eyre::Result<()> {
// send data every 1 sec
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
let mut data = 1.0;
loop {
client
.send(
"org.astarte-platform.rust.get-started.IndividualDevice",
"/double_endpoint",
data,
)
.await?;
info!("Data sent on endpoint /double_endpoint, content: {data}");
data += 3.14;
interval.tick().await;
}
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
// ... configure networking, instantiate and connect the device ...
// ... spawn task to handle polling from the connection ...
// Create a task to send individual datastream to Astarte
let client_cl = client.clone();
tasks.spawn(send_individual(client_cl));
// ... handle tasks termination and client disconnection ...
}
Streaming aggregated data¶
In Astarte interfaces with object
aggregation, Astarte expects the owner to send all of the
interface’s mappings at the same time, packed in a single message.
The following snippet shows how to send a value for an object-aggregated interface. In this example,
two different data types will be sent together and will be inserted into the "/group_data"
datastream, which is part of the "org.astarte-platform.rust.get-started.Aggregated"
datastream
interface.
// ... imports, structs definition ...
# #[cfg(not(feature = "derive"))]
#[derive(Debug, AstarteAggregate)]
struct DataObject {
double_endpoint: f64,
string_endpoint: String,
}
#[tracing::instrument(skip_all)]
async fn send_aggregate(client: DeviceClient<SqliteStore>) -> eyre::Result<()> {
// send data every 1 sec
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
let mut value = 1.0;
loop {
let data = DataObject {
double_endpoint: value,
string_endpoint: "Hello world.".to_string(),
};
info!("Sending {data:?}");
client
.send_object(
"org.astarte-platform.rust.get-started.Aggregated",
"/group_data",
data,
)
.await?;
value += 3.14;
interval.tick().await;
}
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
// ... configure networking, instantiate and connect the device ...
// ... spawn task to handle polling from the connection ...
// Create a task to send aggregate datastream to Astarte
let client_cl = client.clone();
tasks.spawn(send_aggregate(client_cl));
// ... handle tasks termination and client disconnection ...
}
Setting and unsetting properties¶
Interfaces of the property
type represent a persistent, stateful, synchronized state with no
concept of history or timestamping. From a programming point of view, setting and unsetting
properties of device-owned interfaces is rather similar to sending messages on datastream
interfaces.
The following snippet shows how to set a value that will be inserted into the "/double_endpoint"
property, that is part of "org.astarte-platform.rust.get-started.Property"
device-owned properties
interface.
// ... imports, structs definition ...
#[tracing::instrument(skip_all)]
async fn send_property(client: DeviceClient<SqliteStore>) -> eyre::Result<()> {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
let mut data = 1.0;
loop {
client
.send(
"org.astarte-platform.rust.get-started.Property",
"/double_endpoint",
data,
)
.await?;
info!("Data sent on endpoint /double_endpoint, content: {data}");
// wait 1 sec before unsetting the property
interval.tick().await;
client.unset("org.astarte-platform.rust.get-started.Property", "/double_endpoint").await?;
info!("Unset property on /double_endpoint endpoint");
data += 3.14;
interval.tick().await;
}
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
// ... configure networking, instantiate and connect the device ...
// ... spawn task to handle polling from the connection ...
// Create a task to set and unset an Astarte property
let client_cl = client.clone();
tasks.spawn(send_property(client_cl));
// ... handle tasks termination and client disconnection ...
}
It should be noted how a property should be marked as unsettable in its interface definition to be able to use the unsetting method on it.
See the more complete code samples in the GitHub repository of the Rust Astarte device SDK for more information on how to receive data from Astarte, such as server owned properties.