This Rust crate contains code for accessing SunSpec compliant devices in a safe and convenient way.
- Pure Rust library
- No unsafe code
- Panic free
- All communication is abstracted via traits making it runtime agnostic
- Supports Modbus TCP and RTU (via tokio-modbus).
-
tokio-modbusis optional. Custom transports can implement the client trait directly. - Implements "Device Information Model Discovery" as defined in the SunSpec specification.
- Compile-time model selection via Cargo features
- Fully typed models generated from the JSON files contained in the SunSpec models repository
- Fully typed enums
- Fully typed bitfields
- Fully documented. Even the generated models.
- Reading of complete models in a single request.
- Supports nested and repeating groups.
- Supports devices containing the same model multiple times.
- Unknown or unsupported models are reported during discovery.
| Feature | Description | Extra dependencies | Default |
|---|---|---|---|
tokio |
Enable tokio-based timeouts | tokio, tokio/time |
yes |
tokio-modbus |
Enable tokio-modbus support |
tokio-modbus, tokio |
yes |
serde |
Enable serde support |
serde, bitflags/serde |
no |
all-models |
Enable all generated models | model1, model2, ... |
yes |
model<X> |
Enable generated model X |
none | yes |
If you only need a small subset of models, disable default features and opt in to the features and specific models you need:
[dependencies]
sunspec = { version = "...", default-features = false, features = ["tokio-modbus", "model1", "model103"] }The examples directory in the code repository contains the unabridged code.
examples/readme: minimal end-to-end example used in this READMEexamples/model103: reading a common inverter model from a deviceexamples/model712: reading a model with nested and repeating groupsexamples/tls: connecting to a device via Modbus/TCP Security (TLS)
use std::{error::Error, net::SocketAddr, time::Duration};
use clap::Parser;
use itertools::Itertools;
use sunspec::{
client::{AsyncClient, Config},
models::{model1::Model1, model103::Model103},
AnyModel,
};
use tokio::time::sleep;
use tokio_modbus::client::tcp::connect;
#[derive(Parser)]
struct Args {
addr: SocketAddr,
device_id: u8,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let args = Args::parse();
let client = AsyncClient::new(connect(args.addr).await?, Config::default());
let device = client.device(args.device_id).await?;
let m1 = device.model::<Model1>()?.read().await?;
println!("Manufacturer: {}", m1.mn);
println!("Model: {}", m1.md);
println!("Version: {}", m1.vr.as_deref().unwrap_or("(unspecified)"));
println!("Serial Number: {}", m1.sn);
println!(
"Supported models: {}",
device
.models::<AnyModel>()
.map(|model| model.info().id.to_string())
.join(", ")
);
let inverter = device.model::<Model103>()?;
loop {
let m103 = inverter.read().await?;
let w = m103.w as f32 * 10f32.powf(m103.w_sf.into());
let wh = m103.wh as f32 * 10f32.powf(m103.wh_sf.into());
println!("{:12.3} kWh {:9.3} kW", wh / 1000.0, w / 1000.0,);
sleep(Duration::from_secs(1)).await;
}
}How does this crate differ from crates like tokio-sunspec, sunspec-models, sunspec_rs?
-
This crate generates all code using Rust code via the official SunSpec models repository with a code generator that was written in Rust, too.
-
All generated models are plain Rust structs. A single Modbus call can return the complete data for a model rather than having to fetch points individually.
-
All public types are documented. Even the generated models.
-
Full support for nested and repeating groups.
-
Devices containing the same model multiple times (e.g. several inverters or batteries behind one Modbus address) are fully supported. All instances are discovered and accessible.
How do I reduce compile times or binary size?
- Disable default features and enable only the features you need.
- This is useful if you interact only with a small number of models.
Do I have to use tokio-modbus?
- No.
tokio-modbusis just the bundled transport adapter. - You can provide your own transport by implementing the async client trait and
constructing an
AsyncClientwith it. - The separate
tokiofeature only controls tokio-based timeout handling.
What happens if a device exposes models this crate does not know?
- Discovery still succeeds.
- Known models can be selected via
device.models::<AnyModel>(). - Unknown model ids, addresses, and lengths are returned in
device.discovery().unknown_models.
What happens if a device contains the same model multiple times?
device.model::<M>()returns aModelNotUniqueerror.device.models::<M>()returns all instances in the order they appear in the Modbus map, e.g.device.models::<Model804>().nth(2)selects the third one.
How do I access all models of a device without handling each model type?
- This is useful for tools inspecting devices and for gateways forwarding the data of all models, e.g. as JSON.
device.models::<AnyModel>()selects all discovered models in the order they appear in the Modbus map. Each one is read asAnyModel, which is serialized with a"model"tag containing the model name when theserdefeature is enabled.downcast::<M>()converts a selected model into a typed one, e.g. for writing points.ModelInfocontains the id, name and label of a model and can be looked up viaModelInfo::by_idor parsed from a string like"103"or"inverter_three_phase".
Do I need to run the discovery every time I connect to a device?
- The SunSpec specification does not guarantee that the register map of a device stays the same, so running the discovery after connecting is the safe choice.
- If you know that the register map of a device does not change,
device.discovery()returns the discovery result, which can be stored (with theserdefeature) and passed toAsyncClient::device_from_discovery.
Can I scan for all slave IDs on a bus?
- Yes.
AsyncClient::devices()probes slave ids0..=255and returns every device that responds with a valid SunSpec header. - If you already know the slave id, use
AsyncClient::device(slave_id)instead.
How do discovery addresses and timeouts work?
- The default discovery addresses are
[40000, 0, 50000]. - That order avoids unnecessary timeouts on devices that do not behave well at
address
0. - You can override discovery addresses and read/write timeouts via
Config.
How are large models handled?
- Models larger than the Modbus single-request limit are read in chunks and then decoded as one typed model value.
- Nested groups and repeating groups are handled by the generated model code.
- The error enums (
ModbusError,ReadModelError,ReadPointError, …) are exhaustive on purpose: you can match all variants, and new variants are only added in new major versions. AnyModelis#[non_exhaustive]because its variants depend on the Cargo features enabled by all crates in the dependency graph.AsyncClient,Config,DiscoveryResultandUnknownModelcan gain fields in minor versions. Create them viaAsyncClient::new,Config::default()orConfig::builder().
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.