Task #136 — Modicon address-string parser (5/6-digit) + shared addressing assembly
Foundation for the Modbus addressing-grammar work tracked in #137-#145. Adds ModbusModiconAddress.Parse / TryParse that turns classic Modicon strings (40001 / 400001 / 30001 / 00001 / 10001) into (Region, ushort PduOffset). Also extracts ModbusRegion to a new Driver.Modbus.Addressing assembly so the Admin UI (#145) can reference the addressing surface without taking a dep on the wire driver. The new assembly intentionally extends the same ZB.MOM.WW.OtOpcUa.Driver.Modbus namespace as the driver — callers see the type as if it lived in one place; only the project layout changes. No existing call site needed editing (zero-churn move). Behaviour: - Single leading digit selects region (0=Coils, 1=DiscreteInputs, 3=InputRegisters, 4=HoldingRegisters). - 5-digit form: trailing 4 digits are 1-based register, supports 1..9999. - 6-digit form: trailing 5 digits are 1-based register, supports 1..65536 (full PDU address space). - Strict 5-or-6 length check; whitespace trimmed; clear FormatException diagnostics for every malformed shape (wrong length, non-digit body, illegal leading digit, register zero, register overflow). 29/29 new unit tests pass. Full Driver.Modbus suite (182 tests) and the solution-wide build still green after the ModbusRegion move.
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
@@ -48,6 +49,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj"/>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Parses classic Modicon address strings — both 5-digit (<c>40001</c>) and 6-digit
|
||||
/// (<c>400001</c>) forms — into the protocol-level <see cref="ModbusRegion"/> +
|
||||
/// zero-based PDU offset the driver speaks on the wire.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Modicon notation uses a leading region digit (<c>0</c> coil, <c>1</c> discrete input,
|
||||
/// <c>3</c> input register, <c>4</c> holding register) followed by a 1-based register
|
||||
/// number. The two forms differ only in how many trailing digits encode the register
|
||||
/// number: 5-digit caps at 9999, 6-digit at 65535. Both decode to the same wire
|
||||
/// representation — the PDU offset is always 0..65535 — so the only meaningful
|
||||
/// distinction is range coverage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Foundational helper for the addressing grammar work tracked in
|
||||
/// <c>docs/v2/modbus-addressing.md</c>. The richer suffix grammar (type / bit /
|
||||
/// byte-order / array) layered on top in a follow-up calls into this parser to extract
|
||||
/// the region + offset before processing modifiers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ModbusModiconAddress
|
||||
{
|
||||
/// <summary>Parse a Modicon address string.</summary>
|
||||
/// <param name="address">Either 5-digit (<c>40001</c>) or 6-digit (<c>400001</c>) form.</param>
|
||||
/// <returns>Region + zero-based PDU offset the driver uses on the wire.</returns>
|
||||
/// <exception cref="FormatException">When the input is not a valid Modicon address.</exception>
|
||||
public static (ModbusRegion Region, ushort Offset) Parse(string address)
|
||||
{
|
||||
if (TryParse(address, out var region, out var offset, out var error))
|
||||
return (region, offset);
|
||||
throw new FormatException(error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try-parse variant for hot-path / config-bind scenarios where a parse failure should
|
||||
/// surface a structured diagnostic rather than throw. <paramref name="error"/> is
|
||||
/// <c>null</c> on success.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? address, out ModbusRegion region, out ushort offset, out string? error)
|
||||
{
|
||||
region = default;
|
||||
offset = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
{
|
||||
error = "Modicon address is null or empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Range check up-front — keeps the rest of the parser straight-line. 5-digit Modicon
|
||||
// is always exactly 5 chars (40001..49999, with the lead digit selecting region), and
|
||||
// 6-digit is exactly 6 (400001..465536-shaped). Anything else is unambiguously
|
||||
// malformed so we reject before doing the per-character work.
|
||||
var s = address.Trim();
|
||||
if (s.Length is not (5 or 6))
|
||||
{
|
||||
error = $"Modicon address must be 5 or 6 digits, got {s.Length} ('{address}')";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!s.All(char.IsDigit))
|
||||
{
|
||||
error = $"Modicon address must contain only digits ('{address}')";
|
||||
return false;
|
||||
}
|
||||
|
||||
var leading = s[0];
|
||||
region = leading switch
|
||||
{
|
||||
'0' => ModbusRegion.Coils,
|
||||
'1' => ModbusRegion.DiscreteInputs,
|
||||
'3' => ModbusRegion.InputRegisters,
|
||||
'4' => ModbusRegion.HoldingRegisters,
|
||||
_ => (ModbusRegion)(-1),
|
||||
};
|
||||
if ((int)region == -1)
|
||||
{
|
||||
error = $"Modicon address leading digit must be 0/1/3/4, got '{leading}'";
|
||||
return false;
|
||||
}
|
||||
|
||||
// The remaining 4 (5-digit) or 5 (6-digit) digits are the 1-based register number.
|
||||
// 1-based-to-0-based conversion happens here so callers downstream uniformly see PDU
|
||||
// offsets — which is what the wire format actually uses.
|
||||
var registerNumberText = s[1..];
|
||||
if (!int.TryParse(registerNumberText, NumberStyles.None, CultureInfo.InvariantCulture, out var registerNumber))
|
||||
{
|
||||
error = $"Modicon register number is not a valid integer ('{registerNumberText}')";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (registerNumber < 1)
|
||||
{
|
||||
error = $"Modicon register number must be >= 1 (got {registerNumber})";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5-digit form caps at 9999 by construction (4 trailing digits); reject if the parsed
|
||||
// value exceeds the wire-protocol maximum of 65536 (i.e. PDU offset 65535). 6-digit
|
||||
// form can address the full 65535-offset range.
|
||||
if (registerNumber > 65536)
|
||||
{
|
||||
error = $"Modicon register number {registerNumber} exceeds the wire maximum (65536 / PDU offset 65535)";
|
||||
return false;
|
||||
}
|
||||
|
||||
offset = (ushort)(registerNumber - 1);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// The four Modbus register regions a tag can target. Maps directly to function-code
|
||||
/// selection on the wire: <see cref="Coils"/> uses FC01/FC05/FC15, <see cref="DiscreteInputs"/>
|
||||
/// uses FC02 (read-only), <see cref="InputRegisters"/> uses FC04 (read-only), and
|
||||
/// <see cref="HoldingRegisters"/> uses FC03/FC06/FC16.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lives in the shared addressing assembly so Admin UI and the parser library can speak
|
||||
/// about regions without taking a dependency on the wire driver. The driver-side
|
||||
/// <c>Driver.Modbus</c> assembly extends the same namespace, so callers see this type as
|
||||
/// if it lived in one place.
|
||||
/// </remarks>
|
||||
public enum ModbusRegion
|
||||
{
|
||||
Coils,
|
||||
DiscreteInputs,
|
||||
InputRegisters,
|
||||
HoldingRegisters,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus</RootNamespace>
|
||||
<Description>Pure Modbus address-grammar parsing — shared by Driver.Modbus, Driver.Modbus.Cli, and Admin so the wire driver and the configuration UI agree on a single grammar definition. Namespace is intentionally identical to Driver.Modbus's so callers see addressing types as if they lived in one assembly; this assembly stays dependency-free so Admin can reference it without taking a transport-layer dep.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -112,8 +112,6 @@ public sealed record ModbusTagDefinition(
|
||||
ModbusStringByteOrder StringByteOrder = ModbusStringByteOrder.HighByteFirst,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
||||
|
||||
public enum ModbusDataType
|
||||
{
|
||||
Bool,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusModiconAddressTests
|
||||
{
|
||||
[Theory]
|
||||
// 5-digit form, one per region. Trailing 4 digits = 1-based register; PDU offset is one less.
|
||||
[InlineData("00001", ModbusRegion.Coils, (ushort)0)]
|
||||
[InlineData("09999", ModbusRegion.Coils, (ushort)9998)]
|
||||
[InlineData("10001", ModbusRegion.DiscreteInputs, (ushort)0)]
|
||||
[InlineData("19999", ModbusRegion.DiscreteInputs, (ushort)9998)]
|
||||
[InlineData("30001", ModbusRegion.InputRegisters, (ushort)0)]
|
||||
[InlineData("39999", ModbusRegion.InputRegisters, (ushort)9998)]
|
||||
[InlineData("40001", ModbusRegion.HoldingRegisters, (ushort)0)]
|
||||
[InlineData("49999", ModbusRegion.HoldingRegisters, (ushort)9998)]
|
||||
// 6-digit form unlocks the full 16-bit address space — 1..65536 → PDU 0..65535.
|
||||
[InlineData("400001", ModbusRegion.HoldingRegisters, (ushort)0)]
|
||||
[InlineData("410000", ModbusRegion.HoldingRegisters, (ushort)9999)]
|
||||
[InlineData("465536", ModbusRegion.HoldingRegisters, (ushort)65535)]
|
||||
[InlineData("000001", ModbusRegion.Coils, (ushort)0)]
|
||||
[InlineData("100001", ModbusRegion.DiscreteInputs, (ushort)0)]
|
||||
[InlineData("365536", ModbusRegion.InputRegisters, (ushort)65535)]
|
||||
public void Parse_Valid(string address, ModbusRegion expectedRegion, ushort expectedOffset)
|
||||
{
|
||||
var (region, offset) = ModbusModiconAddress.Parse(address);
|
||||
region.ShouldBe(expectedRegion);
|
||||
offset.ShouldBe(expectedOffset);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "null or empty")]
|
||||
[InlineData(" ", "null or empty")]
|
||||
[InlineData("4001", "5 or 6 digits")] // 4 chars
|
||||
[InlineData("4000001", "5 or 6 digits")] // 7 chars
|
||||
[InlineData("4000A", "only digits")] // letter in body
|
||||
[InlineData("X0001", "only digits")] // letter leading
|
||||
[InlineData("20001", "leading digit must be 0/1/3/4")] // region 2 doesn't exist
|
||||
[InlineData("50001", "leading digit must be 0/1/3/4")]
|
||||
[InlineData("90001", "leading digit must be 0/1/3/4")]
|
||||
[InlineData("40000", "must be >= 1")] // 0-based register number
|
||||
[InlineData("400000", "must be >= 1")] // 6-digit zero
|
||||
public void Parse_Invalid_Surfaces_Diagnostic(string address, string fragment)
|
||||
{
|
||||
Should.Throw<FormatException>(() => ModbusModiconAddress.Parse(address))
|
||||
.Message.ShouldContain(fragment, Case.Insensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Returns_False_With_Diagnostic_On_Invalid()
|
||||
{
|
||||
var ok = ModbusModiconAddress.TryParse("not-an-address", out _, out _, out var error);
|
||||
ok.ShouldBeFalse();
|
||||
error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Returns_True_With_Null_Error_On_Valid()
|
||||
{
|
||||
var ok = ModbusModiconAddress.TryParse("40001", out var region, out var offset, out var error);
|
||||
ok.ShouldBeTrue();
|
||||
region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
offset.ShouldBe((ushort)0);
|
||||
error.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Handles_Null()
|
||||
{
|
||||
ModbusModiconAddress.TryParse(null, out _, out _, out var error).ShouldBeFalse();
|
||||
error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Trims_Whitespace()
|
||||
{
|
||||
// Tag spreadsheets often arrive with stray padding; the parser tolerates it rather than
|
||||
// forcing every importer to trim — but stays strict on the 5/6-digit length once trimmed.
|
||||
ModbusModiconAddress.TryParse(" 40001 ", out var region, out var offset, out _).ShouldBeTrue();
|
||||
region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
offset.ShouldBe((ushort)0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user