Skip to main content

rust_ethernet_ip/client/
actor.rs

1//! Actor-backed shared client and opt-in retry wrapper.
2
3use super::EipClient;
4use crate::batch::{BatchError, BatchOperation, BatchResult};
5use crate::error::{EtherNetIpError, Result};
6use crate::monitoring::DiagnosticsSnapshot;
7use crate::route::RoutePath;
8use crate::types::PlcValue;
9use std::time::Duration;
10use tokio::sync::{broadcast, mpsc, oneshot};
11
12type BatchReadResults = Vec<(String, std::result::Result<PlcValue, BatchError>)>;
13type BatchWriteResults = Vec<(String, std::result::Result<(), BatchError>)>;
14
15/// Connection lifecycle event emitted by [`Client`].
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ConnectionEvent {
18    /// The worker started with a connected EtherNet/IP session.
19    Connected,
20    /// The worker explicitly disconnected from the controller.
21    Disconnected,
22    /// The actor command loop exited.
23    WorkerStopped,
24}
25
26/// Cloneable client handle that serializes operations through one worker task.
27#[derive(Debug, Clone)]
28pub struct Client {
29    tx: mpsc::Sender<ClientCommand>,
30    events: broadcast::Sender<ConnectionEvent>,
31}
32
33/// Delay strategy applied between retry attempts.
34#[derive(Debug, Clone)]
35pub enum Backoff {
36    /// Use the same delay between every attempt.
37    Constant(Duration),
38    /// Multiply the delay after each failed attempt up to a ceiling.
39    Exponential {
40        /// Delay before the first retry.
41        initial: Duration,
42        /// Maximum delay between attempts.
43        max: Duration,
44        /// Multiplier applied after each attempt.
45        factor: u32,
46    },
47}
48
49/// Retry limits and backoff behavior for [`RetryClient`].
50#[derive(Debug, Clone)]
51pub struct RetryPolicy {
52    /// Maximum total attempts, including the initial request.
53    pub max_attempts: usize,
54    /// Delay strategy between attempts.
55    pub backoff: Backoff,
56    /// Whether writes may be retried; disabled by default to avoid duplicate effects.
57    pub retry_writes: bool,
58}
59
60impl RetryPolicy {
61    /// Creates a policy with a fixed delay and writes disabled.
62    pub fn constant(max_attempts: usize, delay: Duration) -> Self {
63        Self {
64            max_attempts: max_attempts.max(1),
65            backoff: Backoff::Constant(delay),
66            retry_writes: false,
67        }
68    }
69
70    /// Creates a factor-two exponential policy capped at `max`.
71    pub fn exponential(max_attempts: usize, initial: Duration, max: Duration) -> Self {
72        Self {
73            max_attempts: max_attempts.max(1),
74            backoff: Backoff::Exponential {
75                initial,
76                max,
77                factor: 2,
78            },
79            retry_writes: false,
80        }
81    }
82
83    /// Enables or disables write retries.
84    pub fn retry_writes(mut self, retry_writes: bool) -> Self {
85        self.retry_writes = retry_writes;
86        self
87    }
88
89    fn delay_for_attempt(&self, attempt_index: usize) -> Duration {
90        match self.backoff {
91            Backoff::Constant(delay) => delay,
92            Backoff::Exponential {
93                initial,
94                max,
95                factor,
96            } => {
97                let multiplier = factor.saturating_pow(attempt_index as u32);
98                initial.saturating_mul(multiplier).min(max)
99            }
100        }
101    }
102}
103
104/// Actor-backed client plus a retry policy for transient errors.
105#[derive(Clone)]
106pub struct RetryClient {
107    client: Client,
108    policy: RetryPolicy,
109}
110
111enum ClientCommand {
112    ReadTag {
113        tag_name: String,
114        reply: oneshot::Sender<Result<PlcValue>>,
115    },
116    WriteTag {
117        tag_name: String,
118        value: PlcValue,
119        reply: oneshot::Sender<Result<()>>,
120    },
121    WriteStringTag {
122        tag_name: String,
123        value: String,
124        reply: oneshot::Sender<Result<()>>,
125    },
126    WriteUdtMember {
127        tag_name: String,
128        member_name: String,
129        value: PlcValue,
130        reply: oneshot::Sender<Result<()>>,
131    },
132    ExecuteBatch {
133        operations: Vec<BatchOperation>,
134        reply: oneshot::Sender<Result<Vec<BatchResult>>>,
135    },
136    ReadTagsBatch {
137        tag_names: Vec<String>,
138        reply: oneshot::Sender<Result<BatchReadResults>>,
139    },
140    WriteTagsBatch {
141        tag_values: Vec<(String, PlcValue)>,
142        reply: oneshot::Sender<Result<BatchWriteResults>>,
143    },
144    CheckHealth {
145        reply: oneshot::Sender<bool>,
146    },
147    Diagnostics {
148        verified: bool,
149        reply: oneshot::Sender<Result<DiagnosticsSnapshot>>,
150    },
151}
152
153impl Client {
154    /// Connects directly to a controller and starts the client worker.
155    pub async fn connect(addr: &str) -> Result<Self> {
156        Self::from_eip_client(EipClient::connect(addr).await?)
157    }
158
159    /// Connects using an ordered route and starts the client worker.
160    pub async fn with_route_path(addr: &str, route: RoutePath) -> Result<Self> {
161        Self::from_eip_client(EipClient::with_route_path(addr, route).await?)
162    }
163
164    /// Takes ownership of an existing connected client and starts its worker.
165    pub fn from_eip_client(client: EipClient) -> Result<Self> {
166        let (tx, rx) = mpsc::channel(128);
167        let (events, _) = broadcast::channel(128);
168        let actor = Self {
169            tx,
170            events: events.clone(),
171        };
172
173        tokio::spawn(run_client_actor(client, rx, events));
174        Ok(actor)
175    }
176
177    /// Subscribes to connection lifecycle events.
178    pub fn events(&self) -> broadcast::Receiver<ConnectionEvent> {
179        self.events.subscribe()
180    }
181
182    /// Returns a retrying view of this client.
183    pub fn with_retry(&self, policy: RetryPolicy) -> RetryClient {
184        RetryClient {
185            client: self.clone(),
186            policy,
187        }
188    }
189
190    /// Reads one symbolic tag.
191    pub async fn read_tag(&self, tag_name: &str) -> Result<PlcValue> {
192        let (reply, rx) = oneshot::channel();
193        self.send(ClientCommand::ReadTag {
194            tag_name: tag_name.to_string(),
195            reply,
196        })
197        .await?;
198        rx.await.unwrap_or_else(|_| actor_stopped())
199    }
200
201    /// Writes one symbolic tag.
202    pub async fn write_tag(&self, tag_name: &str, value: PlcValue) -> Result<()> {
203        let (reply, rx) = oneshot::channel();
204        self.send(ClientCommand::WriteTag {
205            tag_name: tag_name.to_string(),
206            value,
207            reply,
208        })
209        .await?;
210        rx.await.unwrap_or_else(|_| actor_stopped())
211    }
212
213    /// Writes a built-in or custom Logix string using handle discovery.
214    pub async fn write_string_tag(&self, tag_name: &str, value: &str) -> Result<()> {
215        let (reply, rx) = oneshot::channel();
216        self.send(ClientCommand::WriteStringTag {
217            tag_name: tag_name.to_string(),
218            value: value.to_string(),
219            reply,
220        })
221        .await?;
222        rx.await.unwrap_or_else(|_| actor_stopped())
223    }
224
225    /// Writes one named member of a UDT tag.
226    pub async fn write_udt_member(
227        &self,
228        udt_tag_name: &str,
229        member_name: &str,
230        value: PlcValue,
231    ) -> Result<()> {
232        let (reply, rx) = oneshot::channel();
233        self.send(ClientCommand::WriteUdtMember {
234            tag_name: udt_tag_name.to_string(),
235            member_name: member_name.to_string(),
236            value,
237            reply,
238        })
239        .await?;
240        rx.await.unwrap_or_else(|_| actor_stopped())
241    }
242
243    /// Writes one member of a UDT array element.
244    pub async fn write_udt_array_member(
245        &self,
246        udt_array_element_path: &str,
247        member_name: &str,
248        value: PlcValue,
249    ) -> Result<()> {
250        self.write_udt_member(udt_array_element_path, member_name, value)
251            .await
252    }
253
254    /// Executes mixed read and write operations using batch packet grouping.
255    pub async fn execute_batch(&self, operations: &[BatchOperation]) -> Result<Vec<BatchResult>> {
256        let (reply, rx) = oneshot::channel();
257        self.send(ClientCommand::ExecuteBatch {
258            operations: operations.to_vec(),
259            reply,
260        })
261        .await?;
262        rx.await.unwrap_or_else(|_| actor_stopped())
263    }
264
265    /// Reads several tags and preserves per-tag errors.
266    pub async fn read_tags_batch(&self, tag_names: &[&str]) -> Result<BatchReadResults> {
267        let (reply, rx) = oneshot::channel();
268        self.send(ClientCommand::ReadTagsBatch {
269            tag_names: tag_names.iter().map(|name| (*name).to_string()).collect(),
270            reply,
271        })
272        .await?;
273        rx.await.unwrap_or_else(|_| actor_stopped())
274    }
275
276    /// Writes several tags and preserves per-tag errors.
277    pub async fn write_tags_batch(
278        &self,
279        tag_values: &[(&str, PlcValue)],
280    ) -> Result<BatchWriteResults> {
281        let (reply, rx) = oneshot::channel();
282        self.send(ClientCommand::WriteTagsBatch {
283            tag_values: tag_values
284                .iter()
285                .map(|(name, value)| ((*name).to_string(), value.clone()))
286                .collect(),
287            reply,
288        })
289        .await?;
290        rx.await.unwrap_or_else(|_| actor_stopped())
291    }
292
293    /// Performs an active controller health check.
294    pub async fn check_health(&self) -> Result<bool> {
295        let (reply, rx) = oneshot::channel();
296        self.send(ClientCommand::CheckHealth { reply }).await?;
297        rx.await.map_err(|_| actor_stopped_error())
298    }
299
300    /// Returns a passive diagnostics snapshot from recorded operation state.
301    pub async fn get_diagnostics_snapshot(&self) -> Result<DiagnosticsSnapshot> {
302        self.diagnostics(false).await
303    }
304
305    /// Performs a health check and returns a verified diagnostics snapshot.
306    pub async fn get_diagnostics_snapshot_detailed(&self) -> Result<DiagnosticsSnapshot> {
307        self.diagnostics(true).await
308    }
309
310    async fn diagnostics(&self, verified: bool) -> Result<DiagnosticsSnapshot> {
311        let (reply, rx) = oneshot::channel();
312        self.send(ClientCommand::Diagnostics { verified, reply })
313            .await?;
314        rx.await.unwrap_or_else(|_| actor_stopped())
315    }
316
317    async fn send(&self, command: ClientCommand) -> Result<()> {
318        self.tx
319            .send(command)
320            .await
321            .map_err(|_| actor_stopped_error())
322    }
323}
324
325impl RetryClient {
326    /// Reads a tag, retrying retriable failures according to the policy.
327    pub async fn read_tag(&self, tag_name: &str) -> Result<PlcValue> {
328        self.retry(|| async { self.client.read_tag(tag_name).await })
329            .await
330    }
331
332    /// Writes a tag; retries occur only when explicitly enabled by the policy.
333    pub async fn write_tag(&self, tag_name: &str, value: PlcValue) -> Result<()> {
334        if !self.policy.retry_writes {
335            return self.client.write_tag(tag_name, value).await;
336        }
337
338        self.retry(|| {
339            let value = value.clone();
340            async move { self.client.write_tag(tag_name, value).await }
341        })
342        .await
343    }
344
345    /// Writes a string; retries occur only when explicitly enabled by the policy.
346    pub async fn write_string_tag(&self, tag_name: &str, value: &str) -> Result<()> {
347        if !self.policy.retry_writes {
348            return self.client.write_string_tag(tag_name, value).await;
349        }
350
351        self.retry(|| async { self.client.write_string_tag(tag_name, value).await })
352            .await
353    }
354
355    async fn retry<T, Fut, Op>(&self, mut op: Op) -> Result<T>
356    where
357        Fut: std::future::Future<Output = Result<T>>,
358        Op: FnMut() -> Fut,
359    {
360        let mut attempt = 0;
361        loop {
362            match op().await {
363                Ok(value) => return Ok(value),
364                Err(err) if err.is_retriable() && attempt + 1 < self.policy.max_attempts => {
365                    let delay = self.policy.delay_for_attempt(attempt);
366                    attempt += 1;
367                    tokio::time::sleep(delay).await;
368                }
369                Err(err) => return Err(err),
370            }
371        }
372    }
373}
374
375async fn run_client_actor(
376    mut client: EipClient,
377    mut rx: mpsc::Receiver<ClientCommand>,
378    events: broadcast::Sender<ConnectionEvent>,
379) {
380    let _ = events.send(ConnectionEvent::Connected);
381
382    while let Some(command) = rx.recv().await {
383        match command {
384            ClientCommand::ReadTag { tag_name, reply } => {
385                let _ = reply.send(client.read_tag(&tag_name).await);
386            }
387            ClientCommand::WriteTag {
388                tag_name,
389                value,
390                reply,
391            } => {
392                let _ = reply.send(client.write_tag(&tag_name, value).await);
393            }
394            ClientCommand::WriteStringTag {
395                tag_name,
396                value,
397                reply,
398            } => {
399                let _ = reply.send(client.write_string_tag(&tag_name, &value).await);
400            }
401            ClientCommand::WriteUdtMember {
402                tag_name,
403                member_name,
404                value,
405                reply,
406            } => {
407                let _ = reply.send(
408                    client
409                        .write_udt_member(&tag_name, &member_name, value)
410                        .await,
411                );
412            }
413            ClientCommand::ExecuteBatch { operations, reply } => {
414                let _ = reply.send(client.execute_batch(&operations).await);
415            }
416            ClientCommand::ReadTagsBatch { tag_names, reply } => {
417                let refs: Vec<&str> = tag_names.iter().map(String::as_str).collect();
418                let _ = reply.send(client.read_tags_batch(&refs).await);
419            }
420            ClientCommand::WriteTagsBatch { tag_values, reply } => {
421                let refs: Vec<(&str, PlcValue)> = tag_values
422                    .iter()
423                    .map(|(name, value)| (name.as_str(), value.clone()))
424                    .collect();
425                let _ = reply.send(client.write_tags_batch(&refs).await);
426            }
427            ClientCommand::CheckHealth { reply } => {
428                let _ = reply.send(client.check_health().await);
429            }
430            ClientCommand::Diagnostics { verified, reply } => {
431                let result = if verified {
432                    client.get_diagnostics_snapshot_detailed().await
433                } else {
434                    Ok(client.get_diagnostics_snapshot().await)
435                };
436                let _ = reply.send(result);
437            }
438        }
439    }
440
441    let _ = client.unregister_session().await;
442    let _ = events.send(ConnectionEvent::Disconnected);
443    let _ = events.send(ConnectionEvent::WorkerStopped);
444}
445
446fn actor_stopped<T>() -> Result<T> {
447    Err(actor_stopped_error())
448}
449
450fn actor_stopped_error() -> EtherNetIpError {
451    EtherNetIpError::ConnectionLost("client actor stopped".to_string())
452}