The poststation-sdk crate
For this next section, we'll use the SDK library, poststation-sdk
. The source for poststation-sdk
can be found
on GitHub.
Make sure that:
- You have installed Rust using the instructions at rustup.rs
- Your poststation server is still running with a simulator device, on the current machine
Create a new binary application project
We'll create a new application for interacting with our devices. We typically refer to these as "host" applications, as they are running on our desktop "hosted" environment.
We'll need to create a new project, and add our basic dependencies.
$ cargo new --bin sdk-example
Creating binary (application) `sdk-example` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cd sdk-example
$ cargo add tokio --features=macros,rt-multi-thread,time
Updating crates.io index
Adding tokio v1.43.0 to dependencies
...
$ cargo add poststation-sdk
Updating crates.io index
Adding poststation-sdk v0.4.1 to dependencies
...
$ cargo add serde_json
Updating crates.io index
Adding serde_json v1.0.138 to dependencies
...
We can then add the following content into the src/main.rs
file:
#[tokio::main] async fn main() { // Connect to the poststation server running locally. // defaults to secure connection settings let client = poststation_sdk::connect("127.0.0.1:51837").await.unwrap(); // Obtain a list of connected devices, similar to // `poststation-cli ls` let devices = client.get_devices().await.unwrap(); for device in devices.iter() { println!( "serial: {:016X} name: {:>10}, connected?: {}", device.serial, device.name, device.is_connected, ); } }
And run our new application:
$ cargo run --release
...
Compiling sdk-example v0.1.0 (/private/tmp/sdk-example)
Finished `release` profile [optimized] target(s) in 9.86s
Running `target/release/sdk-example`
serial: 27927AE08C5C829B name: DIXIE-201, connected?: false
serial: 3588A006538528CC name: YOGURT-150, connected?: false
serial: 563BF78B6A56DF04 name: QUIRKY-344, connected?: false
Check the docs!
For a more in-depth overview of all of the features offered by the SDK crate, check out the documentation hosted on docs.rs.
You can also look at the source of the poststation-cli
on GitHub
as a reference, as it uses the poststation-sdk
crate as well.
Talking with devices
There are generally two ways to interact with devices:
- When you DO NOT have a shared interface crate
- When you DO have a shared interface crate
It is possible with postcard-rpc
to share the definition of types in a common crate between your
"host" application and firmware devices. These are often called "ICD", or "Interface Control Document"
crates.
The Poststation SDK offers methods where we do have these shared types, as well as methods that do not
require them. When we do not have these shared types, JSON is used as a message format, and messages are
checked at runtime for correctness. When we do have these shared types, then messages are sent directly
in binary postcard
format.
If possible, it is recommended to have shared types. This is true in most cases, outside of cases where a tool is expected to interface with many devices, or many versions of devices, for example an application that is collecting log messages from all devices to forward to a log collection tool, like Loki or Prometheus.
The poststation-cli
tool also operates without requiring these shared types, as it is intended to communicate
with any user device.
We'll start by covering the "without" case first.
Without Shared Types
Since Poststation performs service discovery with all connected devices, it is possible to learn about our connected simulator device without sharing types.
Getting logs
We can obtain text-based logs for our devices:
#[tokio::main] async fn main() { // Connect to the poststation server running locally. // defaults to secure connection settings let client = poststation_sdk::connect("127.0.0.1:51837").await.unwrap(); // Obtain a list of connected devices, similar to // `poststation-cli ls` let devices = client.get_devices().await.unwrap(); // Find our device. You will need to change the name here! let device_name = "QUIRKY-344"; let mut serial = None; for device in devices.iter() { if device.name.contains(device_name) { serial = Some(device.serial); break; } } let Some(serial) = serial else { println!("Failed to find device '{device_name}'!"); return; }; // Get the most recent 3 logs from the device let Ok(response) = client.get_device_logs(serial, 3).await else { println!("Request failed!"); return; }; let Some(logs) = response else { println!("No device {serial:016X} found!"); return; }; // Print each log with a timestamp for log in logs { let time = log.uuidv7.id_to_time(); let msg = log.msg; println!("{time}: '{msg}'"); } }
This will print something like this:
$ cargo run --release
Compiling sdk-example v0.1.0 (/private/tmp/sdk-example)
Finished `release` profile [optimized] target(s) in 0.96s
Running `target/release/sdk-example`
2025-01-21 19:07:13.689 +01:00: I hope you have a nice day
2025-01-21 19:07:14.688 +01:00: I hope you have a nice day
2025-01-21 19:07:15.688 +01:00: I hope you have a nice day
Interacting with endpoints
We can get the schemas from our device, and ensure it is capable of the operations we are about to try:
#![allow(unused)] fn main() { let Ok(response) = client.get_device_schemas(serial).await else { println!("Request failed!"); return; }; let Some(schema) = response else { println!("No device {serial:016X} found!"); return; }; // Do we have the LED get and set endpoints? // // Note: you might want to verify the Key of these endpoints too, to ensure // the types have not changed! assert!(schema.endpoints.iter().any(|e| e.path == "simulator/status_led/set")); assert!(schema.endpoints.iter().any(|e| e.path == "simulator/status_led/get")); println!("OK :)"); }
Once we've verified this, we can try:
- Getting the value
- Inverting and setting the value
- Verifying the change has occurred
This could look something like this:
#![allow(unused)] fn main() { use serde_json::{Value, json}; let resp = client .proxy_endpoint_json(serial, "simulator/status_led/get", 1, Value::Null).await; let Ok(response) = resp else { println!("Initial get failed!"); return; }; println!("Current state: '{response}'"); // Get each subfield, and invert the bits let r = !response["r"].as_u64().unwrap() as u8; let g = !response["g"].as_u64().unwrap() as u8; let b = !response["b"].as_u64().unwrap() as u8; // Set the new value let new_value = json!({"r": r, "g": g, "b": b}); let resp = client .proxy_endpoint_json(serial, "simulator/status_led/set", 2, new_value.clone()).await; let Ok(response) = resp else { println!("Set failed!"); return; }; assert_eq!(response, Value::Null); let resp = client .proxy_endpoint_json(serial, "simulator/status_led/get", 1, Value::Null).await; let Ok(response) = resp else { println!("Second Request failed!"); return; }; assert_eq!(response, new_value); println!("Updated state: '{response}'"); }
Which should print something like this:
$ cargo run --release
Finished `release` profile [optimized] target(s) in 0.04s
Running `target/release/sdk-example`
Current state: '{"b":0,"g":0,"r":0}'
Updated state: '{"b":255,"g":255,"r":255}'
With Shared types
In order to share types, we'll need to have a crate that can be included by both the firmware device, as well as our host application.
The ICD for the simulators supported by poststation are available on GitHub,
and published as poststation-sim-icd
. We can go ahead and add that to our example project:
$ cargo add poststation-sim-icd
Updating crates.io index
Adding poststation-sim-icd v0.1.0 to dependencies
...
This gives us access to the types we need.
Now we can access the same endpoints as above, but without having to deal with JSON types:
#![allow(unused)] fn main() { // Import endpoint names and types from the ICD crate use poststation_sim_icd::simulator::{GetStatusLed, Rgb8, SetStatusLed}; let response = client.proxy_endpoint::<GetStatusLed>(serial, 10, &()).await; let Ok(color) = response else { println!("First Request failed!"); return; }; println!("initial status: {color:?}"); let new = Rgb8 { r: !color.r, g: !color.g, b: !color.b }; // Now set a new color let response = client.proxy_endpoint::<SetStatusLed>(serial, 11, &new).await; let Ok(()) = response else { println!("Second Request failed!"); return; }; // And verify the result let response = client.proxy_endpoint::<GetStatusLed>(serial, 12, &()).await; let Ok(new_color) = response else { println!("Third Request failed!"); return; }; println!("New status: {new_color:?}"); assert_eq!(new, new_color); println!("Success!"); }