Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ModbusFamilyParserTests.cs
Joseph Doherty 4cf0b4eb73 Task #144 — Modbus family-native parser branch (DL205 / MELSEC)
Promotes DirectLogicAddress + MelsecAddress from "utility helpers an engineer
calls manually" to "first-class branch of ModbusAddressParser." Users can now
paste DL205-native (V2000, Y0, C100, X17, SP10) and MELSEC-native (D100, M50,
X20 hex/octal, Y0) addresses directly into TagConfig and the parser handles
the PLC-native → Modbus PDU translation.

Changes:

- Both helper files moved into the shared Driver.Modbus.Addressing assembly
  (same namespace, zero-churn for callers). Required because the parser
  needs to call them and the dependency direction is parser→helpers, not
  the other way.
- New ModbusFamily enum (Generic / DL205 / MELSEC) on
  ModbusDriverOptions.Family. Generic preserves pre-#144 behaviour exactly.
- ModbusDriverOptions.MelsecSubFamily picks the X/Y notation (Q_L_iQR hex
  vs F_iQF octal). Default Q_L_iQR.
- ModbusAddressParser.Parse now takes optional family + sub-family hints.
  When non-Generic, family-native parsing runs FIRST; on miss falls back to
  Modicon / mnemonic. Cross-family ambiguity (C100 = Modicon coil under
  Generic, DL205 control relay under DL205) is unambiguous within one
  driver instance.
- Suffix grammar composes with native addresses: V2000:F:CDAB:5 parses
  end-to-end as DL205 V-memory at PDU 1024 + Float32 + word-swap + array of 5.
- Bit suffix composes too: V2000.7 parses as bit 7 of HR[1024].
- Factory DTO fields Family / MelsecSubFamily flow through to BuildTag so
  the JSON binding can drive everything per-driver.

Tests: 16 new ModbusFamilyParserTests covering DL205 V/Y/C/X/SP, MELSEC
D/M/X/Y, sub-family hex-vs-octal disambiguation, cross-family C100 ambiguity,
fallback to Modicon when native misses, and grammar composition with bit/
byte-order/array modifiers. Existing 91 parser tests still green; 220 driver
tests still green.

Caveat: bank-base offsets for MELSEC X/Y/M default to 0 in the grammar
string. Sites with non-zero "Modbus Device Assignment Parameter" bases must
use the structured tag form to override — addressed in the docs refresh
(#138).
2026-04-25 00:10:43 -04:00

152 lines
5.6 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests;
/// <summary>
/// #144 family-native parser branch — DL205 + MELSEC. Family flag drives the parser to
/// try the family's native syntax (V2000, D100, X20 hex/octal) before falling back to
/// Modicon / mnemonic.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusFamilyParserTests
{
// ----- DL205 native: V-memory (octal), Y/C/X/SP coils + discrete -----
[Theory]
[InlineData("V0", 0)]
[InlineData("V2000", 1024)] // octal 2000 = decimal 1024
[InlineData("V40400", 16640)] // octal 40400 = decimal 16640 (system bank in user mapping per the helper)
public void DL205_VMemory_To_HoldingRegisters(string addr, int expectedOffset)
{
var p = ModbusAddressParser.Parse(addr, ModbusFamily.DL205);
p.Region.ShouldBe(ModbusRegion.HoldingRegisters);
p.Offset.ShouldBe((ushort)expectedOffset);
p.DataType.ShouldBe(ModbusDataType.Int16);
}
[Fact]
public void DL205_Y_Output_Maps_To_Coils_Bank()
{
var p = ModbusAddressParser.Parse("Y0", ModbusFamily.DL205);
p.Region.ShouldBe(ModbusRegion.Coils);
p.Offset.ShouldBe((ushort)2048); // YOutputBaseCoil
p.DataType.ShouldBe(ModbusDataType.Bool);
}
[Fact]
public void DL205_C_Relay_Maps_To_Coils_Bank_NotModiconCoil()
{
// Cross-family ambiguity check: under Generic, "C100" is mnemonic Coils[99].
// Under DL205 family, "C100" is a control relay → CRelayBaseCoil + octal(100) = 3072 + 64.
var p = ModbusAddressParser.Parse("C100", ModbusFamily.DL205);
p.Region.ShouldBe(ModbusRegion.Coils);
p.Offset.ShouldBe((ushort)(3072 + 64));
}
[Fact]
public void DL205_X_Input_Maps_To_DiscreteInputs()
{
var p = ModbusAddressParser.Parse("X17", ModbusFamily.DL205);
p.Region.ShouldBe(ModbusRegion.DiscreteInputs);
p.Offset.ShouldBe((ushort)15); // octal 17 = decimal 15
}
[Fact]
public void DL205_SP_Special_Relay_Maps_To_DiscreteInputs()
{
var p = ModbusAddressParser.Parse("SP10", ModbusFamily.DL205);
p.Region.ShouldBe(ModbusRegion.DiscreteInputs);
p.Offset.ShouldBe((ushort)(1024 + 8)); // SpecialBaseDiscrete + octal(10)
}
[Fact]
public void DL205_Falls_Back_To_Modicon_When_Native_Misses()
{
// 40001 isn't a DL205 native form — falls through to the Modicon parser, returns
// HoldingRegisters[0]. Important for users mixing legacy Modicon entries with native.
var p = ModbusAddressParser.Parse("40001", ModbusFamily.DL205);
p.Region.ShouldBe(ModbusRegion.HoldingRegisters);
p.Offset.ShouldBe((ushort)0);
}
// ----- MELSEC native: D / X / Y / M with sub-family-aware X/Y parsing -----
[Fact]
public void MELSEC_D_Register_Maps_To_HoldingRegisters()
{
var p = ModbusAddressParser.Parse("D100", ModbusFamily.MELSEC);
p.Region.ShouldBe(ModbusRegion.HoldingRegisters);
p.Offset.ShouldBe((ushort)100); // base 0 + decimal 100
}
[Fact]
public void MELSEC_M_Relay_Maps_To_Coils_Decimal()
{
var p = ModbusAddressParser.Parse("M50", ModbusFamily.MELSEC);
p.Region.ShouldBe(ModbusRegion.Coils);
p.Offset.ShouldBe((ushort)50);
}
[Fact]
public void MELSEC_Q_Family_Treats_X20_As_Hex()
{
var p = ModbusAddressParser.Parse("X20", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR);
p.Region.ShouldBe(ModbusRegion.DiscreteInputs);
p.Offset.ShouldBe((ushort)0x20); // hex 20 = decimal 32
}
[Fact]
public void MELSEC_F_Family_Treats_X20_As_Octal()
{
var p = ModbusAddressParser.Parse("X20", ModbusFamily.MELSEC, MelsecFamily.F_iQF);
p.Region.ShouldBe(ModbusRegion.DiscreteInputs);
p.Offset.ShouldBe((ushort)16); // octal 20 = decimal 16
}
// ----- Cross-family safety / Generic regression -----
[Fact]
public void Generic_Family_Does_Not_Try_DL205_Branch()
{
// "V2000" under Generic isn't a known mnemonic OR a Modicon address → parse fails.
// (Only DL205 / MELSEC families know V-memory.)
ModbusAddressParser.TryParse("V2000", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _, out var error)
.ShouldBeFalse();
error.ShouldNotBeNull();
}
[Fact]
public void C100_Under_Generic_Means_Modicon_Coil_99()
{
// Regression guard against the cross-family ambiguity: Generic must keep mnemonic "C"
// mapping (Coil at offset = decimal-100 - 1).
var p = ModbusAddressParser.Parse("C100", ModbusFamily.Generic);
p.Region.ShouldBe(ModbusRegion.Coils);
p.Offset.ShouldBe((ushort)99);
}
[Fact]
public void Suffix_Grammar_Composes_With_Native_Address()
{
// V2000:F:CDAB:5 should parse end-to-end: DL205 V2000 → HR[1024], Float32, word-swap, array of 5.
var p = ModbusAddressParser.Parse("V2000:F:CDAB:5", ModbusFamily.DL205);
p.Region.ShouldBe(ModbusRegion.HoldingRegisters);
p.Offset.ShouldBe((ushort)1024);
p.DataType.ShouldBe(ModbusDataType.Float32);
p.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
p.ArrayCount.ShouldBe(5);
}
[Fact]
public void DL205_Bit_Suffix_On_VMemory()
{
var p = ModbusAddressParser.Parse("V2000.7", ModbusFamily.DL205);
p.Region.ShouldBe(ModbusRegion.HoldingRegisters);
p.Offset.ShouldBe((ushort)1024);
p.Bit.ShouldBe((byte)7);
p.DataType.ShouldBe(ModbusDataType.BitInRegister);
}
}