diff --git a/source/get_started/rust.md b/source/get_started/rust.md index eb98fdd..6cb4e5b 100644 --- a/source/get_started/rust.md +++ b/source/get_started/rust.md @@ -1,82 +1,456 @@ # Get started with Rust -Follow this guide to get started with the Astarte device SDK for the Rust programming language. +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. -## Generating a device ID +## Before you begin -A device ID will be required to uniquely identify a device in an Astarte instance. -Some of the Astarte device SDKs provide utilities to generate a deterministic or random device -identifier, in some cases based on hardware information. +There are a few setup steps and requirements that are needed before start working on the example. -This step is only useful when registering a device using a JWT token and the provided Astarte device -SDKs registration APIs. Registration of a device can also be performed outside the device in the -Astarte instance. In such cases the device ID should be obtained via -[astartectl](https://github.com/astarte-platform/astartectl), or the -[Astarte dashboard](https://docs.astarte-platform.org/astarte/latest/015-astarte_dashboard.html). -The device ID should then be loaded manually on the device. +### Local Astarte instance -A device ID can be generate randomly: -```rust -let random_uuid = astarte_sdk::registration::generate_random_uuid(); +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](https://docs.astarte-platform.org/device-sdks/common/astarte_quick_instance.html). + +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](https://rustup.rs/), otherwise make sure you are using one supported by the +[MSRV](https://doc.rust-lang.org/cargo/reference/rust-version.html) of the Astarte device Rust SDK + +Then, create a new project: + +```bash +cargo new astarte-rust-project ``` -Or in a deterministic way: -```rust -let namespaced_id = astarte_sdk::registration::generate_uuid(namespaceUuid, &payload); + +Then, we need to add the following dependencies in the Cargo.toml file: + +- `astarte-device-sdk`, to properly use the Astarte SDK +- `tokio`, a runtime for writing asynchronous applications +- `serde` and `serde_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` and `tracing_subscriber` for printing and showing the logs of the SDK +- `eyre` to convert and report the error into a single trait object + +You can run the following cargo add command: + +```sh +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](https://www.sqlite.org/) 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`: + +```sh +apt install build-essential pkg-config libsqlite3-dev +``` + +You can find more information on the [rusqlite GitHub page](https://github.com/rusqlite/rusqlite). + +## 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: + +```json +{ + "realm": "Realm name", + "device_id": "Device ID", + "credentials_secret": "Credentials secret", + "pairing_url": "Pairing URL" +} ``` -## Registering a device +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 -In order for a device to connect to Astarte a registration procedure is required. This registration -will produce a device specific credential secret that will be used when connecting to Astarte. -Some of the Astarte device SDKs provide utilities to perform a device registration directly on -the device. Those APIs will require a registration JWT to be uploaded to the device. Such JWT should -be discarded following the registration procedure. +## Instantiating and connecting a device -This step is only useful when registering the device through the APIs using the JWT token. -Registration of a device can also be performed outside the device in the Astarte instance using -tools such as [astartectl](https://github.com/astarte-platform/astartectl), the -[Astarte dashboard](https://docs.astarte-platform.org/astarte/latest/015-astarte_dashboard.html), -or the dedicated -[Astarte API for device registration](https://docs.astarte-platform.org/astarte/latest/api/index.html?urls.primaryName=Pairing%20API). -The generated credential secret should then be loaded manually on the 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. ```rust -let credentials_secret = - astarte_sdk::registration::register_device(&jwt_token, &pairing_url, &realm, &device_id) - .await?; +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, DeviceConnection>)> { + // 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::>::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: + +```json +{ + "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: + +```json +{ + "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: + +```json +{ + "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: + +```json +{ + "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 + } + ] +} ``` -## Instantiating and connecting a new device +These must also be saved as `JSON` files (in the format `.json`) in a directory +which will then be used when building th SDK. Thus: + +1. Create an `interface` directory + ```sh + mkdir interfaces + ``` + +2. Save the previously shown interfaces in JSON files or download them using the `curl` command as + follows: + ```bash + 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: -Now that we obtained both a device ID and a credential secret we can create a new device instance. -Some of the SDKs will connect instantly as soon as the device is instantiated while others will -require a call to a `connect` function. -Furthermore, depending on the SDK the introspection of the device might be defined while -instantiating the device or between instantiation and connection. +- 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: + ```bash + astartectl realm-management interfaces sync -u -r \ + -k + ``` + +## 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 ```rust -// declare device options -let mut sdk_options = - AstarteOptions::new(&realm, &device_id, &credentials_secret, &pairing_url); +// ... imports, structs definition ... + +# prelude::*, + +#[tracing::instrument(skip_all)] +async fn receive_data(client: DeviceClient) -> 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: -// load interfaces from a directory -sdk_options - .add_interface_files("./examples/interfaces") - .unwrap(); +```sh +astartectl appengine + --appengine-url '' \ + --realm-management-url '' \ + --realm-key '_private.pem' \ + --realm-name '' \ + devices publish-datastream '' '' '' '' +``` -// instance and connect the device. -let mut device = AstarteDeviceSdk::new(&sdk_options).await.unwrap(); +Where `` and `` are the appengine and +realm-management respective endpoints, `` is your realm name, `` is the device ID +to send the data to, `` is the endpoint to send data to, which in this example should be +composed by a LED id and the `data` endpoint, and `` 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: + +```sh +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 -All Astarte Device SDKs include primitives for sending data to a remote Astarte instance. Streaming of data could be performed for device owned interfaces of `individual` or `object` aggregation type. @@ -85,17 +459,46 @@ aggregation type. In Astarte interfaces with `individual` aggregation, each mapping is treated as an independent value and is managed individually. -The snippet bellow shows how to send a value that will be inserted into the `"/test0/value"` -datastream which is defined by `"/%{sensor_id}/value"` parametric endpoint, that is part of -`"org.astarte-platform.genericsensors.Values"` datastream interface. +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. ```rust -/// send data without an explicit timestamp -device.send("org.astarte-platform.genericsensors.Values", "/test0/value", 3).await?; +// ... imports, structs definition ... + +#[tracing::instrument(skip_all)] +async fn send_individual(client: DeviceClient) -> 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; + } +} -/// send data with an explicit timestamp -let timestamp = Utc.timestamp(1537449422, 0); -device.send_with_timestamp("org.astarte-platform.genericsensors.Values", "/test0/value", 3, timestamp).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 @@ -103,47 +506,123 @@ device.send_with_timestamp("org.astarte-platform.genericsensors.Values", "/test0 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 -examples, `lat` and `long` will be sent together and will be inserted into the `"/coords"` -datastream which is defined by the `"/coords"` endpoint, that is part of `"com.example.GPS"` -datastream interface. +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. ```rust -use astarte_device_sdk_derive::AstarteAggregate; -/// Coords must derive AstarteAggregate -#[derive(AstarteAggregate)] -struct Coords { - lat: f64, - long: f64, -} -[...] -let coords = Coords{lat: 45.409627, long: 11.8765254}; - -// stream data with an explicit timestamp -let timestamp = Utc.timestamp(1537449422, 0); -device.send_object_with_timestamp("com.example.GPS", "/coords", coords, timestamp).await?; - -// stream data without an explicit timestamp -device.send_object("com.example.GPS", "/coords", coords).await?; -``` +// ... imports, structs definition ... -## Setting and unsetting properties +# #[cfg(not(feature = "derive"))] + +#[derive(Debug, AstarteAggregate)] +struct DataObject { + double_endpoint: f64, + string_endpoint: String, +} -Interfaces of `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 interface is rather similar to sending messages on datastream interfaces. +#[tracing::instrument(skip_all)] +async fn send_aggregate(client: DeviceClient) -> eyre::Result<()> { + // send data every 1 sec + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); -The following snippet shows how to set a value that will be inserted into the `"/sensor0/name"` -property which is defined by `"/%{sensor_id}/name"` parametric endpoint, that is part of `"org.astarte-platform.genericsensors.AvailableSensors"` device-owned properties interface. + let mut value = 1.0; -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. + loop { -Set property: -```rust -device.send("org.astarte-platform.genericsensors.AvailableSensors", "/sensor0/name", "foobar").await?; + 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 ... +} ``` -Unset property: + +## 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. + ```rust -device.unset("org.astarte-platform.genericsensors.AvailableSensors", "/sensor0/name").await?; +// ... imports, structs definition ... + +#[tracing::instrument(skip_all)] +async fn send_property(client: DeviceClient) -> 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](https://github.com/astarte-platform/astarte-device-sdk-rust) of the Rust Astarte +device SDK for more information on how to receive data from Astarte, such as server owned +properties.