Skip to main content

instro_ethernetip/
lib.rs

1//! Async EtherNet/IP explicit messaging for simple PLC tag reads and writes.
2//!
3//! This crate provides a small, crate-owned API around `rust-ethernet-ip` for the common
4//! explicit-messaging workflow:
5//!
6//! - connect to one target with [`ExplicitSession::connect`]
7//! - read one or more tags as crate-owned [`Value`]s
8//! - write crate-owned [`Value`]s back to tags
9//! - use [`ExplicitSession::read_tag_struct`] and [`ExplicitSession::write_tag_struct`] for
10//!   caller-defined structured payloads
11//! - explicitly unregister the session with [`ExplicitSession::close`]
12//!
13//! The public API intentionally hides backend transport types so callers can work with a
14//! stable interface centered on [`ExplicitSession`], [`Value`], and [`StructuredValue`].
15//!
16//! # Recovery behavior
17//!
18//! The backend client automatically retries or falls back for some protocol-level transient
19//! failures before an operation returns. If a read, batch read, or write still returns an
20//! error that `rust_ethernet_ip::EtherNetIpError::is_retriable` marks retriable,
21//! [`ExplicitSession`] drops the dead client. The failed operation is returned to the caller, and
22//! the next read or write automatically reconnects with the same address and route path before
23//! issuing the request.
24//!
25//! # Features
26//!
27//! By default, this crate exposes the async [`ExplicitSession`] API. Async callers provide their
28//! own Tokio runtime by awaiting the session methods.
29//!
30//! Enable the `blocking` feature to add `blocking::ExplicitSession`, a synchronous wrapper around
31//! the async session. The blocking API supports the same connect, read, write, structured-value,
32//! and close operations, and it drives them on a private shared Tokio runtime.
33//!
34//! Do not call the blocking API from inside Tokio async code: it uses `Handle::block_on`, which
35//! panics when invoked from an async execution context. Async callers should use the default
36//! [`ExplicitSession`] API directly.
37//!
38//! # Examples
39//!
40//! Connect to a target, read a tag, write an updated value, then close the session:
41//!
42//! ```no_run
43//! use instro_ethernetip::{ExplicitSession, Result, Value};
44//!
45//! # fn main() -> Result<()> {
46//! let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
47//! runtime.block_on(async {
48//!     let mut session = ExplicitSession::connect("192.168.1.10:44818").await?;
49//!
50//!     let motor_running = session.read_tag("MotorRunning").await?;
51//!     assert!(matches!(motor_running, Value::Bool(_)));
52//!
53//!     session.write_tag("CommandSpeed", 1_500_i32.into()).await?;
54//!     session.close().await
55//! })
56//! # }
57//! ```
58//!
59//! Convert Rust values into crate-owned PLC values without exposing backend types:
60//!
61//! ```
62//! use instro_ethernetip::Value;
63//!
64//! assert_eq!(Value::from(true), Value::Bool(true));
65//! assert_eq!(Value::from(42_i32), Value::Dint(42));
66//! assert_eq!(Value::from("ready"), Value::String("ready".to_owned()));
67//! ```
68//!
69//! Preserve a user-defined type payload as opaque bytes with [`StructuredValue`]:
70//!
71//! ```
72//! use instro_ethernetip::{StructuredValue, Value};
73//!
74//! let payload = StructuredValue {
75//!     symbol_id: Some(7),
76//!     data: vec![0xde, 0xad, 0xbe, 0xef],
77//! };
78//!
79//! assert_eq!(Value::from(payload.clone()), Value::Struct(payload));
80//! ```
81//!
82#[cfg(feature = "blocking")]
83pub mod blocking;
84
85pub use error::{BatchReadError, Error};
86pub use value::{StructuredValue, Value};
87
88use std::future::Future;
89use std::pin::Pin;
90
91use rust_ethernet_ip::{BatchError, EipClient, EtherNetIpError, PlcValue, RoutePath};
92
93mod error;
94#[cfg(test)]
95mod mock_client;
96mod value;
97
98pub type Result<T> = std::result::Result<T, Error>;
99
100/// Boxed future used for multithreaded runtime compatibility.
101///
102/// The trait needs the explicit [`Send`] bound, which means this seam cannot use `async fn` in
103/// the trait and instead returns a boxed future directly.
104type ClientFuture<'a, T> =
105    Pin<Box<dyn Future<Output = std::result::Result<T, EtherNetIpError>> + Send + 'a>>;
106
107/// Boxed future returned by [`ExplicitConnector::connect`].
108///
109/// The future represents the in-flight connect operation and resolves to a newly connected
110/// explicit client; it is not the long-lived connection itself.
111type ConnectFuture<'a> = Pin<
112    Box<
113        dyn Future<Output = std::result::Result<Box<dyn ExplicitClient>, EtherNetIpError>>
114            + Send
115            + 'a,
116    >,
117>;
118
119/// Private seam over [`EipClient`] for explicit tag operations and session teardown.
120///
121/// This stays 1:1 with [`EipClient`] so [`ExplicitSession`] can be unit-tested with a mock
122/// client.
123trait ExplicitClient: Send + Sync {
124    fn read_tag<'a>(&'a mut self, tag_name: &'a str) -> ClientFuture<'a, PlcValue>;
125    fn read_tags_batch<'a>(
126        &'a mut self,
127        tag_names: &'a [&'a str],
128    ) -> ClientFuture<'a, Vec<(String, std::result::Result<PlcValue, BatchError>)>>;
129    fn write_tag<'a>(&'a mut self, tag_name: &'a str, value: PlcValue) -> ClientFuture<'a, ()>;
130    fn unregister_session<'a>(&'a mut self) -> ClientFuture<'a, ()>;
131}
132
133trait ExplicitConnector: Send + Sync {
134    fn connect<'a>(&'a self, addr: &'a str, route_path_slots: &'a [u8]) -> ConnectFuture<'a>;
135}
136
137impl ExplicitClient for EipClient {
138    fn read_tag<'a>(&'a mut self, tag_name: &'a str) -> ClientFuture<'a, PlcValue> {
139        Box::pin(EipClient::read_tag(self, tag_name))
140    }
141
142    fn read_tags_batch<'a>(
143        &'a mut self,
144        tag_names: &'a [&'a str],
145    ) -> ClientFuture<'a, Vec<(String, std::result::Result<PlcValue, BatchError>)>> {
146        Box::pin(EipClient::read_tags_batch(self, tag_names))
147    }
148
149    fn write_tag<'a>(&'a mut self, tag_name: &'a str, value: PlcValue) -> ClientFuture<'a, ()> {
150        Box::pin(EipClient::write_tag(self, tag_name, value))
151    }
152
153    // Note that "register" is omitted in this trait because free function `EipClient::connect` does
154    // session registration implicitly.
155    fn unregister_session<'a>(&'a mut self) -> ClientFuture<'a, ()> {
156        Box::pin(EipClient::unregister_session(self))
157    }
158}
159
160struct EipConnector;
161
162impl ExplicitConnector for EipConnector {
163    fn connect<'a>(&'a self, addr: &'a str, route_path_slots: &'a [u8]) -> ConnectFuture<'a> {
164        Box::pin(async move {
165            let client = if route_path_slots.is_empty() {
166                EipClient::connect(addr).await?
167            } else {
168                let route_path = route_path_from_slots(route_path_slots);
169                EipClient::with_route_path(addr, route_path).await?
170            };
171
172            Ok(Box::new(client) as Box<dyn ExplicitClient>)
173        })
174    }
175}
176
177/// An active explicit-messaging EtherNet/IP session for a single target address.
178///
179/// Construct with [`ExplicitSession::connect`], use it for tag reads and writes, and call
180/// [`ExplicitSession::close`] to unregister the session when finished. Dropping
181/// [`ExplicitSession`] only drops the underlying transport; it does not perform the async
182/// unregister handshake. Some transient protocol failures are retried by the backend before an
183/// operation returns; retryable errors that still escape mark the client disconnected so the next
184/// operation reconnects automatically.
185///
186/// The session keeps the original target address and route path separate from the active backend
187/// client. Retryable transport failures drop only the client; the next operation recreates it from
188/// the saved connection identity.
189pub struct ExplicitSession {
190    addr: String,
191    route_path_slots: Vec<u8>,
192    /// Active backend client. `None` means reconnect before the next operation.
193    client: Option<Box<dyn ExplicitClient>>,
194    connector: Box<dyn ExplicitConnector>,
195}
196
197impl ExplicitSession {
198    /// Connect to an EtherNet/IP endpoint and register a session.
199    ///
200    /// `addr` must be parseable as a [`std::net::SocketAddr`] (for example `"192.168.1.10:44818"` or
201    /// `"[::1]:44818"`). Hostnames such as `"plc.local:44818"` are not resolved here.
202    /// Note that this implicitly registers a session with the target device on success.
203    pub async fn connect(addr: &str) -> Result<Self> {
204        Self::connect_with_connector(addr, Vec::new(), Box::new(EipConnector)).await
205    }
206
207    /// Connect to an EtherNet/IP endpoint through a backplane route path.
208    ///
209    /// The supplied slots are added to `rust-ethernet-ip`'s route path in order using
210    /// `RoutePath::add_slot`. This follows the upstream route-path surface exactly: all slot hops
211    /// are encoded before any future network hops.
212    pub async fn connect_with_route_path_slots(addr: &str, slots: &[u8]) -> Result<Self> {
213        Self::connect_with_connector(addr, slots.to_vec(), Box::new(EipConnector)).await
214    }
215
216    async fn connect_with_connector(
217        addr: &str,
218        route_path_slots: Vec<u8>,
219        connector: Box<dyn ExplicitConnector>,
220    ) -> Result<Self> {
221        let client = connector
222            .connect(addr, &route_path_slots)
223            .await
224            .map_err(|source| Error::Connect {
225                addr: addr.to_owned(),
226                source: Box::new(source),
227            })?;
228
229        Ok(Self {
230            addr: addr.to_owned(),
231            route_path_slots,
232            client: Some(client),
233            connector,
234        })
235    }
236
237    async fn reconnect_if_needed(&mut self) -> Result<()> {
238        if self.client.is_some() {
239            return Ok(());
240        }
241
242        let addr = self.addr.clone();
243        let route_path_slots = self.route_path_slots.clone();
244        let client = self
245            .connector
246            .connect(&addr, &route_path_slots)
247            .await
248            .map_err(|source| Error::Connect {
249                addr: self.addr.clone(),
250                source: Box::new(source),
251            })?;
252        self.client = Some(client);
253        Ok(())
254    }
255
256    /// Mark the current client disconnected after retryable failures that survive backend retry.
257    ///
258    /// The failed operation is still reported to the caller, and the next operation will attempt
259    /// to reconnect.
260    fn drop_client_if_retriable(&mut self, source: &EtherNetIpError) {
261        if source.is_retriable() {
262            self.client = None;
263        }
264    }
265
266    #[cfg(test)]
267    fn new_for_test<C>(addr: &str, client: C) -> Self
268    where
269        C: ExplicitClient + 'static,
270    {
271        Self::new_for_test_with_connector(addr, Vec::new(), client, Box::new(EipConnector))
272    }
273
274    #[cfg(test)]
275    fn new_for_test_with_connector<C>(
276        addr: &str,
277        route_path_slots: Vec<u8>,
278        client: C,
279        connector: Box<dyn ExplicitConnector>,
280    ) -> Self
281    where
282        C: ExplicitClient + 'static,
283    {
284        Self {
285            addr: addr.to_owned(),
286            route_path_slots,
287            client: Some(Box::new(client)),
288            connector,
289        }
290    }
291
292    /// Read the raw [`PlcValue`] for a tag.
293    async fn read_tag_raw(&mut self, tag_name: &str) -> Result<PlcValue> {
294        self.reconnect_if_needed().await?;
295        let result = self
296            .client
297            .as_mut()
298            .expect("client should be connected")
299            .read_tag(tag_name)
300            .await;
301
302        result.map_err(|source| {
303            self.drop_client_if_retriable(&source);
304            Error::ReadTag {
305                addr: self.addr.clone(),
306                tag_name: tag_name.to_owned(),
307                source: Box::new(source),
308            }
309        })
310    }
311
312    /// Read a [`Value`] for a tag.
313    pub async fn read_tag(&mut self, tag_name: &str) -> Result<Value> {
314        let value = self.read_tag_raw(tag_name).await?;
315        Ok(value.into())
316    }
317
318    /// Read a structured tag and decode it into a caller-owned type.
319    ///
320    /// This is a convenience wrapper around [`ExplicitSession::read_tag`] for tags backed by
321    /// user-defined types. Callers provide a [`TryFrom`] implementation from
322    /// [`StructuredValue`].
323    pub async fn read_tag_struct<T>(&mut self, tag_name: &str) -> Result<T>
324    where
325        T: TryFrom<StructuredValue>,
326        T::Error: std::error::Error + Send + Sync + 'static,
327    {
328        let value = self.read_tag(tag_name).await?;
329        let structured = match value {
330            Value::Struct(value) => value,
331            other => {
332                return Err(Error::UnexpectedValueType {
333                    addr: self.addr.clone(),
334                    tag_name: tag_name.to_owned(),
335                    actual_type: other.kind_name(),
336                });
337            }
338        };
339
340        structured
341            .try_into()
342            .map_err(|source| Error::DecodeStructuredTag {
343                addr: self.addr.clone(),
344                tag_name: tag_name.to_owned(),
345                target_type: std::any::type_name::<T>(),
346                source: Box::new(source),
347            })
348    }
349
350    /// Read several tags in a single batch request, preserving input order in the returned list.
351    ///
352    /// Tag reads are sent to the PLC as a CIP Multiple Service Packet via the upstream batch
353    /// API, which is significantly more efficient than issuing N separate reads. The upstream
354    /// driver transparently chunks the request when the tag list exceeds packet limits.
355    ///
356    /// The outer [`Result`] reports transport-level failures (the whole batch could not be
357    /// dispatched or its response could not be parsed). On success, the returned list contains
358    /// one entry per requested tag in input order, with a per-tag [`Result`] so partial failures
359    /// are first-class — a missing or type-mismatched tag does not prevent the other tags from
360    /// being returned. Per-tag errors are wrapped as [`Error::BatchReadItem`], whose typed
361    /// [`BatchReadError`] source preserves the upstream variant (tag-not-found, type mismatch,
362    /// CIP error, etc.) for caller branching.
363    pub async fn read_tags<S>(&mut self, tag_names: &[S]) -> Result<Vec<(String, Result<Value>)>>
364    where
365        S: AsRef<str>,
366    {
367        self.reconnect_if_needed().await?;
368        let refs: Vec<&str> = tag_names.iter().map(AsRef::as_ref).collect();
369
370        let batch = self
371            .client
372            .as_mut()
373            .expect("client should be connected")
374            .read_tags_batch(&refs)
375            .await
376            .map_err(|source| {
377                self.drop_client_if_retriable(&source);
378                Error::BatchRead {
379                    addr: self.addr.clone(),
380                    source: Box::new(source),
381                }
382            })?;
383
384        let mut has_retriable_item_error = false;
385        let values = batch
386            .into_iter()
387            .map(|(tag_name, result)| {
388                let value = result.map(Value::from).map_err(|source| {
389                    let source: BatchReadError = source.into();
390                    has_retriable_item_error |= source.is_retriable();
391                    Error::BatchReadItem {
392                        addr: self.addr.clone(),
393                        tag_name: tag_name.clone(),
394                        source,
395                    }
396                });
397                (tag_name, value)
398            })
399            .collect();
400
401        if has_retriable_item_error {
402            self.client = None;
403        }
404
405        Ok(values)
406    }
407
408    /// Write a user-facing [`Value`] to a PLC tag.
409    pub async fn write_tag(&mut self, tag_name: &str, value: Value) -> Result<()> {
410        self.reconnect_if_needed().await?;
411        let value: PlcValue = value.into();
412
413        self.client
414            .as_mut()
415            .expect("client should be connected")
416            .write_tag(tag_name, value)
417            .await
418            .map_err(|source| {
419                self.drop_client_if_retriable(&source);
420                Error::WriteTag {
421                    addr: self.addr.clone(),
422                    tag_name: tag_name.to_owned(),
423                    source: Box::new(source),
424                }
425            })
426    }
427
428    /// Encode a caller-owned type into a [`StructuredValue`] and write it to a tag.
429    ///
430    /// This is a convenience wrapper around [`ExplicitSession::write_tag`] for structured PLC
431    /// payloads. Callers provide an [`Into`] conversion to [`StructuredValue`].
432    pub async fn write_tag_struct<T>(&mut self, tag_name: &str, value: T) -> Result<()>
433    where
434        T: Into<StructuredValue>,
435    {
436        self.write_tag(tag_name, Value::Struct(value.into())).await
437    }
438
439    /// Unregister the explicit EtherNet/IP session.
440    ///
441    /// Call this before dropping [`ExplicitSession`] when you want graceful protocol-level
442    /// cleanup.
443    /// If unregister fails, `self` is still consumed and the caller cannot retry; that is
444    /// acceptable here because the underlying connection is likely already broken anyway.
445    pub async fn close(mut self) -> Result<()> {
446        let Some(mut client) = self.client.take() else {
447            return Ok(());
448        };
449
450        client
451            .unregister_session()
452            .await
453            .map_err(|source| Error::Unregister {
454                addr: self.addr,
455                source: Box::new(source),
456            })
457    }
458}
459
460// Intentionally not a From impl for clarity
461fn route_path_from_slots(slots: &[u8]) -> RoutePath {
462    slots.iter().fold(RoutePath::new(), |route_path, slot| {
463        route_path.add_slot(*slot)
464    })
465}
466
467impl Value {
468    /// Internal only for error messages/labels
469    fn kind_name(&self) -> &'static str {
470        match self {
471            Self::Bool(_) => "bool",
472            Self::Sint(_) => "sint",
473            Self::Int(_) => "int",
474            Self::Dint(_) => "dint",
475            Self::Lint(_) => "lint",
476            Self::Usint(_) => "usint",
477            Self::Uint(_) => "uint",
478            Self::Udint(_) => "udint",
479            Self::Ulint(_) => "ulint",
480            Self::Real(_) => "real",
481            Self::Lreal(_) => "lreal",
482            Self::String(_) => "string",
483            Self::Struct(_) => "struct",
484        }
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    use std::error::Error as StdError;
493    use std::fmt;
494    use std::sync::{Arc, Mutex};
495
496    use rust_ethernet_ip::PlcValue;
497
498    use crate::mock_client::{
499        BatchReadResult, MockClient, MockConnector, MockConnectorState, MockState,
500    };
501
502    #[derive(Debug, PartialEq, Eq)]
503    struct ExampleStruct {
504        bytes: Vec<u8>,
505    }
506
507    impl From<ExampleStruct> for StructuredValue {
508        fn from(value: ExampleStruct) -> Self {
509            Self {
510                symbol_id: Some(11),
511                data: value.bytes,
512            }
513        }
514    }
515
516    #[derive(Debug, PartialEq, Eq)]
517    struct DecodeExampleStructError(&'static str);
518
519    impl fmt::Display for DecodeExampleStructError {
520        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
521            f.write_str(self.0)
522        }
523    }
524
525    impl StdError for DecodeExampleStructError {}
526
527    impl TryFrom<StructuredValue> for ExampleStruct {
528        type Error = DecodeExampleStructError;
529
530        fn try_from(value: StructuredValue) -> std::result::Result<Self, Self::Error> {
531            if value.data.is_empty() {
532                return Err(DecodeExampleStructError("expected non-empty payload"));
533            }
534
535            Ok(Self { bytes: value.data })
536        }
537    }
538
539    fn retryable_connection_error() -> EtherNetIpError {
540        EtherNetIpError::Connection("socket closed".to_owned())
541    }
542
543    fn mock_client_with_results(
544        read_results: Vec<std::result::Result<PlcValue, EtherNetIpError>>,
545        batch_read_results: Vec<BatchReadResult>,
546        write_results: Vec<std::result::Result<(), EtherNetIpError>>,
547    ) -> MockClient {
548        MockClient::new(
549            Arc::new(Mutex::new(MockState::default())),
550            read_results,
551            write_results,
552            Ok(()),
553        )
554        .with_batch_read_results(batch_read_results)
555    }
556
557    /// Verifies that a successful connector result becomes an active session with the requested address.
558    #[tokio::test]
559    async fn connect_wraps_client_and_preserves_address() {
560        let state = Arc::new(Mutex::new(MockState::default()));
561        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
562        let client = ExplicitSession::connect_with_connector(
563            "10.0.0.5",
564            Vec::new(),
565            Box::new(MockConnector::new(
566                connector_state.clone(),
567                vec![Ok(MockClient::new(state.clone(), vec![], vec![], Ok(())))],
568            )),
569        )
570        .await
571        .expect("connect should succeed");
572
573        assert_eq!(client.addr, "10.0.0.5");
574        assert_eq!(
575            connector_state
576                .lock()
577                .expect("mock connector state poisoned")
578                .connect_calls,
579            vec![("10.0.0.5".to_owned(), Vec::<u8>::new())]
580        );
581    }
582
583    /// Verifies that connection failures are returned with the target address in the public error.
584    #[tokio::test]
585    async fn connect_wraps_connection_errors() {
586        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
587        let result = ExplicitSession::connect_with_connector(
588            "10.0.0.5",
589            Vec::new(),
590            Box::new(MockConnector::new(
591                connector_state,
592                vec![Err(EtherNetIpError::Connection("refused".to_owned()))],
593            )),
594        )
595        .await;
596        let error = match result {
597            Ok(_) => panic!("connect should fail"),
598            Err(error) => error,
599        };
600
601        match error {
602            Error::Connect { addr, source } => {
603                assert_eq!(addr, "10.0.0.5");
604                assert_eq!(source.to_string(), "Connection error: refused");
605            }
606            other => panic!("unexpected error: {other:?}"),
607        }
608    }
609
610    /// Verifies that slot numbers are encoded into the upstream backplane route path in order.
611    #[test]
612    fn route_path_from_slots_adds_each_backplane_slot() {
613        let route_path = route_path_from_slots(&[2, 0]);
614
615        assert_eq!(route_path.slots, vec![2, 0]);
616        assert_eq!(route_path.ports, Vec::<u8>::new());
617        assert_eq!(route_path.addresses, Vec::<String>::new());
618        assert_eq!(route_path.to_cip_bytes(), vec![0x01, 0x02, 0x01, 0x00]);
619    }
620
621    /// Verifies that a raw PLC value is converted to the public value type and the tag is sent unchanged.
622    #[tokio::test]
623    async fn read_tag_converts_plc_value_and_records_tag_name() {
624        let state = Arc::new(Mutex::new(MockState::default()));
625        let mut session = ExplicitSession::new_for_test(
626            "plc.local",
627            MockClient::new(state.clone(), vec![Ok(PlcValue::Dint(42))], vec![], Ok(())),
628        );
629
630        let value = session
631            .read_tag("MotorSpeed")
632            .await
633            .expect("read should succeed");
634
635        assert_eq!(value, Value::Dint(42));
636        assert_eq!(
637            state.lock().expect("mock state poisoned").read_calls,
638            vec!["MotorSpeed".to_owned()]
639        );
640    }
641
642    /// Verifies that read errors keep the address, tag name, and backend error in the public error.
643    #[tokio::test]
644    async fn read_tag_wraps_read_errors_with_context() {
645        let mut session = ExplicitSession::new_for_test(
646            "plc.local",
647            MockClient::new(
648                Arc::new(Mutex::new(MockState::default())),
649                vec![Err(EtherNetIpError::TagNotFound("MissingTag".to_owned()))],
650                vec![],
651                Ok(()),
652            ),
653        );
654
655        let error = session
656            .read_tag("MissingTag")
657            .await
658            .expect_err("read should fail");
659
660        match error {
661            Error::ReadTag {
662                addr,
663                tag_name,
664                source,
665            } => {
666                assert_eq!(addr, "plc.local");
667                assert_eq!(tag_name, "MissingTag");
668                assert_eq!(source.to_string(), "Tag not found: MissingTag");
669            }
670            other => panic!("unexpected error: {other:?}"),
671        }
672    }
673
674    /// Verifies that a retryable read error is surfaced once and the next read reconnects automatically.
675    #[tokio::test]
676    async fn retryable_read_error_disconnects_and_next_read_reconnects() {
677        let first_state = Arc::new(Mutex::new(MockState::default()));
678        let reconnected_state = Arc::new(Mutex::new(MockState::default()));
679        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
680        let connector = MockConnector::new(
681            connector_state.clone(),
682            vec![Ok(MockClient::new(
683                reconnected_state.clone(),
684                vec![Ok(PlcValue::Dint(7))],
685                vec![],
686                Ok(()),
687            ))],
688        );
689        let mut session = ExplicitSession::new_for_test_with_connector(
690            "plc.local",
691            vec![2, 0],
692            MockClient::new(
693                first_state.clone(),
694                vec![Err(EtherNetIpError::ConnectionLost(
695                    "socket closed".to_owned(),
696                ))],
697                vec![],
698                Ok(()),
699            ),
700            Box::new(connector),
701        );
702
703        let error = session
704            .read_tag("Speed")
705            .await
706            .expect_err("first read should report the dropped connection");
707        match error {
708            Error::ReadTag {
709                addr,
710                tag_name,
711                source,
712            } => {
713                assert_eq!(addr, "plc.local");
714                assert_eq!(tag_name, "Speed");
715                assert_eq!(source.to_string(), "Connection lost: socket closed");
716            }
717            other => panic!("unexpected error: {other:?}"),
718        }
719
720        let value = session
721            .read_tag("Speed")
722            .await
723            .expect("second read should reconnect and succeed");
724
725        assert_eq!(value, Value::Dint(7));
726        assert_eq!(
727            first_state.lock().expect("mock state poisoned").read_calls,
728            vec!["Speed".to_owned()]
729        );
730        assert_eq!(
731            reconnected_state
732                .lock()
733                .expect("mock state poisoned")
734                .read_calls,
735            vec!["Speed".to_owned()]
736        );
737        assert_eq!(
738            connector_state
739                .lock()
740                .expect("mock connector state poisoned")
741                .connect_calls,
742            vec![("plc.local".to_owned(), vec![2, 0])]
743        );
744    }
745
746    /// Verifies that repeated retryable read failures can reconnect until a later success.
747    #[tokio::test]
748    async fn retryable_read_errors_can_repeat_before_success() {
749        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
750        let client = |result| mock_client_with_results(vec![result], vec![], vec![]);
751        let mut session = ExplicitSession::new_for_test_with_connector(
752            "plc.local",
753            Vec::new(),
754            client(Err(retryable_connection_error())),
755            Box::new(MockConnector::new(
756                connector_state.clone(),
757                [
758                    Ok(client(Err(retryable_connection_error()))),
759                    Ok(client(Err(retryable_connection_error()))),
760                    Ok(client(Err(retryable_connection_error()))),
761                    Ok(client(Err(retryable_connection_error()))),
762                    Ok(client(Ok(PlcValue::Dint(7)))),
763                ]
764                .into(),
765            )),
766        );
767
768        for _ in 0..5 {
769            session
770                .read_tag("Speed")
771                .await
772                .expect_err("retryable failure should be returned");
773        }
774
775        assert_eq!(
776            session
777                .read_tag("Speed")
778                .await
779                .expect("sixth read should reconnect and succeed"),
780            Value::Dint(7)
781        );
782        assert_eq!(
783            connector_state
784                .lock()
785                .expect("mock connector state poisoned")
786                .connect_calls
787                .len(),
788            5
789        );
790    }
791
792    /// Verifies that non-retryable read errors leave the current client connected for later calls.
793    #[tokio::test]
794    async fn non_retryable_read_error_keeps_existing_client() {
795        let state = Arc::new(Mutex::new(MockState::default()));
796        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
797        let mut session = ExplicitSession::new_for_test_with_connector(
798            "plc.local",
799            Vec::new(),
800            MockClient::new(
801                state.clone(),
802                vec![
803                    Err(EtherNetIpError::TagNotFound("MissingTag".to_owned())),
804                    Ok(PlcValue::Dint(11)),
805                ],
806                vec![],
807                Ok(()),
808            ),
809            Box::new(MockConnector::new(connector_state.clone(), vec![])),
810        );
811
812        session
813            .read_tag("MissingTag")
814            .await
815            .expect_err("first read should fail without disconnecting");
816
817        let value = session
818            .read_tag("NextTag")
819            .await
820            .expect("same client should handle the next read");
821
822        assert_eq!(value, Value::Dint(11));
823        assert_eq!(
824            state.lock().expect("mock state poisoned").read_calls,
825            vec!["MissingTag".to_owned(), "NextTag".to_owned()]
826        );
827        assert_eq!(
828            connector_state
829                .lock()
830                .expect("mock connector state poisoned")
831                .connect_calls,
832            Vec::<(String, Vec<u8>)>::new()
833        );
834    }
835
836    /// Verifies that batch reads make one backend batch call and preserve the caller's tag order.
837    #[tokio::test]
838    async fn read_tags_issues_single_batch_call_and_returns_values_in_input_order() {
839        let state = Arc::new(Mutex::new(MockState::default()));
840        let mut session = ExplicitSession::new_for_test(
841            "plc.local",
842            MockClient::new(state.clone(), vec![], vec![], Ok(())).with_batch_read_results(vec![
843                Ok(vec![
844                    Ok(PlcValue::Bool(true)),
845                    Ok(PlcValue::String("ok".to_owned())),
846                ]),
847            ]),
848        );
849
850        let values = session
851            .read_tags(&["Running", "Status"])
852            .await
853            .expect("batch read should succeed");
854
855        assert_eq!(values.len(), 2);
856        assert_eq!(values[0].0, "Running");
857        assert_eq!(
858            values[0].1.as_ref().expect("first read should succeed"),
859            &Value::Bool(true)
860        );
861        assert_eq!(values[1].0, "Status");
862        assert_eq!(
863            values[1].1.as_ref().expect("second read should succeed"),
864            &Value::String("ok".to_owned())
865        );
866
867        let locked = state.lock().expect("mock state poisoned");
868        assert_eq!(locked.read_calls, Vec::<String>::new());
869        assert_eq!(
870            locked.batch_read_calls,
871            vec![vec!["Running".to_owned(), "Status".to_owned()]]
872        );
873    }
874
875    /// Verifies that per-tag batch failures are returned beside successful values instead of failing the whole call.
876    #[tokio::test]
877    async fn read_tags_surfaces_per_tag_errors_alongside_successes() {
878        let state = Arc::new(Mutex::new(MockState::default()));
879        let mut session = ExplicitSession::new_for_test(
880            "plc.local",
881            MockClient::new(state.clone(), vec![], vec![], Ok(())).with_batch_read_results(vec![
882                Ok(vec![
883                    Ok(PlcValue::Bool(true)),
884                    Err(rust_ethernet_ip::BatchError::TagNotFound(
885                        "Status".to_owned(),
886                    )),
887                    Ok(PlcValue::Dint(7)),
888                ]),
889            ]),
890        );
891
892        let values = session
893            .read_tags(&["Running", "Status", "Counter"])
894            .await
895            .expect("batch read should succeed at transport level");
896
897        assert_eq!(values.len(), 3);
898        assert_eq!(values[0].0, "Running");
899        assert!(values[0].1.is_ok());
900
901        match &values[1].1 {
902            Err(Error::BatchReadItem {
903                addr,
904                tag_name,
905                source,
906            }) => {
907                assert_eq!(addr, "plc.local");
908                assert_eq!(tag_name, "Status");
909                assert!(matches!(source, BatchReadError::TagNotFound(t) if t == "Status"));
910            }
911            other => panic!("expected per-tag BatchReadItem error, got {other:?}"),
912        }
913
914        assert_eq!(values[2].0, "Counter");
915        assert_eq!(
916            values[2].1.as_ref().expect("third read should succeed"),
917            &Value::Dint(7)
918        );
919    }
920
921    /// Verifies that a batch data-type mismatch is preserved as the typed public batch error variant.
922    #[tokio::test]
923    async fn read_tags_preserves_data_type_mismatch_variant() {
924        let state = Arc::new(Mutex::new(MockState::default()));
925        let mut session = ExplicitSession::new_for_test(
926            "plc.local",
927            MockClient::new(state.clone(), vec![], vec![], Ok(())).with_batch_read_results(vec![
928                Ok(vec![Err(rust_ethernet_ip::BatchError::DataTypeMismatch {
929                    expected: "DINT".to_owned(),
930                    actual: "REAL".to_owned(),
931                })]),
932            ]),
933        );
934
935        let values = session
936            .read_tags(&["Counter"])
937            .await
938            .expect("batch read should succeed at transport level");
939
940        match &values[0].1 {
941            Err(Error::BatchReadItem { source, .. }) => match source {
942                BatchReadError::DataTypeMismatch { expected, actual } => {
943                    assert_eq!(expected, "DINT");
944                    assert_eq!(actual, "REAL");
945                }
946                other => panic!("expected DataTypeMismatch, got {other:?}"),
947            },
948            other => panic!("expected per-tag BatchReadItem error, got {other:?}"),
949        }
950    }
951
952    /// Verifies that a batch CIP error is preserved as the typed public batch error variant.
953    #[tokio::test]
954    async fn read_tags_preserves_cip_error_variant() {
955        let state = Arc::new(Mutex::new(MockState::default()));
956        let mut session = ExplicitSession::new_for_test(
957            "plc.local",
958            MockClient::new(state.clone(), vec![], vec![], Ok(())).with_batch_read_results(vec![
959                Ok(vec![Err(rust_ethernet_ip::BatchError::CipError {
960                    status: 0x04,
961                    message: "path segment error".to_owned(),
962                })]),
963            ]),
964        );
965
966        let values = session
967            .read_tags(&["Counter"])
968            .await
969            .expect("batch read should succeed at transport level");
970
971        match &values[0].1 {
972            Err(Error::BatchReadItem { source, .. }) => match source {
973                BatchReadError::Cip { status, message } => {
974                    assert_eq!(*status, 0x04);
975                    assert_eq!(message, "path segment error");
976                }
977                other => panic!("expected Cip variant, got {other:?}"),
978            },
979            other => panic!("expected per-tag BatchReadItem error, got {other:?}"),
980        }
981    }
982
983    /// Verifies that per-tag batch network failures mark the current client disconnected.
984    #[tokio::test]
985    async fn retryable_batch_item_error_disconnects_and_next_batch_reconnects() {
986        let first_state = Arc::new(Mutex::new(MockState::default()));
987        let reconnected_state = Arc::new(Mutex::new(MockState::default()));
988        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
989        let reconnected_client = MockClient::new(reconnected_state.clone(), vec![], vec![], Ok(()))
990            .with_batch_read_results(vec![Ok(vec![Ok(PlcValue::Dint(25))])]);
991        let mut session = ExplicitSession::new_for_test_with_connector(
992            "plc.local",
993            Vec::new(),
994            MockClient::new(first_state.clone(), vec![], vec![], Ok(())).with_batch_read_results(
995                vec![Ok(vec![Err(rust_ethernet_ip::BatchError::NetworkError(
996                    "IO error: Broken pipe (os error 32)".to_owned(),
997                ))])],
998            ),
999            Box::new(MockConnector::new(
1000                connector_state.clone(),
1001                vec![Ok(reconnected_client)],
1002            )),
1003        );
1004
1005        let values = session
1006            .read_tags(&["test_dint"])
1007            .await
1008            .expect("per-tag batch failure should not fail the outer batch call");
1009        match &values[0].1 {
1010            Err(Error::BatchReadItem { source, .. }) => {
1011                assert!(
1012                    matches!(source, BatchReadError::Network(message) if message.contains("Broken pipe"))
1013                );
1014            }
1015            other => panic!("expected per-tag network error, got {other:?}"),
1016        }
1017
1018        let values = session
1019            .read_tags(&["test_dint"])
1020            .await
1021            .expect("next batch should reconnect and succeed");
1022
1023        assert_eq!(
1024            values[0].1.as_ref().expect("tag should succeed"),
1025            &Value::Dint(25)
1026        );
1027        assert_eq!(
1028            first_state
1029                .lock()
1030                .expect("mock state poisoned")
1031                .batch_read_calls,
1032            vec![vec!["test_dint".to_owned()]]
1033        );
1034        assert_eq!(
1035            reconnected_state
1036                .lock()
1037                .expect("mock state poisoned")
1038                .batch_read_calls,
1039            vec![vec!["test_dint".to_owned()]]
1040        );
1041        assert_eq!(
1042            connector_state
1043                .lock()
1044                .expect("mock connector state poisoned")
1045                .connect_calls,
1046            vec![("plc.local".to_owned(), Vec::<u8>::new())]
1047        );
1048    }
1049
1050    /// Verifies that a retryable batch-read failure is surfaced once and the next batch reconnects automatically.
1051    #[tokio::test]
1052    async fn retryable_batch_read_error_disconnects_and_next_batch_reconnects() {
1053        let first_state = Arc::new(Mutex::new(MockState::default()));
1054        let reconnected_state = Arc::new(Mutex::new(MockState::default()));
1055        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
1056        let reconnected_client = MockClient::new(reconnected_state.clone(), vec![], vec![], Ok(()))
1057            .with_batch_read_results(vec![Ok(vec![Ok(PlcValue::Bool(true))])]);
1058        let mut session = ExplicitSession::new_for_test_with_connector(
1059            "plc.local",
1060            Vec::new(),
1061            MockClient::new(first_state.clone(), vec![], vec![], Ok(())).with_batch_read_results(
1062                vec![Err(EtherNetIpError::Connection(
1063                    "batch socket closed".to_owned(),
1064                ))],
1065            ),
1066            Box::new(MockConnector::new(
1067                connector_state.clone(),
1068                vec![Ok(reconnected_client)],
1069            )),
1070        );
1071
1072        let error = session
1073            .read_tags(&["Running"])
1074            .await
1075            .expect_err("first batch should report the dropped connection");
1076        match error {
1077            Error::BatchRead { addr, source } => {
1078                assert_eq!(addr, "plc.local");
1079                assert_eq!(source.to_string(), "Connection error: batch socket closed");
1080            }
1081            other => panic!("unexpected error: {other:?}"),
1082        }
1083
1084        let values = session
1085            .read_tags(&["Running"])
1086            .await
1087            .expect("second batch should reconnect and succeed");
1088
1089        assert_eq!(
1090            values[0].1.as_ref().expect("tag should succeed"),
1091            &Value::Bool(true)
1092        );
1093        assert_eq!(
1094            first_state
1095                .lock()
1096                .expect("mock state poisoned")
1097                .batch_read_calls,
1098            vec![vec!["Running".to_owned()]]
1099        );
1100        assert_eq!(
1101            reconnected_state
1102                .lock()
1103                .expect("mock state poisoned")
1104                .batch_read_calls,
1105            vec![vec!["Running".to_owned()]]
1106        );
1107        assert_eq!(
1108            connector_state
1109                .lock()
1110                .expect("mock connector state poisoned")
1111                .connect_calls,
1112            vec![("plc.local".to_owned(), Vec::<u8>::new())]
1113        );
1114    }
1115
1116    /// Verifies that repeated retryable batch failures can reconnect until a later success.
1117    #[tokio::test]
1118    async fn retryable_batch_read_errors_can_repeat_before_success() {
1119        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
1120        let client =
1121            |result: BatchReadResult| mock_client_with_results(vec![], vec![result], vec![]);
1122        let mut session = ExplicitSession::new_for_test_with_connector(
1123            "plc.local",
1124            Vec::new(),
1125            client(Err(retryable_connection_error())),
1126            Box::new(MockConnector::new(
1127                connector_state.clone(),
1128                [
1129                    Ok(client(Err(retryable_connection_error()))),
1130                    Ok(client(Err(retryable_connection_error()))),
1131                    Ok(client(Err(retryable_connection_error()))),
1132                    Ok(client(Err(retryable_connection_error()))),
1133                    Ok(client(Ok(vec![Ok(PlcValue::Bool(true))]))),
1134                ]
1135                .into(),
1136            )),
1137        );
1138
1139        for _ in 0..5 {
1140            session
1141                .read_tags(&["Running"])
1142                .await
1143                .expect_err("retryable failure should be returned");
1144        }
1145
1146        let values = session
1147            .read_tags(&["Running"])
1148            .await
1149            .expect("sixth batch should reconnect and succeed");
1150
1151        assert_eq!(
1152            values[0].1.as_ref().expect("tag should succeed"),
1153            &Value::Bool(true)
1154        );
1155        assert_eq!(
1156            connector_state
1157                .lock()
1158                .expect("mock connector state poisoned")
1159                .connect_calls
1160                .len(),
1161            5
1162        );
1163    }
1164
1165    /// Verifies that writes pass the caller's tag name and converted value to the backend unchanged.
1166    #[tokio::test]
1167    async fn write_tag_passes_through_value_and_tag_name() {
1168        let state = Arc::new(Mutex::new(MockState::default()));
1169        let mut session = ExplicitSession::new_for_test(
1170            "plc.local",
1171            MockClient::new(state.clone(), vec![], vec![Ok(())], Ok(())),
1172        );
1173
1174        session
1175            .write_tag("Setpoint", Value::Real(12.5))
1176            .await
1177            .expect("write should succeed");
1178
1179        let locked = state.lock().expect("mock state poisoned");
1180        assert_eq!(locked.write_calls.len(), 1);
1181        assert_eq!(locked.write_calls[0].0, "Setpoint");
1182        assert!(matches!(locked.write_calls[0].1, PlcValue::Real(value) if value == 12.5));
1183    }
1184
1185    /// Regression guard for retryable write failures: the failed write is reported, the same call is not issued again by this wrapper, and the next operation reconnects automatically.
1186    #[tokio::test]
1187    async fn retryable_write_error_disconnects_and_next_operation_reconnects() {
1188        let first_state = Arc::new(Mutex::new(MockState::default()));
1189        let reconnected_state = Arc::new(Mutex::new(MockState::default()));
1190        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
1191        let mut session = ExplicitSession::new_for_test_with_connector(
1192            "plc.local",
1193            Vec::new(),
1194            MockClient::new(
1195                first_state.clone(),
1196                vec![],
1197                vec![Err(EtherNetIpError::Connection("write failed".to_owned()))],
1198                Ok(()),
1199            ),
1200            Box::new(MockConnector::new(
1201                connector_state.clone(),
1202                vec![Ok(MockClient::new(
1203                    reconnected_state.clone(),
1204                    vec![Ok(PlcValue::Dint(12))],
1205                    vec![],
1206                    Ok(()),
1207                ))],
1208            )),
1209        );
1210
1211        let error = session
1212            .write_tag("Setpoint", Value::Dint(12))
1213            .await
1214            .expect_err("write should report the dropped connection");
1215        match error {
1216            Error::WriteTag {
1217                addr,
1218                tag_name,
1219                source,
1220            } => {
1221                assert_eq!(addr, "plc.local");
1222                assert_eq!(tag_name, "Setpoint");
1223                assert_eq!(source.to_string(), "Connection error: write failed");
1224            }
1225            other => panic!("unexpected error: {other:?}"),
1226        }
1227
1228        let value = session
1229            .read_tag("Setpoint")
1230            .await
1231            .expect("next operation should reconnect");
1232
1233        assert_eq!(value, Value::Dint(12));
1234        assert_eq!(
1235            first_state
1236                .lock()
1237                .expect("mock state poisoned")
1238                .write_calls
1239                .len(),
1240            1
1241        );
1242        assert_eq!(
1243            reconnected_state
1244                .lock()
1245                .expect("mock state poisoned")
1246                .read_calls,
1247            vec!["Setpoint".to_owned()]
1248        );
1249        assert_eq!(
1250            connector_state
1251                .lock()
1252                .expect("mock connector state poisoned")
1253                .connect_calls,
1254            vec![("plc.local".to_owned(), Vec::<u8>::new())]
1255        );
1256    }
1257
1258    /// Verifies that repeated retryable write failures can reconnect until a later success.
1259    #[tokio::test]
1260    async fn retryable_write_errors_can_repeat_before_success() {
1261        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
1262        let client = |result| mock_client_with_results(vec![], vec![], vec![result]);
1263        let mut session = ExplicitSession::new_for_test_with_connector(
1264            "plc.local",
1265            Vec::new(),
1266            client(Err(retryable_connection_error())),
1267            Box::new(MockConnector::new(
1268                connector_state.clone(),
1269                [
1270                    Ok(client(Err(retryable_connection_error()))),
1271                    Ok(client(Err(retryable_connection_error()))),
1272                    Ok(client(Err(retryable_connection_error()))),
1273                    Ok(client(Err(retryable_connection_error()))),
1274                    Ok(client(Ok(()))),
1275                ]
1276                .into(),
1277            )),
1278        );
1279
1280        for _ in 0..5 {
1281            session
1282                .write_tag("Setpoint", Value::Dint(12))
1283                .await
1284                .expect_err("retryable failure should be returned");
1285        }
1286
1287        session
1288            .write_tag("Setpoint", Value::Dint(12))
1289            .await
1290            .expect("sixth write should reconnect and succeed");
1291        assert_eq!(
1292            connector_state
1293                .lock()
1294                .expect("mock connector state poisoned")
1295                .connect_calls
1296                .len(),
1297            5
1298        );
1299    }
1300
1301    /// Verifies that a failed automatic reconnect is reported as a connection error for the same address.
1302    #[tokio::test]
1303    async fn reconnect_failure_is_returned_as_connect_error() {
1304        let connector_state = Arc::new(Mutex::new(MockConnectorState::default()));
1305        let mut session = ExplicitSession::new_for_test_with_connector(
1306            "plc.local",
1307            Vec::new(),
1308            MockClient::new(
1309                Arc::new(Mutex::new(MockState::default())),
1310                vec![Err(EtherNetIpError::Connection("dropped".to_owned()))],
1311                vec![],
1312                Ok(()),
1313            ),
1314            Box::new(MockConnector::new(
1315                connector_state.clone(),
1316                vec![Err(EtherNetIpError::Connection("still down".to_owned()))],
1317            )),
1318        );
1319
1320        session
1321            .read_tag("Speed")
1322            .await
1323            .expect_err("first read should mark the client disconnected");
1324
1325        let error = session
1326            .read_tag("Speed")
1327            .await
1328            .expect_err("reconnect should fail clearly");
1329
1330        match error {
1331            Error::Connect { addr, source } => {
1332                assert_eq!(addr, "plc.local");
1333                assert_eq!(source.to_string(), "Connection error: still down");
1334            }
1335            other => panic!("unexpected error: {other:?}"),
1336        }
1337        assert_eq!(
1338            connector_state
1339                .lock()
1340                .expect("mock connector state poisoned")
1341                .connect_calls,
1342            vec![("plc.local".to_owned(), Vec::<u8>::new())]
1343        );
1344    }
1345
1346    /// Verifies that structured writes encode the caller-owned type before sending it to the backend.
1347    #[tokio::test]
1348    async fn write_tag_struct_converts_typed_value() {
1349        let state = Arc::new(Mutex::new(MockState::default()));
1350        let mut session = ExplicitSession::new_for_test(
1351            "plc.local",
1352            MockClient::new(state.clone(), vec![], vec![Ok(())], Ok(())),
1353        );
1354
1355        session
1356            .write_tag_struct(
1357                "Recipe",
1358                ExampleStruct {
1359                    bytes: vec![1, 2, 3],
1360                },
1361            )
1362            .await
1363            .expect("write should succeed");
1364
1365        let locked = state.lock().expect("mock state poisoned");
1366        assert_eq!(locked.write_calls.len(), 1);
1367        assert_eq!(locked.write_calls[0].0, "Recipe");
1368        assert_eq!(
1369            locked.write_calls[0].1,
1370            PlcValue::Udt(rust_ethernet_ip::UdtData {
1371                symbol_id: 11,
1372                data: vec![1, 2, 3],
1373            })
1374        );
1375    }
1376
1377    /// Verifies that structured reads decode backend UDT bytes into the caller-owned target type.
1378    #[tokio::test]
1379    async fn read_tag_struct_decodes_typed_value() {
1380        let mut session = ExplicitSession::new_for_test(
1381            "plc.local",
1382            MockClient::new(
1383                Arc::new(Mutex::new(MockState::default())),
1384                vec![Ok(PlcValue::Udt(rust_ethernet_ip::UdtData {
1385                    symbol_id: 11,
1386                    data: vec![9, 8, 7],
1387                }))],
1388                vec![],
1389                Ok(()),
1390            ),
1391        );
1392
1393        let value: ExampleStruct = session
1394            .read_tag_struct("Recipe")
1395            .await
1396            .expect("read should succeed");
1397
1398        assert_eq!(
1399            value,
1400            ExampleStruct {
1401                bytes: vec![9, 8, 7]
1402            }
1403        );
1404    }
1405
1406    /// Verifies that structured reads reject scalar PLC values before attempting user decoding.
1407    #[tokio::test]
1408    async fn read_tag_struct_rejects_non_struct_value() {
1409        let mut session = ExplicitSession::new_for_test(
1410            "plc.local",
1411            MockClient::new(
1412                Arc::new(Mutex::new(MockState::default())),
1413                vec![Ok(PlcValue::Bool(true))],
1414                vec![],
1415                Ok(()),
1416            ),
1417        );
1418
1419        let error = session
1420            .read_tag_struct::<ExampleStruct>("Recipe")
1421            .await
1422            .expect_err("read should fail");
1423
1424        match error {
1425            Error::UnexpectedValueType {
1426                addr,
1427                tag_name,
1428                actual_type,
1429            } => {
1430                assert_eq!(addr, "plc.local");
1431                assert_eq!(tag_name, "Recipe");
1432                assert_eq!(actual_type, "bool");
1433            }
1434            other => panic!("unexpected error: {other:?}"),
1435        }
1436    }
1437
1438    /// Verifies that structured decode failures include the address, tag name, and target type.
1439    #[tokio::test]
1440    async fn read_tag_struct_wraps_decode_errors_with_context() {
1441        let mut session = ExplicitSession::new_for_test(
1442            "plc.local",
1443            MockClient::new(
1444                Arc::new(Mutex::new(MockState::default())),
1445                vec![Ok(PlcValue::Udt(rust_ethernet_ip::UdtData {
1446                    symbol_id: 11,
1447                    data: vec![],
1448                }))],
1449                vec![],
1450                Ok(()),
1451            ),
1452        );
1453
1454        let error = session
1455            .read_tag_struct::<ExampleStruct>("Recipe")
1456            .await
1457            .expect_err("decode should fail");
1458
1459        match error {
1460            Error::DecodeStructuredTag {
1461                addr,
1462                tag_name,
1463                target_type,
1464                source,
1465            } => {
1466                assert_eq!(addr, "plc.local");
1467                assert_eq!(tag_name, "Recipe");
1468                assert!(target_type.ends_with("ExampleStruct"));
1469                assert_eq!(source.to_string(), "expected non-empty payload");
1470            }
1471            other => panic!("unexpected error: {other:?}"),
1472        }
1473    }
1474
1475    /// Verifies that closing a session unregisters it through the backend client.
1476    #[tokio::test]
1477    async fn close_unregisters_session() {
1478        let state = Arc::new(Mutex::new(MockState::default()));
1479        let session = ExplicitSession::new_for_test(
1480            "plc.local",
1481            MockClient::new(state.clone(), vec![], vec![], Ok(())),
1482        );
1483
1484        session.close().await.expect("close should succeed");
1485
1486        assert_eq!(
1487            state.lock().expect("mock state poisoned").unregister_calls,
1488            1
1489        );
1490    }
1491}