use crate::EtherNetIpStream;
use crate::batch::{BatchConfig, BatchOperation};
use crate::error::{EtherNetIpError, Result};
use crate::protocol::cip::{CipRequest, CipResponse, READ_TAG, SendDataRequest, WRITE_TAG};
use crate::protocol::encap::{EncapsulationHeader, REGISTER_SESSION, UNREGISTER_SESSION};
use crate::protocol::values;
use crate::protocol::{Decode, Encode};
use crate::route::RoutePath;
use crate::subscription::TagSubscription;
use crate::tag_group::TagGroupConfig;
use crate::tag_manager::{TagManager, TagMetadata, TagPermissions, TagScope};
use crate::types::{ConnectedSession, PlcValue, UdtData};
use crate::udt::{TagAttributes, UdtDefinition, UdtManager};
use crate::{TagPath, udt};
use bytes::BytesMut;
use std::collections::HashMap;
use std::net::SocketAddr;
#[cfg(feature = "ffi")]
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[cfg(feature = "ffi")]
use tokio::runtime::Runtime;
use tokio::sync::Mutex;
use tokio::time::{Duration, Instant, timeout};
mod actor;
mod batch_exec;
mod diagnostics;
mod schema_export;
mod service_layer;
mod string;
mod subscriptions;
pub use actor::{Backoff, Client, ConnectionEvent, RetryClient, RetryPolicy};
#[derive(Debug)]
struct TagListPage {
tags: Vec<TagAttributes>,
last_instance_id: Option<u32>,
partial_transfer: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TemplateAttributes {
structure_handle: u16,
member_count: u16,
definition_size_words: u32,
structure_size_bytes: u32,
}
#[cfg(feature = "ffi")]
pub(crate) static RUNTIME: LazyLock<std::io::Result<Runtime>> = LazyLock::new(Runtime::new);
#[derive(Clone)]
pub struct EipClient {
stream: Arc<Mutex<Box<dyn EtherNetIpStream>>>,
session_handle: u32,
tag_manager: Arc<Mutex<TagManager>>,
udt_manager: Arc<Mutex<UdtManager>>,
route_path: Arc<StdMutex<Option<RoutePath>>>,
max_packet_size: Arc<AtomicU32>,
last_activity: Arc<Mutex<Instant>>,
batch_config: BatchConfig,
connected_sessions: Arc<Mutex<HashMap<String, ConnectedSession>>>,
connection_sequence: Arc<Mutex<u32>>,
subscriptions: Arc<Mutex<Vec<TagSubscription>>>,
tag_groups: Arc<Mutex<HashMap<String, TagGroupConfig>>>,
}
#[cfg(test)]
const _: fn() = || {
fn assert_send_sync_static<T: Send + Sync + 'static>() {}
assert_send_sync_static::<EipClient>();
};
impl std::fmt::Debug for EipClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EipClient")
.field("session_handle", &self.session_handle)
.field("route_path", &self.route_path_snapshot())
.field("max_packet_size", &self.max_packet_size())
.field("batch_config", &self.batch_config)
.field("stream", &"<stream>")
.field("tag_manager", &"<tag_manager>")
.field("udt_manager", &"<udt_manager>")
.field("connected_sessions", &"<connected_sessions>")
.field("subscriptions", &"<subscriptions>")
.field("tag_groups", &"<tag_groups>")
.finish()
}
}
impl EipClient {
async fn from_stream<S>(stream: S) -> Result<Self>
where
S: EtherNetIpStream + 'static,
{
let mut client = Self {
stream: Arc::new(Mutex::new(Box::new(stream))),
session_handle: 0,
tag_manager: Arc::new(Mutex::new(TagManager::new())),
udt_manager: Arc::new(Mutex::new(UdtManager::new())),
route_path: Arc::new(StdMutex::new(None)),
max_packet_size: Arc::new(AtomicU32::new(4000)),
last_activity: Arc::new(Mutex::new(Instant::now())),
batch_config: BatchConfig::default(),
connected_sessions: Arc::new(Mutex::new(HashMap::new())),
connection_sequence: Arc::new(Mutex::new(1)),
subscriptions: Arc::new(Mutex::new(Vec::new())),
tag_groups: Arc::new(Mutex::new(HashMap::new())),
};
client.register_session().await?;
client.negotiate_packet_size().await?;
Ok(client)
}
pub async fn new(addr: &str) -> Result<Self> {
let addr = addr
.parse::<SocketAddr>()
.map_err(|e| EtherNetIpError::Protocol(format!("Invalid address format: {e}")))?;
let stream = TcpStream::connect(addr).await?;
Self::from_stream(stream).await
}
pub async fn connect(addr: &str) -> Result<Self> {
Self::new(addr).await
}
#[cfg(test)]
fn new_unconnected_for_testing() -> Self {
let (stream, _peer) = tokio::io::duplex(64);
Self {
stream: Arc::new(Mutex::new(Box::new(stream))),
session_handle: 0,
tag_manager: Arc::new(Mutex::new(TagManager::new())),
udt_manager: Arc::new(Mutex::new(UdtManager::new())),
route_path: Arc::new(StdMutex::new(None)),
max_packet_size: Arc::new(AtomicU32::new(4000)),
last_activity: Arc::new(Mutex::new(Instant::now())),
batch_config: BatchConfig::default(),
connected_sessions: Arc::new(Mutex::new(HashMap::new())),
connection_sequence: Arc::new(Mutex::new(1)),
subscriptions: Arc::new(Mutex::new(Vec::new())),
tag_groups: Arc::new(Mutex::new(HashMap::new())),
}
}
async fn register_session(&mut self) -> crate::error::Result<()> {
tracing::debug!("Starting session registration...");
let mut packet = BytesMut::with_capacity(28);
EncapsulationHeader::new(REGISTER_SESSION, 4, 0).encode(&mut packet);
packet.extend_from_slice(&[0x01, 0x00]); packet.extend_from_slice(&[0x00, 0x00]);
tracing::trace!("Sending Register Session packet: {:02X?}", packet);
self.stream
.lock()
.await
.write_all(&packet)
.await
.map_err(|e| {
tracing::error!("Failed to send Register Session packet: {}", e);
EtherNetIpError::Io(e)
})?;
let mut buf = [0u8; 1024];
tracing::debug!("Waiting for Register Session response...");
let n = match timeout(
Duration::from_secs(5),
self.stream.lock().await.read(&mut buf),
)
.await
{
Ok(Ok(n)) => {
tracing::trace!("Received {} bytes in response", n);
n
}
Ok(Err(e)) => {
tracing::error!("Error reading response: {}", e);
return Err(EtherNetIpError::Io(e));
}
Err(_) => {
tracing::warn!("Timeout waiting for response");
return Err(EtherNetIpError::Timeout(Duration::from_secs(5)));
}
};
if n < 28 {
tracing::error!("Response too short: {} bytes (expected 28)", n);
return Err(EtherNetIpError::Protocol("Response too short".to_string()));
}
let mut response = &buf[..n];
let header = EncapsulationHeader::decode(&mut response)?;
self.session_handle = header.session_handle;
tracing::debug!("Session handle: 0x{:08X}", self.session_handle);
let status = header.status;
tracing::trace!("Status code: 0x{:08X}", status);
if status != 0 {
tracing::error!("Session registration failed with status: 0x{:08X}", status);
return Err(EtherNetIpError::Protocol(format!(
"Session registration failed with status: 0x{status:08X}"
)));
}
tracing::info!("Session registration successful");
Ok(())
}
pub fn set_max_packet_size(&mut self, size: u32) {
self.max_packet_size
.store(size.min(4000), Ordering::Relaxed);
}
pub(crate) fn max_packet_size(&self) -> u32 {
self.max_packet_size.load(Ordering::Relaxed)
}
fn route_path_snapshot(&self) -> Option<RoutePath> {
self.route_path
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub async fn discover_tags(&mut self) -> crate::error::Result<()> {
let response = self
.send_cip_request(&self.build_list_tags_request())
.await?;
let cip_data = self.extract_cip_from_response(&response)?;
if let Err(e) = self.check_cip_error(&cip_data) {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Tag discovery failed: {}. Some PLCs may not support tag discovery. Try reading tags directly by name.",
e
)));
}
let tags = {
let tag_manager = self.tag_manager.lock().await;
tag_manager.parse_tag_list(&cip_data)?
};
tracing::debug!("Initial tag discovery found {} tags", tags.len());
let hierarchical_tags = {
let tag_manager = self.tag_manager.lock().await;
let hierarchical_tags = tag_manager.drill_down_tags(&tags).await?;
drop(tag_manager);
hierarchical_tags
};
tracing::debug!(
"After drill-down: {} total tags discovered",
hierarchical_tags.len()
);
{
let tag_manager = self.tag_manager.lock().await;
let mut cache = tag_manager.cache.write()?;
for (name, metadata) in hierarchical_tags {
cache.insert(name, metadata);
}
}
Ok(())
}
pub async fn discover_udt_members(
&mut self,
udt_name: &str,
) -> crate::error::Result<Vec<(String, TagMetadata)>> {
let cip_request = {
let tag_manager = self.tag_manager.lock().await;
tag_manager.build_udt_definition_request(udt_name)?
};
let response = self.send_cip_request(&cip_request).await?;
let definition = {
let tag_manager = self.tag_manager.lock().await;
tag_manager.parse_udt_definition_response(&response, udt_name)?
};
{
let tag_manager = self.tag_manager.lock().await;
let mut definitions = tag_manager.udt_definitions.write()?;
definitions.insert(udt_name.to_string(), definition.clone());
}
let mut members = Vec::new();
for member in &definition.members {
let member_name = member.name.clone();
let full_name = format!("{}.{}", udt_name, member_name);
let metadata = TagMetadata {
data_type: member.data_type,
scope: TagScope::Controller,
permissions: TagPermissions {
readable: true,
writable: true,
},
is_array: false,
dimensions: Vec::new(),
last_access: std::time::Instant::now(),
size: member.size,
array_info: None,
last_updated: std::time::Instant::now(),
};
members.push((full_name, metadata));
}
Ok(members)
}
pub async fn get_udt_definition_cached(&self, udt_name: &str) -> Option<UdtDefinition> {
let tag_manager = self.tag_manager.lock().await;
tag_manager.get_udt_definition_cached(udt_name)
}
pub async fn list_udt_definitions(&self) -> Vec<String> {
let tag_manager = self.tag_manager.lock().await;
tag_manager.list_udt_definitions()
}
pub async fn discover_tags_detailed(&mut self) -> crate::error::Result<Vec<TagAttributes>> {
let (tags, _) = self.discover_tags_detailed_internal(false).await?;
Ok(tags)
}
async fn discover_tags_detailed_internal(
&mut self,
best_effort: bool,
) -> crate::error::Result<(Vec<TagAttributes>, Vec<String>)> {
let mut start_instance = 0u32;
let mut tags = Vec::new();
let mut warnings = Vec::new();
loop {
let request = self.build_tag_list_request_from_instance(start_instance)?;
let response = match self.send_cip_request(&request).await {
Ok(response) => response,
Err(err) if best_effort && !tags.is_empty() => {
warnings.push(format!(
"Tag discovery stopped early at instance {} after transport/protocol failure: {}",
start_instance, err
));
break;
}
Err(err) => return Err(err),
};
let cip_data = match self.extract_cip_from_response(&response) {
Ok(cip_data) => cip_data,
Err(err) if best_effort && !tags.is_empty() => {
warnings.push(format!(
"Tag discovery stopped early at instance {} after response extraction failure: {}",
start_instance, err
));
break;
}
Err(err) => return Err(err),
};
let page = match self.parse_tag_list_response_page(&cip_data) {
Ok(page) => page,
Err(err) if best_effort && !tags.is_empty() => {
warnings.push(format!(
"Tag discovery stopped early at instance {} after page-parse failure: {}",
start_instance, err
));
break;
}
Err(err) => return Err(err),
};
tags.extend(page.tags);
if !page.partial_transfer {
break;
}
let Some(last_instance_id) = page.last_instance_id else {
return Err(crate::error::EtherNetIpError::Protocol(
"Tag discovery returned Partial transfer without a last instance ID"
.to_string(),
));
};
if last_instance_id == u32::MAX || last_instance_id < start_instance {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Tag discovery pagination stalled at instance {}",
last_instance_id
)));
}
start_instance = last_instance_id.saturating_add(1);
}
Ok((tags, warnings))
}
pub async fn discover_program_tags(
&mut self,
program_name: &str,
) -> crate::error::Result<Vec<TagAttributes>> {
let request = self.build_program_tag_list_request(program_name)?;
let response = self.send_cip_request(&request).await?;
let cip_data = self.extract_cip_from_response(&response)?;
if let Err(e) = self.check_cip_error(&cip_data) {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Program tag discovery failed for '{}': {}. Some PLCs may not support tag discovery. Try reading tags directly by name.",
program_name, e
)));
}
self.parse_tag_list_response(&cip_data)
}
pub async fn list_cached_tag_attributes(&self) -> Vec<String> {
self.udt_manager.lock().await.list_tag_attributes()
}
pub async fn clear_caches(&mut self) {
if let Err(error) = self.tag_manager.lock().await.clear_cache().await {
tracing::warn!("failed to clear tag metadata cache: {error}");
}
self.udt_manager.lock().await.clear_cache();
}
pub async fn with_route_path(addr: &str, route: RoutePath) -> crate::error::Result<Self> {
let mut client = Self::new(addr).await?;
client.set_route_path(route);
Ok(client)
}
pub async fn connect_with_stream<S>(stream: S, route: Option<RoutePath>) -> Result<Self>
where
S: EtherNetIpStream + 'static,
{
let mut client = Self::from_stream(stream).await?;
if let Some(route) = route {
client.set_route_path(route);
}
Ok(client)
}
pub fn set_route_path(&mut self, route: RoutePath) {
*self
.route_path
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(route);
}
pub fn get_route_path(&self) -> Option<RoutePath> {
self.route_path_snapshot()
}
pub fn clear_route_path(&mut self) {
*self
.route_path
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
pub async fn get_tag_metadata(&self, tag_name: &str) -> Option<TagMetadata> {
let tag_manager = self.tag_manager.lock().await;
match tag_manager.cache.read() {
Ok(cache) => cache.get(tag_name).cloned(),
Err(_) => {
tracing::warn!("failed to read tag metadata cache: lock poisoned");
None
}
}
}
pub async fn read_tag(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
self.validate_session().await?;
if let Some((base_name, index)) = self.parse_array_element_access(tag_name) {
if let Some(bracket_start) = tag_name.find('[')
&& let Some(bracket_end_rel) = tag_name[bracket_start..].find(']')
{
let bracket_end_abs = bracket_start + bracket_end_rel;
let after_bracket = &tag_name[bracket_end_abs + 1..];
tracing::debug!(
"Array element detected for '{}': base='{}', index={}, after_bracket='{}'",
tag_name,
base_name,
index,
after_bracket
);
if !after_bracket.starts_with('.') {
tracing::debug!(
"Detected simple array element access: {}[{}], using workaround",
base_name,
index
);
return self.read_array_element_workaround(&base_name, index).await;
} else {
tracing::debug!(
"Array element '{}[{}]' has member access after bracket ('{}'), using TagPath::parse()",
base_name,
index,
after_bracket
);
}
}
}
if let Some((parent_path, index)) = self.parse_final_array_element_access(tag_name)
&& self.detect_bool_array_path(&parent_path).await?
{
return self
.read_bool_array_element_workaround(&parent_path, index)
.await;
}
let response = self
.send_cip_request(&self.build_read_request(tag_name)?)
.await?;
let cip_data = self.extract_cip_from_response(&response)?;
self.parse_cip_response(&cip_data)
}
pub async fn read_bit(&mut self, tag_base: &str, bit_index: u8) -> crate::error::Result<bool> {
if bit_index >= 32 {
return Err(crate::error::EtherNetIpError::Protocol(
"bit_index must be 0..32 for DINT bit access".to_string(),
));
}
let path = format!("{}.{}", tag_base, bit_index);
match self.read_tag(&path).await? {
PlcValue::Bool(b) => Ok(b),
PlcValue::Dint(n) => {
Ok((n >> bit_index) & 1 != 0)
}
other => Err(crate::error::EtherNetIpError::DataTypeMismatch {
expected: "BOOL or DINT".to_string(),
actual: format!("{:?}", other),
}),
}
}
pub async fn write_bit(
&mut self,
tag_base: &str,
bit_index: u8,
value: bool,
) -> crate::error::Result<()> {
if bit_index >= 32 {
return Err(crate::error::EtherNetIpError::Protocol(
"bit_index must be 0..32 for DINT bit access".to_string(),
));
}
let path = format!("{}.{}", tag_base, bit_index);
self.write_tag(&path, PlcValue::Bool(value)).await
}
fn parse_array_element_access(&self, tag_name: &str) -> Option<(String, u32)> {
if let Some(bracket_pos) = tag_name.rfind('[')
&& let Some(close_bracket_pos) = tag_name.rfind(']')
&& close_bracket_pos > bracket_pos
{
let base_name = tag_name[..bracket_pos].to_string();
let index_str = &tag_name[bracket_pos + 1..close_bracket_pos];
if let Ok(index) = index_str.parse::<u32>()
&& !tag_name[..bracket_pos].contains('[')
{
return Some((base_name, index));
}
}
None
}
fn parse_final_array_element_access(&self, tag_name: &str) -> Option<(String, u32)> {
match TagPath::parse(tag_name).ok()? {
TagPath::Array { base_path, indices } if indices.len() == 1 => {
Some((base_path.as_string(), indices[0]))
}
_ => None,
}
}
async fn detect_bool_array_path(&mut self, array_path: &str) -> crate::error::Result<bool> {
let test_response = self
.send_cip_request(&self.build_read_request_with_count(array_path, 1)?)
.await?;
let test_cip_data = self.extract_cip_from_response(&test_response)?;
if self.check_cip_error(&test_cip_data).is_err() || test_cip_data.len() < 6 {
return Ok(false);
}
let test_data_type = u16::from_le_bytes([test_cip_data[4], test_cip_data[5]]);
Ok(test_data_type == values::BOOL_ARRAY_DWORD)
}
fn parse_bool_array_dword_response(&self, cip_data: &[u8]) -> crate::error::Result<u32> {
if cip_data.len() < 6 {
return Err(EtherNetIpError::Protocol(
"BOOL array response too short".to_string(),
));
}
self.check_cip_error(cip_data)?;
let service_reply = cip_data[0];
if service_reply != 0xCC {
return Err(EtherNetIpError::Protocol(format!(
"Unexpected service reply: 0x{service_reply:02X}"
)));
}
let data_type = u16::from_le_bytes([cip_data[4], cip_data[5]]);
if data_type != values::BOOL_ARRAY_DWORD {
return Err(EtherNetIpError::Protocol(format!(
"Expected BOOL array DWORD data type 0x00D3, got 0x{data_type:04X}"
)));
}
let value_data = if cip_data.len() >= 12 {
&cip_data[8..]
} else if cip_data.len() >= 10 {
&cip_data[6..]
} else {
return Err(EtherNetIpError::Protocol(
"BOOL array response too short for data".to_string(),
));
};
if value_data.len() < 4 {
return Err(EtherNetIpError::Protocol(format!(
"BOOL array data too short: need 4 bytes (DWORD), got {} bytes",
value_data.len()
)));
}
Ok(u32::from_le_bytes([
value_data[0],
value_data[1],
value_data[2],
value_data[3],
]))
}
async fn read_array_element_workaround(
&mut self,
base_array_name: &str,
index: u32,
) -> crate::error::Result<PlcValue> {
tracing::debug!(
"Reading array element '{}[{}]' using element addressing",
base_array_name,
index
);
let test_response = self
.send_cip_request(&self.build_read_request_with_count(base_array_name, 1)?)
.await?;
let test_cip_data = self.extract_cip_from_response(&test_response)?;
self.check_cip_error(&test_cip_data)?;
if test_cip_data.len() >= 6 {
let test_data_type = u16::from_le_bytes([test_cip_data[4], test_cip_data[5]]);
if test_data_type == 0x00D3 {
return self
.read_bool_array_element_workaround(base_array_name, index)
.await;
}
}
let request = self.build_read_array_request(base_array_name, index, 1);
let response = self.send_cip_request(&request).await?;
let cip_data = self.extract_cip_from_response(&response)?;
self.check_cip_error(&cip_data)?;
self.parse_cip_response(&cip_data)
}
async fn read_bool_array_element_workaround(
&mut self,
base_array_name: &str,
index: u32,
) -> crate::error::Result<PlcValue> {
tracing::debug!(
"BOOL array detected - reading DWORD and extracting bit [{}]",
index
);
let dword_index = index / 32;
let response = self
.send_cip_request(&self.build_read_array_request(base_array_name, dword_index, 1))
.await?;
let cip_data = self.extract_cip_from_response(&response)?;
let dword_value = self.parse_bool_array_dword_response(&cip_data)?;
let bit_index = (index % 32) as u8;
let bool_value = (dword_value >> bit_index) & 1 != 0;
Ok(PlcValue::Bool(bool_value))
}
async fn read_array_in_chunks(
&mut self,
base_array_name: &str,
data_type: u16,
start_index: u32,
target_element_count: u32,
) -> crate::error::Result<Vec<u8>> {
let element_size = match data_type {
0x00C1 => 1, 0x00C2 => 1, 0x00C3 => 2, 0x00C4 => 4, 0x00C5 => 8, 0x00C6 => 1, 0x00C7 => 2, 0x00C8 => 4, 0x00C9 => 8, 0x00CA => 4, 0x00CB => 8, _ => {
return Err(EtherNetIpError::Protocol(format!(
"Unsupported array data type for chunked reading: 0x{:04X}",
data_type
)));
}
};
let elements_per_chunk = match element_size {
1 => 30, 2 => 15, 4 => 8, 8 => 4, _ => 8,
};
let end_index = start_index
.checked_add(target_element_count)
.ok_or_else(|| EtherNetIpError::Protocol("Array range overflow".to_string()))?;
let mut all_data = Vec::new();
let mut next_chunk_start = start_index;
tracing::debug!(
"Reading array '{}' in chunks: {} elements per chunk, target: {} elements",
base_array_name,
elements_per_chunk,
target_element_count
);
while next_chunk_start < end_index {
let chunk_end = (next_chunk_start + elements_per_chunk as u32).min(end_index);
let chunk_size = (chunk_end - next_chunk_start) as u16;
tracing::trace!(
"Reading chunk: elements {} to {} ({} elements) using element addressing",
next_chunk_start,
chunk_end - 1,
chunk_size
);
let response = self
.send_cip_request(&self.build_read_array_request(
base_array_name,
next_chunk_start,
chunk_size,
))
.await?;
let cip_data = self.extract_cip_from_response(&response)?;
if cip_data.len() < 8 {
if cip_data.len() >= 3 {
let general_status = cip_data[2];
if general_status != 0x00 {
let error_msg = self.get_cip_error_message(general_status);
return Err(EtherNetIpError::Protocol(format!(
"CIP Error {} when reading chunk (elements {} to {}): {}",
general_status,
next_chunk_start,
chunk_end - 1,
error_msg
)));
}
}
return Err(EtherNetIpError::Protocol(format!(
"Chunk response too short: got {} bytes, expected at least 8 (requested {} elements starting at {})",
cip_data.len(),
chunk_size,
next_chunk_start
)));
}
if cip_data.len() >= 3 {
let general_status = cip_data[2];
if general_status != 0x00 {
let error_msg = self.get_cip_error_message(general_status);
return Err(EtherNetIpError::Protocol(format!(
"CIP Error {} when reading chunk (elements {} to {}): {}",
general_status,
next_chunk_start,
chunk_end - 1,
error_msg
)));
}
}
if !cip_data.is_empty() && cip_data[0] != 0xCC {
return Err(EtherNetIpError::Protocol(format!(
"Unexpected service reply in chunk: 0x{:02X} (expected 0xCC)",
cip_data[0]
)));
}
if cip_data.len() < 6 {
return Err(EtherNetIpError::Protocol(format!(
"Chunk response too short for data type: got {} bytes, expected at least 6",
cip_data.len()
)));
}
let chunk_data_type = u16::from_le_bytes([cip_data[4], cip_data[5]]);
if chunk_data_type != data_type {
return Err(EtherNetIpError::Protocol(format!(
"Data type mismatch in chunk: expected 0x{:04X}, got 0x{:04X}",
data_type, chunk_data_type
)));
}
let value_data_start = if cip_data.len() >= 8 {
8
} else {
6
};
let chunk_value_data = &cip_data[value_data_start..];
let chunk_complete_bytes = (chunk_value_data.len() / element_size) * element_size;
let chunk_data = &chunk_value_data[..chunk_complete_bytes];
if !chunk_data.is_empty() {
all_data.extend_from_slice(chunk_data);
let elements_received = chunk_data.len() / element_size;
next_chunk_start += elements_received as u32;
tracing::trace!(
"Chunk read: {} elements ({} bytes) starting at index {}, total so far: {} elements",
elements_received,
chunk_data.len(),
next_chunk_start - elements_received as u32,
all_data.len() / element_size
);
if next_chunk_start >= end_index {
tracing::trace!(
"Reached target element count ({}), stopping chunked read",
target_element_count
);
break;
}
} else {
break;
}
}
let final_element_count = all_data.len() / element_size;
tracing::debug!(
"Chunked read complete: {} total elements ({} bytes), target was {} elements",
final_element_count,
all_data.len(),
target_element_count
);
if final_element_count < target_element_count as usize {
return Err(EtherNetIpError::Protocol(format!(
"Incomplete array read: requested {} elements, received {}",
target_element_count, final_element_count
)));
}
Ok(all_data)
}
fn array_element_size(data_type: u16) -> Option<usize> {
match data_type {
0x00C1 => Some(1), 0x00C2 => Some(1), 0x00C3 => Some(2), 0x00C4 => Some(4), 0x00C5 => Some(8), 0x00C6 => Some(1), 0x00C7 => Some(2), 0x00C8 => Some(4), 0x00C9 => Some(8), 0x00CA => Some(4), 0x00CB => Some(8), _ => None,
}
}
fn decode_array_bytes(
&self,
data_type: u16,
bytes: &[u8],
) -> crate::error::Result<Vec<PlcValue>> {
let Some(element_size) = Self::array_element_size(data_type) else {
return Err(EtherNetIpError::Protocol(format!(
"Unsupported data type for array decoding: 0x{:04X}",
data_type
)));
};
if !bytes.len().is_multiple_of(element_size) {
return Err(EtherNetIpError::Protocol(format!(
"Array payload length {} is not aligned to element size {}",
bytes.len(),
element_size
)));
}
let mut values = Vec::with_capacity(bytes.len() / element_size);
for chunk in bytes.chunks_exact(element_size) {
values.push(values::decode_array_element(data_type, chunk)?);
}
Ok(values)
}
pub async fn read_array_range(
&mut self,
base_array_name: &str,
start_index: u32,
element_count: u32,
) -> crate::error::Result<Vec<PlcValue>> {
if element_count == 0 {
return Ok(Vec::new());
}
let probe_response = self
.send_cip_request(&self.build_read_array_request(base_array_name, start_index, 1))
.await?;
let probe_cip = self.extract_cip_from_response(&probe_response)?;
self.check_cip_error(&probe_cip)?;
if probe_cip.len() < 6 {
return Err(EtherNetIpError::Protocol(
"Array probe response too short".to_string(),
));
}
let data_type = u16::from_le_bytes([probe_cip[4], probe_cip[5]]);
let raw = self
.read_array_in_chunks(base_array_name, data_type, start_index, element_count)
.await?;
let values = self.decode_array_bytes(data_type, &raw)?;
if values.len() != element_count as usize {
return Err(EtherNetIpError::Protocol(format!(
"Array read count mismatch: requested {}, got {}",
element_count,
values.len()
)));
}
Ok(values)
}
async fn write_array_element_workaround(
&mut self,
base_array_name: &str,
index: u32,
value: PlcValue,
) -> crate::error::Result<()> {
tracing::debug!(
"Writing to array element '{}[{}]' using element addressing",
base_array_name,
index
);
let test_response = self
.send_cip_request(&self.build_read_request_with_count(base_array_name, 1)?)
.await?;
let test_cip_data = self.extract_cip_from_response(&test_response)?;
if test_cip_data.len() < 3 {
return Err(EtherNetIpError::Protocol(
"Test read response too short".to_string(),
));
}
if let Err(e) = self.check_cip_error(&test_cip_data) {
return Err(EtherNetIpError::Protocol(format!(
"Cannot write to array element: Test read failed: {}",
e
)));
}
if test_cip_data.len() < 6 {
return Err(EtherNetIpError::Protocol(
"Test read response too short to determine data type".to_string(),
));
}
let test_data_type = u16::from_le_bytes([test_cip_data[4], test_cip_data[5]]);
if test_data_type == 0x00D3 {
return self
.write_bool_array_element_workaround(base_array_name, index, value)
.await;
}
let data_type = test_data_type;
let value_bytes = value.to_bytes();
let request = self.build_write_array_request_with_index(
base_array_name,
index,
1, data_type,
&value_bytes,
)?;
let response = self.send_cip_request(&request).await?;
let cip_data = self.extract_cip_from_response(&response)?;
self.check_cip_error(&cip_data)?;
tracing::info!("Array element write completed successfully");
Ok(())
}
async fn write_bool_array_element_workaround(
&mut self,
base_array_name: &str,
index: u32,
value: PlcValue,
) -> crate::error::Result<()> {
tracing::debug!(
"BOOL array element write - reading DWORD, modifying bit [{}], writing back",
index
);
let dword_index = index / 32;
let response = self
.send_cip_request(&self.build_read_array_request(base_array_name, dword_index, 1))
.await?;
let cip_data = self.extract_cip_from_response(&response)?;
let bool_value = match value {
PlcValue::Bool(b) => b,
_ => {
return Err(EtherNetIpError::Protocol(
"Expected BOOL value for BOOL array element".to_string(),
));
}
};
let original_dword_value = self.parse_bool_array_dword_response(&cip_data)?;
let mut dword_value = original_dword_value;
let bit_index = (index % 32) as u8;
if bool_value {
dword_value |= 1u32 << bit_index;
} else {
dword_value &= !(1u32 << bit_index);
}
tracing::trace!(
"Modified BOOL[{}] in DWORD: 0x{:08X} -> 0x{:08X} (bit {} = {})",
index,
original_dword_value,
dword_value,
bit_index,
bool_value
);
let write_request = self.build_write_array_request_with_index(
base_array_name,
dword_index,
1,
values::BOOL_ARRAY_DWORD,
&dword_value.to_le_bytes(),
)?;
let write_response = self.send_cip_request(&write_request).await?;
let write_cip_data = self.extract_cip_from_response(&write_response)?;
self.check_cip_error(&write_cip_data)?;
tracing::info!("BOOL array element write completed successfully");
Ok(())
}
#[allow(dead_code)]
fn build_write_array_request(
&self,
tag_name: &str,
data_type: u16,
element_count: u16,
data: &[u8],
) -> crate::error::Result<Vec<u8>> {
let mut cip_request = Vec::new();
cip_request.push(0x4D);
let path = self.build_tag_path(tag_name);
cip_request.push((path.len() / 2) as u8);
cip_request.extend_from_slice(&path);
cip_request.extend_from_slice(&data_type.to_le_bytes());
cip_request.extend_from_slice(&element_count.to_le_bytes());
cip_request.extend_from_slice(data);
Ok(cip_request)
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn build_write_array_request_with_index(
&self,
base_array_name: &str,
start_index: u32,
element_count: u16,
data_type: u16,
data: &[u8],
) -> crate::error::Result<Vec<u8>> {
let mut cip_request = Vec::new();
cip_request.push(0x4D);
let mut full_path = self.build_base_tag_path(base_array_name);
full_path.extend_from_slice(&self.build_element_id_segment(start_index));
if !full_path.len().is_multiple_of(2) {
full_path.push(0x00);
}
let path_size = (full_path.len() / 2) as u8;
cip_request.push(path_size);
cip_request.extend_from_slice(&full_path);
cip_request.extend_from_slice(&data_type.to_le_bytes());
cip_request.extend_from_slice(&element_count.to_le_bytes());
cip_request.extend_from_slice(data);
Ok(cip_request)
}
pub async fn read_udt_chunked(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
self.validate_session().await?;
tracing::debug!("[CHUNKED] Starting advanced UDT reading for: {}", tag_name);
match self.read_tag(tag_name).await {
Ok(value) => {
tracing::debug!("[CHUNKED] Normal read successful");
return Ok(value);
}
Err(crate::error::EtherNetIpError::Protocol(msg))
if msg.contains("Partial transfer") =>
{
tracing::debug!("[CHUNKED] Partial transfer detected, using advanced chunking");
}
Err(e) => {
tracing::warn!("[CHUNKED] Normal read failed: {}", e);
return Err(e);
}
}
self.read_udt_advanced_chunked(tag_name).await
}
async fn read_udt_advanced_chunked(
&mut self,
tag_name: &str,
) -> crate::error::Result<PlcValue> {
tracing::debug!("[ADVANCED] Using multiple strategies for large UDT");
let chunk_sizes = vec![512, 256, 128, 64, 32, 16, 8, 4];
for chunk_size in chunk_sizes {
tracing::trace!("[ADVANCED] Trying chunk size: {}", chunk_size);
match self.read_udt_with_chunk_size(tag_name, chunk_size).await {
Ok(udt_value) => {
tracing::debug!("[ADVANCED] Success with chunk size {}", chunk_size);
return Ok(udt_value);
}
Err(e) => {
tracing::trace!("[ADVANCED] Chunk size {} failed: {}", chunk_size, e);
continue;
}
}
}
tracing::debug!("[ADVANCED] Trying member-by-member discovery");
match self.read_udt_member_discovery(tag_name).await {
Ok(udt_value) => {
tracing::debug!("[ADVANCED] Member discovery successful");
return Ok(udt_value);
}
Err(e) => {
tracing::warn!("[ADVANCED] Member discovery failed: {}", e);
}
}
tracing::debug!("[ADVANCED] Trying progressive reading");
match self.read_udt_progressive(tag_name).await {
Ok(udt_value) => {
tracing::debug!("[ADVANCED] Progressive reading successful");
return Ok(udt_value);
}
Err(e) => {
tracing::warn!("[ADVANCED] Progressive reading failed: {}", e);
}
}
tracing::warn!("[ADVANCED] All strategies failed, using fallback");
let symbol_id = self
.get_tag_attributes(tag_name)
.await
.ok()
.and_then(|attr| attr.template_instance_id)
.unwrap_or(0) as i32;
Ok(PlcValue::Udt(UdtData {
symbol_id,
data: vec![], }))
}
async fn read_udt_with_chunk_size(
&mut self,
tag_name: &str,
mut chunk_size: usize,
) -> crate::error::Result<PlcValue> {
let mut all_data = Vec::new();
let mut offset = 0;
let mut consecutive_failures = 0;
const MAX_FAILURES: usize = 3;
loop {
match self
.read_udt_chunk_advanced(tag_name, offset, chunk_size)
.await
{
Ok(chunk_data) => {
if chunk_data.is_empty() {
break; }
all_data.extend_from_slice(&chunk_data);
offset += chunk_data.len();
consecutive_failures = 0;
tracing::trace!(
"[CHUNK] Read {} bytes at offset {}, total: {}",
chunk_data.len(),
offset - chunk_data.len(),
all_data.len()
);
if chunk_data.len() < chunk_size {
break;
}
}
Err(e) => {
consecutive_failures += 1;
tracing::warn!(
"[CHUNK] Chunk read failed (attempt {}): {}",
consecutive_failures,
e
);
if consecutive_failures >= MAX_FAILURES {
break;
}
if chunk_size > 4 {
chunk_size /= 2;
continue;
}
}
}
}
if all_data.is_empty() {
return Err(crate::error::EtherNetIpError::Protocol(
"No data read from UDT".to_string(),
));
}
tracing::debug!("[CHUNK] Total data collected: {} bytes", all_data.len());
let symbol_id = self
.get_tag_attributes(tag_name)
.await
.ok()
.and_then(|attr| attr.template_instance_id)
.unwrap_or(0) as i32;
Ok(PlcValue::Udt(UdtData {
symbol_id,
data: all_data,
}))
}
async fn read_udt_chunk_advanced(
&mut self,
tag_name: &str,
offset: usize,
size: usize,
) -> crate::error::Result<Vec<u8>> {
let mut request = Vec::new();
request.push(0x4C);
let tag_path = self.build_tag_path(tag_name);
let path_size = (tag_path.len() / 2) as u8;
request.push(path_size);
request.extend_from_slice(&tag_path);
if offset > 0 {
request.push(0x28); request.push(0x02); request.extend_from_slice(&(offset as u16).to_le_bytes());
}
request.push(0x28); request.push(0x02); request.extend_from_slice(&(size as u16).to_le_bytes());
request.push(0x00);
request.push(0x01);
let response = self.send_cip_request(&request).await?;
let cip_data = self.extract_cip_from_response(&response)?;
if cip_data.len() < 2 {
return Ok(Vec::new()); }
let _data_type = u16::from_le_bytes([cip_data[0], cip_data[1]]);
let data = &cip_data[2..];
Ok(data.to_vec())
}
async fn read_udt_member_discovery(
&mut self,
tag_name: &str,
) -> crate::error::Result<PlcValue> {
tracing::debug!("[DISCOVERY] Reading UDT as raw data for: {}", tag_name);
let attributes = self.get_tag_attributes(tag_name).await?;
let symbol_id = attributes.template_instance_id.ok_or_else(|| {
crate::error::EtherNetIpError::Protocol(
"UDT template instance ID not found in tag attributes".to_string(),
)
})?;
let raw_data = self.read_tag_raw(tag_name).await?;
tracing::debug!(
"[DISCOVERY] Read {} bytes of UDT data with symbol_id: {}",
raw_data.len(),
symbol_id
);
Ok(PlcValue::Udt(UdtData {
symbol_id: symbol_id as i32,
data: raw_data,
}))
}
async fn read_udt_progressive(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
tracing::debug!("[PROGRESSIVE] Starting progressive reading");
let mut chunk_size = 4;
let mut all_data = Vec::new();
let mut offset = 0;
while chunk_size <= 512 {
match self
.read_udt_chunk_advanced(tag_name, offset, chunk_size)
.await
{
Ok(chunk_data) => {
if chunk_data.is_empty() {
break;
}
all_data.extend_from_slice(&chunk_data);
offset += chunk_data.len();
tracing::trace!(
"[PROGRESSIVE] Read {} bytes with chunk size {}",
chunk_data.len(),
chunk_size
);
if chunk_data.len() == chunk_size {
chunk_size = (chunk_size * 2).min(512);
}
}
Err(_) => {
chunk_size /= 2;
if chunk_size < 4 {
break;
}
}
}
}
if all_data.is_empty() {
return Err(crate::error::EtherNetIpError::Protocol(
"Progressive reading failed".to_string(),
));
}
tracing::debug!("[PROGRESSIVE] Collected {} bytes total", all_data.len());
let symbol_id = self
.get_tag_attributes(tag_name)
.await
.ok()
.and_then(|attr| attr.template_instance_id)
.unwrap_or(0) as i32;
Ok(PlcValue::Udt(UdtData {
symbol_id,
data: all_data,
}))
}
#[allow(dead_code)]
async fn read_udt_in_chunks(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
const MAX_CHUNK_SIZE: usize = 1000; let mut all_data = Vec::new();
let mut offset = 0;
let mut chunk_size = MAX_CHUNK_SIZE;
loop {
match self.read_udt_chunk(tag_name, offset, chunk_size).await {
Ok(chunk_data) => {
all_data.extend_from_slice(&chunk_data);
offset += chunk_data.len();
if chunk_data.len() < chunk_size {
break;
}
}
Err(crate::error::EtherNetIpError::Protocol(msg))
if msg.contains("Partial transfer") =>
{
chunk_size /= 2;
if chunk_size < 100 {
return Err(crate::error::EtherNetIpError::Protocol(
"UDT too large even for smallest chunk size".to_string(),
));
}
continue;
}
Err(e) => return Err(e),
}
}
let symbol_id = self
.get_tag_attributes(tag_name)
.await
.ok()
.and_then(|attr| attr.template_instance_id)
.unwrap_or(0) as i32;
Ok(PlcValue::Udt(UdtData {
symbol_id,
data: all_data,
}))
}
#[allow(dead_code)]
async fn read_udt_chunk(
&mut self,
tag_name: &str,
offset: usize,
size: usize,
) -> crate::error::Result<Vec<u8>> {
let mut request = Vec::new();
request.push(0x4C);
let path_size = 2 + tag_name.len().div_ceil(2); request.push(path_size as u8);
request.extend_from_slice(tag_name.as_bytes());
if !tag_name.len().is_multiple_of(2) {
request.push(0); }
request.push(0x28); request.push(0x02); request.extend_from_slice(&(offset as u16).to_le_bytes());
request.push(0x28); request.push(0x02); request.extend_from_slice(&(size as u16).to_le_bytes());
request.push(0x00);
request.push(0x01);
let response = self.send_cip_request(&request).await?;
let cip_data = self.extract_cip_from_response(&response)?;
if cip_data.len() < 2 {
return Err(crate::error::EtherNetIpError::Protocol(
"Response too short".to_string(),
));
}
let _data_type = u16::from_le_bytes([cip_data[0], cip_data[1]]);
let data = &cip_data[2..];
Ok(data.to_vec())
}
pub async fn read_udt_member_by_offset(
&mut self,
udt_name: &str,
member_offset: usize,
member_size: usize,
data_type: u16,
) -> crate::error::Result<PlcValue> {
self.validate_session().await?;
let udt_data = self.read_tag_raw(udt_name).await?;
if member_offset + member_size > udt_data.len() {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Member data incomplete: offset {} + size {} > UDT size {}",
member_offset,
member_size,
udt_data.len()
)));
}
let member_data = &udt_data[member_offset..member_offset + member_size];
let member = crate::udt::UdtMember {
name: "temp".to_string(),
data_type,
offset: member_offset as u32,
size: member_size as u32,
};
let udt = crate::udt::UserDefinedType::new(udt_name.to_string());
Ok(udt.parse_member_value(&member, member_data)?)
}
pub async fn write_udt_member_by_offset(
&mut self,
udt_name: &str,
member_offset: usize,
member_size: usize,
data_type: u16,
value: PlcValue,
) -> crate::error::Result<()> {
self.validate_session().await?;
let mut udt_data = self.read_tag_raw(udt_name).await?;
if member_offset + member_size > udt_data.len() {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Member data incomplete: offset {} + size {} > UDT size {}",
member_offset,
member_size,
udt_data.len()
)));
}
let member = crate::udt::UdtMember {
name: "temp".to_string(),
data_type,
offset: member_offset as u32,
size: member_size as u32,
};
let udt = crate::udt::UserDefinedType::new(udt_name.to_string());
let member_data = udt.serialize_member_value(&member, &value)?;
let end_offset = member_offset + member_data.len();
if end_offset <= udt_data.len() {
udt_data[member_offset..end_offset].copy_from_slice(&member_data);
} else {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Member data exceeds UDT size: {} > {}",
end_offset,
udt_data.len()
)));
}
self.write_tag_raw(udt_name, &udt_data).await
}
pub async fn get_udt_definition(
&mut self,
udt_name: &str,
) -> crate::error::Result<UdtDefinition> {
if let Some(cached) = self.udt_manager.lock().await.get_definition(udt_name) {
return Ok(cached.clone());
}
let attributes = self.get_tag_attributes(udt_name).await?;
if attributes.data_type != 0x00A0 {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Tag '{}' is not a UDT (type: {})",
udt_name, attributes.data_type_name
)));
}
let template_id = attributes.template_instance_id.ok_or_else(|| {
crate::error::EtherNetIpError::Protocol(
"UDT template instance ID not found".to_string(),
)
})?;
let (definition, _structure_size_bytes) = self
.load_udt_definition_from_template(template_id, udt_name)
.await?;
Ok(definition)
}
async fn get_udt_definition_by_template_id(
&mut self,
template_id: u32,
udt_name: &str,
) -> crate::error::Result<(UdtDefinition, u32)> {
if let Some(cached) = self.udt_manager.lock().await.get_definition(udt_name) {
return Ok((cached.clone(), 0));
}
self.load_udt_definition_from_template(template_id, udt_name)
.await
}
async fn load_udt_definition_from_template(
&mut self,
template_id: u32,
udt_name: &str,
) -> crate::error::Result<(UdtDefinition, u32)> {
let (template_attributes, template_data) = self.read_udt_template(template_id).await?;
let template = self.udt_manager.lock().await.parse_udt_template(
template_id,
template_attributes.member_count,
template_attributes.structure_size_bytes,
&template_data,
)?;
let definition = UdtDefinition {
name: udt_name.to_string(),
members: template.members,
};
self.udt_manager
.lock()
.await
.add_definition(definition.clone());
Ok((definition, template_attributes.structure_size_bytes))
}
pub async fn get_tag_attributes(
&mut self,
tag_name: &str,
) -> crate::error::Result<TagAttributes> {
if let Some(cached) = self.udt_manager.lock().await.get_tag_attributes(tag_name) {
return Ok(cached.clone());
}
let request = self.build_get_attributes_request(tag_name)?;
let response = self.send_cip_request(&request).await?;
let cip_data = self.extract_cip_from_response(&response)?;
let attributes = self.parse_attributes_response(tag_name, &cip_data)?;
self.udt_manager
.lock()
.await
.add_tag_attributes(attributes.clone());
Ok(attributes)
}
async fn read_udt_template(
&mut self,
template_id: u32,
) -> crate::error::Result<(TemplateAttributes, Vec<u8>)> {
let template_attributes = self.get_template_attributes(template_id).await?;
let read_size = template_attributes
.definition_size_words
.checked_mul(4)
.and_then(|bytes| bytes.checked_sub(23))
.ok_or_else(|| {
crate::error::EtherNetIpError::Protocol(format!(
"Template {} reported invalid definition size {} words",
template_id, template_attributes.definition_size_words
))
})?;
let mut template_data = Vec::with_capacity(read_size as usize);
let mut offset = 0u32;
while offset < read_size {
let chunk_size = (read_size - offset).min(200);
let request = self.build_read_template_request(template_id, offset, chunk_size)?;
let response = self.send_cip_request(&request).await?;
let cip_data = self.extract_cip_from_response(&response)?;
let (chunk, partial_transfer) = self.parse_template_response_chunk(&cip_data)?;
if chunk.is_empty() {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Template {} returned an empty chunk at offset {}",
template_id, offset
)));
}
offset = offset.saturating_add(chunk.len() as u32);
template_data.extend_from_slice(&chunk);
if !partial_transfer && chunk.len() < chunk_size as usize {
break;
}
}
Ok((template_attributes, template_data))
}
async fn get_template_attributes(
&mut self,
template_id: u32,
) -> crate::error::Result<TemplateAttributes> {
let request = self.build_get_template_attributes_request(template_id)?;
let response = self.send_cip_request(&request).await?;
let cip_data = self.extract_cip_from_response(&response)?;
self.parse_template_attributes_response(template_id, &cip_data)
}
fn build_get_attributes_request(&self, tag_name: &str) -> crate::error::Result<Vec<u8>> {
let mut request = Vec::new();
request.push(0x03);
let tag_bytes = tag_name.as_bytes();
request.push(0x91); request.push(tag_bytes.len() as u8);
request.extend_from_slice(tag_bytes);
request.extend_from_slice(&[0x02, 0x00]);
request.extend_from_slice(&[0x01, 0x00]);
request.extend_from_slice(&[0x02, 0x00]);
Ok(request)
}
fn build_get_template_attributes_request(
&self,
template_id: u32,
) -> crate::error::Result<Vec<u8>> {
let mut request = Vec::new();
let template_id = u16::try_from(template_id).map_err(|_| {
crate::error::EtherNetIpError::Protocol(format!(
"Template instance {} exceeds 16-bit path encoding",
template_id
))
})?;
request.push(0x03);
request.push(0x03);
request.extend_from_slice(&[0x20, 0x6C, 0x25, 0x00]);
request.extend_from_slice(&template_id.to_le_bytes());
request.extend_from_slice(&[0x04, 0x00]);
request.extend_from_slice(&[0x01, 0x00]);
request.extend_from_slice(&[0x02, 0x00]);
request.extend_from_slice(&[0x04, 0x00]);
request.extend_from_slice(&[0x05, 0x00]);
Ok(request)
}
fn build_read_template_request(
&self,
template_id: u32,
read_offset: u32,
read_size: u32,
) -> crate::error::Result<Vec<u8>> {
let mut request = Vec::new();
let template_id = u16::try_from(template_id).map_err(|_| {
crate::error::EtherNetIpError::Protocol(format!(
"Template instance {} exceeds 16-bit path encoding",
template_id
))
})?;
let read_size = u16::try_from(read_size).map_err(|_| {
crate::error::EtherNetIpError::Protocol(format!(
"Template read size {} exceeds 16-bit service limit",
read_size
))
})?;
request.push(0x4C);
request.push(0x03);
request.extend_from_slice(&[0x20, 0x6C, 0x25, 0x00]);
request.extend_from_slice(&template_id.to_le_bytes());
request.extend_from_slice(&read_offset.to_le_bytes());
request.extend_from_slice(&read_size.to_le_bytes());
Ok(request)
}
fn parse_attributes_response(
&self,
tag_name: &str,
response: &[u8],
) -> crate::error::Result<TagAttributes> {
if response.len() < 8 {
return Err(crate::error::EtherNetIpError::Protocol(
"Attributes response too short".to_string(),
));
}
let mut offset = 0;
let data_type = u16::from_le_bytes([response[offset], response[offset + 1]]);
offset += 2;
let size = u32::from_le_bytes([
response[offset],
response[offset + 1],
response[offset + 2],
response[offset + 3],
]);
offset += 4;
let template_instance_id = if response.len() > offset + 4 {
Some(u32::from_le_bytes([
response[offset],
response[offset + 1],
response[offset + 2],
response[offset + 3],
]))
} else {
None
};
let attributes = TagAttributes {
name: tag_name.to_string(),
data_type,
data_type_name: self.get_data_type_name(data_type),
dimensions: Vec::new(), permissions: udt::TagPermissions::ReadWrite, scope: if tag_name.contains(':') {
let parts: Vec<&str> = tag_name.split(':').collect();
if parts.len() >= 2 {
udt::TagScope::Program(parts[0].to_string())
} else {
udt::TagScope::Controller
}
} else {
udt::TagScope::Controller
},
template_instance_id,
size,
};
Ok(attributes)
}
fn parse_template_attributes_response(
&self,
template_id: u32,
response: &[u8],
) -> crate::error::Result<TemplateAttributes> {
if response.len() < 4 {
return Err(crate::error::EtherNetIpError::Protocol(
"Template attribute response too short".to_string(),
));
}
let general_status = response[2];
if general_status != 0x00 {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Template {} attribute read failed: {}",
template_id,
self.get_cip_error_message(general_status)
)));
}
let additional_status_words = response[3] as usize;
let mut offset = 4 + additional_status_words * 2;
if response.len() < offset + 2 {
return Err(crate::error::EtherNetIpError::Protocol(
"Template attribute response missing attribute count".to_string(),
));
}
let attr_count = u16::from_le_bytes([response[offset], response[offset + 1]]) as usize;
offset += 2;
let mut attributes = TemplateAttributes {
structure_handle: 0,
member_count: 0,
definition_size_words: 0,
structure_size_bytes: 0,
};
for _ in 0..attr_count {
if response.len() < offset + 4 {
return Err(crate::error::EtherNetIpError::Protocol(
"Template attribute response truncated".to_string(),
));
}
let attr_id = u16::from_le_bytes([response[offset], response[offset + 1]]);
let attr_status = u16::from_le_bytes([response[offset + 2], response[offset + 3]]);
offset += 4;
if attr_status != 0 {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Template {} attribute {} read returned status 0x{:04X}",
template_id, attr_id, attr_status
)));
}
match attr_id {
1 => {
if response.len() < offset + 2 {
return Err(crate::error::EtherNetIpError::Protocol(
"Template attribute 1 missing value".to_string(),
));
}
attributes.structure_handle =
u16::from_le_bytes([response[offset], response[offset + 1]]);
offset += 2;
}
2 => {
if response.len() < offset + 2 {
return Err(crate::error::EtherNetIpError::Protocol(
"Template attribute 2 missing value".to_string(),
));
}
attributes.member_count =
u16::from_le_bytes([response[offset], response[offset + 1]]);
offset += 2;
}
4 => {
if response.len() < offset + 4 {
return Err(crate::error::EtherNetIpError::Protocol(
"Template attribute 4 missing value".to_string(),
));
}
attributes.definition_size_words = u32::from_le_bytes([
response[offset],
response[offset + 1],
response[offset + 2],
response[offset + 3],
]);
offset += 4;
}
5 => {
if response.len() < offset + 4 {
return Err(crate::error::EtherNetIpError::Protocol(
"Template attribute 5 missing value".to_string(),
));
}
attributes.structure_size_bytes = u32::from_le_bytes([
response[offset],
response[offset + 1],
response[offset + 2],
response[offset + 3],
]);
offset += 4;
}
_ => {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Unexpected template attribute {} in response",
attr_id
)));
}
}
}
if attributes.definition_size_words == 0 {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Template {} reported zero definition size",
template_id
)));
}
Ok(attributes)
}
fn parse_template_response_chunk(
&self,
response: &[u8],
) -> crate::error::Result<(Vec<u8>, bool)> {
if response.len() < 4 {
return Err(crate::error::EtherNetIpError::Protocol(
"Template response too short".to_string(),
));
}
let general_status = response[2];
let partial_transfer = general_status == 0x06;
if general_status != 0x00 && !partial_transfer {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Template read failed: {}",
self.get_cip_error_message(general_status)
)));
}
let additional_status_words = response[3] as usize;
let data_start = 4 + additional_status_words * 2;
if data_start > response.len() {
return Err(crate::error::EtherNetIpError::Protocol(
"Template response missing payload".to_string(),
));
}
Ok((response[data_start..].to_vec(), partial_transfer))
}
fn get_data_type_name(&self, data_type: u16) -> String {
match data_type {
0x00C1 => "BOOL".to_string(),
0x00C2 => "SINT".to_string(),
0x00C3 => "INT".to_string(),
0x00C4 => "DINT".to_string(),
0x00C5 => "LINT".to_string(),
0x00C6 => "USINT".to_string(),
0x00C7 => "UINT".to_string(),
0x00C8 => "UDINT".to_string(),
0x00C9 => "ULINT".to_string(),
0x00CA => "REAL".to_string(),
0x00CB => "LREAL".to_string(),
0x00CE => "STRING".to_string(),
0x00A0 => "UDT".to_string(),
_ => format!("UNKNOWN(0x{:04X})", data_type),
}
}
fn build_tag_list_request_from_instance(
&self,
start_instance: u32,
) -> crate::error::Result<Vec<u8>> {
let start_instance = u16::try_from(start_instance).map_err(|_| {
crate::error::EtherNetIpError::Protocol(format!(
"Tag discovery start instance {} exceeds 16-bit Symbol Object range",
start_instance
))
})?;
let mut request = vec![
0x55, 0x03, 0x20, 0x6B, 0x25, 0x00,
];
request.extend_from_slice(&start_instance.to_le_bytes());
request.extend_from_slice(&[0x02, 0x00]);
request.extend_from_slice(&[0x01, 0x00]);
request.extend_from_slice(&[0x02, 0x00]);
Ok(request)
}
fn build_program_tag_list_request(&self, _program_name: &str) -> crate::error::Result<Vec<u8>> {
let mut request = vec![
0x55, 0x03, 0x20, 0x6C, 0x25,
];
request.extend_from_slice(&[0x00, 0x00, 0x00]);
request.extend_from_slice(&[0x02, 0x00]);
request.extend_from_slice(&[0x01, 0x00]);
request.extend_from_slice(&[0x02, 0x00]);
Ok(request)
}
fn parse_tag_list_response_page(&self, response: &[u8]) -> crate::error::Result<TagListPage> {
if response.len() < 4 {
return Err(crate::error::EtherNetIpError::Protocol(
"Tag list response too short".to_string(),
));
}
let general_status = response[2];
let partial_transfer = general_status == 0x06;
if general_status != 0x00 && !partial_transfer {
return Err(crate::error::EtherNetIpError::Protocol(format!(
"Tag discovery failed: {}. Some PLCs may not support tag discovery. Try reading tags directly by name.",
self.get_cip_error_message(general_status)
)));
}
let additional_status_words = response[3] as usize;
let mut offset = 4 + additional_status_words * 2;
if response.len() == offset {
return Ok(TagListPage {
tags: Vec::new(),
last_instance_id: None,
partial_transfer: false,
});
}
if response.len() < offset + 4 {
return Err(crate::error::EtherNetIpError::Protocol(
"Tag list response missing first entry".to_string(),
));
}
let mut tags = Vec::new();
let mut last_instance_id = None;
while offset + 8 <= response.len() {
let instance_id = u32::from_le_bytes([
response[offset],
response[offset + 1],
response[offset + 2],
response[offset + 3],
]);
last_instance_id = Some(instance_id);
offset += 4;
let name_length = u16::from_le_bytes([response[offset], response[offset + 1]]) as usize;
offset += 2;
if offset
.checked_add(name_length)
.is_none_or(|end| end > response.len())
{
break;
}
let name_bytes = &response[offset..offset + name_length];
let tag_name = String::from_utf8_lossy(name_bytes).to_string();
offset += name_length;
if offset + 2 > response.len() {
break;
}
let raw_tag_type = u16::from_le_bytes([response[offset], response[offset + 1]]);
offset += 2;
if tag_name.starts_with("__") || tag_name.contains(':') {
continue;
}
let array_dims = ((raw_tag_type & 0x6000) >> 13) as usize;
let is_structure = (raw_tag_type & 0x8000) != 0;
let reserved = (raw_tag_type & 0x1000) != 0;
let type_param = raw_tag_type & 0x0FFF;
let is_user_atomic =
!is_structure && !reserved && (0x0001..=0x00FF).contains(&type_param);
let is_user_structure =
is_structure && !reserved && (0x0100..=0x0EFF).contains(&type_param);
if !is_user_atomic && !is_user_structure {
continue;
}
let data_type = if is_structure {
0x00A0
} else if (raw_tag_type & 0x00FF) == 0x00C1 {
0x00C1
} else {
type_param
};
let template_instance_id = if is_structure && !reserved {
Some(type_param as u32)
} else {
None
};
tags.push(TagAttributes {
name: tag_name,
data_type,
data_type_name: if is_structure {
"UDT".to_string()
} else {
self.get_data_type_name(data_type)
},
dimensions: vec![0; array_dims],
permissions: udt::TagPermissions::ReadWrite,
scope: udt::TagScope::Controller,
template_instance_id,
size: 0,
});
}
Ok(TagListPage {
tags,
last_instance_id,
partial_transfer,
})
}
fn parse_tag_list_response(&self, response: &[u8]) -> crate::error::Result<Vec<TagAttributes>> {
Ok(self.parse_tag_list_response_page(response)?.tags)
}
async fn negotiate_packet_size(&mut self) -> crate::error::Result<()> {
let mut request = vec![
0x03, 0x02, 0x20, 0x02, 0x24, 0x01, ];
request.extend_from_slice(&[0x01, 0x00]); request.extend_from_slice(&[0x04, 0x00]);
let response = self.send_cip_request(&request).await?;
let cip_data = self.extract_cip_from_response(&response)?;
if cip_data.len() >= 12 && cip_data[2] == 0x00 {
let max_packet_size = u16::from_le_bytes([cip_data[10], cip_data[11]]) as u32;
self.max_packet_size
.store(max_packet_size.clamp(504, 4000), Ordering::Relaxed);
tracing::debug!("Negotiated packet size: {} bytes", self.max_packet_size());
} else {
self.max_packet_size.store(4000, Ordering::Relaxed);
tracing::debug!(
"Using default packet size: {} bytes",
self.max_packet_size()
);
}
Ok(())
}
pub async fn write_tag(&mut self, tag_name: &str, value: PlcValue) -> crate::error::Result<()> {
tracing::debug!(
"Writing '{}' to tag '{}'",
match &value {
PlcValue::String(s) => format!("\"{s}\""),
_ => format!("{value:?}"),
},
tag_name
);
let value = if let PlcValue::Udt(udt_data) = &value {
if udt_data.symbol_id == 0 {
tracing::debug!("[UDT WRITE] symbol_id is 0, reading tag to get symbol_id");
let attributes = self.get_tag_attributes(tag_name).await?;
let symbol_id = attributes.template_instance_id.ok_or_else(|| {
crate::error::EtherNetIpError::Protocol(
"UDT template instance ID not found. Cannot write UDT without symbol_id."
.to_string(),
)
})? as i32;
PlcValue::Udt(UdtData {
symbol_id,
data: udt_data.data.clone(),
})
} else {
value
}
} else {
value
};
if let Some((base_name, index)) = self.parse_array_element_access(tag_name) {
tracing::debug!(
"Detected array element write: {}[{}], using workaround",
base_name,
index
);
return self
.write_array_element_workaround(&base_name, index, value)
.await;
}
if let PlcValue::Bool(_) = value
&& let Some((parent_path, index)) = self.parse_final_array_element_access(tag_name)
&& self.detect_bool_array_path(&parent_path).await?
{
return self
.write_bool_array_element_workaround(&parent_path, index, value)
.await;
}
let cip_request = self.build_write_request(tag_name, &value)?;
let response = self.send_cip_request(&cip_request).await?;
let cip_response = self.extract_cip_from_response(&response)?;
if cip_response.len() < 3 {
return Err(EtherNetIpError::Protocol(
"Write response too short".to_string(),
));
}
let service_reply = cip_response[0]; let general_status = cip_response[2];
tracing::trace!(
"Write response - Service: 0x{:02X}, Status: 0x{:02X}",
service_reply,
general_status
);
if let Err(e) = self.check_cip_error(&cip_response) {
tracing::error!("[WRITE] CIP Error: {}", e);
return Err(e);
}
tracing::info!("Write operation completed successfully");
Ok(())
}
fn _build_ab_string_write_request(
&self,
tag_name: &str,
value: &PlcValue,
) -> crate::error::Result<Vec<u8>> {
if let PlcValue::String(string_value) = value {
tracing::debug!(
"Building correct Allen-Bradley string write request for tag: '{}'",
tag_name
);
let mut cip_request = Vec::new();
cip_request.push(0x4D);
let tag_bytes = tag_name.as_bytes();
let path_len = if tag_bytes.len().is_multiple_of(2) {
tag_bytes.len() + 2
} else {
tag_bytes.len() + 3
} / 2;
cip_request.push(path_len as u8);
cip_request.push(0x91); cip_request.push(tag_bytes.len() as u8);
cip_request.extend_from_slice(tag_bytes);
if !tag_bytes.len().is_multiple_of(2) {
cip_request.push(0x00);
}
cip_request.extend_from_slice(&[0xA0, 0x02]);
cip_request.extend_from_slice(&[0x01, 0x00]);
let string_bytes = string_value.as_bytes();
let max_len: u16 = 82; let current_len = string_bytes.len().min(max_len as usize) as u16;
cip_request.extend_from_slice(¤t_len.to_le_bytes());
cip_request.extend_from_slice(&max_len.to_le_bytes());
let mut data_array = vec![0u8; max_len as usize];
data_array[..current_len as usize]
.copy_from_slice(&string_bytes[..current_len as usize]);
cip_request.extend_from_slice(&data_array);
tracing::trace!(
"Built correct AB string write request ({} bytes): len={}, maxlen={}, data_len={}",
cip_request.len(),
current_len,
max_len,
string_bytes.len()
);
tracing::trace!(
"First 32 bytes: {:02X?}",
&cip_request[..std::cmp::min(32, cip_request.len())]
);
Ok(cip_request)
} else {
Err(EtherNetIpError::Protocol(
"Expected string value for Allen-Bradley string write".to_string(),
))
}
}
fn build_write_request(
&self,
tag_name: &str,
value: &PlcValue,
) -> crate::error::Result<Vec<u8>> {
tracing::debug!("Building write request for tag: '{}'", tag_name);
let path = self.build_tag_path(tag_name);
let mut data = BytesMut::new();
data.extend_from_slice(&values::write_data_type(value).to_le_bytes());
data.extend_from_slice(&[0x01, 0x00]); values::encode_payload(value, &mut data);
let request = CipRequest::new(WRITE_TAG, path, data.to_vec());
let mut cip_request = BytesMut::new();
request.encode(&mut cip_request)?;
tracing::trace!(
"Built CIP write request ({} bytes): {:02X?}",
cip_request.len(),
cip_request
);
Ok(cip_request.to_vec())
}
fn build_write_request_raw(
&self,
tag_name: &str,
data: &[u8],
) -> crate::error::Result<Vec<u8>> {
let path = self.build_tag_path(tag_name);
let request = CipRequest::new(WRITE_TAG, path, data.to_vec());
let mut cip_request = BytesMut::new();
request.encode(&mut cip_request)?;
Ok(cip_request.to_vec())
}
#[allow(dead_code)]
fn serialize_value(&self, value: &PlcValue) -> crate::error::Result<Vec<u8>> {
let mut data = BytesMut::new();
value.encode(&mut data);
Ok(data.to_vec())
}
pub fn build_list_tags_request(&self) -> Vec<u8> {
tracing::debug!("Building list tags request");
let path_array = vec![
0x20, 0x6B, 0x25, 0x00, 0x00, 0x00,
];
let request_data = vec![0x02, 0x00, 0x01, 0x00, 0x02, 0x00];
let request = CipRequest::new(0x55, path_array, request_data);
let mut cip_request = BytesMut::new();
request
.encode(&mut cip_request)
.expect("list-tags request path is static and valid");
tracing::trace!(
"Built CIP list tags request ({} bytes): {:02X?}",
cip_request.len(),
cip_request
);
cip_request.to_vec()
}
fn parse_extended_error(&self, cip_data: &[u8]) -> crate::error::Result<String> {
if cip_data.len() < 6 {
return Err(EtherNetIpError::Protocol(
"Extended error response too short".to_string(),
));
}
let additional_status_size = cip_data[3] as usize; if additional_status_size == 0 || cip_data.len() < 4 + (additional_status_size * 2) {
return Ok("Extended error (no additional status)".to_string());
}
let extended_error_code_le = u16::from_le_bytes([cip_data[4], cip_data[5]]);
let extended_error_code_be = u16::from_be_bytes([cip_data[4], cip_data[5]]);
let error_msg = match extended_error_code_le {
0x0001 => "Connection failure (extended)".to_string(),
0x0002 => "Resource unavailable (extended)".to_string(),
0x0003 => "Invalid parameter value (extended)".to_string(),
0x0004 => "Path segment error (extended)".to_string(),
0x0005 => "Path destination unknown (extended)".to_string(),
0x0006 => "Partial transfer (extended)".to_string(),
0x0007 => "Connection lost (extended)".to_string(),
0x0008 => "Service not supported (extended)".to_string(),
0x0009 => "Invalid attribute value (extended)".to_string(),
0x000A => "Attribute list error (extended)".to_string(),
0x000B => "Already in requested mode/state (extended)".to_string(),
0x000C => "Object state conflict (extended)".to_string(),
0x000D => "Object already exists (extended)".to_string(),
0x000E => "Attribute not settable (extended)".to_string(),
0x000F => "Privilege violation (extended)".to_string(),
0x0010 => "Device state conflict (extended)".to_string(),
0x0011 => "Reply data too large (extended)".to_string(),
0x0012 => "Fragmentation of a primitive value (extended)".to_string(),
0x0013 => "Not enough data (extended)".to_string(),
0x0014 => "Attribute not supported (extended)".to_string(),
0x0015 => "Too much data (extended)".to_string(),
0x0016 => "Object does not exist (extended)".to_string(),
0x0017 => "Service fragmentation sequence not in progress (extended)".to_string(),
0x0018 => "No stored attribute data (extended)".to_string(),
0x0019 => "Store operation failure (extended)".to_string(),
0x001A => "Routing failure, request packet too large (extended)".to_string(),
0x001B => "Routing failure, response packet too large (extended)".to_string(),
0x001C => "Missing attribute list entry data (extended)".to_string(),
0x001D => "Invalid attribute value list (extended)".to_string(),
0x001E => "Embedded service error (extended)".to_string(),
0x001F => "Vendor specific error (extended)".to_string(),
0x0020 => "Invalid parameter (extended)".to_string(),
0x0021 => "Write-once value or medium already written (extended)".to_string(),
0x0022 => "Invalid reply received (extended)".to_string(),
0x0023 => "Buffer overflow (extended)".to_string(),
0x0024 => "Invalid message format (extended)".to_string(),
0x0025 => "Key failure in path (extended)".to_string(),
0x0026 => "Path size invalid (extended)".to_string(),
0x0027 => "Unexpected attribute in list (extended)".to_string(),
0x0028 => "Invalid member ID (extended)".to_string(),
0x0029 => "Member not settable (extended)".to_string(),
0x002A => "Group 2 only server general failure (extended)".to_string(),
0x002B => "Unknown Modbus error (extended)".to_string(),
0x002C => "Attribute not gettable (extended)".to_string(),
_ => {
match extended_error_code_be {
0x0001 => "Connection failure (extended, BE)".to_string(),
0x0002 => "Resource unavailable (extended, BE)".to_string(),
0x0003 => "Invalid parameter value (extended, BE)".to_string(),
0x0004 => "Path segment error (extended, BE)".to_string(),
0x0005 => "Path destination unknown (extended, BE)".to_string(),
0x0006 => "Partial transfer (extended, BE)".to_string(),
0x0007 => "Connection lost (extended, BE)".to_string(),
0x0008 => "Service not supported (extended, BE)".to_string(),
0x0009 => "Invalid attribute value (extended, BE)".to_string(),
0x000A => "Attribute list error (extended, BE)".to_string(),
0x000B => "Already in requested mode/state (extended, BE)".to_string(),
0x000C => "Object state conflict (extended, BE)".to_string(),
0x000D => "Object already exists (extended, BE)".to_string(),
0x000E => "Attribute not settable (extended, BE)".to_string(),
0x000F => "Privilege violation (extended, BE)".to_string(),
0x0010 => "Device state conflict (extended, BE)".to_string(),
0x0011 => "Reply data too large (extended, BE)".to_string(),
0x0012 => "Fragmentation of a primitive value (extended, BE)".to_string(),
0x0013 => "Not enough data (extended, BE)".to_string(),
0x0014 => "Attribute not supported (extended, BE)".to_string(),
0x0015 => "Too much data (extended, BE)".to_string(),
0x0016 => "Object does not exist (extended, BE)".to_string(),
0x0017 => {
"Service fragmentation sequence not in progress (extended, BE)".to_string()
}
0x0018 => "No stored attribute data (extended, BE)".to_string(),
0x0019 => "Store operation failure (extended, BE)".to_string(),
0x001A => {
"Routing failure, request packet too large (extended, BE)".to_string()
}
0x001B => {
"Routing failure, response packet too large (extended, BE)".to_string()
}
0x001C => "Missing attribute list entry data (extended, BE)".to_string(),
0x001D => "Invalid attribute value list (extended, BE)".to_string(),
0x001E => "Embedded service error (extended, BE)".to_string(),
0x001F => "Vendor specific error (extended, BE)".to_string(),
0x0020 => "Invalid parameter (extended, BE)".to_string(),
0x0021 => {
"Write-once value or medium already written (extended, BE)".to_string()
}
0x0022 => "Invalid reply received (extended, BE)".to_string(),
0x0023 => "Buffer overflow (extended, BE)".to_string(),
0x0024 => "Invalid message format (extended, BE)".to_string(),
0x0025 => "Key failure in path (extended, BE)".to_string(),
0x0026 => "Path size invalid (extended, BE)".to_string(),
0x0027 => "Unexpected attribute in list (extended, BE)".to_string(),
0x0028 => "Invalid member ID (extended, BE)".to_string(),
0x0029 => "Member not settable (extended, BE)".to_string(),
0x002A => "Group 2 only server general failure (extended, BE)".to_string(),
0x002B => "Unknown Modbus error (extended, BE)".to_string(),
0x002C => "Attribute not gettable (extended, BE)".to_string(),
_ if extended_error_code_le == 0x2107 || extended_error_code_be == 0x2107 => {
format!(
"Vendor-specific or composite extended error: 0x{extended_error_code_le:04X} (LE) / 0x{extended_error_code_be:04X} (BE). Raw bytes: [0x{:02X}, 0x{:02X}]. This may indicate the PLC does not support writing to UDT array element members directly.",
cip_data[4], cip_data[5]
)
}
_ => format!(
"Unknown extended CIP error code: 0x{extended_error_code_le:04X} (LE) / 0x{extended_error_code_be:04X} (BE). Raw bytes: [0x{:02X}, 0x{:02X}]",
cip_data[4], cip_data[5]
),
}
}
};
Ok(error_msg)
}
fn check_cip_error(&self, cip_data: &[u8]) -> crate::error::Result<()> {
if cip_data.len() < 3 {
return Err(EtherNetIpError::Protocol(
"CIP response too short for status check".to_string(),
));
}
let general_status = cip_data[2];
if general_status == 0x00 {
return Ok(());
}
if general_status == 0xFF {
let error_msg = self.parse_extended_error(cip_data)?;
return Err(EtherNetIpError::Protocol(format!(
"CIP Extended Error: {error_msg}"
)));
}
let error_msg = self.get_cip_error_message(general_status);
Err(EtherNetIpError::Protocol(format!(
"CIP Error 0x{general_status:02X}: {error_msg}"
)))
}
fn get_cip_error_message(&self, status: u8) -> String {
match status {
0x00 => "Success".to_string(),
0x01 => "Connection failure".to_string(),
0x02 => "Resource unavailable".to_string(),
0x03 => "Invalid parameter value".to_string(),
0x04 => "Path segment error".to_string(),
0x05 => "Path destination unknown".to_string(),
0x06 => "Partial transfer".to_string(),
0x07 => "Connection lost".to_string(),
0x08 => "Service not supported".to_string(),
0x09 => "Invalid attribute value".to_string(),
0x0A => "Attribute list error".to_string(),
0x0B => "Already in requested mode/state".to_string(),
0x0C => "Object state conflict".to_string(),
0x0D => "Object already exists".to_string(),
0x0E => "Attribute not settable".to_string(),
0x0F => "Privilege violation".to_string(),
0x10 => "Device state conflict".to_string(),
0x11 => "Reply data too large".to_string(),
0x12 => "Fragmentation of a primitive value".to_string(),
0x13 => "Not enough data".to_string(),
0x14 => "Attribute not supported".to_string(),
0x15 => "Too much data".to_string(),
0x16 => "Object does not exist".to_string(),
0x17 => "Service fragmentation sequence not in progress".to_string(),
0x18 => "No stored attribute data".to_string(),
0x19 => "Store operation failure".to_string(),
0x1A => "Routing failure, request packet too large".to_string(),
0x1B => "Routing failure, response packet too large".to_string(),
0x1C => "Missing attribute list entry data".to_string(),
0x1D => "Invalid attribute value list".to_string(),
0x1E => "Embedded service error".to_string(),
0x1F => "Vendor specific error".to_string(),
0x20 => "Invalid parameter".to_string(),
0x21 => "Write-once value or medium already written".to_string(),
0x22 => "Invalid reply received".to_string(),
0x23 => "Buffer overflow".to_string(),
0x24 => "Invalid message format".to_string(),
0x25 => "Key failure in path".to_string(),
0x26 => "Path size invalid".to_string(),
0x27 => "Unexpected attribute in list".to_string(),
0x28 => "Invalid member ID".to_string(),
0x29 => "Member not settable".to_string(),
0x2A => "Group 2 only server general failure".to_string(),
0x2B => "Unknown Modbus error".to_string(),
0x2C => "Attribute not gettable".to_string(),
_ => format!("Unknown CIP error code: 0x{status:02X}"),
}
}
fn describe_multiple_service_error(
&self,
general_status: u8,
operations: &[BatchOperation],
) -> String {
if general_status == 0x1E
&& operations.iter().any(|op| {
matches!(
op,
BatchOperation::Write {
value: PlcValue::String(_),
..
}
)
})
{
return "Multiple Service Response error: 0x1E (Embedded service error). On CompactLogix/ControlLogix this commonly indicates the controller rejected a direct STRING write in the batch request; treat it as a PLC firmware limitation, not a protocol bug.".to_string();
}
format!("Multiple Service Response error: 0x{general_status:02X}")
}
async fn validate_session(&mut self) -> crate::error::Result<()> {
let time_since_activity = self.last_activity.lock().await.elapsed();
if time_since_activity > Duration::from_secs(30) {
self.send_keep_alive().await?;
}
Ok(())
}
async fn send_keep_alive(&mut self) -> crate::error::Result<()> {
let packet = vec![0u8; 24];
let mut stream = self.stream.lock().await;
stream.write_all(&packet).await?;
*self.last_activity.lock().await = Instant::now();
Ok(())
}
async fn read_tag_raw(&mut self, tag_name: &str) -> crate::error::Result<Vec<u8>> {
let response = self
.send_cip_request(&self.build_read_request(tag_name)?)
.await?;
self.extract_cip_from_response(&response)
}
#[allow(dead_code)]
async fn write_tag_raw(&mut self, tag_name: &str, data: &[u8]) -> crate::error::Result<()> {
let request = self.build_write_request_raw(tag_name, data)?;
let response = self.send_cip_request(&request).await?;
let cip_response = self.extract_cip_from_response(&response)?;
if cip_response.len() < 3 {
return Err(EtherNetIpError::Protocol(
"Write response too short".to_string(),
));
}
let service_reply = cip_response[0]; let general_status = cip_response[2];
tracing::trace!(
"Write response - Service: 0x{:02X}, Status: 0x{:02X}",
service_reply,
general_status
);
if let Err(e) = self.check_cip_error(&cip_response) {
tracing::error!("[WRITE] CIP Error: {}", e);
return Err(e);
}
tracing::info!("Write completed successfully");
Ok(())
}
fn build_unconnected_send(&self, embedded_message: &[u8]) -> Vec<u8> {
let mut ucmm = vec![
0x52, 0x02,
0x20, 0x06, 0x24, 0x01, 0x0A, 0xF0,
];
let msg_len = embedded_message.len() as u16;
ucmm.extend_from_slice(&msg_len.to_le_bytes());
ucmm.extend_from_slice(embedded_message);
if embedded_message.len() % 2 == 1 {
ucmm.push(0x00);
}
let route_path_bytes = if let Some(route_path) = self.route_path_snapshot() {
route_path.to_cip_bytes()
} else {
Vec::new()
};
let route_path_words = if route_path_bytes.is_empty() {
0
} else {
(route_path_bytes.len() / 2) as u8
};
ucmm.push(route_path_words);
ucmm.push(0x00);
if !route_path_bytes.is_empty() {
tracing::trace!(
"Adding route path to Unconnected Send: {:02X?} ({} bytes, {} words)",
route_path_bytes,
route_path_bytes.len(),
route_path_words
);
ucmm.extend_from_slice(&route_path_bytes);
}
ucmm
}
pub async fn send_cip_request(&self, cip_request: &[u8]) -> Result<Vec<u8>> {
tracing::trace!(
"Sending CIP request ({} bytes): {:02X?}",
cip_request.len(),
cip_request
);
let ucmm_message = self.build_unconnected_send(cip_request);
tracing::trace!(
"Unconnected Send message ({} bytes): {:02X?}",
ucmm_message.len(),
&ucmm_message[..std::cmp::min(64, ucmm_message.len())]
);
let response_data = self.send_rr_data_item(&ucmm_message).await?;
if let Ok(raw_cip_data) = self.extract_unconnected_data_item(&response_data) {
let use_direct_fallback = raw_cip_data.len() >= 3
&& raw_cip_data[0] == 0xD2
&& raw_cip_data[2] != 0x00
&& self.route_path_snapshot().is_none();
if use_direct_fallback {
tracing::warn!(
"Unconnected Send returned 0xD2 status 0x{:02X}; retrying with direct CIP SendRRData fallback",
raw_cip_data[2]
);
return self.send_rr_data_item(cip_request).await;
}
}
Ok(response_data)
}
async fn send_rr_data_item(&self, item_data: &[u8]) -> Result<Vec<u8>> {
let send_data = SendDataRequest::unconnected(item_data);
let mut packet = BytesMut::new();
let mut cpf = BytesMut::new();
send_data.encode(&mut cpf);
EncapsulationHeader::send_rr_data(cpf.len() as u16, self.session_handle)
.encode(&mut packet);
packet.extend_from_slice(&cpf);
tracing::trace!(
"Built packet ({} bytes): {:02X?}",
packet.len(),
&packet[..std::cmp::min(64, packet.len())]
);
let mut stream = self.stream.lock().await;
stream
.write_all(&packet)
.await
.map_err(EtherNetIpError::Io)?;
let mut header = [0u8; 24];
match timeout(Duration::from_secs(10), stream.read_exact(&mut header)).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => return Err(EtherNetIpError::Io(e)),
Err(_) => return Err(EtherNetIpError::Timeout(Duration::from_secs(10))),
}
let mut header_bytes = &header[..];
let response_header = EncapsulationHeader::decode(&mut header_bytes)?;
if response_header.status != 0 {
return Err(EtherNetIpError::Protocol(format!(
"EIP Command failed. Status: 0x{:08X}",
response_header.status
)));
}
let response_length = response_header.length as usize;
if response_length == 0 {
return Ok(Vec::new());
}
let mut response_data = vec![0u8; response_length];
match timeout(
Duration::from_secs(10),
stream.read_exact(&mut response_data),
)
.await
{
Ok(Ok(_)) => {}
Ok(Err(e)) => return Err(EtherNetIpError::Io(e)),
Err(_) => return Err(EtherNetIpError::Timeout(Duration::from_secs(10))),
}
*self.last_activity.lock().await = Instant::now();
tracing::trace!(
"Received response ({} bytes): {:02X?}",
response_data.len(),
&response_data[..std::cmp::min(32, response_data.len())]
);
Ok(response_data)
}
fn extract_unconnected_data_item(&self, response: &[u8]) -> crate::error::Result<Vec<u8>> {
let mut response = response;
let send_data = SendDataRequest::decode(&mut response)?;
if let Some(item) = send_data
.items
.into_iter()
.find(|item| item.type_id == 0x00B2)
{
return Ok(item.data);
}
Err(EtherNetIpError::Protocol(
"No Unconnected Data Item (0x00B2) found in response".to_string(),
))
}
fn unwrap_unconnected_send_reply(&self, cip_data: &[u8]) -> crate::error::Result<Vec<u8>> {
if cip_data.is_empty() || cip_data[0] != 0xD2 {
return Ok(cip_data.to_vec());
}
if cip_data.len() < 4 {
return Err(EtherNetIpError::Protocol(
"Unconnected Send reply too short".to_string(),
));
}
let general_status = cip_data[2];
let additional_status_words = cip_data[3] as usize;
let embedded_offset = 4 + (additional_status_words * 2);
if general_status != 0x00 {
let error_msg = self.get_cip_error_message(general_status);
return Err(EtherNetIpError::Protocol(format!(
"Unconnected Send failed (0xD2): CIP Error 0x{general_status:02X}: {error_msg}"
)));
}
if embedded_offset >= cip_data.len() {
return Err(EtherNetIpError::Protocol(
"Unconnected Send succeeded but no embedded response payload was returned"
.to_string(),
));
}
Ok(cip_data[embedded_offset..].to_vec())
}
fn extract_cip_from_response(&self, response: &[u8]) -> crate::error::Result<Vec<u8>> {
tracing::trace!(
"Extracting CIP from response ({} bytes): {:02X?}",
response.len(),
&response[..std::cmp::min(32, response.len())]
);
let cip_data = self.extract_unconnected_data_item(response)?;
tracing::trace!(
"Found Unconnected Data Item, extracted CIP data ({} bytes)",
cip_data.len()
);
tracing::trace!(
"CIP data bytes: {:02X?}",
&cip_data[..std::cmp::min(16, cip_data.len())]
);
self.unwrap_unconnected_send_reply(&cip_data)
}
fn parse_cip_response(&self, cip_response: &[u8]) -> crate::error::Result<PlcValue> {
tracing::trace!(
"Parsing CIP response ({} bytes): {:02X?}",
cip_response.len(),
cip_response
);
if let Err(e) = self.check_cip_error(cip_response) {
tracing::error!("CIP Error: {}", e);
return Err(e);
}
let mut response_bytes = cip_response;
let response = CipResponse::decode(&mut response_bytes)?;
if response.service == 0xCC {
if response.data.len() < 2 {
return Err(EtherNetIpError::Protocol(
"Read response too short for data".to_string(),
));
}
let data_type = u16::from_le_bytes([response.data[0], response.data[1]]);
let value_data = &response.data[2..];
tracing::trace!(
"Data type: 0x{:04X}, Value data ({} bytes): {:02X?}",
data_type,
value_data.len(),
value_data
);
Ok(values::decode_payload(data_type, value_data)?)
} else if response.service == 0xCD {
tracing::debug!("Write operation successful");
Ok(PlcValue::Bool(true))
} else {
Err(EtherNetIpError::Protocol(format!(
"Unknown service reply: 0x{:02X}",
response.service
)))
}
}
pub async fn unregister_session(&mut self) -> crate::error::Result<()> {
tracing::info!("Unregistering session and cleaning up connections...");
let _ = self.close_all_connected_sessions().await;
let mut packet = BytesMut::with_capacity(24);
EncapsulationHeader::new(UNREGISTER_SESSION, 0, self.session_handle).encode(&mut packet);
self.stream
.lock()
.await
.write_all(&packet)
.await
.map_err(EtherNetIpError::Io)?;
tracing::info!("Session unregistered and all connections closed");
Ok(())
}
fn build_read_request(&self, tag_name: &str) -> crate::error::Result<Vec<u8>> {
self.build_read_request_with_count(tag_name, 1)
}
fn build_read_request_with_count(
&self,
tag_name: &str,
element_count: u16,
) -> crate::error::Result<Vec<u8>> {
tracing::debug!(
"Building read request for tag: '{}' with count: {}",
tag_name,
element_count
);
let path = self.build_tag_path(tag_name);
let path_size_words = (path.len() / 2) as u8;
tracing::debug!(
"Path size calculation: {} bytes / 2 = {} words for tag '{}'",
path.len(),
path_size_words,
tag_name
);
tracing::debug!(
"Path bytes ({} bytes, {} words) for tag '{}': {:02X?}",
path.len(),
path_size_words,
tag_name,
path
);
let request = CipRequest::new(READ_TAG, path, element_count.to_le_bytes().to_vec());
let mut cip_request = BytesMut::new();
request.encode(&mut cip_request)?;
tracing::debug!(
"Built CIP read request ({} bytes) for tag '{}': {:02X?}",
cip_request.len(),
tag_name,
cip_request
);
Ok(cip_request.to_vec())
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn build_element_id_segment(&self, index: u32) -> Vec<u8> {
let mut segment = Vec::new();
if index <= 255 {
segment.push(0x28);
segment.push(index as u8);
} else if index <= 65535 {
segment.push(0x29);
segment.push(0x00); segment.extend_from_slice(&(index as u16).to_le_bytes());
} else {
segment.push(0x2A);
segment.push(0x00); segment.extend_from_slice(&index.to_le_bytes());
}
segment
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn build_base_tag_path(&self, tag_name: &str) -> Vec<u8> {
match TagPath::parse(tag_name) {
Ok(path) => {
let base_path = match &path {
TagPath::Array { base_path, .. } => base_path.as_ref(),
_ => &path,
};
base_path.to_cip_path().unwrap_or_else(|_| {
let mut path = Vec::new();
path.push(0x91); let name_bytes = tag_name.as_bytes();
path.push(name_bytes.len() as u8);
path.extend_from_slice(name_bytes);
if path.len() % 2 != 0 {
path.push(0x00);
}
path
})
}
Err(_) => {
let mut path = Vec::new();
path.push(0x91); let name_bytes = tag_name.as_bytes();
path.push(name_bytes.len() as u8);
path.extend_from_slice(name_bytes);
if path.len() % 2 != 0 {
path.push(0x00);
}
path
}
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn build_read_array_request(
&self,
base_array_name: &str,
start_index: u32,
element_count: u16,
) -> Vec<u8> {
let mut cip_request = Vec::new();
cip_request.push(0x4C);
let mut full_path = self.build_base_tag_path(base_array_name);
tracing::trace!(
"build_read_array_request: base_path for '{}' = {:02X?} ({} bytes)",
base_array_name,
full_path,
full_path.len()
);
let element_segment = self.build_element_id_segment(start_index);
tracing::trace!(
"build_read_array_request: element_segment for index {} = {:02X?} ({} bytes)",
start_index,
element_segment,
element_segment.len()
);
full_path.extend_from_slice(&element_segment);
if !full_path.len().is_multiple_of(2) {
full_path.push(0x00);
}
let path_size = (full_path.len() / 2) as u8;
cip_request.push(path_size);
cip_request.extend_from_slice(&full_path);
cip_request.extend_from_slice(&element_count.to_le_bytes());
tracing::trace!(
"build_read_array_request: final request = {:02X?} ({} bytes), path_size = {} words ({} bytes)",
cip_request,
cip_request.len(),
path_size,
full_path.len()
);
cip_request
}
fn build_tag_path(&self, tag_name: &str) -> Vec<u8> {
match TagPath::parse(tag_name) {
Ok(tag_path) => {
tracing::debug!("Parsed tag path for '{}': {:?}", tag_name, tag_path);
match tag_path.to_cip_path() {
Ok(path) => {
tracing::debug!(
"TagPath generated {} bytes ({} words) for '{}': {:02X?}",
path.len(),
path.len() / 2,
tag_name,
path
);
path
}
Err(e) => {
tracing::warn!("TagPath.to_cip_path() failed for '{}': {}", tag_name, e);
self.build_simple_tag_path_legacy(tag_name)
}
}
}
Err(e) => {
tracing::warn!("TagPath::parse() failed for '{}': {}", tag_name, e);
self.build_simple_tag_path_legacy(tag_name)
}
}
}
fn build_simple_tag_path_legacy(&self, tag_name: &str) -> Vec<u8> {
let mut path = Vec::new();
path.push(0x91); path.push(tag_name.len() as u8);
path.extend_from_slice(tag_name.as_bytes());
if !tag_name.len().is_multiple_of(2) {
path.push(0x00);
}
path
}
async fn _get_connected_session(
&mut self,
session_name: &str,
) -> crate::error::Result<ConnectedSession> {
{
let sessions = self.connected_sessions.lock().await;
if let Some(session) = sessions.get(session_name) {
return Ok(session.clone());
}
}
let session = self.establish_connected_session(session_name).await?;
let mut sessions = self.connected_sessions.lock().await;
sessions.insert(session_name.to_string(), session.clone());
Ok(session)
}
#[allow(dead_code)]
fn parse_udt_structure(&self, data: &[u8]) -> crate::error::Result<PlcValue> {
tracing::debug!("Parsing UDT structure with {} bytes", data.len());
if data.len() >= 12 {
let _offset = 0;
for alignment in 0..4 {
if alignment + 12 <= data.len() {
let aligned_data = &data[alignment..];
if aligned_data.len() >= 4 {
let dint1_bytes = [
aligned_data[0],
aligned_data[1],
aligned_data[2],
aligned_data[3],
];
let dint1_value = i32::from_le_bytes(dint1_bytes);
if aligned_data.len() >= 8 {
let dint2_bytes = [
aligned_data[4],
aligned_data[5],
aligned_data[6],
aligned_data[7],
];
let dint2_value = i32::from_le_bytes(dint2_bytes);
if aligned_data.len() >= 12 {
let real_bytes = [
aligned_data[8],
aligned_data[9],
aligned_data[10],
aligned_data[11],
];
let real_value = f32::from_le_bytes(real_bytes);
tracing::trace!(
"Alignment {}: DINT1={}, DINT2={}, REAL={}",
alignment,
dint1_value,
dint2_value,
real_value
);
if self.is_reasonable_udt_values(
dint1_value,
dint2_value,
real_value,
) {
tracing::debug!(
"Found reasonable UDT values at alignment {}",
alignment
);
return Ok(PlcValue::Udt(UdtData {
symbol_id: 0, data: data.to_vec(),
}));
}
}
}
}
}
}
}
if data.len() >= 4 {
let interpretations = vec![
("DINT_at_start", 0, 4),
("DINT_at_end", data.len().saturating_sub(4), data.len()),
("DINT_middle", data.len() / 2, data.len() / 2 + 4),
];
for (name, start, end) in interpretations {
if end <= data.len() && end > start {
let bytes = &data[start..end];
if bytes.len() == 4 {
let dint_value =
i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
tracing::trace!("{}: DINT = {}", name, dint_value);
if self.is_reasonable_value(dint_value) {
return Ok(PlcValue::Udt(UdtData {
symbol_id: 0, data: data.to_vec(),
}));
}
}
}
}
}
Err(crate::error::EtherNetIpError::Protocol(
"Could not parse UDT structure".to_string(),
))
}
#[allow(dead_code)]
fn parse_udt_simple(&self, data: &[u8]) -> crate::error::Result<PlcValue> {
Ok(PlcValue::Udt(UdtData {
symbol_id: 0, data: data.to_vec(),
}))
}
#[allow(dead_code)]
fn is_reasonable_udt_values(&self, dint1: i32, dint2: i32, real: f32) -> bool {
let dint1_reasonable = (-1000..=1000).contains(&dint1);
let dint2_reasonable = (-1000..=1000).contains(&dint2);
let real_reasonable = (-1000.0..=1000.0).contains(&real) && real.is_finite();
tracing::trace!(
"Reasonableness check: DINT1={} ({}), DINT2={} ({}), REAL={} ({})",
dint1,
dint1_reasonable,
dint2,
dint2_reasonable,
real,
real_reasonable
);
dint1_reasonable && dint2_reasonable && real_reasonable
}
#[allow(dead_code)]
fn is_reasonable_value(&self, value: i32) -> bool {
(-1000..=1000).contains(&value)
}
}
#[cfg(test)]
mod discovery_tests {
use super::{EipClient, TemplateAttributes};
#[test]
fn build_tag_list_request_rejects_instance_above_u16() {
let client = EipClient::new_unconnected_for_testing();
let request = client
.build_tag_list_request_from_instance(0x12345678)
.expect_err("instance should be rejected");
assert!(format!("{request}").contains("exceeds 16-bit"));
}
#[test]
fn build_tag_list_request_encodes_path_size_and_start_instance() {
let client = EipClient::new_unconnected_for_testing();
let request = client
.build_tag_list_request_from_instance(0x5678)
.expect("request should build");
assert_eq!(request[0], 0x55);
assert_eq!(request[1], 0x03);
assert_eq!(&request[2..8], &[0x20, 0x6B, 0x25, 0x00, 0x78, 0x56]);
}
#[test]
fn parse_tag_list_response_page_handles_partial_transfer() {
let client = EipClient::new_unconnected_for_testing();
let response = [
0xD5, 0x00, 0x06,
0x00, 0x34, 0x12, 0x00, 0x00, 0x04, 0x00, b'R', b'a', b't', b'e', 0xC4, 0x00, ];
let page = client
.parse_tag_list_response_page(&response)
.expect("response should parse");
assert!(page.partial_transfer);
assert_eq!(page.last_instance_id, Some(0x1234));
assert_eq!(page.tags.len(), 1);
assert_eq!(page.tags[0].name, "Rate");
assert_eq!(page.tags[0].data_type, 0x00C4);
assert_eq!(page.tags[0].data_type_name, "DINT");
}
#[test]
fn build_get_template_attributes_request_encodes_template_object_path() {
let client = EipClient::new_unconnected_for_testing();
let request = client
.build_get_template_attributes_request(0x0456)
.expect("request should build");
assert_eq!(request[0], 0x03);
assert_eq!(request[1], 0x03);
assert_eq!(&request[2..8], &[0x20, 0x6C, 0x25, 0x00, 0x56, 0x04]);
assert_eq!(
&request[8..],
&[0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x04, 0x00, 0x05, 0x00]
);
}
#[test]
fn build_read_template_request_encodes_template_read_size() {
let client = EipClient::new_unconnected_for_testing();
let request = client
.build_read_template_request(0x0456, 0x0010, 0x0032)
.expect("request should build");
assert_eq!(request[0], 0x4C);
assert_eq!(request[1], 0x03);
assert_eq!(&request[2..8], &[0x20, 0x6C, 0x25, 0x00, 0x56, 0x04]);
assert_eq!(&request[8..12], &[0x10, 0x00, 0x00, 0x00]);
assert_eq!(&request[12..14], &[0x32, 0x00]);
}
#[test]
fn parse_template_attributes_response_reads_mixed_width_values() {
let client = EipClient::new_unconnected_for_testing();
let response = [
0x83, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x34, 0x12, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x04, 0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x58, 0x00, 0x00, 0x00, ];
let attributes = client
.parse_template_attributes_response(0x0456, &response)
.expect("response should parse");
assert_eq!(
attributes,
TemplateAttributes {
structure_handle: 0x1234,
member_count: 7,
definition_size_words: 25,
structure_size_bytes: 88,
}
);
}
}