Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusByteOrderTests.cs
Joseph Doherty 850b816873 Task #137 — Modbus per-tag suffix grammar (type / bit / byte-order / array)
Adds the full Wonderware/Kepware/Ignition-style address suffix grammar so
users paste tag spreadsheets without per-tag manual translation:

  <region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]

Examples that now parse end-to-end:
  40001                          HoldingRegisters[0], Int16
  400001                         same, 6-digit form
  40001.5                        bit 5 of HR[0]
  40001:F                        Float32 (HR[0..1])
  40001:F:CDAB                   word-swapped Float32
  40001:STR20                    20-char ASCII string
  HR1:DI                         Int32 via mnemonic region
  C100                           Coils[99] (mnemonic)
  40001:F:5                      Float32[5] array (3-field shorthand)
  40001:I:CDAB:10                Int16[10] word-swapped (4-field strict)

Driver-side plumbing:
- ModbusAddressParser + ParsedModbusAddress in the shared Addressing
  assembly. 91 parser tests (every grammar variant + malformed shapes).
- ModbusDataType / ModbusByteOrder moved to shared (with the same namespace
  so callers compile unchanged). ModbusByteOrder gains ByteSwap (BADC) and
  FullReverse (DCBA) alongside the existing BigEndian (ABCD) and WordSwap
  (CDAB).
- NormalizeWordOrder extended to honor all four orders for both 4-byte and
  8-byte values. Old WordSwap behavior preserved bit-for-bit.
- ModbusTagDefinition gains optional ArrayCount.
- ReadOneAsync / WriteOneAsync handle array fan-out: one FC03/04 read covers
  N consecutive register-typed elements, decoded into a typed array (short[],
  float[], etc.). Coil arrays use FC01 reads + FC15 writes (FakeTransport
  in tests gains FC15 support to match).
- DriverAttributeInfo IsArray / ArrayDim flow from ArrayCount so the OPC UA
  address space surfaces ValueRank=1 + ArrayDimensions to clients.
- ModbusDriverFactoryExtensions gains AddressString DTO field. When
  present, the parser drives Region/Address/DataType/ByteOrder/Bit/
  StringLength/ArrayCount; structured fields (Writable, WriteIdempotent,
  StringByteOrder) still come from the DTO. Existing structured tag rows
  keep working unchanged.

Tests: 91 parser unit tests (Driver.Modbus.Addressing.Tests, all green) +
204 driver tests including new ModbusByteOrderTests (BADC/DCBA roundtrips
across Int32/Float32/Float64) and ModbusArrayTests (Int16[5], Float32[3]
CDAB, Coil[10], length-mismatch error, IsArray/ArrayDim discovery).
Solution-wide build clean.

Caveat: grammar names (type codes, byte-order mnemonics, the :count
shorthand) were synthesized from training-era vendor docs. Verify against
current Kepware Modbus Ethernet Driver Help and Ignition Modbus Addressing
manuals before freezing for production deployments — naming may need a
back-compat layer if vendor wording has shifted.
2026-04-24 23:49:22 -04:00

74 lines
3.0 KiB
C#

using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Coverage for the new ByteSwap (BADC) and FullReverse (DCBA) byte orders added by #137.
/// The existing BigEndian (ABCD) and WordSwap (CDAB) cases live in <see cref="ModbusDataTypeTests"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusByteOrderTests
{
[Fact]
public void Int32_ByteSwap_decodes_BADC_layout()
{
// Value 0x12345678. PLC stores bytes within each register swapped:
// register[0] = 0x3412, register[1] = 0x7856 → wire [0x34, 0x12, 0x78, 0x56].
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
ByteOrder: ModbusByteOrder.ByteSwap);
var bytes = new byte[] { 0x34, 0x12, 0x78, 0x56 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
}
[Fact]
public void Int32_FullReverse_decodes_DCBA_layout()
{
// Value 0x12345678 stored fully little-endian:
// wire [0x78, 0x56, 0x34, 0x12].
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
ByteOrder: ModbusByteOrder.FullReverse);
var bytes = new byte[] { 0x78, 0x56, 0x34, 0x12 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
}
[Theory]
[InlineData(ModbusByteOrder.BigEndian)]
[InlineData(ModbusByteOrder.WordSwap)]
[InlineData(ModbusByteOrder.ByteSwap)]
[InlineData(ModbusByteOrder.FullReverse)]
public void Float32_All_ByteOrders_Roundtrip(ModbusByteOrder order)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32, ByteOrder: order);
var wire = ModbusDriver.EncodeRegister(3.14159f, tag);
wire.Length.ShouldBe(4);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(3.14159f);
}
[Theory]
[InlineData(ModbusByteOrder.BigEndian)]
[InlineData(ModbusByteOrder.WordSwap)]
[InlineData(ModbusByteOrder.ByteSwap)]
[InlineData(ModbusByteOrder.FullReverse)]
public void Float64_All_ByteOrders_Roundtrip(ModbusByteOrder order)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float64, ByteOrder: order);
var wire = ModbusDriver.EncodeRegister(2.718281828459045d, tag);
wire.Length.ShouldBe(8);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(2.718281828459045d);
}
[Theory]
[InlineData(ModbusByteOrder.BigEndian)]
[InlineData(ModbusByteOrder.WordSwap)]
[InlineData(ModbusByteOrder.ByteSwap)]
[InlineData(ModbusByteOrder.FullReverse)]
public void Int32_All_ByteOrders_Roundtrip(ModbusByteOrder order)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32, ByteOrder: order);
var wire = ModbusDriver.EncodeRegister(unchecked((int)0xDEADBEEF), tag);
wire.Length.ShouldBe(4);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(unchecked((int)0xDEADBEEF));
}
}