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

@@ -0,0 +1,165 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// AutomationDirect DirectLOGIC address-translation helpers. DL205 / DL260 / DL350 CPUs
/// address V-memory in OCTAL while the Modbus wire uses DECIMAL PDU addresses — operators
/// see "V2000" in the PLC ladder-logic editor but the Modbus client must write PDU 0x0400.
/// The formulas differ between user V-memory (simple octal-to-decimal) and system V-memory
/// (fixed bank mappings), so the two cases are separate methods rather than one overloaded
/// "ToPdu" call.
/// </summary>
/// <remarks>
/// See <c>docs/v2/dl205.md</c> §V-memory for the full CPU-family matrix + rationale.
/// References: D2-USER-M appendix (DL205/D2-260), H2-ECOM-M §6.5 (absolute vs relative
/// addressing), AutomationDirect forum guidance on V40400 system-base.
/// </remarks>
public static class DirectLogicAddress
{
/// <summary>
/// Convert a DirectLOGIC user V-memory address (octal) to a 0-based Modbus PDU address.
/// Accepts bare octal (<c>"2000"</c>) or <c>V</c>-prefixed (<c>"V2000"</c>). Range
/// depends on CPU model — DL205 D2-260 user memory is V1400-V7377 + V10000-V17777
/// octal, DL260 extends to V77777 octal.
/// </summary>
/// <exception cref="ArgumentException">Input is null / empty / contains non-octal digits (8,9).</exception>
/// <exception cref="OverflowException">Parsed value exceeds ushort.MaxValue (0xFFFF).</exception>
public static ushort UserVMemoryToPdu(string vAddress)
{
if (string.IsNullOrWhiteSpace(vAddress))
throw new ArgumentException("V-memory address must not be empty", nameof(vAddress));
var s = vAddress.Trim();
if (s[0] == 'V' || s[0] == 'v') s = s.Substring(1);
if (s.Length == 0)
throw new ArgumentException($"V-memory address '{vAddress}' has no digits", nameof(vAddress));
// Octal conversion. Reject 8/9 digits up-front — int.Parse in the obvious base would
// accept them silently because .NET has no built-in base-8 parser.
uint result = 0;
foreach (var ch in s)
{
if (ch < '0' || ch > '7')
throw new ArgumentException(
$"V-memory address '{vAddress}' contains non-octal digit '{ch}' — DirectLOGIC V-addresses are octal (0-7)",
nameof(vAddress));
result = result * 8 + (uint)(ch - '0');
if (result > ushort.MaxValue)
throw new OverflowException(
$"V-memory address '{vAddress}' exceeds the 16-bit Modbus PDU address range");
}
return (ushort)result;
}
/// <summary>
/// DirectLOGIC system V-memory starts at octal V40400 on DL260 / H2-ECOM100 in factory
/// "absolute" addressing mode. Unlike user V-memory, the mapping is NOT a simple
/// octal-to-decimal conversion — the CPU relocates the system bank to Modbus PDU 0x2100
/// (decimal 8448). This helper returns the CPU-family base plus a user-supplied offset
/// within the system bank.
/// </summary>
public const ushort SystemVMemoryBasePdu = 0x2100;
/// <param name="offsetWithinSystemBank">
/// 0-based register offset within the system bank. Pass 0 for V40400 itself; pass 1 for
/// V40401 (octal), and so on. NOT an octal-decoded value — the system bank lives at
/// consecutive PDU addresses, so the offset is plain decimal.
/// </param>
public static ushort SystemVMemoryToPdu(ushort offsetWithinSystemBank)
{
var pdu = SystemVMemoryBasePdu + offsetWithinSystemBank;
if (pdu > ushort.MaxValue)
throw new OverflowException(
$"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF");
return (ushort)pdu;
}
// Bit-memory bases per DL260 user manual §I/O-configuration.
// Numbers after X / Y / C / SP are OCTAL in DirectLOGIC notation. The Modbus base is
// added to the octal-decoded offset; e.g. Y017 = Modbus coil 2048 + octal(17) = 2048 + 15 = 2063.
/// <summary>
/// DL260 Y-output coil base. Y0 octal → Modbus coil address 2048 (0-based).
/// </summary>
public const ushort YOutputBaseCoil = 2048;
/// <summary>
/// DL260 C-relay coil base. C0 octal → Modbus coil address 3072 (0-based).
/// </summary>
public const ushort CRelayBaseCoil = 3072;
/// <summary>
/// DL260 X-input discrete-input base. X0 octal → Modbus discrete input 0.
/// </summary>
public const ushort XInputBaseDiscrete = 0;
/// <summary>
/// DL260 SP special-relay discrete-input base. SP0 octal → Modbus discrete input 1024.
/// Read-only; writing SP relays is rejected with Illegal Data Address.
/// </summary>
public const ushort SpecialBaseDiscrete = 1024;
/// <summary>
/// Translate a DirectLOGIC Y-output address (e.g. <c>"Y0"</c>, <c>"Y17"</c>) to its
/// 0-based Modbus coil address on DL260. The trailing number is OCTAL, matching the
/// ladder-logic editor's notation.
/// </summary>
public static ushort YOutputToCoil(string yAddress) =>
AddOctalOffset(YOutputBaseCoil, StripPrefix(yAddress, 'Y'));
/// <summary>
/// Translate a DirectLOGIC C-relay address (e.g. <c>"C0"</c>, <c>"C1777"</c>) to its
/// 0-based Modbus coil address.
/// </summary>
public static ushort CRelayToCoil(string cAddress) =>
AddOctalOffset(CRelayBaseCoil, StripPrefix(cAddress, 'C'));
/// <summary>
/// Translate a DirectLOGIC X-input address (e.g. <c>"X0"</c>, <c>"X17"</c>) to its
/// 0-based Modbus discrete-input address. Reading an unpopulated X returns 0, not an
/// exception — the CPU sizes the table to configured I/O, not installed modules.
/// </summary>
public static ushort XInputToDiscrete(string xAddress) =>
AddOctalOffset(XInputBaseDiscrete, StripPrefix(xAddress, 'X'));
/// <summary>
/// Translate a DirectLOGIC SP-special-relay address (e.g. <c>"SP0"</c>) to its 0-based
/// Modbus discrete-input address. Accepts <c>"SP"</c> prefix case-insensitively.
/// </summary>
public static ushort SpecialToDiscrete(string spAddress)
{
if (string.IsNullOrWhiteSpace(spAddress))
throw new ArgumentException("SP address must not be empty", nameof(spAddress));
var s = spAddress.Trim();
if (s.Length >= 2 && (s[0] == 'S' || s[0] == 's') && (s[1] == 'P' || s[1] == 'p'))
s = s.Substring(2);
return AddOctalOffset(SpecialBaseDiscrete, s);
}
private static string StripPrefix(string address, char expectedPrefix)
{
if (string.IsNullOrWhiteSpace(address))
throw new ArgumentException("Address must not be empty", nameof(address));
var s = address.Trim();
if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix))
s = s.Substring(1);
return s;
}
private static ushort AddOctalOffset(ushort baseAddr, string octalDigits)
{
if (octalDigits.Length == 0)
throw new ArgumentException("Address has no digits", nameof(octalDigits));
uint offset = 0;
foreach (var ch in octalDigits)
{
if (ch < '0' || ch > '7')
throw new ArgumentException(
$"Address contains non-octal digit '{ch}' — DirectLOGIC I/O addresses are octal (0-7)",
nameof(octalDigits));
offset = offset * 8 + (uint)(ch - '0');
}
var result = baseAddr + offset;
if (result > ushort.MaxValue)
throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF");
return (ushort)result;
}
}

View File

@@ -0,0 +1,161 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Mitsubishi MELSEC PLC family selector for address-translation helpers. The Q/L/iQ-R
/// families write bit-device addresses (X, Y) in <b>hexadecimal</b> in GX Works and the
/// CPU manuals; the FX and iQ-F families write them in <b>octal</b> (same convention as
/// AutomationDirect DirectLOGIC). Mixing the two up is the #1 MELSEC driver bug source —
/// an operator typing <c>X20</c> into a Q-series tag config means decimal 32, but the
/// same string on an FX3U means decimal 16, so the helper must know the family to route
/// correctly.
/// </summary>
public enum MelsecFamily
{
/// <summary>
/// MELSEC-Q / MELSEC-L / MELSEC iQ-R. X and Y device numbers are interpreted as
/// <b>hexadecimal</b>; <c>X20</c> means decimal 32.
/// </summary>
Q_L_iQR,
/// <summary>
/// MELSEC-F (FX3U / FX3GE / FX3G) and MELSEC iQ-F (FX5U). X and Y device numbers
/// are interpreted as <b>octal</b> (same as DirectLOGIC); <c>X20</c> means decimal 16.
/// iQ-F has a GX Works3 project toggle that can flip to decimal — if a site uses
/// that, configure the tag's Address directly as a decimal PDU address and do not
/// route through this helper.
/// </summary>
F_iQF,
}
/// <summary>
/// Mitsubishi MELSEC address-translation helpers for the QJ71MT91 / LJ71MT91 / RJ71EN71 /
/// iQ-R built-in / iQ-F / FX3U-ENET-P502 Modbus modules. MELSEC does NOT hard-wire
/// Modbus-to-device mappings like DL260 does — every site configures its own "Modbus
/// Device Assignment Parameter" block of up to 16 entries. The helpers here cover only
/// the <b>address-notation</b> portion of the translation (hex X20 vs octal X20 + adding
/// the bank base); the caller is still responsible for knowing the assignment-block
/// offset for their site.
/// </summary>
/// <remarks>
/// See <c>docs/v2/mitsubishi.md</c> §device-assignment + §X-Y-hex-trap for the full
/// matrix and primary-source citations.
/// </remarks>
public static class MelsecAddress
{
/// <summary>
/// Translate a MELSEC X-input address (e.g. <c>"X0"</c>, <c>"X10"</c>) to a 0-based
/// Modbus discrete-input address, given the PLC family's address notation (hex or
/// octal) and the Modbus Device Assignment block's X-range base.
/// </summary>
/// <param name="xAddress">MELSEC X address. <c>X</c> prefix optional, case-insensitive.</param>
/// <param name="family">The PLC family — determines whether the trailing digits are hex or octal.</param>
/// <param name="xBankBase">
/// 0-based Modbus DI address the assignment-block has configured X0 to land at.
/// Typical default on QJ71MT91 sample projects: 0. Pass the site-specific value.
/// </param>
public static ushort XInputToDiscrete(string xAddress, MelsecFamily family, ushort xBankBase = 0) =>
AddFamilyOffset(xBankBase, StripPrefix(xAddress, 'X'), family);
/// <summary>
/// Translate a MELSEC Y-output address to a 0-based Modbus coil address. Same rules
/// as <see cref="XInputToDiscrete"/> for hex/octal parsing.
/// </summary>
public static ushort YOutputToCoil(string yAddress, MelsecFamily family, ushort yBankBase = 0) =>
AddFamilyOffset(yBankBase, StripPrefix(yAddress, 'Y'), family);
/// <summary>
/// Translate a MELSEC M-relay address (internal relay) to a 0-based Modbus coil
/// address. M-addresses are <b>decimal</b> on every MELSEC family — unlike X/Y which
/// are hex on Q/L/iQ-R. Includes the bank base that the assignment-block configured.
/// </summary>
public static ushort MRelayToCoil(string mAddress, ushort mBankBase = 0)
{
var digits = StripPrefix(mAddress, 'M');
if (!ushort.TryParse(digits, out var offset))
throw new ArgumentException(
$"M-relay address '{mAddress}' is not a valid decimal integer", nameof(mAddress));
var result = mBankBase + offset;
if (result > ushort.MaxValue)
throw new OverflowException($"M-relay {mAddress} + base {mBankBase} exceeds 0xFFFF");
return (ushort)result;
}
/// <summary>
/// Translate a MELSEC D-register address (data register) to a 0-based Modbus holding
/// register address. D-addresses are <b>decimal</b>. Default assignment convention is
/// D0 → HR 0 (pass <paramref name="dBankBase"/> = 0); sites with shifted layouts pass
/// their configured base.
/// </summary>
public static ushort DRegisterToHolding(string dAddress, ushort dBankBase = 0)
{
var digits = StripPrefix(dAddress, 'D');
if (!ushort.TryParse(digits, out var offset))
throw new ArgumentException(
$"D-register address '{dAddress}' is not a valid decimal integer", nameof(dAddress));
var result = dBankBase + offset;
if (result > ushort.MaxValue)
throw new OverflowException($"D-register {dAddress} + base {dBankBase} exceeds 0xFFFF");
return (ushort)result;
}
private static string StripPrefix(string address, char expectedPrefix)
{
if (string.IsNullOrWhiteSpace(address))
throw new ArgumentException("Address must not be empty", nameof(address));
var s = address.Trim();
if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix))
s = s.Substring(1);
if (s.Length == 0)
throw new ArgumentException($"Address '{address}' has no digits after prefix", nameof(address));
return s;
}
private static ushort AddFamilyOffset(ushort baseAddr, string digits, MelsecFamily family)
{
uint offset = family switch
{
MelsecFamily.Q_L_iQR => ParseHex(digits),
MelsecFamily.F_iQF => ParseOctal(digits),
_ => throw new ArgumentOutOfRangeException(nameof(family), family, "Unknown MELSEC family"),
};
var result = baseAddr + offset;
if (result > ushort.MaxValue)
throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF");
return (ushort)result;
}
private static uint ParseHex(string digits)
{
uint result = 0;
foreach (var ch in digits)
{
uint nibble;
if (ch >= '0' && ch <= '9') nibble = (uint)(ch - '0');
else if (ch >= 'A' && ch <= 'F') nibble = (uint)(ch - 'A' + 10);
else if (ch >= 'a' && ch <= 'f') nibble = (uint)(ch - 'a' + 10);
else throw new ArgumentException(
$"Address contains non-hex digit '{ch}' — Q/L/iQ-R X/Y addresses are hexadecimal",
nameof(digits));
result = result * 16 + nibble;
if (result > ushort.MaxValue)
throw new OverflowException($"Hex address exceeds 0xFFFF");
}
return result;
}
private static uint ParseOctal(string digits)
{
uint result = 0;
foreach (var ch in digits)
{
if (ch < '0' || ch > '7')
throw new ArgumentException(
$"Address contains non-octal digit '{ch}' — FX/iQ-F X/Y addresses are octal (0-7)",
nameof(digits));
result = result * 8 + (uint)(ch - '0');
if (result > ushort.MaxValue)
throw new OverflowException($"Octal address exceeds 0xFFFF");
}
return result;
}
}

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,
}