Skip to main content

rusty_modbus_sim/
simulator.rs

1//! `ModbusSimulator` — wraps `ModbusServer` with YAML-configurable register maps.
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5
6use rusty_modbus_server::ModbusServer;
7use rusty_modbus_server::config::{DeviceIdentification, ServerConfig};
8use rusty_modbus_server::store::memory::{InMemoryStore, StoreConfig};
9use rusty_modbus_types::UnitId;
10
11use crate::config::{CoilBlock, RegisterBlock, RegisterConfig, SimConfig};
12use crate::error::SimError;
13
14const MODBUS_ADDRESS_SPACE: usize = 65_536;
15
16/// Device simulator wrapping a `ModbusServer` with preconfigured register maps.
17pub struct ModbusSimulator {
18    config: SimConfig,
19    store: Arc<InMemoryStore>,
20    server: Option<ModbusServer<InMemoryStore>>,
21}
22
23impl ModbusSimulator {
24    /// Create a simulator from a YAML configuration string.
25    ///
26    /// # Errors
27    ///
28    /// Returns [`SimError::ConfigParse`] if the YAML is invalid, or
29    /// [`SimError::Config`] if any configured block is out of range.
30    pub fn from_yaml(yaml: &str) -> Result<Self, SimError> {
31        let config: SimConfig = serde_yaml_ng::from_str(yaml).map_err(SimError::ConfigParse)?;
32        Self::from_config(config)
33    }
34
35    /// Create a simulator from a programmatic configuration.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`SimError::Config`] if any configured block exceeds the Modbus
40    /// 16-bit address space.
41    pub fn from_config(config: SimConfig) -> Result<Self, SimError> {
42        validate_register_config(&config.registers)?;
43
44        let store = Arc::new(InMemoryStore::try_new(StoreConfig::default())?);
45        apply_register_config(&store, &config.registers);
46
47        Ok(Self {
48            config,
49            store,
50            server: None,
51        })
52    }
53
54    /// Start the simulator server. Returns the bound address.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`SimError::Server`] if the server fails to start.
59    pub async fn start(&mut self) -> Result<SocketAddr, SimError> {
60        let listen_addr: SocketAddr = self
61            .config
62            .device
63            .listen_addr
64            .parse()
65            .map_err(|e| SimError::Config(format!("invalid listen address: {e}")))?;
66
67        let server_config = ServerConfig {
68            listen_addr,
69            unit_id: UnitId(self.config.device.unit_id),
70            device_id: DeviceIdentification {
71                vendor_name: self.config.device.vendor_name.clone(),
72                product_code: self.config.device.product_code.clone(),
73                major_minor_revision: self.config.device.revision.clone(),
74                ..DeviceIdentification::default()
75            },
76            ..ServerConfig::default()
77        };
78
79        let server = ModbusServer::start(server_config, Arc::clone(&self.store))
80            .await
81            .map_err(SimError::Server)?;
82
83        let addr = server.local_addr();
84        self.server = Some(server);
85        Ok(addr)
86    }
87
88    /// Stop the simulator server.
89    pub async fn stop(&mut self) {
90        if let Some(server) = &self.server {
91            server.stop().await;
92        }
93        self.server = None;
94    }
95
96    /// Update a holding register at runtime.
97    pub fn set_holding_register(&self, address: u16, value: u16) -> Result<(), SimError> {
98        self.store
99            .set_holding_register(address, value)
100            .map_err(SimError::Store)
101    }
102
103    /// Update an input register at runtime.
104    pub fn set_input_register(&self, address: u16, value: u16) -> Result<(), SimError> {
105        self.store
106            .set_input_register(address, value)
107            .map_err(SimError::Store)
108    }
109
110    /// Update a coil at runtime.
111    pub fn set_coil(&self, address: u16, value: bool) -> Result<(), SimError> {
112        self.store.set_coil(address, value).map_err(SimError::Store)
113    }
114
115    /// Get the bound address (only valid after `start()`).
116    ///
117    /// # Panics
118    ///
119    /// Panics if the server hasn't been started.
120    #[must_use]
121    pub fn local_addr(&self) -> SocketAddr {
122        self.server
123            .as_ref()
124            .expect("simulator not started")
125            .local_addr()
126    }
127}
128
129impl std::fmt::Debug for ModbusSimulator {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("ModbusSimulator")
132            .field("unit_id", &self.config.device.unit_id)
133            .field("running", &self.server.is_some())
134            .finish_non_exhaustive()
135    }
136}
137
138fn validate_register_config(config: &RegisterConfig) -> Result<(), SimError> {
139    for block in &config.holding {
140        check_block_range("holding", block.address, block.count)?;
141    }
142    for block in &config.input {
143        check_block_range("input", block.address, block.count)?;
144    }
145    for block in &config.coils {
146        check_block_range("coils", block.address, block.count)?;
147    }
148    for block in &config.discrete_inputs {
149        check_block_range("discrete_inputs", block.address, block.count)?;
150    }
151    Ok(())
152}
153
154fn check_block_range(kind: &str, address: u16, count: u16) -> Result<(), SimError> {
155    let end = usize::from(address) + usize::from(count);
156    if end <= MODBUS_ADDRESS_SPACE {
157        Ok(())
158    } else {
159        Err(SimError::Config(format!(
160            "{kind} block at address {address} with count {count} exceeds Modbus address space"
161        )))
162    }
163}
164
165/// Apply register configuration to the in-memory store.
166fn apply_register_config(store: &InMemoryStore, config: &RegisterConfig) {
167    apply_register_blocks(&config.holding, |address, value| {
168        store
169            .set_holding_register(address, value)
170            .expect("validated holding register config should fit store");
171    });
172    apply_register_blocks(&config.input, |address, value| {
173        store
174            .set_input_register(address, value)
175            .expect("validated input register config should fit store");
176    });
177    apply_coil_blocks(&config.coils, |address, value| {
178        store
179            .set_coil(address, value)
180            .expect("validated coil config should fit store");
181    });
182    apply_coil_blocks(&config.discrete_inputs, |address, value| {
183        store
184            .set_discrete_input(address, value)
185            .expect("validated discrete input config should fit store");
186    });
187}
188
189fn apply_register_blocks(blocks: &[RegisterBlock], mut set: impl FnMut(u16, u16)) {
190    for block in blocks {
191        for (i, &val) in block.initial.iter().enumerate() {
192            if i < usize::from(block.count)
193                && let Some(address) = offset_address(block.address, i)
194            {
195                set(address, val);
196            }
197        }
198    }
199}
200
201fn apply_coil_blocks(blocks: &[CoilBlock], mut set: impl FnMut(u16, bool)) {
202    for block in blocks {
203        for (i, &val) in block.initial.iter().enumerate() {
204            if i < usize::from(block.count)
205                && let Some(address) = offset_address(block.address, i)
206            {
207                set(address, val);
208            }
209        }
210    }
211}
212
213fn offset_address(address: u16, offset: usize) -> Option<u16> {
214    let offset = u16::try_from(offset).ok()?;
215    address.checked_add(offset)
216}