1use super::EipClient;
2use crate::error::{EtherNetIpError, Result};
3use crate::types::{PlcValue, UdtData};
4use crate::udt::UserDefinedType;
5use std::future::Future;
6use std::pin::Pin;
7
8type WriteFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
9
10trait MemberWriteStrategy {
11 fn write_direct_member<'a>(
12 &'a mut self,
13 member_path: &'a str,
14 value: PlcValue,
15 ) -> WriteFuture<'a>;
16
17 fn write_member_via_full_value<'a>(
18 &'a mut self,
19 udt_tag_name: &'a str,
20 member_name: &'a str,
21 value: PlcValue,
22 ) -> WriteFuture<'a>;
23}
24
25impl MemberWriteStrategy for EipClient {
26 fn write_direct_member<'a>(
27 &'a mut self,
28 member_path: &'a str,
29 value: PlcValue,
30 ) -> WriteFuture<'a> {
31 Box::pin(async move { self.write_tag(member_path, value).await })
32 }
33
34 fn write_member_via_full_value<'a>(
35 &'a mut self,
36 udt_tag_name: &'a str,
37 member_name: &'a str,
38 value: PlcValue,
39 ) -> WriteFuture<'a> {
40 Box::pin(async move {
41 self.write_udt_member_via_full_value(udt_tag_name, member_name, value)
42 .await
43 })
44 }
45}
46
47impl EipClient {
48 pub async fn write_string_tag(&mut self, tag_name: &str, value: &str) -> Result<()> {
49 self.write_tag(tag_name, PlcValue::String(value.to_string()))
50 .await
51 }
52
53 pub async fn read_string_tag(&mut self, tag_name: &str) -> Result<String> {
58 match self.read_tag(tag_name).await? {
59 PlcValue::String(value) => Ok(value),
60 PlcValue::Udt(udt) if udt.data.len() >= 6 => {
61 let len = u32::from_le_bytes([udt.data[2], udt.data[3], udt.data[4], udt.data[5]])
62 as usize;
63 let end = (6 + len).min(udt.data.len());
64 Ok(String::from_utf8_lossy(&udt.data[6..end]).to_string())
65 }
66 other => Err(EtherNetIpError::DataTypeMismatch {
67 expected: "STRING".to_string(),
68 actual: format!("{other:?}"),
69 }),
70 }
71 }
72
73 pub async fn write_udt_member(
74 &mut self,
75 udt_tag_name: &str,
76 member_name: &str,
77 value: PlcValue,
78 ) -> Result<()> {
79 write_udt_member_direct_first(self, udt_tag_name, member_name, value).await
80 }
81
82 pub async fn write_udt_array_member(
83 &mut self,
84 udt_array_element_path: &str,
85 member_name: &str,
86 value: PlcValue,
87 ) -> Result<()> {
88 write_udt_member_direct_first(self, udt_array_element_path, member_name, value).await
89 }
90
91 async fn write_udt_member_via_full_value(
92 &mut self,
93 udt_tag_name: &str,
94 member_name: &str,
95 value: PlcValue,
96 ) -> Result<()> {
97 let current = self.read_tag(udt_tag_name).await?;
98 let PlcValue::Udt(udt_data) = current else {
99 return Err(EtherNetIpError::DataTypeMismatch {
100 expected: "UDT".to_string(),
101 actual: format!("{current:?}"),
102 });
103 };
104
105 let definition = self.get_udt_definition(udt_tag_name).await?;
106 let mut user_def = UserDefinedType::new(definition.name.clone());
107 for member in &definition.members {
108 user_def.add_member(member.clone());
109 }
110
111 let mut members = udt_data.parse(&user_def)?;
112 if !members.contains_key(member_name) {
113 return Err(EtherNetIpError::TagNotFound(format!(
114 "{udt_tag_name}.{member_name}"
115 )));
116 }
117
118 members.insert(member_name.to_string(), value);
119 let modified = UdtData::from_hash_map(&members, &user_def, udt_data.symbol_id)?;
120 self.write_tag(udt_tag_name, PlcValue::Udt(modified)).await
121 }
122}
123
124async fn write_udt_member_direct_first<T: MemberWriteStrategy>(
125 strategy: &mut T,
126 udt_tag_name: &str,
127 member_name: &str,
128 value: PlcValue,
129) -> Result<()> {
130 if matches!(value, PlcValue::String(_)) {
131 return strategy
132 .write_member_via_full_value(udt_tag_name, member_name, value)
133 .await;
134 }
135
136 let member_path = format!("{udt_tag_name}.{member_name}");
137 match strategy
138 .write_direct_member(&member_path, value.clone())
139 .await
140 {
141 Ok(()) => Ok(()),
142 Err(error) if is_2107_type_mismatch(&error) => {
143 strategy
144 .write_member_via_full_value(udt_tag_name, member_name, value)
145 .await
146 }
147 Err(error) => Err(error),
148 }
149}
150
151pub(crate) fn is_2107_type_mismatch(error: &EtherNetIpError) -> bool {
152 error.to_string().to_ascii_lowercase().contains("0x2107")
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use std::future;
159
160 #[derive(Default)]
161 struct FakeMemberWriteStrategy {
162 direct_error: Option<EtherNetIpError>,
163 direct_paths: Vec<String>,
164 rmw_calls: Vec<(String, String, PlcValue)>,
165 }
166
167 impl MemberWriteStrategy for FakeMemberWriteStrategy {
168 fn write_direct_member<'a>(
169 &'a mut self,
170 member_path: &'a str,
171 _value: PlcValue,
172 ) -> WriteFuture<'a> {
173 self.direct_paths.push(member_path.to_string());
174 Box::pin(future::ready(match self.direct_error.take() {
175 Some(error) => Err(error),
176 None => Ok(()),
177 }))
178 }
179
180 fn write_member_via_full_value<'a>(
181 &'a mut self,
182 udt_tag_name: &'a str,
183 member_name: &'a str,
184 value: PlcValue,
185 ) -> WriteFuture<'a> {
186 self.rmw_calls
187 .push((udt_tag_name.to_string(), member_name.to_string(), value));
188 Box::pin(future::ready(Ok(())))
189 }
190 }
191
192 #[tokio::test]
193 async fn scalar_member_write_falls_back_to_rmw_on_2107() {
194 let mut strategy = FakeMemberWriteStrategy {
195 direct_error: Some(EtherNetIpError::Protocol(
196 "CIP Extended Error: Read/Write Tag data-type mismatch extended error: 0x2107"
197 .to_string(),
198 )),
199 ..Default::default()
200 };
201
202 write_udt_member_direct_first(
203 &mut strategy,
204 "UDT_ARRAY[3]",
205 "DINT_MEMBER",
206 PlcValue::Dint(77),
207 )
208 .await
209 .expect("fallback should succeed");
210
211 assert_eq!(strategy.direct_paths, vec!["UDT_ARRAY[3].DINT_MEMBER"]);
212 assert_eq!(strategy.rmw_calls.len(), 1);
213 assert_eq!(strategy.rmw_calls[0].0, "UDT_ARRAY[3]");
214 assert_eq!(strategy.rmw_calls[0].1, "DINT_MEMBER");
215 assert_eq!(strategy.rmw_calls[0].2, PlcValue::Dint(77));
216 }
217
218 #[tokio::test]
219 async fn string_member_write_uses_rmw_without_direct_attempt() {
220 let mut strategy = FakeMemberWriteStrategy {
221 direct_error: Some(EtherNetIpError::Protocol(
222 "direct write should not be called".to_string(),
223 )),
224 ..Default::default()
225 };
226
227 write_udt_member_direct_first(
228 &mut strategy,
229 "gTestUDT",
230 "Member5_String",
231 PlcValue::String("updated".to_string()),
232 )
233 .await
234 .expect("string RMW should succeed");
235
236 assert!(strategy.direct_paths.is_empty());
237 assert_eq!(strategy.rmw_calls.len(), 1);
238 assert_eq!(strategy.rmw_calls[0].0, "gTestUDT");
239 assert_eq!(strategy.rmw_calls[0].1, "Member5_String");
240 assert_eq!(
241 strategy.rmw_calls[0].2,
242 PlcValue::String("updated".to_string())
243 );
244 }
245
246 #[tokio::test]
247 async fn non_2107_direct_error_does_not_fallback() {
248 let mut strategy = FakeMemberWriteStrategy {
249 direct_error: Some(EtherNetIpError::Protocol(
250 "CIP Error 0x04: Path segment error".to_string(),
251 )),
252 ..Default::default()
253 };
254
255 let error = write_udt_member_direct_first(
256 &mut strategy,
257 "UDT_ARRAY[3]",
258 "DINT_MEMBER",
259 PlcValue::Dint(77),
260 )
261 .await
262 .expect_err("non-2107 error should be returned");
263
264 assert!(error.to_string().contains("0x04"));
265 assert_eq!(strategy.direct_paths, vec!["UDT_ARRAY[3].DINT_MEMBER"]);
266 assert!(strategy.rmw_calls.is_empty());
267 }
268}