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