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:
@@ -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;
|
||||
}
|
||||
}
|
||||
161
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/MelsecAddress.cs
Normal file
161
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/MelsecAddress.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user