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:
Joseph Doherty
2026-04-24 23:34:18 -04:00
parent fb760bc465
commit 501d8f494b
8 changed files with 271 additions and 2 deletions

View File

@@ -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;
}
}

View File

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

View File

@@ -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>

View File

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

View File

@@ -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>