diff --git a/README.id.md b/README.id.md index 060de36..4548e3e 100644 --- a/README.id.md +++ b/README.id.md @@ -34,7 +34,7 @@ axum + rust-embed + Svelte/xyflow untuk editornya. | `split` / `join` | pecah pesan jadi bagian / gabung kembali | | `sort` / `batch` | urutkan array / kelompokkan pesan jadi array | | `modbus-read` | master: baca holding/input/coils/discrete → emit | - | `modbus-write` | master: tulis register/coil dari payload | + | `modbus-write` | master: tulis register/coil, atau float/uint 32-bit (2 register), dari payload | | `modbus-slave` | **server**: jadi device Modbus yang dipoll master eksternal | | `serial-ascii` | baca baris ASCII dari serial (mis. timbangan) + kirim command; regex, byte-map, parity/stop/data bits | | `mqtt-in` / `mqtt-out` | subscribe / publish MQTT | diff --git a/README.md b/README.md index 04ecb71..ec4e13e 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ rust-embed + Svelte/xyflow for the editor. | `split` / `join` | break a message into parts / reassemble them | | `sort` / `batch` | sort an array / group messages into arrays | | `modbus-read` | master: read holding/input/coils/discrete → emit | - | `modbus-write` | master: write a register/coil from the payload | + | `modbus-write` | master: write a register/coil, or a 32-bit float/uint over two registers, from the payload | | `modbus-slave` | **server**: act as a Modbus device polled by an external master | | `serial-ascii` | read ASCII lines from a serial port (e.g. a scale) + write commands; regex, byte-map, parity/stop/data bits | | `mqtt-in` / `mqtt-out` | subscribe / publish MQTT | diff --git a/src/catalog.rs b/src/catalog.rs index fe30b2c..328f539 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -285,7 +285,10 @@ pub fn catalog() -> Value { { "key": "baud", "label": "Baud (rtu-serial)", "type": "number", "default": 9600 }, { "key": "unit_id", "label": "Unit ID", "type": "number", "default": 1 }, { "key": "function", "label": "Function", "type": "select", - "options": ["register", "coil"], "default": "register" }, + "options": ["register", "coil", "float32", "uint32"], "default": "register" }, + { "key": "byte_order", "label": "Byte/word order (float32/uint32)", "type": "select", + "options": ["big_big", "big_little", "little_big", "little_little"], "default": "big_big", + "showIf": { "function": ["float32", "uint32"] } }, { "key": "address", "label": "Address", "type": "number", "default": 0 } ] }, diff --git a/src/runtime/nodes/modbus.rs b/src/runtime/nodes/modbus.rs index 2ea81bb..b248cac 100644 --- a/src/runtime/nodes/modbus.rs +++ b/src/runtime/nodes/modbus.rs @@ -118,15 +118,23 @@ impl Node for ModbusReadNode { // Write // --------------------------------------------------------------------------- +fn default_byte_order() -> String { + "big_big".to_string() +} + #[derive(Debug, Default, Deserialize)] pub struct ModbusWriteNode { #[serde(flatten)] conn: MbConn, - /// "register" | "coil". + /// "register" (single u16) | "coil" | "float32" | "uint32" (two registers). #[serde(default = "default_write_fn")] function: String, #[serde(default)] address: u16, + /// Byte/word order for float32/uint32: big_big | big_little | little_big | + /// little_little (`_`). + #[serde(default = "default_byte_order")] + byte_order: String, } impl ModbusWriteNode { @@ -136,22 +144,44 @@ impl ModbusWriteNode { } /// Extract a numeric value from the payload (number, bool, or numeric string). -fn payload_to_u64(payload: &Value) -> Option { +fn payload_to_f64(payload: &Value) -> Option { match payload { - Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f as u64)), - Value::Bool(b) => Some(*b as u64), - Value::String(s) => s.trim().parse::().ok(), + Value::Number(n) => n.as_f64(), + Value::Bool(b) => Some(*b as i64 as f64), + Value::String(s) => s.trim().parse::().ok(), _ => None, } } +/// Split a 32-bit value into two registers per `byte_order` = `_`: +/// the first token sets the byte order inside each register (`little` swaps the +/// two bytes), the second the word order (`big` = low register holds the high +/// word). Covers big_big / big_little / little_big / little_little. +fn split_u32(v: u32, byte_order: &str) -> [u16; 2] { + let hi = (v >> 16) as u16; + let lo = (v & 0xffff) as u16; + let (bytes, words) = byte_order.split_once('_').unwrap_or(("big", "big")); + // Word order: "little" => low register holds the low word. + let (mut r0, mut r1) = if words == "little" { + (lo, hi) + } else { + (hi, lo) + }; + // Byte order within each register: "little" swaps the two bytes. + if bytes == "little" { + r0 = r0.swap_bytes(); + r1 = r1.swap_bytes(); + } + [r0, r1] +} + #[async_trait] impl Node for ModbusWriteNode { async fn run(self: Box, mut ctx: NodeCtx) -> anyhow::Result<()> { let mut conn: Option = None; while let Some(msg) = ctx.rx.recv().await { - let Some(value) = payload_to_u64(&msg.payload) else { + let Some(value) = payload_to_f64(&msg.payload) else { ctx.status("error", "payload_not_number", Value::Null); continue; }; @@ -175,7 +205,15 @@ impl Node for ModbusWriteNode { let c = conn.as_mut().expect("just connected"); let result = match self.function.as_str() { - "coil" => c.write_single_coil(self.address, value != 0).await, + "coil" => c.write_single_coil(self.address, value != 0.0).await, + "float32" => { + let regs = split_u32((value as f32).to_bits(), &self.byte_order); + c.write_multiple_registers(self.address, ®s).await + } + "uint32" => { + let regs = split_u32(value as u32, &self.byte_order); + c.write_multiple_registers(self.address, ®s).await + } _ => c.write_single_register(self.address, value as u16).await, }; match result { @@ -202,3 +240,49 @@ impl Node for ModbusWriteNode { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_u32_covers_all_four_orders() { + let v = 0x1234_5678; + assert_eq!(split_u32(v, "big_big"), [0x1234, 0x5678]); + assert_eq!(split_u32(v, "big_little"), [0x5678, 0x1234]); + assert_eq!(split_u32(v, "little_big"), [0x3412, 0x7856]); + assert_eq!(split_u32(v, "little_little"), [0x7856, 0x3412]); + } + + /// Reassemble two registers back into a u32 (inverse of `split_u32`). + fn join_u32(regs: [u16; 2], byte_order: &str) -> u32 { + let (bytes, words) = byte_order.split_once('_').unwrap_or(("big", "big")); + let (mut r0, mut r1) = (regs[0], regs[1]); + if bytes == "little" { + r0 = r0.swap_bytes(); + r1 = r1.swap_bytes(); + } + let (hi, lo) = if words == "little" { + (r1, r0) + } else { + (r0, r1) + }; + ((hi as u32) << 16) | lo as u32 + } + + #[test] + fn float32_encode_is_inverse_of_decode() { + for order in ["big_big", "big_little", "little_big", "little_little"] { + let regs = split_u32(1500.5_f32.to_bits(), order); + assert_eq!(f32::from_bits(join_u32(regs, order)), 1500.5, "{order}"); + } + } + + #[test] + fn payload_to_f64_parses_forms() { + assert_eq!(payload_to_f64(&json!(12.5)), Some(12.5)); + assert_eq!(payload_to_f64(&json!("1500")), Some(1500.0)); + assert_eq!(payload_to_f64(&json!(true)), Some(1.0)); + assert_eq!(payload_to_f64(&json!("x")), None); + } +}