High-level overview
API is defined as a collections of user-defined types and resources - methods, properties, streams and traits. Intended crate organisation is as follows:
my_device_apicrate - contains user-defined types and resources, common to firmware and it's driver (server and client). This crate must supportno_stdand optionallystd.- MCU firmware depends on the API crate, uses common data types and implements a server. WireWeaver generates serdes and dispatch code, while user provides actual implementation.
- Rust driver also depends on the API crate (optionally with std feature). WireWeaver generates serdes and client side code, user can optionally provide a higher-level client implementation on top of the generated one.
- CLI, GUI and other applications depend on the Rust driver crate in order to communicate with the device.
- Python wrapper is also automatically generated and uses Rust driver code.
Name of the API crate (from Cargo.toml) is assumed to be a globally unique identifier (see ww_version::FullVersion),
hence it is advised to eventually publish it to crates.io if you are working on an open-source project or ensure to use
unique enough name for internal use.
Version of the API crate is used for compatibility checks upon connection to the device. You can use it in your code as well, to show proper messages to user when interacting with an older or newer firmware from the perspective of the driver. Normal SemVer rules apply.
WireWeaver supports both backwards and forwards compatibility at the wire format level, but you need to ensure to follow the evolution rules for this to work properly.
Methods
Methods are defined using standard Rust syntax. Any number of arguments are supported and they can be of any type (supported by SerDes).
#[ww_trait]
trait MyDevice {
fn led_on();
fn set_brightness(value: f32);
fn temperature() -> f32;
fn user_type(state: LedState);
}
#[derive_shrink_wrap]
#[ww_repr(unib32)]
pub enum LedState {
Off,
On,
}
On the server side, this is how generated server code is tied with user provided implementation:
struct ServerState {}
impl ServerState {
async fn set_brightness(&mut self, value: f32) {
// do things
}
}
ww_api!(
"../../api/src/lib.rs" as api::MyDevice for ServerState,
server = true, no_alloc = true, use_async = true,
);
ww_api proc-macro invocation will implement process_request_bytes function, which takes in request bytes,
deserializes and processes them and eventually calls set_brightness on self.
Note that you can request blocking implementation by setting use_async = false. And there is also a possibility to
return values later, via a provided request id (for example if executing a method and getting a result takes a long
time).
More on that on the detailed page.
Streams
Two types of streams are supported - from server to client (stream!) and from client to server (sink!).
I.e., naming is from the perspective of the device (node) - stream out, sink in.
#[ww_trait]
trait MyDevice {
stream!(byte: u8);
sink!(word: u32);
stream!(slice: Vec<u8>);
sink!(user_defined: Vec<LedState>);
}
Any type supported by the SerDes system works with streams as well. Streams can be used for many things, e.g., sending status updates or bytes from USART, frames to be transmitted on CAN bus, etc.
Stream writes are not acknowledged - write message is sent out and no response is awaited by client, server publishes a stream update and similarly do not wait for any response from client. It is possible though to implement a token-based or some other form of backpressure using sideband channel.
Streams can have a beginning and an end, for example to implement a file IO or firmware update, to deal with small chunks at a time and yet be able to signal a completion event. It is also possible to send a user defined delimiter, to be delivered in order with stream data, that can be used to implement frame synchronisation.
Another useful property of streams is that they work on object level. For the slice stream in the example above,
each individual array size is guaranteed to be preserved, even if multiple stream updates are transferred together at
transport level. Sending [1, 2, 3], [4], [5, 6] will result in the same arrays received on the other end, in the
same order.
You can subscribe to stream updates, in an asynchronous or blocking manner, see more on the detailed page.
Properties
Properties of any type can be defined as follows:
#[ww_trait]
trait MyDevice {
property!(ro button_pressed: bool);
property!(rw speed: f32);
}
Property write is acknowledged by a server, unless request ID of 0 is used.
Properties have access mode associated with them:
- Const (
const) - property is not going to change, observe not available - Read only (
ro) - property can only be read, can change and be observed for changes - Write only (
wo) - property can only be written - Read/Write (
rw) - property can be read, written and observed for changes
There are two supported way of implementing properties on the server side:
- get / set - user code provides
get_speedandset_speedimplementation. - value / on_changed - generated code directly reads and writes
speedfield and calls user providedspeed_changedimplementation.
Traits
Traits in WireWeaver are used to define API blocks, as you can see from examples above, entry point for a device API is also a trait. They carry similar meaning to Rust traits, in a sense that trait defines some functionality, that server "implements" and client code can then interact with.
But they are not actually traits under the hood, #[ww_trait] macro leaves only some static checks and removes the
rest.
Rust syntax is currently used to bypass writing a whole parser from scratch.
All the magic happens through code generation in the #[ww_api] macro.
Traits for API resources grouping
Trait defined in the same file as the API root itself is a way to cleanly group related resources together.
#[ww_trait]
trait MyDevice {
ww_impl!(motor_control: MotorControl);
ww_impl!(led_control: LedControl);
}
#[ww_trait]
trait MotorControl {
fn turn_on();
fn turn_off();
}
#[ww_trait]
trait MotorControl {
fn led_on();
fn set_brightness(value: f32);
}
Note that in this case, one additional path index will be used, so in total there will be 4 valid paths here:
- [0, 0] -
turn_on - [0, 1] -
turn_off - [1, 0] -
led_on - [1, 1] -
set_brightness
If preserving very small size is of big importance, try not to create too many levels. Also one can put more important
functionality higher up, in order to leverage variable length encoding (e.g. numbers 0..=7 take only 4 bits on the
wire).
TODO: splitting into multiple files
Traits (global) for extracting common functionality
The idea behind global traits is to leverage crates.io to define a set of common traits used across many devices. Device can then implement all the traits it needs and on the client side, common code can be used to control similar functionality of different devices.
Traits generic enough to be global and currently planned are:
- FirmwareUpdate
- EmbeddedLog
- BoardInfo
- Counters
- Gpio
- DeviceUserInfo
- RegisterAccess
- CanBus
Device API, instead of re-implementing the same things over and over, can the look like follows:
#[ww_trait]
trait MyAwesomeDevice {
ww_impl!(firmware_update: ww_firmware_update "0.1.0" :: FirmwareUpdate);
ww_impl!(board_info: ww_board_info "0.1.0" :: BoardInfo);
// and some device specific functionality in addition to common things
}
Client code can be written in a completely agnostic way, e.g., only capable of interacting with FirmwareUpdate trait,
regardless of which exact device it is implemented on or how it is physically connected.
One can also interact with devices using trait-addressing mode, e.g., calling set_indication_mode(Mode::Night) on all
devices on a CAN bus, putting all boards with LEDs into night mode. More on that on
the addressing page
Resource arrays
Any resource can also be an array - method, property, stream and even a trait implementation:
#[ww_trait]
trait ArrayOf {
fn run<N: u32>();
stream!(adc[]: u16);
property!(led[]: bool);
ww_impl!(motor[]: ww_motor_control "0.1.0" :: Motor);
}
TODO: size bounds
Traits inside other traits can also contain arrays, all the indices leading up to them are accumulated and passed as
Rust array [u32; N] argument into a corresponding user handler.
That way generated code can be kept efficient and simple, because the whole API tree is essentially flattened and
simple function calls are used to interface with user provided implementation. At least that is the case for now on
no_std targets.
Array of resources vs resource of array
Here, resource led is itself an array, when accessing it - an index will be added to the resource path. Each one of three bool's is accessed separately from each other.
#[ww_trait]
trait ArrayOfResources {
property!(led[3]: bool);
}
On the other hand, here led is not an array, but its type is. All three boolean's are accessed in one go.
#[ww_trait]
trait ResourceOfArrays {
property!(led: [bool; 3]);
}
Both can be used together as well, for example:
#[ww_trait]
trait ArrayOfArrays {
property!(rgb_led[3]: [u8; 3]);
}