Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions crates/modbusmaster-app/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,8 @@ pub async fn read_once(
pub struct WriteSingleRegRequest {
pub address: u16,
pub value: u16,
#[serde(default)]
pub slave_id: Option<u8>,
}

#[tauri::command]
Expand All @@ -1007,7 +1009,7 @@ pub async fn write_single_register(

conn_state
.connection
.write_single_register(request.address, request.value)
.write_single_register_with_slave(request.slave_id, request.address, request.value)
.await
.map_err(|e| format!("{}", e))
}
Expand All @@ -1016,6 +1018,8 @@ pub async fn write_single_register(
pub struct WriteSingleCoilRequest {
pub address: u16,
pub value: bool,
#[serde(default)]
pub slave_id: Option<u8>,
}

#[tauri::command]
Expand All @@ -1031,7 +1035,7 @@ pub async fn write_single_coil(

conn_state
.connection
.write_single_coil(request.address, request.value)
.write_single_coil_with_slave(request.slave_id, request.address, request.value)
.await
.map_err(|e| format!("{}", e))
}
Expand All @@ -1040,6 +1044,8 @@ pub async fn write_single_coil(
pub struct WriteMultiRegsRequest {
pub address: u16,
pub values: Vec<u16>,
#[serde(default)]
pub slave_id: Option<u8>,
}

#[tauri::command]
Expand All @@ -1055,7 +1061,7 @@ pub async fn write_multiple_registers(

conn_state
.connection
.write_multiple_registers(request.address, &request.values)
.write_multiple_registers_with_slave(request.slave_id, request.address, &request.values)
.await
.map_err(|e| format!("{}", e))
}
Expand All @@ -1064,6 +1070,8 @@ pub async fn write_multiple_registers(
pub struct WriteMultiCoilsRequest {
pub address: u16,
pub values: Vec<bool>,
#[serde(default)]
pub slave_id: Option<u8>,
}

#[tauri::command]
Expand All @@ -1079,7 +1087,7 @@ pub async fn write_multiple_coils(

conn_state
.connection
.write_multiple_coils(request.address, &request.values)
.write_multiple_coils_with_slave(request.slave_id, request.address, &request.values)
.await
.map_err(|e| format!("{}", e))
}
Expand Down Expand Up @@ -1708,3 +1716,50 @@ pub async fn load_project_file(state: State<'_, AppState>, path: String) -> Resu
pub fn list_serial_ports() -> Vec<transport::SerialPortInfo> {
transport::list_serial_ports()
}

#[cfg(test)]
mod write_request_tests {
use super::*;
use serde_json::json;

#[test]
fn write_requests_preserve_optional_slave_ids() {
macro_rules! check_request {
($request:ty, $payload:expr) => {
let mut payload = $payload;
assert_eq!(
serde_json::from_value::<$request>(payload.clone())
.unwrap()
.slave_id,
None
);
for slave_id in [None, Some(2)] {
payload["slave_id"] = json!(slave_id);
assert_eq!(
serde_json::from_value::<$request>(payload.clone())
.unwrap()
.slave_id,
slave_id
);
}
for invalid in [json!(-1), json!(256), json!("2"), json!(2.5)] {
payload["slave_id"] = invalid;
assert!(serde_json::from_value::<$request>(payload.clone()).is_err());
}
};
}
check_request!(WriteSingleRegRequest, json!({ "address": 10, "value": 42 }));
check_request!(
WriteSingleCoilRequest,
json!({ "address": 10, "value": true })
);
check_request!(
WriteMultiRegsRequest,
json!({ "address": 10, "values": [42] })
);
check_request!(
WriteMultiCoilsRequest,
json!({ "address": 10, "values": [true] })
);
}
}
93 changes: 73 additions & 20 deletions crates/modbussim-core/src/master.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,37 +373,57 @@ impl MasterConnection {
.await
}

fn resolve_write_slave_id(&self, slave_id: Option<u8>) -> Result<u8, MasterError> {
if slave_id.is_some_and(|id| !(1..=247).contains(&id)) {
return Err(MasterError::InvalidConfig(
"slave ID must be between 1 and 247".into(),
));
}
Ok(slave_id.unwrap_or(self.config.slave_id))
}

/// Write a single coil (FC05).
pub async fn write_single_coil(&self, address: u16, value: bool) -> Result<(), MasterError> {
self.write_single_coil_with_slave(None, address, value)
.await
}

/// Write with an optional slave ID override (1..=247); `None` uses the connection default.
pub async fn write_single_coil_with_slave(
&self,
slave_id: Option<u8>,
address: u16,
value: bool,
) -> Result<(), MasterError> {
let slave_id = self.resolve_write_slave_id(slave_id)?;
let transport_ctx = self.get_transport_ctx()?;
let timeout = self.timeout_duration();
let _permit = self.request_pacer.acquire().await;
self.log_tx(
FunctionCode::WriteSingleCoil,
&format!("W {} = {}", address, value),
&format!("Slave {slave_id}: W {} = {}", address, value),
)
.await;
match &transport_ctx {
TransportCtx::Tcp(ctx) => {
let mut ctx = ctx.lock().await;
ctx.set_slave(Slave(self.config.slave_id));
ctx.set_slave(Slave(slave_id));
tokio::time::timeout(timeout, ctx.write_single_coil(address, value))
.await
.map_err(|_| MasterError::Timeout("Write single coil timed out".into()))?
.map_err(|e| MasterError::Transport(format!("{e}")))?
.map_err(MasterError::Exception)?;
}
TransportCtx::TcpTls(tls) => {
tls.write_single_coil(self.config.slave_id, address, value, timeout)
tls.write_single_coil(slave_id, address, value, timeout)
.await?;
}
other => {
let coil_value: u16 = if value { 0xFF00 } else { 0x0000 };
let mut pdu = vec![0x05];
pdu.extend_from_slice(&address.to_be_bytes());
pdu.extend_from_slice(&coil_value.to_be_bytes());
let resp =
send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?;
let resp = send_pdu_via_transport(other, slave_id, &pdu, timeout).await?;
check_write_response(&resp, 0x05)?;
}
}
Expand All @@ -412,34 +432,45 @@ impl MasterConnection {

/// Write a single holding register (FC06).
pub async fn write_single_register(&self, address: u16, value: u16) -> Result<(), MasterError> {
self.write_single_register_with_slave(None, address, value)
.await
}

/// Write with an optional slave ID override (1..=247); `None` uses the connection default.
pub async fn write_single_register_with_slave(
&self,
slave_id: Option<u8>,
address: u16,
value: u16,
) -> Result<(), MasterError> {
let slave_id = self.resolve_write_slave_id(slave_id)?;
let transport_ctx = self.get_transport_ctx()?;
let timeout = self.timeout_duration();
let _permit = self.request_pacer.acquire().await;
self.log_tx(
FunctionCode::WriteSingleRegister,
&format!("W {} = {:#06x}", address, value),
&format!("Slave {slave_id}: W {} = {:#06x}", address, value),
)
.await;
match &transport_ctx {
TransportCtx::Tcp(ctx) => {
let mut ctx = ctx.lock().await;
ctx.set_slave(Slave(self.config.slave_id));
ctx.set_slave(Slave(slave_id));
tokio::time::timeout(timeout, ctx.write_single_register(address, value))
.await
.map_err(|_| MasterError::Timeout("Write single register timed out".into()))?
.map_err(|e| MasterError::Transport(format!("{e}")))?
.map_err(MasterError::Exception)?;
}
TransportCtx::TcpTls(tls) => {
tls.write_single_register(self.config.slave_id, address, value, timeout)
tls.write_single_register(slave_id, address, value, timeout)
.await?;
}
other => {
let mut pdu = vec![0x06];
pdu.extend_from_slice(&address.to_be_bytes());
pdu.extend_from_slice(&value.to_be_bytes());
let resp =
send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?;
let resp = send_pdu_via_transport(other, slave_id, &pdu, timeout).await?;
check_write_response(&resp, 0x06)?;
}
}
Expand All @@ -452,26 +483,38 @@ impl MasterConnection {
address: u16,
values: &[bool],
) -> Result<(), MasterError> {
self.write_multiple_coils_with_slave(None, address, values)
.await
}

/// Write with an optional slave ID override (1..=247); `None` uses the connection default.
pub async fn write_multiple_coils_with_slave(
&self,
slave_id: Option<u8>,
address: u16,
values: &[bool],
) -> Result<(), MasterError> {
let slave_id = self.resolve_write_slave_id(slave_id)?;
let transport_ctx = self.get_transport_ctx()?;
let timeout = self.timeout_duration();
let _permit = self.request_pacer.acquire().await;
self.log_tx(
FunctionCode::WriteMultipleCoils,
&format!("W {} x{}", address, values.len()),
&format!("Slave {slave_id}: W {} x{}", address, values.len()),
)
.await;
match &transport_ctx {
TransportCtx::Tcp(ctx) => {
let mut ctx = ctx.lock().await;
ctx.set_slave(Slave(self.config.slave_id));
ctx.set_slave(Slave(slave_id));
tokio::time::timeout(timeout, ctx.write_multiple_coils(address, values))
.await
.map_err(|_| MasterError::Timeout("Write multiple coils timed out".into()))?
.map_err(|e| MasterError::Transport(format!("{e}")))?
.map_err(MasterError::Exception)?;
}
TransportCtx::TcpTls(tls) => {
tls.write_multiple_coils(self.config.slave_id, address, values, timeout)
tls.write_multiple_coils(slave_id, address, values, timeout)
.await?;
}
other => {
Expand All @@ -488,8 +531,7 @@ impl MasterConnection {
pdu.extend_from_slice(&quantity.to_be_bytes());
pdu.push(byte_count as u8);
pdu.extend_from_slice(&coil_bytes);
let resp =
send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?;
let resp = send_pdu_via_transport(other, slave_id, &pdu, timeout).await?;
check_write_response(&resp, 0x0F)?;
}
}
Expand All @@ -502,26 +544,38 @@ impl MasterConnection {
address: u16,
values: &[u16],
) -> Result<(), MasterError> {
self.write_multiple_registers_with_slave(None, address, values)
.await
}

/// Write with an optional slave ID override (1..=247); `None` uses the connection default.
pub async fn write_multiple_registers_with_slave(
&self,
slave_id: Option<u8>,
address: u16,
values: &[u16],
) -> Result<(), MasterError> {
let slave_id = self.resolve_write_slave_id(slave_id)?;
let transport_ctx = self.get_transport_ctx()?;
let timeout = self.timeout_duration();
let _permit = self.request_pacer.acquire().await;
self.log_tx(
FunctionCode::WriteMultipleRegisters,
&format!("W {} x{}", address, values.len()),
&format!("Slave {slave_id}: W {} x{}", address, values.len()),
)
.await;
match &transport_ctx {
TransportCtx::Tcp(ctx) => {
let mut ctx = ctx.lock().await;
ctx.set_slave(Slave(self.config.slave_id));
ctx.set_slave(Slave(slave_id));
tokio::time::timeout(timeout, ctx.write_multiple_registers(address, values))
.await
.map_err(|_| MasterError::Timeout("Write multiple registers timed out".into()))?
.map_err(|e| MasterError::Transport(format!("{e}")))?
.map_err(MasterError::Exception)?;
}
TransportCtx::TcpTls(tls) => {
tls.write_multiple_registers(self.config.slave_id, address, values, timeout)
tls.write_multiple_registers(slave_id, address, values, timeout)
.await?;
}
other => {
Expand All @@ -534,8 +588,7 @@ impl MasterConnection {
for v in values {
pdu.extend_from_slice(&v.to_be_bytes());
}
let resp =
send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?;
let resp = send_pdu_via_transport(other, slave_id, &pdu, timeout).await?;
check_write_response(&resp, 0x10)?;
}
}
Expand Down
34 changes: 34 additions & 0 deletions crates/modbussim-core/tests/tls_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,40 @@ async fn test_tls_read_holding_registers() {
assert!(found.is_empty());
assert!(progress_rx.recv().await.unwrap().cancelled);

// All TLS write functions must honor an explicit unit and preserve default fallback.
for (target, value) in [(Some(2), 22), (None, 11)] {
master
.write_single_coil_with_slave(target, 5, true)
.await
.unwrap();
master
.write_single_register_with_slave(target, 5, value)
.await
.unwrap();
master
.write_multiple_coils_with_slave(target, 7, &[true, false, true])
.await
.unwrap();
master
.write_multiple_registers_with_slave(target, 7, &[value; 3])
.await
.unwrap();
let devices = slave.devices.read().await;
for (slave_id, expected) in [(1, if target.is_some() { 0 } else { 11 }), (2, 22)] {
let map = &devices.get(&slave_id).unwrap().register_map;
assert_eq!(map.holding_registers.get(&5), Some(&expected));
assert_eq!(map.coils.get(&5), Some(&(expected != 0)));
for address in 7..10 {
assert_eq!(map.holding_registers.get(&address), Some(&expected));
assert_eq!(
map.coils.get(&address),
Some(&(expected != 0 && address != 8))
);
}
}
}
assert_eq!(master.config.slave_id, 1);

assert_eq!(slave.clients.list().len(), 1);
assert!(slave.clients.list()[0]
.peer_address
Expand Down
Loading
Loading