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    /// Writes a built-in or custom Logix string using its discovered structure handle.
49    pub async fn write_string_tag(&mut self, tag_name: &str, value: &str) -> Result<()> {
50        self.write_tag(tag_name, PlcValue::String(value.to_string()))
51            .await
52    }
53
54    /// Reads a Logix STRING tag as text, whether it is the built-in `STRING` type or a custom
55    /// string type (own name/length). `read_tag` decodes only the built-in handle (0x0FCE) to
56    /// `PlcValue::String` and returns any other structure as `PlcValue::Udt`; here the caller has
57    /// asserted the tag is a string, so a structure payload is decoded as `[handle][LEN][DATA]`.
58    pub async fn read_string_tag(&mut self, tag_name: &str) -> Result<String> {
59        match self.read_tag(tag_name).await? {
60            PlcValue::String(value) => Ok(value),
61            PlcValue::Udt(udt) if udt.data.len() >= 6 => {
62                let len = u32::from_le_bytes([udt.data[2], udt.data[3], udt.data[4], udt.data[5]])
63                    as usize;
64                let end = (6 + len).min(udt.data.len());
65                Ok(String::from_utf8_lossy(&udt.data[6..end]).to_string())
66            }
67            other => Err(EtherNetIpError::DataTypeMismatch {
68                expected: "STRING".to_string(),
69                actual: format!("{other:?}"),
70            }),
71        }
72    }
73
74    /// Writes one named member of a UDT tag.
75    pub async fn write_udt_member(
76        &mut self,
77        udt_tag_name: &str,
78        member_name: &str,
79        value: PlcValue,
80    ) -> Result<()> {
81        write_udt_member_direct_first(self, udt_tag_name, member_name, value).await
82    }
83
84    /// Writes one named member of a UDT array element.
85    pub async fn write_udt_array_member(
86        &mut self,
87        udt_array_element_path: &str,
88        member_name: &str,
89        value: PlcValue,
90    ) -> Result<()> {
91        write_udt_member_direct_first(self, udt_array_element_path, member_name, value).await
92    }
93
94    async fn write_udt_member_via_full_value(
95        &mut self,
96        udt_tag_name: &str,
97        member_name: &str,
98        value: PlcValue,
99    ) -> Result<()> {
100        let current = self.read_tag(udt_tag_name).await?;
101        let PlcValue::Udt(udt_data) = current else {
102            return Err(EtherNetIpError::DataTypeMismatch {
103                expected: "UDT".to_string(),
104                actual: format!("{current:?}"),
105            });
106        };
107
108        let definition = self.get_udt_definition(udt_tag_name).await?;
109        let mut user_def = UserDefinedType::new(definition.name.clone());
110        for member in &definition.members {
111            user_def.add_member(member.clone());
112        }
113
114        let mut members = udt_data.parse(&user_def)?;
115        if !members.contains_key(member_name) {
116            return Err(EtherNetIpError::TagNotFound(format!(
117                "{udt_tag_name}.{member_name}"
118            )));
119        }
120
121        members.insert(member_name.to_string(), value);
122        let modified = UdtData::from_hash_map(&members, &user_def, udt_data.symbol_id)?;
123        self.write_tag(udt_tag_name, PlcValue::Udt(modified)).await
124    }
125}
126
127async fn write_udt_member_direct_first<T: MemberWriteStrategy>(
128    strategy: &mut T,
129    udt_tag_name: &str,
130    member_name: &str,
131    value: PlcValue,
132) -> Result<()> {
133    if matches!(value, PlcValue::String(_)) {
134        return strategy
135            .write_member_via_full_value(udt_tag_name, member_name, value)
136            .await;
137    }
138
139    let member_path = format!("{udt_tag_name}.{member_name}");
140    match strategy
141        .write_direct_member(&member_path, value.clone())
142        .await
143    {
144        Ok(()) => Ok(()),
145        Err(error) if is_2107_type_mismatch(&error) => {
146            strategy
147                .write_member_via_full_value(udt_tag_name, member_name, value)
148                .await
149        }
150        Err(error) => Err(error),
151    }
152}
153
154pub(crate) fn is_2107_type_mismatch(error: &EtherNetIpError) -> bool {
155    error.to_string().to_ascii_lowercase().contains("0x2107")
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use std::future;
162
163    #[derive(Default)]
164    struct FakeMemberWriteStrategy {
165        direct_error: Option<EtherNetIpError>,
166        direct_paths: Vec<String>,
167        rmw_calls: Vec<(String, String, PlcValue)>,
168    }
169
170    impl MemberWriteStrategy for FakeMemberWriteStrategy {
171        fn write_direct_member<'a>(
172            &'a mut self,
173            member_path: &'a str,
174            _value: PlcValue,
175        ) -> WriteFuture<'a> {
176            self.direct_paths.push(member_path.to_string());
177            Box::pin(future::ready(match self.direct_error.take() {
178                Some(error) => Err(error),
179                None => Ok(()),
180            }))
181        }
182
183        fn write_member_via_full_value<'a>(
184            &'a mut self,
185            udt_tag_name: &'a str,
186            member_name: &'a str,
187            value: PlcValue,
188        ) -> WriteFuture<'a> {
189            self.rmw_calls
190                .push((udt_tag_name.to_string(), member_name.to_string(), value));
191            Box::pin(future::ready(Ok(())))
192        }
193    }
194
195    #[tokio::test]
196    async fn scalar_member_write_falls_back_to_rmw_on_2107() {
197        let mut strategy = FakeMemberWriteStrategy {
198            direct_error: Some(EtherNetIpError::Protocol(
199                "CIP Extended Error: Read/Write Tag data-type mismatch extended error: 0x2107"
200                    .to_string(),
201            )),
202            ..Default::default()
203        };
204
205        write_udt_member_direct_first(
206            &mut strategy,
207            "UDT_ARRAY[3]",
208            "DINT_MEMBER",
209            PlcValue::Dint(77),
210        )
211        .await
212        .expect("fallback should succeed");
213
214        assert_eq!(strategy.direct_paths, vec!["UDT_ARRAY[3].DINT_MEMBER"]);
215        assert_eq!(strategy.rmw_calls.len(), 1);
216        assert_eq!(strategy.rmw_calls[0].0, "UDT_ARRAY[3]");
217        assert_eq!(strategy.rmw_calls[0].1, "DINT_MEMBER");
218        assert_eq!(strategy.rmw_calls[0].2, PlcValue::Dint(77));
219    }
220
221    #[tokio::test]
222    async fn string_member_write_uses_rmw_without_direct_attempt() {
223        let mut strategy = FakeMemberWriteStrategy {
224            direct_error: Some(EtherNetIpError::Protocol(
225                "direct write should not be called".to_string(),
226            )),
227            ..Default::default()
228        };
229
230        write_udt_member_direct_first(
231            &mut strategy,
232            "gTestUDT",
233            "Member5_String",
234            PlcValue::String("updated".to_string()),
235        )
236        .await
237        .expect("string RMW should succeed");
238
239        assert!(strategy.direct_paths.is_empty());
240        assert_eq!(strategy.rmw_calls.len(), 1);
241        assert_eq!(strategy.rmw_calls[0].0, "gTestUDT");
242        assert_eq!(strategy.rmw_calls[0].1, "Member5_String");
243        assert_eq!(
244            strategy.rmw_calls[0].2,
245            PlcValue::String("updated".to_string())
246        );
247    }
248
249    #[tokio::test]
250    async fn non_2107_direct_error_does_not_fallback() {
251        let mut strategy = FakeMemberWriteStrategy {
252            direct_error: Some(EtherNetIpError::Protocol(
253                "CIP Error 0x04: Path segment error".to_string(),
254            )),
255            ..Default::default()
256        };
257
258        let error = write_udt_member_direct_first(
259            &mut strategy,
260            "UDT_ARRAY[3]",
261            "DINT_MEMBER",
262            PlcValue::Dint(77),
263        )
264        .await
265        .expect_err("non-2107 error should be returned");
266
267        assert!(error.to_string().contains("0x04"));
268        assert_eq!(strategy.direct_paths, vec!["UDT_ARRAY[3].DINT_MEMBER"]);
269        assert!(strategy.rmw_calls.is_empty());
270    }
271}