Skip to main content

rust_ethernet_ip/client/
service_layer.rs

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    /// Reads a Logix STRING tag as text, whether it is the built-in `STRING` type or a custom
54    /// string type (own name/length). `read_tag` decodes only the built-in handle (0x0FCE) to
55    /// `PlcValue::String` and returns any other structure as `PlcValue::Udt`; here the caller has
56    /// asserted the tag is a string, so a structure payload is decoded as `[handle][LEN][DATA]`.
57    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}