diff --git a/crates/modbusmaster-app/src/commands.rs b/crates/modbusmaster-app/src/commands.rs index a8f459c..3c21e5f 100644 --- a/crates/modbusmaster-app/src/commands.rs +++ b/crates/modbusmaster-app/src/commands.rs @@ -992,6 +992,8 @@ pub async fn read_once( pub struct WriteSingleRegRequest { pub address: u16, pub value: u16, + #[serde(default)] + pub slave_id: Option, } #[tauri::command] @@ -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)) } @@ -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, } #[tauri::command] @@ -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)) } @@ -1040,6 +1044,8 @@ pub async fn write_single_coil( pub struct WriteMultiRegsRequest { pub address: u16, pub values: Vec, + #[serde(default)] + pub slave_id: Option, } #[tauri::command] @@ -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)) } @@ -1064,6 +1070,8 @@ pub async fn write_multiple_registers( pub struct WriteMultiCoilsRequest { pub address: u16, pub values: Vec, + #[serde(default)] + pub slave_id: Option, } #[tauri::command] @@ -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)) } @@ -1708,3 +1716,50 @@ pub async fn load_project_file(state: State<'_, AppState>, path: String) -> Resu pub fn list_serial_ports() -> Vec { 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] }) + ); + } +} diff --git a/crates/modbussim-core/src/master.rs b/crates/modbussim-core/src/master.rs index 4579332..acf6966 100644 --- a/crates/modbussim-core/src/master.rs +++ b/crates/modbussim-core/src/master.rs @@ -373,20 +373,41 @@ impl MasterConnection { .await } + fn resolve_write_slave_id(&self, slave_id: Option) -> Result { + 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, + 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()))? @@ -394,7 +415,7 @@ impl MasterConnection { .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 => { @@ -402,8 +423,7 @@ impl MasterConnection { 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)?; } } @@ -412,18 +432,30 @@ 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, + 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()))? @@ -431,15 +463,14 @@ impl MasterConnection { .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)?; } } @@ -452,18 +483,30 @@ 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, + 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()))? @@ -471,7 +514,7 @@ impl MasterConnection { .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 => { @@ -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)?; } } @@ -502,18 +544,30 @@ 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, + 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()))? @@ -521,7 +575,7 @@ impl MasterConnection { .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 => { @@ -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)?; } } diff --git a/crates/modbussim-core/tests/tls_e2e.rs b/crates/modbussim-core/tests/tls_e2e.rs index 242128f..de6a828 100644 --- a/crates/modbussim-core/tests/tls_e2e.rs +++ b/crates/modbussim-core/tests/tls_e2e.rs @@ -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 diff --git a/crates/modbussim-core/tests/write_targets.rs b/crates/modbussim-core/tests/write_targets.rs new file mode 100644 index 0000000..fac0f5c --- /dev/null +++ b/crates/modbussim-core/tests/write_targets.rs @@ -0,0 +1,228 @@ +use modbussim_core::log_collector::LogCollector; +use modbussim_core::log_entry::FunctionCode; +use modbussim_core::master::{ + MasterConfig, MasterConnection, MasterError, PollEvent, ReadFunction, ReadResult, ScanGroup, +}; +use modbussim_core::slave::{SlaveConnection, SlaveDevice}; +use modbussim_core::transport::Transport; +use std::sync::Arc; +use std::time::Duration; + +async fn assert_written(slave: &SlaveConnection, slave_id: u8, coil: bool, value: u16) { + let devices = slave.devices.read().await; + let map = &devices.get(&slave_id).unwrap().register_map; + assert_eq!(map.coils.get(&10), Some(&coil)); + assert_eq!(map.holding_registers.get(&10), Some(&value)); + for address in 20..23 { + assert_eq!(map.coils.get(&address), Some(&coil)); + assert_eq!(map.holding_registers.get(&address), Some(&value)); + } +} + +async fn exercise_write_targets(rtu: bool) { + let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = reservation.local_addr().unwrap().port(); + drop(reservation); + let transport = if rtu { + Transport::RtuOverTcp { + host: "127.0.0.1".into(), + port, + } + } else { + Transport::Tcp { + host: "127.0.0.1".into(), + port, + } + }; + let mut slave = SlaveConnection::new(transport.clone()); + for slave_id in [1, 2] { + let mut device = SlaveDevice::with_default_registers(slave_id, "Write target", 30); + device + .register_map + .write_holding_register(0, u16::from(slave_id) * 1000); + slave.add_device(device).await.unwrap(); + } + slave.start().await.unwrap(); + let collector = Arc::new(LogCollector::new()); + let mut master = MasterConnection::new( + MasterConfig { + port, + slave_id: 1, + ..Default::default() + }, + transport, + ) + .with_log_collector(collector.clone()); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if master.connect().await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("slave must become ready"); + let group = ScanGroup { + id: "device-2".into(), + name: "Device 2".into(), + function: ReadFunction::ReadHoldingRegisters, + start_address: 0, + quantity: 1, + interval_ms: 1, + enabled: true, + slave_id: Some(2), + }; + let mut polls = master.start_scan_group(&group).await.unwrap(); + let event = tokio::time::timeout(Duration::from_secs(3), polls.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + event, + PollEvent::Data(ReadResult::HoldingRegisters(values)) if values == vec![2000] + )); + + // The selected group is device 2 while the connection still defaults to device 1. + master + .write_single_coil_with_slave(group.slave_id, 10, true) + .await + .unwrap(); + master + .write_single_register_with_slave(group.slave_id, 10, 42) + .await + .unwrap(); + master + .write_multiple_coils_with_slave(group.slave_id, 20, &[true; 3]) + .await + .unwrap(); + master + .write_multiple_registers_with_slave(group.slave_id, 20, &[42; 3]) + .await + .unwrap(); + assert_written(&slave, 2, true, 42).await; + assert_written(&slave, 1, false, 0).await; + assert_eq!(master.config.slave_id, 1); + + // Missing/null command targets must reset the shared TCP context to the default. + master + .write_single_coil_with_slave(None, 10, true) + .await + .unwrap(); + master + .write_single_register_with_slave(None, 10, 99) + .await + .unwrap(); + master + .write_multiple_coils_with_slave(None, 20, &[true; 3]) + .await + .unwrap(); + master + .write_multiple_registers_with_slave(None, 20, &[99; 3]) + .await + .unwrap(); + assert_written(&slave, 1, true, 99).await; + assert_written(&slave, 2, true, 42).await; + + // The original public methods remain source-compatible and retain their default target. + master.write_single_coil(10, false).await.unwrap(); + master.write_single_register(10, 77).await.unwrap(); + master.write_multiple_coils(20, &[false; 3]).await.unwrap(); + master.write_multiple_registers(20, &[77; 3]).await.unwrap(); + assert_written(&slave, 1, false, 77).await; + assert_written(&slave, 2, true, 42).await; + + // Reject broadcast/reserved overrides before sending anything, for every write function. + for slave_id in [0, 248, 255] { + let results = [ + master + .write_single_coil_with_slave(Some(slave_id), 10, true) + .await, + master + .write_single_register_with_slave(Some(slave_id), 10, 999) + .await, + master + .write_multiple_coils_with_slave(Some(slave_id), 20, &[true; 3]) + .await, + master + .write_multiple_registers_with_slave(Some(slave_id), 20, &[999; 3]) + .await, + ]; + for result in results { + assert!(matches!(result, Err(MasterError::InvalidConfig(_)))); + } + } + assert_written(&slave, 1, false, 77).await; + assert_written(&slave, 2, true, 42).await; + master.stop_all_scans().await; + while let Some(event) = polls.recv().await { + assert!(matches!( + event, + PollEvent::Data(ReadResult::HoldingRegisters(values)) if values == vec![2000] + )); + } + + let writes: Vec<_> = collector + .get_all() + .await + .into_iter() + .filter(|entry| { + matches!( + entry.function_code, + FunctionCode::WriteSingleCoil + | FunctionCode::WriteSingleRegister + | FunctionCode::WriteMultipleCoils + | FunctionCode::WriteMultipleRegisters + ) + }) + .collect(); + assert_eq!(writes.len(), 12); + for (index, entry) in writes.iter().enumerate() { + let slave_id = if index < 4 { 2 } else { 1 }; + assert!(entry.detail.starts_with(&format!("Slave {slave_id}: W "))); + } + master.disconnect().await.unwrap(); + slave.stop().await.unwrap(); +} + +#[tokio::test] +async fn tcp_writes_preserve_scan_group_target_and_default() { + exercise_write_targets(false).await; +} + +#[tokio::test] +async fn rtu_tcp_writes_preserve_scan_group_target_and_default() { + exercise_write_targets(true).await; +} + +#[tokio::test] +async fn write_target_validation_preserves_legacy_default_ids() { + let master = MasterConnection::new( + MasterConfig { + slave_id: 255, + ..Default::default() + }, + Transport::Tcp { + host: "127.0.0.1".into(), + port: 502, + }, + ); + // Validation accepts both override boundaries and the legacy configured default. + for slave_id in [None, Some(1), Some(247)] { + let results = [ + master.write_single_coil_with_slave(slave_id, 0, true).await, + master + .write_single_register_with_slave(slave_id, 0, 1) + .await, + master + .write_multiple_coils_with_slave(slave_id, 0, &[true]) + .await, + master + .write_multiple_registers_with_slave(slave_id, 0, &[1]) + .await, + ]; + for result in results { + assert!(matches!(result, Err(MasterError::NotConnected))); + } + } +} diff --git a/frontend/package.json b/frontend/package.json index cbedaea..5f77a9c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "@tanstack/vue-virtual": "^3", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2", - "vue": "^3.5.30", + "vue": "^3.5.42", "shared-frontend": "*" }, "devDependencies": { diff --git a/master-frontend/package.json b/master-frontend/package.json index 47aae81..1b365a2 100644 --- a/master-frontend/package.json +++ b/master-frontend/package.json @@ -12,7 +12,7 @@ "@tanstack/vue-virtual": "^3", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2", - "vue": "^3.5.30", + "vue": "^3.5.42", "shared-frontend": "*" }, "devDependencies": { diff --git a/master-frontend/src/components/Toolbar.vue b/master-frontend/src/components/Toolbar.vue index 8e216f2..24599c1 100644 --- a/master-frontend/src/components/Toolbar.vue +++ b/master-frontend/src/components/Toolbar.vue @@ -13,6 +13,7 @@ import { showAlert, showConfirm, } from 'shared-frontend' +import type { ScanGroupInfo } from '../types' import ScanDialog from './ScanDialog.vue' import NewConnectionDialog from './NewConnectionDialog.vue' import NewScanGroupDialog from './NewScanGroupDialog.vue' @@ -21,6 +22,7 @@ import WriteDialog from './WriteDialog.vue' const { t } = useI18n() const selectedConnectionId = inject>('selectedConnectionId')! +const selectedScanGroup = inject>('selectedScanGroup')! const selectedConnectionState = inject>('selectedConnectionState')! const refreshTree = inject<() => void>('refreshTree')! @@ -198,6 +200,7 @@ const quickActions = computed(() => [ diff --git a/master-frontend/src/components/ValuePanel.vue b/master-frontend/src/components/ValuePanel.vue index 746d055..67f3534 100644 --- a/master-frontend/src/components/ValuePanel.vue +++ b/master-frontend/src/components/ValuePanel.vue @@ -34,6 +34,15 @@ const allSameType = computed(() => { return sortedRegs.value.every(r => r.is_bool === t) }) +const contiguousSelection = computed(() => allSameType.value && sortedRegs.value.every((reg, index, regs) => + Number.isInteger(reg.address) && reg.address >= 0 && reg.address <= 65535 + && (index === 0 || reg.address === regs[index - 1].address + 1), +)) +const writeSlaveId = computed(() => selectedScanGroup.value?.slave_id ?? null) +const writeTarget = computed(() => writeSlaveId.value === null + ? t('valuePanel.writeDefaultTarget') + : t('valuePanel.writeTarget', { id: writeSlaveId.value })) + // Editing state const editingField = ref(null) const editValue = ref('') @@ -65,7 +74,7 @@ const rawValue = computed(() => firstReg.value ? Number(firstReg.value.raw_value const { signed16, unsigned16, hex16, binary16 } = use16BitFormat(rawValue) // 32-bit -const show32bit = computed(() => selCount.value >= 2 && allSameType.value && !isBoolType.value) +const show32bit = computed(() => selCount.value >= 2 && contiguousSelection.value && !isBoolType.value) const reg32Hi = computed(() => show32bit.value ? Number(sortedRegs.value[0].raw_value) & 0xFFFF : 0) const reg32Lo = computed(() => show32bit.value ? Number(sortedRegs.value[1].raw_value) & 0xFFFF : 0) @@ -73,7 +82,7 @@ const { longABCD, longCDAB, longBADC, longDCBA, floatABCD, floatCDAB, floatBADC, use32BitFormat(reg32Hi, reg32Lo, show32bit) // 64-bit -const show64bit = computed(() => selCount.value >= 4 && allSameType.value && !isBoolType.value) +const show64bit = computed(() => selCount.value >= 4 && contiguousSelection.value && !isBoolType.value) const reg64Values = computed(() => { if (!show64bit.value) return [0, 0, 0, 0] @@ -98,8 +107,15 @@ function cancelEdit() { editReady.value = false } -// Only cancel edit when selected register addresses change, not on poll value updates -watch(() => selectedRegisters.value.map(r => r.address).join(','), () => cancelEdit()) +// Cancel before a blur/Enter can send an edit to a new selection or target. +// Poll value updates alone must not interrupt editing. +watch(() => [ + selectedConnectionId.value, + selectedScanGroup.value?.id, + selectedScanGroup.value?.function, + writeSlaveId.value, + selectedRegisters.value.map(r => `${r.address}:${r.is_bool}`).join(','), +], () => cancelEdit(), { flush: 'sync' }) function reverseParseField(field: string, input: string): { address: number; value: number }[] | null { const regs = sortedRegs.value @@ -121,7 +137,7 @@ function reverseParseField(field: string, input: string): { address: number; val // 32-bit Long if (field === 'longABCD' || field === 'longCDAB' || field === 'longBADC' || field === 'longDCBA') { - if (regs.length < 2) return null; const n = Number(input); if (isNaN(n)) return null + if (!show32bit.value) return null; const n = Number(input); if (isNaN(n)) return null const u = n >>> 0; const hi = (u >>> 16) & 0xFFFF; const lo = u & 0xFFFF const map: Record = { longABCD: [hi, lo], longCDAB: [lo, hi], longBADC: [swapBytes16(hi), swapBytes16(lo)], longDCBA: [swapBytes16(lo), swapBytes16(hi)] } const [r0, r1] = map[field] @@ -130,7 +146,7 @@ function reverseParseField(field: string, input: string): { address: number; val // 32-bit Float if (field === 'floatABCD' || field === 'floatCDAB' || field === 'floatBADC' || field === 'floatDCBA') { - if (regs.length < 2) return null; const n = parseFloat(input); if (isNaN(n)) return null + if (!show32bit.value) return null; const n = parseFloat(input); if (isNaN(n)) return null const order = field.replace('float', '') as ByteOrder const [r0, r1] = float32ToU16Pair(n, order) return [{ address: regs[0].address, value: r0 }, { address: regs[1].address, value: r1 }] @@ -138,7 +154,7 @@ function reverseParseField(field: string, input: string): { address: number; val // 64-bit Double if (field === 'double' || field === 'doubleReversed' || field === 'doubleByteSwap' || field === 'doubleLittleEndian') { - if (regs.length < 4) return null + if (!show64bit.value) return null const n = parseFloat(input); if (isNaN(n)) return null const buf = new ArrayBuffer(8); const view = new DataView(buf); view.setFloat64(0, n) const w = [view.getUint16(0), view.getUint16(2), view.getUint16(4), view.getUint16(6)] @@ -156,30 +172,39 @@ function reverseParseField(field: string, input: string): { address: number; val async function writeRegisters(writes: { address: number; value: number }[]) { if (!selectedConnectionId.value) return const category = writeCategory.value - if (!category) return + if (!category || writes.length === 0) return + // FC15/FC16 carry a start address and packed values: never discard holes, + // duplicate/reversed addresses, or registers outside the current selection. + if (!allSameType.value || (category === 'coil') !== isBoolType.value + || (writes.length > 1 && !contiguousSelection.value) + || writes.some((write, index) => !Number.isInteger(write.address) + || write.address < 0 || write.address > 65535 + || (index > 0 && write.address !== writes[index - 1].address + 1) + || !sortedRegs.value.some(reg => reg.address === write.address))) return + const slave_id = writeSlaveId.value try { if (category === 'register') { if (writes.length === 1) { await invoke('write_single_register', { connectionId: selectedConnectionId.value, - request: { address: writes[0].address, value: writes[0].value } + request: { slave_id, address: writes[0].address, value: writes[0].value } }) } else { await invoke('write_multiple_registers', { connectionId: selectedConnectionId.value, - request: { address: writes[0].address, values: writes.map(w => w.value) } + request: { slave_id, address: writes[0].address, values: writes.map(w => w.value) } }) } } else if (category === 'coil') { if (writes.length === 1) { await invoke('write_single_coil', { connectionId: selectedConnectionId.value, - request: { address: writes[0].address, value: writes[0].value !== 0 } + request: { slave_id, address: writes[0].address, value: writes[0].value !== 0 } }) } else { await invoke('write_multiple_coils', { connectionId: selectedConnectionId.value, - request: { address: writes[0].address, values: writes.map(w => w.value !== 0) } + request: { slave_id, address: writes[0].address, values: writes.map(w => w.value !== 0) } }) } } @@ -237,6 +262,9 @@ const writeCategory = computed<'register' | 'coil' | null>(() => {