Skip to content
bikeshedderPublic

About

Rust crate for accessing SunSpec compliant devices in a safe and convenient way.

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Repository files navigation

SunSpec Rust Implementation

Latest Version CI Unsafe forbidden Rust 1.76+

This Rust crate contains code for accessing SunSpec compliant devices in a safe and convenient way.

Highlights

  • 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-modbus is 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.

Features

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"] }

Examples

The examples directory in the code repository contains the unabridged code.

  • examples/readme: minimal end-to-end example used in this README
  • examples/model103: reading a common inverter model from a device
  • examples/model712: reading a model with nested and repeating groups
  • examples/tls: connecting to a device via Modbus/TCP Security (TLS)

Example code for accessing data from a three phase inverter using the model 103

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;
    }
}

FAQ

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-modbus is just the bundled transport adapter.
  • You can provide your own transport by implementing the async client trait and constructing an AsyncClient with it.
  • The separate tokio feature 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 a ModelNotUnique error.
  • 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 as AnyModel, which is serialized with a "model" tag containing the model name when the serde feature is enabled.
  • downcast::<M>() converts a selected model into a typed one, e.g. for writing points.
  • ModelInfo contains the id, name and label of a model and can be looked up via ModelInfo::by_id or 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 the serde feature) and passed to AsyncClient::device_from_discovery.

Can I scan for all slave IDs on a bus?

  • Yes. AsyncClient::devices() probes slave ids 0..=255 and 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.

Stability

  • 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.
  • AnyModel is #[non_exhaustive] because its variants depend on the Cargo features enabled by all crates in the dependency graph.
  • AsyncClient, Config, DiscoveryResult and UnknownModel can gain fields in minor versions. Create them via AsyncClient::new, Config::default() or Config::builder().

License

Licensed under either of

at your option.

About

Rust crate for accessing SunSpec compliant devices in a safe and convenient way.

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages