Skip to main content

rust_ethernet_ip/
plc_manager.rs

1use crate::EipClient;
2use crate::error::{EtherNetIpError, Result};
3use std::collections::HashMap;
4use std::net::SocketAddr;
5use std::time::{Duration, Instant};
6
7/// Configuration for a PLC connection
8#[derive(Debug, Clone)]
9pub struct PlcConfig {
10    /// IP address and port of the PLC
11    pub address: SocketAddr,
12    /// Maximum number of connections to maintain
13    pub max_connections: u32,
14    /// Connection timeout in milliseconds
15    pub connection_timeout: Duration,
16    /// Health check interval in milliseconds
17    pub health_check_interval: Duration,
18    /// Maximum packet size in bytes
19    pub max_packet_size: usize,
20}
21
22impl Default for PlcConfig {
23    fn default() -> Self {
24        Self {
25            address: SocketAddr::from(([127, 0, 0, 1], 44818)),
26            max_connections: 5,
27            connection_timeout: Duration::from_secs(5),
28            health_check_interval: Duration::from_secs(30),
29            max_packet_size: 4000,
30        }
31    }
32}
33
34/// Represents the health status of a PLC connection
35#[derive(Debug, Clone)]
36pub struct ConnectionHealth {
37    /// Whether the connection is currently active
38    pub is_active: bool,
39    /// Last successful communication timestamp
40    pub last_success: Instant,
41    /// Number of failed attempts since last success
42    pub failed_attempts: u32,
43    /// Current connection latency in milliseconds
44    pub latency: Duration,
45}
46
47/// Represents a connection to a PLC
48#[derive(Debug)]
49pub struct PlcConnection {
50    /// The EIP client instance
51    client: EipClient,
52    /// Health status of the connection
53    health: ConnectionHealth,
54    /// Last time this connection was used
55    last_used: Instant,
56}
57
58impl PlcConnection {
59    /// Creates a new PLC connection
60    pub fn new(client: EipClient) -> Self {
61        Self {
62            client,
63            health: ConnectionHealth {
64                is_active: true,
65                last_success: Instant::now(),
66                failed_attempts: 0,
67                latency: Duration::from_millis(0),
68            },
69            last_used: Instant::now(),
70        }
71    }
72
73    /// Updates the health status of the connection
74    pub fn update_health(&mut self, is_active: bool, latency: Duration) {
75        self.health.is_active = is_active;
76        if is_active {
77            self.health.last_success = Instant::now();
78            self.health.failed_attempts = 0;
79            self.health.latency = latency;
80        } else {
81            self.health.failed_attempts += 1;
82        }
83    }
84}
85
86/// Manager for multiple PLC connections
87#[derive(Debug)]
88#[deprecated(
89    since = "1.2.0",
90    note = "PlcManager predates Fleet and cannot provide concurrent pooled use through its &mut self API; use Fleet for multi-PLC management. The type will be removed in 2.0."
91)]
92pub struct PlcManager {
93    /// Configuration for each PLC
94    configs: HashMap<SocketAddr, PlcConfig>,
95    /// Active connections for each PLC
96    connections: HashMap<SocketAddr, Vec<PlcConnection>>,
97}
98
99#[expect(
100    deprecated,
101    reason = "CODEX-AQ keeps PlcManager compatibility until 2.0 removal"
102)]
103impl Default for PlcManager {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109#[expect(
110    deprecated,
111    reason = "CODEX-AQ keeps PlcManager compatibility until 2.0 removal"
112)]
113impl PlcManager {
114    /// Creates a new PLC manager
115    pub fn new() -> Self {
116        Self {
117            configs: HashMap::new(),
118            connections: HashMap::new(),
119        }
120    }
121
122    /// Adds a PLC configuration
123    pub fn add_plc(&mut self, config: PlcConfig) {
124        self.configs.insert(config.address, config);
125    }
126
127    /// Gets a connection to a PLC
128    pub async fn get_connection(&mut self, address: SocketAddr) -> Result<&mut EipClient> {
129        let config = self
130            .configs
131            .get(&address)
132            .ok_or_else(|| EtherNetIpError::Connection("PLC not configured".to_string()))?;
133        let max_connections = config.max_connections as usize;
134        let max_packet_size = config.max_packet_size as u32;
135
136        // First check if we have any connections for this address
137        if let std::collections::hash_map::Entry::Vacant(e) = self.connections.entry(address) {
138            // No connections exist, create a new one
139            let mut client = EipClient::new(&address.to_string()).await?;
140            client.set_max_packet_size(max_packet_size);
141            let mut new_conn = PlcConnection::new(client);
142            new_conn.last_used = Instant::now();
143            e.insert(vec![new_conn]);
144            let connections = self.connections.get_mut(&address).ok_or_else(|| {
145                EtherNetIpError::Connection("connection pool missing after insert".to_string())
146            })?;
147            let connection = connections.first_mut().ok_or_else(|| {
148                EtherNetIpError::Connection("connection pool was empty after insert".to_string())
149            })?;
150            return Ok(&mut connection.client);
151        }
152
153        // Get mutable access to the connections
154        let connections = self
155            .connections
156            .get_mut(&address)
157            .ok_or_else(|| EtherNetIpError::Connection("connection pool missing".to_string()))?;
158
159        // First try to find an inactive connection
160        if let Some(i) = connections
161            .iter()
162            .position(|connection| !connection.health.is_active)
163        {
164            let mut client = EipClient::new(&address.to_string()).await?;
165            client.set_max_packet_size(max_packet_size);
166            let connection = &mut connections[i];
167            connection.client = client;
168            connection.health.is_active = true;
169            connection.health.last_success = Instant::now();
170            connection.health.failed_attempts = 0;
171            connection.health.latency = Duration::from_millis(0);
172            connection.last_used = Instant::now();
173            return Ok(&mut connection.client);
174        }
175
176        // If we have room for more connections, create a new one
177        if connections.len() < max_connections {
178            let mut client = EipClient::new(&address.to_string()).await?;
179            client.set_max_packet_size(max_packet_size);
180            let mut new_conn = PlcConnection::new(client);
181            new_conn.last_used = Instant::now();
182            connections.push(new_conn);
183            let connection = connections.last_mut().ok_or_else(|| {
184                EtherNetIpError::Connection("connection pool was empty after push".to_string())
185            })?;
186            return Ok(&mut connection.client);
187        }
188
189        // Pool is full: return the least-recently-used existing connection.
190        // Recreating the client here causes avoidable reconnect churn.
191        let lru_index = connections
192            .iter()
193            .enumerate()
194            .min_by_key(|(_, conn)| conn.last_used)
195            .map(|(i, _)| i)
196            .ok_or_else(|| EtherNetIpError::Connection("connection pool is empty".to_string()))?;
197
198        // Mark usage and return the existing client to maximize reuse.
199        connections[lru_index].last_used = Instant::now();
200        Ok(&mut connections[lru_index].client)
201    }
202
203    /// Performs health checks on all connections
204    pub async fn check_health(&mut self) {
205        for (address, connections) in &mut self.connections {
206            for conn in connections.iter_mut() {
207                if !conn.health.is_active {
208                    let new_client = EipClient::new(&address.to_string()).await;
209                    if let Ok(new_client) = new_client {
210                        conn.client = new_client;
211                        conn.health.is_active = true;
212                        conn.health.last_success = Instant::now();
213                        conn.health.failed_attempts = 0;
214                        conn.health.latency = Duration::from_millis(0);
215                        conn.last_used = Instant::now();
216                    }
217                }
218            }
219        }
220    }
221
222    /// Removes inactive connections
223    pub fn cleanup_connections(&mut self) {
224        for connections in self.connections.values_mut() {
225            connections.retain(|conn| conn.health.is_active);
226        }
227    }
228
229    pub async fn get_client(&mut self, address: &str) -> Result<&mut EipClient> {
230        let addr = address
231            .parse::<SocketAddr>()
232            .map_err(|_| EtherNetIpError::Connection("Invalid address format".to_string()))?;
233        self.get_connection(addr).await
234    }
235}
236
237#[cfg(test)]
238#[expect(
239    deprecated,
240    reason = "CODEX-AQ keeps PlcManager unit coverage until 2.0 removal"
241)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_plc_config_default() {
247        let config = PlcConfig::default();
248        assert_eq!(config.max_connections, 5);
249        assert_eq!(config.max_packet_size, 4000);
250    }
251
252    #[tokio::test]
253    async fn test_plc_manager_connection_pool() {
254        let mut manager = PlcManager::new();
255        let config = PlcConfig {
256            address: "127.0.0.1:44818".parse().unwrap(),
257            max_connections: 2,
258            ..Default::default()
259        };
260        manager.add_plc(config.clone());
261
262        // This will fail in tests since there's no actual PLC
263        // but it demonstrates the connection pool logic
264        let result = manager.get_connection(config.address).await;
265        assert!(result.is_err());
266    }
267}