From 4cf0b4eb7321ea3df576820c8330b268a3e8014b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 00:10:43 -0400 Subject: [PATCH] =?UTF-8?q?Task=20#144=20=E2=80=94=20Modbus=20family-nativ?= =?UTF-8?q?e=20parser=20branch=20(DL205=20/=20MELSEC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../DirectLogicAddress.cs | 0 .../MelsecAddress.cs | 0 .../ModbusAddressParser.cs | 124 ++++++++++++-- .../ModbusFamily.cs | 39 +++++ .../ModbusDriverFactoryExtensions.cs | 18 ++- .../ModbusDriverOptions.cs | 14 ++ .../ModbusFamilyParserTests.cs | 151 ++++++++++++++++++ 7 files changed, 334 insertions(+), 12 deletions(-) rename src/{ZB.MOM.WW.OtOpcUa.Driver.Modbus => ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing}/DirectLogicAddress.cs (100%) rename src/{ZB.MOM.WW.OtOpcUa.Driver.Modbus => ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing}/MelsecAddress.cs (100%) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusFamily.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ModbusFamilyParserTests.cs 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); + } +}