diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/DirectLogicAddress.cs
similarity index 100%
rename from src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs
rename to src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/DirectLogicAddress.cs
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/MelsecAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/MelsecAddress.cs
similarity index 100%
rename from src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/MelsecAddress.cs
rename to src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/MelsecAddress.cs
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusAddressParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusAddressParser.cs
index 477bb94..dbc26eb 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusAddressParser.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusAddressParser.cs
@@ -33,18 +33,27 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
public static class ModbusAddressParser
{
/// Parse an address string. Throws on invalid input.
- public static ParsedModbusAddress Parse(string address)
+ public static ParsedModbusAddress Parse(string address) => Parse(address, ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
+
+ /// Parse with a family hint (#144 family-native branch).
+ public static ParsedModbusAddress Parse(string address, ModbusFamily family, MelsecFamily melsecSubFamily = MelsecFamily.Q_L_iQR)
{
- if (TryParse(address, out var parsed, out var error))
+ if (TryParse(address, family, melsecSubFamily, out var parsed, out var error))
return parsed!;
throw new FormatException(error);
}
- ///
- /// Try-parse variant for config-bind paths that surface diagnostics rather than throw.
- /// is null and non-null on failure.
- ///
public static bool TryParse(string? address, out ParsedModbusAddress? result, out string? error)
+ => TryParse(address, ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out result, out error);
+
+ ///
+ /// Try-parse with a family hint. When is non-Generic, the
+ /// parser tries the family-native form first (DL205 V-memory, MELSEC D-register, etc.)
+ /// and falls back to Modicon / mnemonic on miss. is null and
+ /// non-null on failure.
+ ///
+ public static bool TryParse(string? address, ModbusFamily family, MelsecFamily melsecSubFamily,
+ out ParsedModbusAddress? result, out string? error)
{
result = null;
@@ -90,7 +99,7 @@ public static class ModbusAddressParser
countPart = parts[3];
}
- if (!TryParseRegionAndOffset(addressPart, out var region, out var offset, out var bit, out error))
+ if (!TryParseRegionAndOffset(addressPart, family, melsecSubFamily, out var region, out var offset, out var bit, out error))
return false;
// Type field — defaults: Bool for Coils/DiscreteInputs, Int16 for InputRegisters/HoldingRegisters,
@@ -157,7 +166,8 @@ public static class ModbusAddressParser
return true;
}
- private static bool TryParseRegionAndOffset(string text, out ModbusRegion region, out ushort offset, out byte? bit, out string? error)
+ private static bool TryParseRegionAndOffset(string text, ModbusFamily family, MelsecFamily melsecSubFamily,
+ out ModbusRegion region, out ushort offset, out byte? bit, out string? error)
{
region = default;
offset = 0;
@@ -183,9 +193,15 @@ public static class ModbusAddressParser
bit = bitVal;
}
+ // Family-native branch (#144) — when a non-Generic family is configured, try its native
+ // syntax first. Successful native parse wins; failure falls through to Modicon / mnemonic.
+ // The order matters for cross-family ambiguity: DL205 'C100' is a control relay, not a
+ // Modicon coil, when the user has explicitly selected DL205.
+ if (family != ModbusFamily.Generic && TryParseFamilyNative(addrText, family, melsecSubFamily, out region, out offset, out error))
+ return true;
+
// Try mnemonic prefix first (HR, IR, C, DI). Cheaper than the digit branch and
- // unambiguous when present. DI must be checked before D — we don't currently use D
- // alone but stay defensive.
+ // unambiguous when present.
if (TryParseMnemonicAddress(addrText, out region, out offset, out error))
return true;
@@ -197,6 +213,94 @@ public static class ModbusAddressParser
return false;
}
+ private static bool TryParseFamilyNative(string text, ModbusFamily family, MelsecFamily melsecSubFamily,
+ out ModbusRegion region, out ushort offset, out string? error)
+ {
+ region = default;
+ offset = 0;
+ error = null;
+
+ try
+ {
+ switch (family)
+ {
+ case ModbusFamily.DL205:
+ // V-memory → HoldingRegisters; Y → Coils; C → Coils (relays); X → DiscreteInputs;
+ // SP → DiscreteInputs (special relays).
+ if (text.StartsWith("V", StringComparison.OrdinalIgnoreCase))
+ {
+ offset = DirectLogicAddress.UserVMemoryToPdu(text);
+ region = ModbusRegion.HoldingRegisters;
+ return true;
+ }
+ if (text.StartsWith("Y", StringComparison.OrdinalIgnoreCase))
+ {
+ offset = DirectLogicAddress.YOutputToCoil(text);
+ region = ModbusRegion.Coils;
+ return true;
+ }
+ if (text.StartsWith("C", StringComparison.OrdinalIgnoreCase))
+ {
+ offset = DirectLogicAddress.CRelayToCoil(text);
+ region = ModbusRegion.Coils;
+ return true;
+ }
+ if (text.StartsWith("X", StringComparison.OrdinalIgnoreCase))
+ {
+ offset = DirectLogicAddress.XInputToDiscrete(text);
+ region = ModbusRegion.DiscreteInputs;
+ return true;
+ }
+ if (text.StartsWith("SP", StringComparison.OrdinalIgnoreCase))
+ {
+ offset = DirectLogicAddress.SpecialToDiscrete(text);
+ region = ModbusRegion.DiscreteInputs;
+ return true;
+ }
+ return false;
+
+ case ModbusFamily.MELSEC:
+ // D-registers → HoldingRegisters; X → DiscreteInputs; Y → Coils; M → Coils.
+ // The MelsecAddress helpers honour the sub-family (Q hex vs F octal) and use
+ // bank base 0; users with non-zero assignment bases must use the structured
+ // tag form to override. The grammar string covers the common base-0 path.
+ if (text.StartsWith("D", StringComparison.OrdinalIgnoreCase))
+ {
+ offset = MelsecAddress.DRegisterToHolding(text);
+ region = ModbusRegion.HoldingRegisters;
+ return true;
+ }
+ if (text.StartsWith("X", StringComparison.OrdinalIgnoreCase))
+ {
+ offset = MelsecAddress.XInputToDiscrete(text, melsecSubFamily);
+ region = ModbusRegion.DiscreteInputs;
+ return true;
+ }
+ if (text.StartsWith("Y", StringComparison.OrdinalIgnoreCase))
+ {
+ offset = MelsecAddress.YOutputToCoil(text, melsecSubFamily);
+ region = ModbusRegion.Coils;
+ return true;
+ }
+ if (text.StartsWith("M", StringComparison.OrdinalIgnoreCase))
+ {
+ offset = MelsecAddress.MRelayToCoil(text);
+ region = ModbusRegion.Coils;
+ return true;
+ }
+ return false;
+
+ default:
+ return false;
+ }
+ }
+ catch (Exception ex) when (ex is ArgumentException or OverflowException)
+ {
+ error = $"Family-native parse for {family} failed on '{text}': {ex.Message}";
+ return false;
+ }
+ }
+
private static bool TryParseMnemonicAddress(string text, out ModbusRegion region, out ushort offset, out string? error)
{
region = default;
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusFamily.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusFamily.cs
new file mode 100644
index 0000000..5e75bde
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusFamily.cs
@@ -0,0 +1,39 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// PLC family selector that drives the parser's family-native branch (#144). When the
+/// driver is configured for a specific family, address strings using that family's native
+/// notation (DirectLOGIC V2000 octal, MELSEC X20 hex/octal, etc.) are
+/// translated to + PDU offset directly — without forcing
+/// integration engineers to pre-translate to Modicon notation.
+///
+///
+///
+/// When set to (the default), the parser only accepts Modicon and
+/// mnemonic forms — preserves pre-#144 behaviour exactly. Setting a non-Generic family
+/// is the only way to enable family-native parsing.
+///
+///
+/// Cross-family ambiguity: C100 means coil 100 under
+/// , but DL260 control-relay 100 under , etc.
+/// Per-driver Family selection makes the choice unambiguous within one driver instance;
+/// users with mixed families need separate driver instances per device.
+///
+///
+public enum ModbusFamily
+{
+ /// Default — only Modicon (4xxxx) and mnemonic (HR1, C100) forms are accepted.
+ Generic,
+
+ ///
+ /// AutomationDirect DirectLOGIC (DL205 / DL260 / DL350). V-memory is octal; Y / C
+ /// are coils with hard-wired bank bases; X / SP are discrete inputs.
+ ///
+ DL205,
+
+ ///
+ /// Mitsubishi MELSEC. X / Y interpretation depends on sub-family selection — see
+ /// . Defaults to Q/L/iQR (hex) when this family is selected.
+ ///
+ MELSEC,
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs
index 3e2f048..2b790a7 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs
@@ -46,9 +46,18 @@ public static class ModbusDriverFactoryExtensions
UseFC16ForSingleRegisterWrites = dto.UseFC16ForSingleRegisterWrites ?? false,
DisableFC23 = dto.DisableFC23 ?? false,
WriteOnChangeOnly = dto.WriteOnChangeOnly ?? false,
+ Family = dto.Family is null ? ModbusFamily.Generic
+ : ParseEnum(dto.Family, "", driverInstanceId, "Family"),
+ MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
+ : ParseEnum(dto.MelsecSubFamily, "", driverInstanceId, "MelsecSubFamily"),
AutoReconnect = dto.AutoReconnect ?? true,
Tags = dto.Tags is { Count: > 0 }
- ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
+ ? [.. dto.Tags.Select(t => BuildTag(
+ t, driverInstanceId,
+ dto.Family is null ? ModbusFamily.Generic
+ : ParseEnum(dto.Family, "", driverInstanceId, "Family"),
+ dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
+ : ParseEnum(dto.MelsecSubFamily, "", driverInstanceId, "MelsecSubFamily")))]
: [],
Probe = new ModbusProbeOptions
{
@@ -77,6 +86,9 @@ public static class ModbusDriverFactoryExtensions
}
private static ModbusTagDefinition BuildTag(ModbusTagDto t, string driverInstanceId)
+ => BuildTag(t, driverInstanceId, ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
+
+ private static ModbusTagDefinition BuildTag(ModbusTagDto t, string driverInstanceId, ModbusFamily family, MelsecFamily melsecSubFamily)
{
var name = t.Name ?? throw new InvalidOperationException(
$"Modbus config for '{driverInstanceId}' has a tag missing Name");
@@ -87,7 +99,7 @@ public static class ModbusDriverFactoryExtensions
// from the grammar (Writable, WriteIdempotent, StringByteOrder) always come from the DTO.
if (!string.IsNullOrWhiteSpace(t.AddressString))
{
- if (!ModbusAddressParser.TryParse(t.AddressString, out var parsed, out var parseError))
+ if (!ModbusAddressParser.TryParse(t.AddressString, family, melsecSubFamily, out var parsed, out var parseError))
throw new InvalidOperationException(
$"Modbus tag '{name}' in '{driverInstanceId}' has invalid AddressString '{t.AddressString}': {parseError}");
return new ModbusTagDefinition(
@@ -159,6 +171,8 @@ public static class ModbusDriverFactoryExtensions
public bool? UseFC16ForSingleRegisterWrites { get; init; }
public bool? DisableFC23 { get; init; }
public bool? WriteOnChangeOnly { get; init; }
+ public string? Family { get; init; }
+ public string? MelsecSubFamily { get; init; }
public bool? AutoReconnect { get; init; }
public List? Tags { get; init; }
public ModbusProbeDto? Probe { get; init; }
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
index 687f0ae..d9e2ab8 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
@@ -79,6 +79,20 @@ public sealed class ModbusDriverOptions
///
public bool DisableFC23 { get; init; } = false;
+ ///
+ /// PLC family hint that drives the parser's family-native branch (#144). When set to a
+ /// non-Generic value, address strings using that family's native syntax (DL205 V2000 /
+ /// MELSEC D100) parse to the right region + offset directly. Defaults to
+ /// = Modicon-only behaviour preserved from #137.
+ ///
+ public ModbusFamily Family { get; init; } = ModbusFamily.Generic;
+
+ ///
+ /// MELSEC sub-family selector — only consulted when = MELSEC.
+ /// Default Q/L/iQR (hex X/Y interpretation). Set F_iQF for FX-series PLCs.
+ ///
+ public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
+
///
/// When true, the driver suppresses redundant writes: if the most recent
/// successful write to a tag carried value V and a new write of V arrives, the second
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ModbusFamilyParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ModbusFamilyParserTests.cs
new file mode 100644
index 0000000..3971ae8
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ModbusFamilyParserTests.cs
@@ -0,0 +1,151 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests;
+
+///
+/// #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.
+///
+[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);
+ }
+}