opendaq 0.1.1

Safe Rust bindings for openDAQ, the open-source data acquisition SDK
Documentation

openDAQ Rust bindings

Crates.io Documentation License

Safe Rust bindings for interacting with openDAQ devices. Prebuilt native binaries for x64 Linux, x64 Windows, and ARM macOS are downloaded automatically on first use (only for your platform, ~15 MB), so the crate builds and runs without an openDAQ SDK, headers, or C toolchain.

Quickstart

cargo add opendaq
use opendaq::{Instance, StreamReader};

fn main() -> opendaq::Result<()> {
    let instance = Instance::new()?;

    println!("Available devices:");
    for info in instance.available_devices()? {
        println!(" - {}", info.connection_string()?);
    }

    let device = instance.add_device("daqref://device0")?.expect("device");
    let channel = instance
        .find_component("Dev/RefDev0/IO/AI/RefCh0")?
        .expect("reference channel not found")
        .cast::<opendaq::Channel>()?;
    let signal = &channel.signals()?[0];
    let reader = StreamReader::<f64>::new(signal)?;

    device.set_property_value("GlobalSampleRate", 100)?;
    channel.set_property_value("Frequency", 0.5)?;

    println!("{:?}", reader.read(100, 2000)?.values());
    Ok(())
}

This prints 100 samples from the simulator device set to run at 100 Hz, a sine wave at 0.5 Hz.

If something fails, run a healthcheck to verify that the crate can find and load the native openDAQ libraries:

opendaq::healthcheck();

See the examples/ folder for the most common use cases: stream/tail/block/multi readers, building signals and sending packets by hand, property batching, events, function properties, search filters, device locking, log-file download, and stateful callbacks driven by data-ready notifications.

Native binaries

The first use downloads the prebuilt openDAQ libraries for your platform from the GitHub release pinned in src/native_manifest.rs, verifies their SHA-256 checksum, and caches them per user (~/.cache/opendaq-rs/ on Linux, ~/Library/Caches/opendaq-rs/ on macOS, %LOCALAPPDATA%\opendaq-rs\cache\ on Windows). Subsequent runs reuse the cache. The download needs curl and tar, which ship with Linux, macOS, and Windows 10+.

The loader looks for the libraries in this order:

  1. the directory named by the OPENDAQ_RUST_NATIVE_DIR environment variable,
  2. bin/<platform>/ inside the crate source directory (a local development build),
  3. the per-user download cache, downloading into it when necessary.

Set OPENDAQ_NO_DOWNLOAD to forbid the automatic download (e.g. in offline or CI environments); you can still trigger it explicitly with opendaq::install_native_libraries(). Set OPENDAQ_NATIVE_ARCHIVE_URL to fetch the archive from a mirror instead of GitHub (checksum verification is skipped for mirrors). Call opendaq::init() to surface loading problems as a Result instead of a panic on first use.

Some details

The crate is composed of two layers:

  • opendaq::sys contains the low-level FFI surface that closely mirrors the C API: one typed unsafe extern "C" function pointer per C function (resolved at runtime from the loaded libraries), the enums, and the error-code constants. It is autogenerated from the C header files (in openDAQ/bindings/c/) and not intended for direct use.
  • The crate root contains the high-level API that wraps the low level in safe, idiomatic Rust. This is the intended interface for end users.

The native libraries are loaded dynamically at runtime (COM-style flat C ABI), so no linking happens at build time; an ABI mismatch surfaces as a missing-symbol error rather than memory corruption, and symbols missing from older/newer library builds surface as OPENDAQ_ERR_NOTIMPLEMENTED errors only when actually called.

Every openDAQ object is a reference-counted interface. Wrappers release their reference on drop and add one on clone, so objects are never freed manually. The interface hierarchy is modeled with Deref: a Channel dereferences to FunctionBlock, then Folder, Component, PropertyObject, down to BaseObject, so parent-interface methods are directly available. Conversions:

  • obj.cast::<T>() — query the object for interface T (a checked queryInterface); try_cast::<T>() returns an Option instead of an error and is_a::<T>() just tests.
  • obj.to_value() — cross into Rust: a boxed scalar yields its native value, a daq list a Vec, a daq dict key/value pairs, recursively; an object with no Rust form stays an Object wrapper.
  • obj.component_kind() — the most-derived component interface (Channel, Signal, Device, ...), for discovering a component's concrete type before casting.

Boxing / unboxing

The high-level API insulates you from openDAQ's C types. Generic arguments accept plain Rust values (boxing, on input, via impl Into<Value>) and generic results come back as Value (unboxing, on output); the two directions are inverses:

Rust value openDAQ core type
String / &str String
i64 (and smaller ints) Int
f64 / f32 Float
bool Bool
Ratio Ratio
Complex ComplexNumber
Vec<T: Into<Value>> List
Value::Dict pairs Dict
any interface wrapper its object

Typed signatures don't need Value at all: strings cross as &str/String, typed lists as Vec<T>, dicts as HashMap, enums as Rust enums, and nullable objects as Option<T>. property_value already applies the unboxing for you.

Readers

Readers are generic over the sample types they decode into: StreamReader<V = f64, D = i64> reads values as V and domain stamps as D, so read returns typed data with no buffers in sight. read returns Samples<V>, which carries the per-sample width alongside the flat values: for scalar signals it behaves like a Vec, while dimensioned signals (e.g. FFT spectra) expose one row per sample via .rows(). TailReader, BlockReader, and MultiReader follow the same pattern.

When a signal's descriptor changes to a sample type the reader cannot convert, the reader is invalidated: read fails with an error whose is_reader_invalidated() is true, and reading is resumed by building a from_existing reader with matching read types.

Callbacks

openDAQ events and callables invoke plain Rust closures:

let handler = context.on_core_event()?.expect("event").subscribe(|_sender, args| {
    if let Some(args) = args {
        println!("{}", args);
    }
})?;

let sum = opendaq::FunctionObject::from_fn(|args| {
    Ok(opendaq::Value::from(args[0].as_i64().unwrap_or(0) + args[1].as_i64().unwrap_or(0)))
})?;
obj.set_property_value("Sum", &sum)?;

Closures must be Send + Sync (openDAQ may invoke them from its worker threads); a panic or error inside one is reported to the native caller as an openDAQ error instead of unwinding across the C boundary. Since openDAQ has no destruction hook for callables, each closure-backed procedure/function keeps its closure alive for the rest of the process (event-handler slots are recycled by Event::unsubscribe).

Development

The Makefile handles cloning the pinned openDAQ source, building the native libraries into bin/<platform>/, and regenerating the generated Rust bindings.

make bindings             # clone pinned openDAQ source, build native libs, regenerate bindings
make regenerate-bindings  # regenerate src/sys/generated.rs and src/generated/ from the C headers
make test                 # cargo test
make examples             # build all examples

Override OPENDAQ_REF in the Makefile for a different upstream version. The generator lives in tools/generate_bindings.py (with tools/parse_bindings.py); regenerate whenever the pinned openDAQ version changes.

Folder structure

  • src/ — hand-written runtime (loader, object model, values, readers, callbacks, ...)
  • src/sys/generated.rs, src/generated/ — autogenerated low-level and high-level bindings (committed)
  • examples/ — stand-alone example programs
  • tools/ — the binding generator
  • tests/ — integration tests
  • bin/ — locally built or downloaded native openDAQ libraries (not committed). End users instead get the release archives pinned in src/native_manifest.rs, which the Build native binaries GitHub Actions workflow publishes as release assets

License

MIT. openDAQ itself is licensed under Apache-2.0.