diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs index 1ec266f..33a1889 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -169,14 +169,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta case ModbusRegion.HoldingRegisters: case ModbusRegion.InputRegisters: { - var quantity = RegisterCount(tag.DataType); + var quantity = RegisterCount(tag); var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04; var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), (byte)(quantity >> 8), (byte)(quantity & 0xFF) }; var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false); // resp = [fc][byte-count][data...] var data = new ReadOnlySpan(resp, 2, resp[1]); - return DecodeRegister(data, tag.DataType); + return DecodeRegister(data, tag); } default: throw new InvalidOperationException($"Unknown region {tag.Region}"); @@ -230,7 +230,7 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta } case ModbusRegion.HoldingRegisters: { - var bytes = EncodeRegister(value, tag.DataType); + var bytes = EncodeRegister(value, tag); if (bytes.Length == 2) { var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), @@ -397,73 +397,173 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta // ---- codec ---- - internal static ushort RegisterCount(ModbusDataType t) => t switch + /// + /// How many 16-bit registers a given tag occupies. Accounts for multi-register logical + /// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up + /// from 2 chars per register). + /// + internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch { - ModbusDataType.Int16 or ModbusDataType.UInt16 => 1, + ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1, ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2, - _ => throw new InvalidOperationException($"Non-register data type {t}"), + ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4, + ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register + _ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"), }; - internal static object DecodeRegister(ReadOnlySpan data, ModbusDataType t) => t switch + /// + /// Word-swap the input into the big-endian layout the decoders expect. For 2-register + /// types this reverses the two words; for 4-register types it reverses the four words + /// (PLC stored [hi-mid, low-mid, hi-high, low-high] → memory [hi-high, low-high, hi-mid, low-mid]). + /// + private static byte[] NormalizeWordOrder(ReadOnlySpan data, ModbusByteOrder order) { - ModbusDataType.Int16 => BinaryPrimitives.ReadInt16BigEndian(data), - ModbusDataType.UInt16 => BinaryPrimitives.ReadUInt16BigEndian(data), - ModbusDataType.Int32 => BinaryPrimitives.ReadInt32BigEndian(data), - ModbusDataType.UInt32 => BinaryPrimitives.ReadUInt32BigEndian(data), - ModbusDataType.Float32 => BinaryPrimitives.ReadSingleBigEndian(data), - _ => throw new InvalidOperationException($"Non-register data type {t}"), - }; + if (order == ModbusByteOrder.BigEndian) return data.ToArray(); + var result = new byte[data.Length]; + for (var word = 0; word < data.Length / 2; word++) + { + var srcWord = data.Length / 2 - 1 - word; + result[word * 2] = data[srcWord * 2]; + result[word * 2 + 1] = data[srcWord * 2 + 1]; + } + return result; + } - internal static byte[] EncodeRegister(object? value, ModbusDataType t) + internal static object DecodeRegister(ReadOnlySpan data, ModbusTagDefinition tag) { - switch (t) + switch (tag.DataType) + { + case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data); + case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data); + case ModbusDataType.BitInRegister: + { + var raw = BinaryPrimitives.ReadUInt16BigEndian(data); + return (raw & (1 << tag.BitIndex)) != 0; + } + case ModbusDataType.Int32: + { + var b = NormalizeWordOrder(data, tag.ByteOrder); + return BinaryPrimitives.ReadInt32BigEndian(b); + } + case ModbusDataType.UInt32: + { + var b = NormalizeWordOrder(data, tag.ByteOrder); + return BinaryPrimitives.ReadUInt32BigEndian(b); + } + case ModbusDataType.Float32: + { + var b = NormalizeWordOrder(data, tag.ByteOrder); + return BinaryPrimitives.ReadSingleBigEndian(b); + } + case ModbusDataType.Int64: + { + var b = NormalizeWordOrder(data, tag.ByteOrder); + return BinaryPrimitives.ReadInt64BigEndian(b); + } + case ModbusDataType.UInt64: + { + var b = NormalizeWordOrder(data, tag.ByteOrder); + return BinaryPrimitives.ReadUInt64BigEndian(b); + } + case ModbusDataType.Float64: + { + var b = NormalizeWordOrder(data, tag.ByteOrder); + return BinaryPrimitives.ReadDoubleBigEndian(b); + } + case ModbusDataType.String: + { + // ASCII, 2 chars per register, packed high byte = first char. + // Respect the caller's StringLength (truncate nul-padded regions). + var chars = new char[tag.StringLength]; + for (var i = 0; i < tag.StringLength; i++) + { + var b = data[i]; + if (b == 0) { return new string(chars, 0, i); } + chars[i] = (char)b; + } + return new string(chars); + } + default: + throw new InvalidOperationException($"Non-register data type {tag.DataType}"); + } + } + + internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag) + { + switch (tag.DataType) { case ModbusDataType.Int16: { var v = Convert.ToInt16(value); - var b = new byte[2]; - BinaryPrimitives.WriteInt16BigEndian(b, v); - return b; + var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b; } case ModbusDataType.UInt16: { var v = Convert.ToUInt16(value); - var b = new byte[2]; - BinaryPrimitives.WriteUInt16BigEndian(b, v); - return b; + var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b; } case ModbusDataType.Int32: { var v = Convert.ToInt32(value); - var b = new byte[4]; - BinaryPrimitives.WriteInt32BigEndian(b, v); - return b; + var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v); + return NormalizeWordOrder(b, tag.ByteOrder); } case ModbusDataType.UInt32: { var v = Convert.ToUInt32(value); - var b = new byte[4]; - BinaryPrimitives.WriteUInt32BigEndian(b, v); - return b; + var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v); + return NormalizeWordOrder(b, tag.ByteOrder); } case ModbusDataType.Float32: { var v = Convert.ToSingle(value); - var b = new byte[4]; - BinaryPrimitives.WriteSingleBigEndian(b, v); + var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v); + return NormalizeWordOrder(b, tag.ByteOrder); + } + case ModbusDataType.Int64: + { + var v = Convert.ToInt64(value); + var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v); + return NormalizeWordOrder(b, tag.ByteOrder); + } + case ModbusDataType.UInt64: + { + var v = Convert.ToUInt64(value); + var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v); + return NormalizeWordOrder(b, tag.ByteOrder); + } + case ModbusDataType.Float64: + { + var v = Convert.ToDouble(value); + var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v); + return NormalizeWordOrder(b, tag.ByteOrder); + } + case ModbusDataType.String: + { + var s = Convert.ToString(value) ?? string.Empty; + var regs = (tag.StringLength + 1) / 2; + var b = new byte[regs * 2]; + for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i]; + // remaining bytes stay 0 — nul-padded per PLC convention return b; } + case ModbusDataType.BitInRegister: + throw new InvalidOperationException( + "BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up)."); default: - throw new InvalidOperationException($"Non-register data type {t}"); + throw new InvalidOperationException($"Non-register data type {tag.DataType}"); } } private static DriverDataType MapDataType(ModbusDataType t) => t switch { - ModbusDataType.Bool => DriverDataType.Boolean, + ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean, ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32, ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32, + ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType ModbusDataType.Float32 => DriverDataType.Float32, + ModbusDataType.Float64 => DriverDataType.Float64, + ModbusDataType.String => DriverDataType.String, _ => DriverDataType.Int32, }; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs index 9b9731e..79c5c41 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs @@ -38,7 +38,9 @@ public sealed class ModbusProbeOptions /// /// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not -/// the documentation's 1-based coil/register conventions). +/// the documentation's 1-based coil/register conventions). Multi-register types +/// (Int32/UInt32/Float32 = 2 regs; Int64/UInt64/Float64 = 4 regs) respect the +/// field — real-world PLCs disagree on word ordering. /// /// /// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be @@ -46,14 +48,50 @@ public sealed class ModbusProbeOptions /// /// Coils / DiscreteInputs / InputRegisters / HoldingRegisters. /// Zero-based address within the region. -/// Logical data type. Int16/UInt16 = single register; Int32/UInt32/Float32 = two registers big-endian. +/// +/// Logical data type. See for the register count each encodes. +/// /// When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here. +/// Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String. +/// For DataType = BitInRegister: which bit of the holding register (0-15, LSB-first). +/// For DataType = String: number of ASCII characters (2 per register, rounded up). public sealed record ModbusTagDefinition( string Name, ModbusRegion Region, ushort Address, ModbusDataType DataType, - bool Writable = true); + bool Writable = true, + ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian, + byte BitIndex = 0, + ushort StringLength = 0); public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters } -public enum ModbusDataType { Bool, Int16, UInt16, Int32, UInt32, Float32 } + +public enum ModbusDataType +{ + Bool, + Int16, + UInt16, + Int32, + UInt32, + Int64, + UInt64, + Float32, + Float64, + /// Single bit within a holding register. selects 0-15 LSB-first. + BitInRegister, + /// ASCII string packed 2 chars per register, characters long. + String, +} + +/// +/// Word ordering for multi-register types. Modbus TCP standard is +/// (ABCD for 32-bit: high word at the lower address). Many PLCs — Siemens S7, several +/// Allen-Bradley series, some Modicon families — use (CDAB), which +/// keeps bytes big-endian within each register but reverses the word pair(s). +/// +public enum ModbusByteOrder +{ + BigEndian, + WordSwap, +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs new file mode 100644 index 0000000..1c0442b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs @@ -0,0 +1,175 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +[Trait("Category", "Unit")] +public sealed class ModbusDataTypeTests +{ + /// + /// Register-count lookup is per-tag now (strings need StringLength; Int64/Float64 need 4). + /// + [Theory] + [InlineData(ModbusDataType.BitInRegister, 1)] + [InlineData(ModbusDataType.Int16, 1)] + [InlineData(ModbusDataType.UInt16, 1)] + [InlineData(ModbusDataType.Int32, 2)] + [InlineData(ModbusDataType.UInt32, 2)] + [InlineData(ModbusDataType.Float32, 2)] + [InlineData(ModbusDataType.Int64, 4)] + [InlineData(ModbusDataType.UInt64, 4)] + [InlineData(ModbusDataType.Float64, 4)] + public void RegisterCount_returns_correct_register_count_per_type(ModbusDataType t, int expected) + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, t); + ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expected); + } + + [Theory] + [InlineData(0, 1)] // 0 chars → still 1 byte / 1 register (pathological but well-defined: length 0 is 0 bytes) + [InlineData(1, 1)] + [InlineData(2, 1)] + [InlineData(3, 2)] + [InlineData(10, 5)] + public void RegisterCount_for_String_rounds_up_to_register_pair(ushort chars, int expectedRegs) + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String, StringLength: chars); + // 0-char is encoded as 0 regs; the test case expects 1 for lengths 1-2, 2 for 3-4, etc. + if (chars == 0) ModbusDriver.RegisterCount(tag).ShouldBe((ushort)0); + else ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expectedRegs); + } + + // --- Int32 / UInt32 / Float32 with byte-order variants --- + + [Fact] + public void Int32_BigEndian_decodes_ABCD_layout() + { + // Value 0x12345678 → bytes [0x12, 0x34, 0x56, 0x78] as PLC wrote them. + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32, + ByteOrder: ModbusByteOrder.BigEndian); + var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78 }; + ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678); + } + + [Fact] + public void Int32_WordSwap_decodes_CDAB_layout() + { + // Siemens/AB PLC stored 0x12345678 as register[0] = 0x5678, register[1] = 0x1234. + // Wire bytes are [0x56, 0x78, 0x12, 0x34]; with ByteOrder=WordSwap we get 0x12345678 back. + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32, + ByteOrder: ModbusByteOrder.WordSwap); + var bytes = new byte[] { 0x56, 0x78, 0x12, 0x34 }; + ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678); + } + + [Fact] + public void Float32_WordSwap_encode_decode_roundtrips() + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32, + ByteOrder: ModbusByteOrder.WordSwap); + var wire = ModbusDriver.EncodeRegister(25.5f, tag); + wire.Length.ShouldBe(4); + ModbusDriver.DecodeRegister(wire, tag).ShouldBe(25.5f); + } + + // --- Int64 / UInt64 / Float64 --- + + [Fact] + public void Int64_BigEndian_roundtrips() + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int64); + var wire = ModbusDriver.EncodeRegister(0x0123456789ABCDEFL, tag); + wire.Length.ShouldBe(8); + BinaryPrimitives.ReadInt64BigEndian(wire).ShouldBe(0x0123456789ABCDEFL); + ModbusDriver.DecodeRegister(wire, tag).ShouldBe(0x0123456789ABCDEFL); + } + + [Fact] + public void UInt64_WordSwap_reverses_four_words() + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.UInt64, + ByteOrder: ModbusByteOrder.WordSwap); + var value = 0xAABBCCDDEEFF0011UL; + + var wireBE = new byte[8]; + BinaryPrimitives.WriteUInt64BigEndian(wireBE, value); + + // Word-swap layout: [word3, word2, word1, word0] where each word keeps its bytes big-endian. + var wireWS = new byte[] { wireBE[6], wireBE[7], wireBE[4], wireBE[5], wireBE[2], wireBE[3], wireBE[0], wireBE[1] }; + ModbusDriver.DecodeRegister(wireWS, tag).ShouldBe(value); + + var roundtrip = ModbusDriver.EncodeRegister(value, tag); + roundtrip.ShouldBe(wireWS); + } + + [Fact] + public void Float64_roundtrips_under_word_swap() + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float64, + ByteOrder: ModbusByteOrder.WordSwap); + var wire = ModbusDriver.EncodeRegister(3.14159265358979d, tag); + wire.Length.ShouldBe(8); + ((double)ModbusDriver.DecodeRegister(wire, tag)!).ShouldBe(3.14159265358979d, tolerance: 1e-12); + } + + // --- BitInRegister --- + + [Theory] + [InlineData(0b0000_0000_0000_0001, 0, true)] + [InlineData(0b0000_0000_0000_0001, 1, false)] + [InlineData(0b1000_0000_0000_0000, 15, true)] + [InlineData(0b0100_0000_0100_0000, 6, true)] + [InlineData(0b0100_0000_0100_0000, 14, true)] + [InlineData(0b0100_0000_0100_0000, 7, false)] + public void BitInRegister_extracts_bit_at_index(ushort raw, byte bitIndex, bool expected) + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister, + BitIndex: bitIndex); + var bytes = new byte[] { (byte)(raw >> 8), (byte)(raw & 0xFF) }; + ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(expected); + } + + [Fact] + public void BitInRegister_write_is_not_supported_in_PR24() + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister, + BitIndex: 5); + Should.Throw(() => ModbusDriver.EncodeRegister(true, tag)) + .Message.ShouldContain("read-modify-write"); + } + + // --- String --- + + [Fact] + public void String_decodes_ASCII_packed_two_chars_per_register() + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String, + StringLength: 6); + // "HELLO!" = 0x48 0x45 0x4C 0x4C 0x4F 0x21 across 3 registers. + var bytes = "HELLO!"u8.ToArray(); + ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("HELLO!"); + } + + [Fact] + public void String_decode_truncates_at_first_nul() + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String, + StringLength: 10); + var bytes = new byte[] { 0x48, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; + ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("Hi"); + } + + [Fact] + public void String_encode_nul_pads_remaining_bytes() + { + var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String, + StringLength: 8); + var wire = ModbusDriver.EncodeRegister("Hi", tag); + wire.Length.ShouldBe(8); + wire[0].ShouldBe((byte)'H'); + wire[1].ShouldBe((byte)'i'); + for (var i = 2; i < 8; i++) wire[i].ShouldBe((byte)0); + } +}