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
2 changes: 1 addition & 1 deletion README.id.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 4 additions & 1 deletion src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
]
},
Expand Down
98 changes: 91 additions & 7 deletions src/runtime/nodes/modbus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<bytes-in-register>_<word-order>`).
#[serde(default = "default_byte_order")]
byte_order: String,
}

impl ModbusWriteNode {
Expand All @@ -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<u64> {
fn payload_to_f64(payload: &Value) -> Option<f64> {
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::<u64>().ok(),
Value::Number(n) => n.as_f64(),
Value::Bool(b) => Some(*b as i64 as f64),
Value::String(s) => s.trim().parse::<f64>().ok(),
_ => None,
}
}

/// Split a 32-bit value into two registers per `byte_order` = `<bytes>_<words>`:
/// 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<Self>, mut ctx: NodeCtx) -> anyhow::Result<()> {
let mut conn: Option<ModbusCtx> = 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;
};
Expand All @@ -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, &regs).await
}
"uint32" => {
let regs = split_u32(value as u32, &self.byte_order);
c.write_multiple_registers(self.address, &regs).await
}
_ => c.write_single_register(self.address, value as u16).await,
};
match result {
Expand All @@ -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);
}
}