From eea31dcc4ef64fc93808c3af022314a58d7fe97e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 12:27:12 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2024=20=E2=80=94=20Modbus=20PLC?= =?UTF-8?q?=20data=20type=20extensions.=20Extends=20ModbusDataType=20beyon?= =?UTF-8?q?d=20the=20textbook=20Int16/UInt16/Int32/UInt32/Float32=20set=20?= =?UTF-8?q?with=20Int64/UInt64/Float64=20(4-register=20types),=20BitInRegi?= =?UTF-8?q?ster=20(single=20bit=20within=20a=20holding=20register,=20BitIn?= =?UTF-8?q?dex=200-15=20LSB-first),=20and=20String=20(ASCII=20packed=202?= =?UTF-8?q?=20chars=20per=20register=20with=20StringLength-driven=20sizing?= =?UTF-8?q?).=20Adds=20ModbusByteOrder=20enum=20on=20ModbusTagDefinition?= =?UTF-8?q?=20covering=20the=20two=20word-orderings=20that=20matter=20in?= =?UTF-8?q?=20the=20real=20PLC=20population:=20BigEndian=20(ABCD=20?= =?UTF-8?q?=E2=80=94=20Modbus=20TCP=20standard,=20Schneider=20PLCs=20that?= =?UTF-8?q?=20follow=20it=20strictly)=20and=20WordSwap=20(CDAB=20=E2=80=94?= =?UTF-8?q?=20Siemens=20S7=20family,=20several=20Allen-Bradley=20series,?= =?UTF-8?q?=20some=20Modicon=20families).=20NormalizeWordOrder=20helper=20?= =?UTF-8?q?reverses=20word=20pairs=20in-place=20for=2032-bit=20values=20an?= =?UTF-8?q?d=20reverses=20all=20four=20words=20for=2064-bit=20values=20(ke?= =?UTF-8?q?eps=20bytes=20big-endian=20within=20each=20register,=20which=20?= =?UTF-8?q?is=20universal;=20swaps=20only=20the=20word=20positions).=20Int?= =?UTF-8?q?ernal=20codec=20surface=20switched=20from=20(bytes,=20ModbusDat?= =?UTF-8?q?aType)=20pairs=20to=20(bytes,=20ModbusTagDefinition)=20because?= =?UTF-8?q?=20the=20tag=20carries=20the=20ByteOrder=20+=20BitIndex=20+=20S?= =?UTF-8?q?tringLength=20context=20the=20codec=20needs;=20RegisterCount=20?= =?UTF-8?q?similarly=20takes=20the=20tag=20so=20strings=20can=20compute=20?= =?UTF-8?q?ceil(StringLength/2).=20DriverDataType=20mapping=20in=20MapData?= =?UTF-8?q?Type=20extended=20to=20cover=20the=20new=20logical=20types=20?= =?UTF-8?q?=E2=80=94=20Int64/UInt64=20widen=20to=20Int32=20(PR=2025=20foll?= =?UTF-8?q?ow-up:=20extend=20DriverDataType=20enum=20with=20Int64=20to=20a?= =?UTF-8?q?void=20precision=20loss),=20Float64=20maps=20to=20DriverDataTyp?= =?UTF-8?q?e.Float64,=20String=20maps=20to=20DriverDataType.String,=20BitI?= =?UTF-8?q?nRegister=20surfaces=20as=20Boolean,=20all=20other=20mappings?= =?UTF-8?q?=20preserved.=20BitInRegister=20writes=20throw=20a=20deliberate?= =?UTF-8?q?=20InvalidOperationException=20with=20a=20'read-modify-write'?= =?UTF-8?q?=20hint=20=E2=80=94=20to=20atomically=20flip=20a=20single=20bit?= =?UTF-8?q?=20the=20driver=20needs=20to=20FC03=20the=20register,=20OR/AND?= =?UTF-8?q?=20in=20the=20bit,=20then=20FC06=20it=20back;=20that's=20a=20se?= =?UTF-8?q?parate=20PR=20because=20the=20bit-modify=20atomicity=20story=20?= =?UTF-8?q?needs=20a=20per-register=20mutex=20and=20optional=20compare-and?= =?UTF-8?q?-write=20semantics.=20Everything=20else=20(decoder=20paths=20fo?= =?UTF-8?q?r=20both=20byte=20orders,=20Int64/UInt64/Float64=20encode=20+?= =?UTF-8?q?=20decode,=20bit-index=20extraction=20across=20both=20register?= =?UTF-8?q?=20halves,=20String=20nul-truncation=20on=20decode,=20String=20?= =?UTF-8?q?nul-padding=20on=20encode)=20ships=20here.=20Tests=20(21=20new?= =?UTF-8?q?=20ModbusDataTypeTests):=20RegisterCount=5Freturns=5Fcorrect=5F?= =?UTF-8?q?register=5Fcount=5Fper=5Ftype=20theory=20(10=20rows=20covering?= =?UTF-8?q?=20every=20numeric=20type);=20RegisterCount=5Ffor=5FString=5Fro?= =?UTF-8?q?unds=5Fup=5Fto=5Fregister=5Fpair=20theory=20(5=20rows=20includi?= =?UTF-8?q?ng=20the=200-char=20edge=20case=20that=20returns=200=20register?= =?UTF-8?q?s);=20Int32=5FBigEndian=5Fdecodes=5FABCD=5Flayout=20+=20Int32?= =?UTF-8?q?=5FWordSwap=5Fdecodes=5FCDAB=5Flayout=20+=20Float32=5FWordSwap?= =?UTF-8?q?=5Fencode=5Fdecode=5Froundtrips=20(covers=20the=20two=20most-co?= =?UTF-8?q?mmon=2032-bit=20orderings);=20Int64=5FBigEndian=5Froundtrips=20?= =?UTF-8?q?+=20UInt64=5FWordSwap=5Freverses=5Ffour=5Fwords=20(word-swap=20?= =?UTF-8?q?on=2064-bit=20reverses=20the=20four-word=20layout=20explicitly,?= =?UTF-8?q?=20with=20the=20test=20computing=20the=20expected=20wire=20shap?= =?UTF-8?q?e=20by=20hand=20rather=20than=20trusting=20the=20implementation?= =?UTF-8?q?)=20+=20Float64=5Froundtrips=5Funder=5Fword=5Fswap=20(3.1415926?= =?UTF-8?q?5358979=20survives=20the=20round-trip=20with=201e-12=20toleranc?= =?UTF-8?q?e);=20BitInRegister=5Fextracts=5Fbit=5Fat=5Findex=20theory=20(6?= =?UTF-8?q?=20rows=20including=20LSB,=20MSB,=20and=20arbitrary=20bits=20in?= =?UTF-8?q?=20a=20multi-bit=20mask);=20BitInRegister=5Fwrite=5Fis=5Fnot=5F?= =?UTF-8?q?supported=5Fin=5FPR24=20(asserts=20the=20exception=20message=20?= =?UTF-8?q?steers=20the=20reader=20to=20the=20'read-modify-write'=20follow?= =?UTF-8?q?-up);=20String=5Fdecodes=5FASCII=5Fpacked=5Ftwo=5Fchars=5Fper?= =?UTF-8?q?=5Fregister=20(decodes=20'HELLO!'=20from=203=20packed=20registe?= =?UTF-8?q?rs=20with=20the=20'HELLO!'u8=20test-only=20UTF-8=20literal=20wh?= =?UTF-8?q?ich=20happens=20to=20equal=20the=20ASCII=20bytes=20for=20this?= =?UTF-8?q?=20ASCII=20input);=20String=5Fdecode=5Ftruncates=5Fat=5Ffirst?= =?UTF-8?q?=5Fnul=20('Hi'=20padded=20with=20nuls=20reads=20back=20as=20'Hi?= =?UTF-8?q?');=20String=5Fencode=5Fnul=5Fpads=5Fremaining=5Fbytes=20(short?= =?UTF-8?q?=20input=20writes=20remaining=20bytes=20as=200).=20Full=20solut?= =?UTF-8?q?ion:=200=20errors,=20217=20unit=20+=20integration=20tests=20pas?= =?UTF-8?q?s=20(22=20+=2030=20new=20Modbus=20=3D=2052=20Modbus=20total,=20?= =?UTF-8?q?165=20pre-existing).=20ModbusDriver=20capability=20footprint=20?= =?UTF-8?q?now=20matches=20the=20most=20common=20industrial=20PLC=20worklo?= =?UTF-8?q?ads=20=E2=80=94=20Siemens=20S7=20+=20Allen-Bradley=20+=20Modico?= =?UTF-8?q?n=20all=20supported=20via=20ByteOrder=20config=20without=20driv?= =?UTF-8?q?er=20forks.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ModbusDriver.cs | 164 ++++++++++++---- .../ModbusDriverOptions.cs | 46 ++++- .../ModbusDataTypeTests.cs | 175 ++++++++++++++++++ 3 files changed, 349 insertions(+), 36 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs 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); + } +} -- 2.49.1