Phase 3 PR 24 — Modbus PLC data type extensions. Extends ModbusDataType beyond the textbook Int16/UInt16/Int32/UInt32/Float32 set with Int64/UInt64/Float64 (4-register types), BitInRegister (single bit within a holding register, BitIndex 0-15 LSB-first), and String (ASCII packed 2 chars per register with StringLength-driven sizing). Adds ModbusByteOrder enum on ModbusTagDefinition covering the two word-orderings that matter in the real PLC population: BigEndian (ABCD — Modbus TCP standard, Schneider PLCs that follow it strictly) and WordSwap (CDAB — Siemens S7 family, several Allen-Bradley series, some Modicon families). NormalizeWordOrder helper reverses word pairs in-place for 32-bit values and reverses all four words for 64-bit values (keeps bytes big-endian within each register, which is universal; swaps only the word positions). Internal codec surface switched from (bytes, ModbusDataType) pairs to (bytes, ModbusTagDefinition) because the tag carries the ByteOrder + BitIndex + StringLength context the codec needs; RegisterCount similarly takes the tag so strings can compute ceil(StringLength/2). DriverDataType mapping in MapDataType extended to cover the new logical types — Int64/UInt64 widen to Int32 (PR 25 follow-up: extend DriverDataType enum with Int64 to avoid precision loss), Float64 maps to DriverDataType.Float64, String maps to DriverDataType.String, BitInRegister surfaces as Boolean, all other mappings preserved. BitInRegister writes throw a deliberate InvalidOperationException with a 'read-modify-write' hint — to atomically flip a single bit the driver needs to FC03 the register, OR/AND in the bit, then FC06 it back; that's a separate PR because the bit-modify atomicity story needs a per-register mutex and optional compare-and-write semantics. Everything else (decoder paths for both byte orders, Int64/UInt64/Float64 encode + decode, bit-index extraction across both register halves, String nul-truncation on decode, String nul-padding on encode) ships here. Tests (21 new ModbusDataTypeTests): RegisterCount_returns_correct_register_count_per_type theory (10 rows covering every numeric type); RegisterCount_for_String_rounds_up_to_register_pair theory (5 rows including the 0-char edge case that returns 0 registers); Int32_BigEndian_decodes_ABCD_layout + Int32_WordSwap_decodes_CDAB_layout + Float32_WordSwap_encode_decode_roundtrips (covers the two most-common 32-bit orderings); Int64_BigEndian_roundtrips + UInt64_WordSwap_reverses_four_words (word-swap on 64-bit reverses the four-word layout explicitly, with the test computing the expected wire shape by hand rather than trusting the implementation) + Float64_roundtrips_under_word_swap (3.14159265358979 survives the round-trip with 1e-12 tolerance); BitInRegister_extracts_bit_at_index theory (6 rows including LSB, MSB, and arbitrary bits in a multi-bit mask); BitInRegister_write_is_not_supported_in_PR24 (asserts the exception message steers the reader to the 'read-modify-write' follow-up); String_decodes_ASCII_packed_two_chars_per_register (decodes 'HELLO!' from 3 packed registers with the 'HELLO!'u8 test-only UTF-8 literal which happens to equal the ASCII bytes for this ASCII input); String_decode_truncates_at_first_nul ('Hi' padded with nuls reads back as 'Hi'); String_encode_nul_pads_remaining_bytes (short input writes remaining bytes as 0). Full solution: 0 errors, 217 unit + integration tests pass (22 + 30 new Modbus = 52 Modbus total, 165 pre-existing). ModbusDriver capability footprint now matches the most common industrial PLC workloads — Siemens S7 + Allen-Bradley + Modicon all supported via ByteOrder config without driver forks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-18 12:27:12 -04:00
parent 8a692d4ba8
commit eea31dcc4e
3 changed files with 349 additions and 36 deletions

View File

@@ -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
{
/// <summary>
/// Register-count lookup is per-tag now (strings need StringLength; Int64/Float64 need 4).
/// </summary>
[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<InvalidOperationException>(() => 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);
}
}