plc-comm-slmp-rust 0.1.1

Async Rust SLMP client based on the plc-comm-slmp-dotnet implementation
Documentation

CI crates.io docs.rs License: MIT

SLMP Protocol for Rust

Async Rust implementation of the SLMP library, based on the plc-comm-slmp-dotnet implementation and aligned with the shared plc-comm-slmp-cross-verify harness.

The crate focuses on Binary 3E / 4E SLMP over TCP and UDP and keeps the same operation meaning as the existing Python, .NET, C++, Node-RED, and Rust verification clients.

What This Repo Contains

  • async Rust library crate: src/
  • cross-verify wrapper binary: src/bin/slmp_verify_client.rs
  • runnable examples: examples/
  • address and usage guides: docs/
  • minimal napi-rs workspace member for future Node packaging: crates/slmp-node

Current Scope

  • raw device access: word, bit, dword, float32
  • random read/write
  • block read/write
  • extended-device read/write
  • memory read/write
  • extend-unit read/write
  • remote operations and self-test
  • high-level typed helpers
  • high-level named read/write and polling helpers
  • slmp_verify_client wrapper for plc-comm-slmp-cross-verify
  • minimal napi-rs Node binding scaffold in crates/slmp-node

Installation

Install from crates.io:

cargo add plc-comm-slmp-rust

The public package name is plc-comm-slmp-rust, and the library import path is plc_comm_slmp.

Requires Rust 1.85 or newer.

Cargo.toml:

[dependencies]
plc-comm-slmp-rust = "0.1.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

Quick Start

Raw Client Usage

use plc_comm_slmp::{
    SlmpAddress, SlmpClient, SlmpCompatibilityMode, SlmpConnectionOptions, SlmpFrameType,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut options = SlmpConnectionOptions::new("192.168.250.100");
    options.port = 1025;
    options.frame_type = SlmpFrameType::Frame4E;
    options.compatibility_mode = SlmpCompatibilityMode::Iqr;

    let client = SlmpClient::connect(options).await?;
    let values = client.read_words_raw(SlmpAddress::parse("D100")?, 2).await?;
    println!("{values:?}");
    Ok(())
}

Recommended High-Level Usage

use plc_comm_slmp::{
    read_named, write_named, NamedAddress, SlmpClient, SlmpCompatibilityMode,
    SlmpConnectionOptions, SlmpFrameType, SlmpValue,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut options = SlmpConnectionOptions::new("192.168.250.100");
    options.port = 1025;
    options.frame_type = SlmpFrameType::Frame4E;
    options.compatibility_mode = SlmpCompatibilityMode::Iqr;

    let client = SlmpClient::connect(options).await?;

    let snapshot = read_named(
        &client,
        &["D100".into(), "D200:F".into(), "D50.3".into()],
    )
    .await?;

    println!("{:?}", snapshot["D100"]);
    println!("{:?}", snapshot["D200:F"]);
    println!("{:?}", snapshot["D50.3"]);

    let mut updates = NamedAddress::new();
    updates.insert("D300".into(), SlmpValue::U16(42));
    updates.insert("D400:F".into(), SlmpValue::F32(3.14));
    write_named(&client, &updates).await?;

    Ok(())
}

Typed Access

use plc_comm_slmp::{read_typed, write_typed, SlmpAddress, SlmpValue};

# async fn demo(client: &plc_comm_slmp::SlmpClient) -> Result<(), plc_comm_slmp::SlmpError> {
let temperature = read_typed(client, SlmpAddress::parse("D200")?, "F").await?;
let position = read_typed(client, SlmpAddress::parse("D300")?, "L").await?;

write_typed(client, SlmpAddress::parse("D100")?, "U", &SlmpValue::U16(42)).await?;
write_typed(client, SlmpAddress::parse("D200")?, "F", &SlmpValue::F32(3.14)).await?;
write_typed(client, SlmpAddress::parse("D300")?, "L", &SlmpValue::I32(-100)).await?;
# Ok(())
# }

Runnable Examples

The repository includes examples that compile as part of the crate and can be run directly against a PLC or mock server.

raw_read_write

Low-level word read plus optional write/read-back.

SLMP_HOST=192.168.250.100 \
SLMP_PORT=1025 \
SLMP_FRAME=4e \
SLMP_SERIES=iqr \
cargo run --example raw_read_write

Enable writes explicitly:

SLMP_ENABLE_WRITES=1 \
SLMP_WRITE_ADDRESS=D600 \
SLMP_WRITE_VALUES=111,222 \
cargo run --example raw_read_write

named_helpers

Named snapshot, typed decoding, optional write_named, and one poll_named tick.

SLMP_HOST=192.168.250.100 \
SLMP_NAMED_ADDRESSES='D100,D200:F,D50.3,LTN10:D,LTS10' \
cargo run --example named_helpers

advanced_operations

Safe read-heavy sample that covers type-name, random read, block read, extended device read, and self-test loopback.

SLMP_HOST=192.168.250.100 \
SLMP_RANDOM_WORDS='D100,R10' \
SLMP_RANDOM_DWORDS='D200,LTN10' \
SLMP_EXT_DEVICE='J1\W10' \
cargo run --example advanced_operations

device_matrix_compare

Real-PLC regression sample that writes the same address through multiple command paths and checks that read-back stays aligned.

  • bit devices: write_bits, write_random_bits, write_typed, write_named, raw request
  • word devices: write_words, write_random_words, write_typed, write_named, raw request
  • 32-bit devices: write_dwords, write_random_words, write_typed, write_named, raw request
  • J1\\... devices: extended helper APIs plus raw request
SLMP_HOST=192.168.250.100 \
SLMP_PORT=1025 \
SLMP_FRAME=4e \
SLMP_SERIES=iqr \
cargo run --example device_matrix_compare

This example exits non-zero when command paths for the same address disagree.

Focus on a subset while debugging:

SLMP_COMPARE_ONLY='LTS10,LTC10,LCS10,LCC10,LTN10,LSTN10' \
cargo run --example device_matrix_compare

The shared environment variables for these examples are documented in docs/RECIPES.md.

Public API Surface

Main exports:

  • SlmpConnectionOptions
  • SlmpClient
  • SlmpAddress
  • read_typed / write_typed
  • write_bit_in_word
  • read_named / write_named
  • poll_named
  • read_words_single_request / read_dwords_single_request
  • read_words_chunked / read_dwords_chunked
  • write_words_single_request / write_dwords_single_request
  • write_words_chunked / write_dwords_chunked

Important model types:

  • SlmpDeviceAddress
  • SlmpQualifiedDeviceAddress
  • SlmpTargetAddress
  • SlmpExtensionSpec
  • SlmpTypeNameInfo
  • SlmpRandomReadResult
  • SlmpBlockRead, SlmpBlockWrite, SlmpBlockReadResult
  • SlmpLongTimerResult
  • SlmpValue

Supported Address Forms

High-level helpers are intended to cover these forms first.

  • plain word devices: D100, R50, ZR0, TN0, CN0
  • plain bit devices: M1000, X20, Y20, B10
  • typed suffixes: D100:S, D200:D, D300:L, D400:F
  • bit-in-word form: D50.3
  • long current-value forms: LTN10:D, LSTN20:D, LCN30:D
  • extended devices: J1\\SW0, U3\\G100, U1\\HG0

.bit notation is only valid for word devices. Address bit devices directly.

See also:

Choosing the Right API

  • Use raw device methods when you need exact SLMP request control.
  • Use read_typed and write_typed when one address maps to one scalar value.
  • Use read_named and write_named when your application needs a snapshot with mixed dtypes and bit-in-word decoding.
  • Use poll_named for a lightweight periodic stream.
  • Use read_random and read_block when you want to keep request counts low.
  • Use the extended-device methods for J... and U... paths.
  • read_named and write_named currently target plain device addresses, not J... or U... qualified addresses.

Long-Family Behavior

The Rust implementation follows the same normalized behavior as the other libraries:

  • LTN, LSTN, LCN, and LZ default to 32-bit reads
  • LTS, LTC, LSTS, LSTC, LCS, and LCC are state reads
  • LCS and LCC are decoded from the LCN 4-word status block, not by a standalone direct-bit batch read
  • LCS and LCC are rejected for Read Random (0x0403), Read Block (0x0406), Write Block (0x1406), and Entry Monitor Device (0x0801)
  • direct reads for LTS, LTC, LSTS, and LSTC are rejected by the Rust client API; use helper APIs or 4-word block reads from LTN / LSTN
  • direct dword reads for LTN and LSTN are rejected; use helper APIs or explicit 4-word block reads

That behavior is intentional and is enforced through plc-comm-slmp-cross-verify.

Cross-Verify

This repo is designed to participate in:

  • plc-comm-slmp-cross-verify/specs/shared
  • python verify.py --clients rust
  • full parity runs with Python, .NET, C++, and Node-RED
  • live PLC verification through the same saved baseline/profile flow

The wrapper binary used by the harness is:

cargo run --bin slmp_verify_client -- 127.0.0.1 9000 read-type

Development

Format and test:

cargo fmt
cargo test

Run the Rust tests:

cargo test

Check the Node binding scaffold:

cargo check -p slmp-node

Run Rust-only parity through the canonical harness:

cd ../plc-comm-slmp-cross-verify
python verify.py --clients rust

Run full parity:

cd ../plc-comm-slmp-cross-verify
python verify.py

Run live PLC verification with the validated R120 profile:

cd ../plc-comm-slmp-cross-verify
python slmp_live_verify.py \
  --ip 192.168.250.100 \
  --port 1025 \
  --profile r120pcpu_tcp1025 \
  --include-stateful \
  --include-remote

Node Binding

crates/slmp-node is currently a thin napi-rs scaffold. It is not yet the main delivery path. The current purpose is to keep the Rust workspace ready for future Node package work without redesigning the crate layout later.

License

Distributed under the MIT License.