plc-comm-hostlink-rust 0.1.1

Async Rust Host Link client based on plc-comm-hostlink-dotnet
Documentation
use futures_util::{StreamExt, pin_mut};
use plc_comm_hostlink::{
    HostLinkConnectionOptions, HostLinkValue, open_and_connect, read_comments, read_dwords,
    read_named, read_words, write_bit_in_word,
};
use serde_json::{Value, json};

#[tokio::main]
async fn main() {
    let args = std::env::args().collect::<Vec<_>>();
    if args.len() < 4 {
        println!(
            "{}",
            json!({"status": "error", "message": "Not enough arguments"})
        );
        return;
    }

    let result = run(&args).await.unwrap_or_else(|error| {
        json!({
            "status": "error",
            "message": error.to_string(),
        })
    });
    println!("{result}");
}

async fn run(args: &[String]) -> Result<Value, Box<dyn std::error::Error>> {
    let host = &args[1];
    let port = args[2].parse::<u16>()?;
    let command = args[3].to_ascii_lowercase();
    let address = args.get(4).cloned().unwrap_or_default();

    let mut dtype = String::new();
    let mut count = 1usize;
    let mut interval_ms = 10u64;
    let mut extra = Vec::new();
    let mut index = 5usize;
    while index < args.len() {
        match args[index].as_str() {
            "--dtype" if index + 1 < args.len() => {
                dtype = args[index + 1].clone();
                index += 2;
            }
            "--count" if index + 1 < args.len() => {
                count = args[index + 1].parse()?;
                index += 2;
            }
            "--interval-ms" if index + 1 < args.len() => {
                interval_ms = args[index + 1].parse()?;
                index += 2;
            }
            _ => {
                extra.push(args[index].clone());
                index += 1;
            }
        }
    }

    let mut options = HostLinkConnectionOptions::new(host.clone());
    options.port = port;
    let client = open_and_connect(options).await?;

    let result = match command.as_str() {
        "write-typed" => {
            if dtype.trim().is_empty() || extra.is_empty() {
                json!({"status": "error", "message": "write-typed requires --dtype and one value"})
            } else {
                let value = parse_typed_value(&dtype, &extra[0])?;
                client.write_typed(&address, &dtype, value).await?;
                json!({"status": "success"})
            }
        }
        "read-typed" => {
            if dtype.trim().is_empty() {
                json!({"status": "error", "message": "read-typed requires --dtype"})
            } else {
                let value = client.read_typed(&address, &dtype).await?;
                json!({"status": "success", "value": normalize_value(&value)})
            }
        }
        "read-comments" => {
            let value = read_comments(client.inner_client(), &address, true).await?;
            json!({"status": "success", "value": value})
        }
        "write-bit-in-word" => {
            if extra.len() < 2 {
                json!({"status": "error", "message": "write-bit-in-word requires bit-index and bool value"})
            } else {
                let bit_index = extra[0].parse::<u8>()?;
                let value = parse_bool(&extra[1]);
                write_bit_in_word(client.inner_client(), &address, bit_index, value).await?;
                json!({"status": "success"})
            }
        }
        "read-named" => {
            let addresses = ([address.clone()]
                .into_iter()
                .filter(|item| !item.is_empty()))
            .chain(extra.iter().cloned())
            .collect::<Vec<_>>();
            if addresses.is_empty() {
                json!({"status": "error", "message": "read-named requires at least one address"})
            } else {
                let values = read_named(client.inner_client(), &addresses).await?;
                json!({"status": "success", "values": normalize_named(&values)})
            }
        }
        "poll" => {
            let addresses = ([address.clone()]
                .into_iter()
                .filter(|item| !item.is_empty()))
            .chain(extra.iter().cloned())
            .collect::<Vec<_>>();
            if addresses.is_empty() {
                json!({"status": "error", "message": "poll requires at least one address"})
            } else {
                let stream = client.poll(&addresses, std::time::Duration::from_millis(interval_ms));
                pin_mut!(stream);
                let mut snapshots = Vec::new();
                while let Some(snapshot) = stream.next().await {
                    snapshots.push(normalize_named(&snapshot?));
                    if snapshots.len() >= count {
                        break;
                    }
                }
                json!({"status": "success", "snapshots": snapshots})
            }
        }
        "read-words" => {
            if extra.is_empty() {
                json!({"status": "error", "message": "read-words requires count"})
            } else {
                let values = read_words(client.inner_client(), &address, extra[0].parse()?).await?;
                json!({"status": "success", "values": values.into_iter().map(|value| value.to_string()).collect::<Vec<_>>()})
            }
        }
        "read-dwords" => {
            if extra.is_empty() {
                json!({"status": "error", "message": "read-dwords requires count"})
            } else {
                let values =
                    read_dwords(client.inner_client(), &address, extra[0].parse()?).await?;
                json!({"status": "success", "values": values.into_iter().map(|value| value.to_string()).collect::<Vec<_>>()})
            }
        }
        _ => json!({"status": "error", "message": format!("Unknown command: {command}")}),
    };

    let _ = client.close().await;
    Ok(result)
}

fn parse_typed_value(dtype: &str, raw: &str) -> Result<HostLinkValue, Box<dyn std::error::Error>> {
    let key = dtype.trim_start_matches('.').to_ascii_uppercase();
    Ok(match key.as_str() {
        "F" => HostLinkValue::F32(raw.parse()?),
        "S" => HostLinkValue::I16(raw.parse()?),
        "D" => HostLinkValue::U32(raw.parse()?),
        "L" => HostLinkValue::I32(raw.parse()?),
        _ => HostLinkValue::U16(raw.parse()?),
    })
}

fn parse_bool(raw: &str) -> bool {
    matches!(
        raw.trim().to_ascii_lowercase().as_str(),
        "1" | "true" | "on" | "yes"
    )
}

fn normalize_value(value: &HostLinkValue) -> Value {
    match value {
        HostLinkValue::Bool(value) => json!(value),
        HostLinkValue::F32(value) => json!(
            format!("{value:.9}")
                .trim_end_matches('0')
                .trim_end_matches('.')
        ),
        HostLinkValue::Text(value) => json!(value),
        HostLinkValue::U16(value) => json!(value.to_string()),
        HostLinkValue::I16(value) => json!(value.to_string()),
        HostLinkValue::U32(value) => json!(value.to_string()),
        HostLinkValue::I32(value) => json!(value.to_string()),
    }
}

fn normalize_named(values: &plc_comm_hostlink::NamedSnapshot) -> Value {
    let mut map = serde_json::Map::new();
    for (key, value) in values {
        map.insert(key.clone(), normalize_value(value));
    }
    Value::Object(map)
}