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).
This commit is contained in:
Joseph Doherty
2026-04-25 00:10:43 -04:00
parent 4bffe879c5
commit 4cf0b4eb73
7 changed files with 334 additions and 12 deletions

View File

@@ -33,18 +33,27 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
public static class ModbusAddressParser
{
/// <summary>Parse an address string. Throws <see cref="FormatException"/> on invalid input.</summary>
public static ParsedModbusAddress Parse(string address)
public static ParsedModbusAddress Parse(string address) => Parse(address, ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
/// <summary>Parse with a family hint (#144 family-native branch).</summary>
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);
}
/// <summary>
/// Try-parse variant for config-bind paths that surface diagnostics rather than throw.
/// <paramref name="result"/> is null and <paramref name="error"/> non-null on failure.
/// </summary>
public static bool TryParse(string? address, out ParsedModbusAddress? result, out string? error)
=> TryParse(address, ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out result, out error);
/// <summary>
/// Try-parse with a family hint. When <paramref name="family"/> 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. <paramref name="result"/> is null and
/// <paramref name="error"/> non-null on failure.
/// </summary>
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;

View File

@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// 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 <c>V2000</c> octal, MELSEC <c>X20</c> hex/octal, etc.) are
/// translated to <see cref="ModbusRegion"/> + PDU offset directly — without forcing
/// integration engineers to pre-translate to Modicon notation.
/// </summary>
/// <remarks>
/// <para>
/// When set to <see cref="Generic"/> (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.
/// </para>
/// <para>
/// <b>Cross-family ambiguity</b>: <c>C100</c> means coil 100 under
/// <see cref="Generic"/>, but DL260 control-relay 100 under <see cref="DL205"/>, etc.
/// Per-driver Family selection makes the choice unambiguous within one driver instance;
/// users with mixed families need separate driver instances per device.
/// </para>
/// </remarks>
public enum ModbusFamily
{
/// <summary>Default — only Modicon (4xxxx) and mnemonic (HR1, C100) forms are accepted.</summary>
Generic,
/// <summary>
/// AutomationDirect DirectLOGIC (DL205 / DL260 / DL350). V-memory is octal; Y / C
/// are coils with hard-wired bank bases; X / SP are discrete inputs.
/// </summary>
DL205,
/// <summary>
/// Mitsubishi MELSEC. X / Y interpretation depends on sub-family selection — see
/// <see cref="MelsecFamily"/>. Defaults to Q/L/iQR (hex) when this family is selected.
/// </summary>
MELSEC,
}

View File

@@ -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<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", 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<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", 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<ModbusTagDto>? Tags { get; init; }
public ModbusProbeDto? Probe { get; init; }

View File

@@ -79,6 +79,20 @@ public sealed class ModbusDriverOptions
/// </summary>
public bool DisableFC23 { get; init; } = false;
/// <summary>
/// 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
/// <see cref="ModbusFamily.Generic"/> = Modicon-only behaviour preserved from #137.
/// </summary>
public ModbusFamily Family { get; init; } = ModbusFamily.Generic;
/// <summary>
/// MELSEC sub-family selector — only consulted when <see cref="Family"/> = MELSEC.
/// Default Q/L/iQR (hex X/Y interpretation). Set F_iQF for FX-series PLCs.
/// </summary>
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
/// <summary>
/// When <c>true</c>, 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

View File

@@ -0,0 +1,151 @@
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);
}
}