plc-comm-slmp-rust 0.1.1

Async Rust SLMP client based on the plc-comm-slmp-dotnet implementation
Documentation
use crate::error::SlmpError;
use crate::model::{
    SlmpCompatibilityMode, SlmpDeviceAddress, SlmpDeviceCode, SlmpNamedTarget,
    SlmpQualifiedDeviceAddress, SlmpTargetAddress,
};

pub struct SlmpAddress;

impl SlmpAddress {
    pub fn parse(text: &str) -> Result<SlmpDeviceAddress, SlmpError> {
        parse_device(text)
    }

    pub fn try_parse(text: &str) -> Option<SlmpDeviceAddress> {
        parse_device(text).ok()
    }

    pub fn format(address: SlmpDeviceAddress) -> String {
        let number = if address.code.is_hex_addressed() {
            format!("{:X}", address.number)
        } else {
            address.number.to_string()
        };
        format!("{}{}", address.code.prefix(), number)
    }

    pub fn normalize(text: &str) -> Result<String, SlmpError> {
        Ok(Self::format(Self::parse(text)?))
    }
}

pub fn parse_named_address(address: &str) -> Result<NamedAddressParts, SlmpError> {
    let trimmed = address.trim();
    if let Some((base, dtype)) = trimmed.split_once(':') {
        return Ok(NamedAddressParts {
            base: base.trim().to_string(),
            dtype: dtype.trim().to_uppercase(),
            bit_index: None,
        });
    }

    if let Some((base, bit)) = trimmed.split_once('.')
        && let Ok(bit_index) = u8::from_str_radix(bit.trim(), 16)
    {
        return Ok(NamedAddressParts {
            base: base.trim().to_string(),
            dtype: "BIT_IN_WORD".to_string(),
            bit_index: Some(bit_index),
        });
    }

    Ok(NamedAddressParts {
        base: trimmed.to_string(),
        dtype: "U".to_string(),
        bit_index: None,
    })
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamedAddressParts {
    pub base: String,
    pub dtype: String,
    pub bit_index: Option<u8>,
}

pub fn normalize_named_address(address: &str) -> Result<String, SlmpError> {
    let parts = parse_named_address(address)?;
    let canonical_base = SlmpAddress::normalize(&parts.base)?;
    if let Some(bit_index) = parts.bit_index {
        return Ok(format!("{canonical_base}.{bit_index:X}"));
    }
    if address.contains(':') {
        return Ok(format!("{canonical_base}:{}", parts.dtype));
    }
    Ok(canonical_base)
}

pub fn parse_device(text: &str) -> Result<SlmpDeviceAddress, SlmpError> {
    let token = text.trim().to_uppercase();
    if token.is_empty() {
        return Err(SlmpError::new("Device text is required."));
    }

    for prefix in [
        "LSTS", "LSTC", "LSTN", "LTS", "LTC", "LTN", "STS", "STC", "STN", "SM", "SD", "TS", "TC",
        "TN", "CS", "CC", "CN", "SB", "SW", "DX", "DY", "LCS", "LCC", "LCN", "LZ", "ZR", "RD",
        "HG", "X", "Y", "M", "L", "F", "V", "B", "D", "W", "Z", "R", "G",
    ] {
        if token.starts_with(prefix)
            && let Some(code) = SlmpDeviceCode::parse_prefix(prefix)
        {
            let number_text = &token[prefix.len()..];
            let number = if code.is_hex_addressed() {
                u32::from_str_radix(number_text, 16)
            } else {
                number_text.parse::<u32>()
            };
            if let Ok(number) = number {
                return Ok(SlmpDeviceAddress::new(code, number));
            }
        }
    }

    Err(SlmpError::new(format!(
        "Invalid SLMP device string '{text}'."
    )))
}

pub fn parse_qualified_device(text: &str) -> Result<SlmpQualifiedDeviceAddress, SlmpError> {
    let token = text.trim().to_uppercase();
    if token.is_empty() {
        return Err(SlmpError::new("Device text is required."));
    }

    if let Some(rest) = token.strip_prefix('J')
        && let Some((network, device_text)) = split_slash(rest)
    {
        let network: u16 = network
            .parse()
            .map_err(|_| SlmpError::new("Invalid J-direct network."))?;
        return Ok(SlmpQualifiedDeviceAddress {
            device: parse_device(device_text)?,
            extension_specification: Some(network),
            direct_memory_specification: Some(0xF9),
        });
    }

    if let Some(rest) = token.strip_prefix('U')
        && let Some((extension, device_text)) = split_slash(rest)
    {
        let extension_specification = u16::from_str_radix(extension, 16)
            .map_err(|_| SlmpError::new("Invalid extension specification."))?;
        let device = parse_device(device_text)?;
        let direct_memory_specification = match device.code {
            SlmpDeviceCode::G => Some(0xF8),
            SlmpDeviceCode::HG => Some(0xFA),
            _ => None,
        };
        return Ok(SlmpQualifiedDeviceAddress {
            device,
            extension_specification: Some(extension_specification),
            direct_memory_specification,
        });
    }

    Ok(SlmpQualifiedDeviceAddress {
        device: parse_device(&token)?,
        extension_specification: None,
        direct_memory_specification: None,
    })
}

fn split_slash(text: &str) -> Option<(&str, &str)> {
    text.split_once('\\').or_else(|| text.split_once('/'))
}

pub fn parse_target_auto_number(text: &str) -> Result<u32, SlmpError> {
    if let Some(hex) = text.strip_prefix("0X").or_else(|| text.strip_prefix("0x")) {
        return u32::from_str_radix(hex, 16).map_err(|_| SlmpError::new("Invalid numeric text."));
    }
    text.parse::<u32>()
        .map_err(|_| SlmpError::new("Invalid numeric text."))
}

pub fn parse_named_target(text: &str) -> Result<SlmpNamedTarget, SlmpError> {
    let token = text.trim();
    if token.eq_ignore_ascii_case("SELF") {
        return Ok(SlmpNamedTarget {
            name: "SELF".to_string(),
            target: SlmpTargetAddress::default(),
        });
    }
    if let Some(cpu) = token.strip_prefix("SELF-CPU") {
        let index: u16 = cpu
            .parse()
            .map_err(|_| SlmpError::new("Invalid SELF-CPU target."))?;
        if !(1..=4).contains(&index) {
            return Err(SlmpError::new("SELF-CPU must be 1..4."));
        }
        return Ok(SlmpNamedTarget {
            name: format!("SELF-CPU{index}"),
            target: SlmpTargetAddress {
                module_io: 0x03E0 + (index - 1),
                ..SlmpTargetAddress::default()
            },
        });
    }
    if let Some(rest) = token.strip_prefix("NW")
        && let Some((network, station)) = rest.split_once("-ST")
    {
        return Ok(SlmpNamedTarget {
            name: format!("NW{}-ST{}", network, station),
            target: SlmpTargetAddress {
                network: network
                    .parse()
                    .map_err(|_| SlmpError::new("Invalid network."))?,
                station: station
                    .parse()
                    .map_err(|_| SlmpError::new("Invalid station."))?,
                ..SlmpTargetAddress::default()
            },
        });
    }
    let parts: Vec<_> = token.split(',').map(str::trim).collect();
    if parts.len() == 5 {
        return Ok(SlmpNamedTarget {
            name: parts[0].to_string(),
            target: SlmpTargetAddress {
                network: parse_target_auto_number(parts[1])? as u8,
                station: parse_target_auto_number(parts[2])? as u8,
                module_io: parse_target_auto_number(parts[3])? as u16,
                multidrop: parse_target_auto_number(parts[4])? as u8,
            },
        });
    }
    Err(SlmpError::new(
        "target must be SELF, SELF-CPU1..4, NWx-STy, or NAME,NETWORK,STATION,MODULE_IO,MULTIDROP",
    ))
}

pub fn device_spec_size(mode: SlmpCompatibilityMode) -> usize {
    match mode {
        SlmpCompatibilityMode::Legacy => 4,
        SlmpCompatibilityMode::Iqr => 6,
    }
}