1#[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
100type ClientFuture<'a, T> =
105 Pin<Box<dyn Future<Output = std::result::Result<T, EtherNetIpError>> + Send + 'a>>;
106
107type ConnectFuture<'a> = Pin<
112 Box<
113 dyn Future<Output = std::result::Result<Box<dyn ExplicitClient>, EtherNetIpError>>
114 + Send
115 + 'a,
116 >,
117>;
118
119trait 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 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
177pub struct ExplicitSession {
190 addr: String,
191 route_path_slots: Vec<u8>,
192 client: Option<Box<dyn ExplicitClient>>,
194 connector: Box<dyn ExplicitConnector>,
195}
196
197impl ExplicitSession {
198 pub async fn connect(addr: &str) -> Result<Self> {
204 Self::connect_with_connector(addr, Vec::new(), Box::new(EipConnector)).await
205 }
206
207 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 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 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 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 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 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 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 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 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
460fn 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}