refactor(driver-modbus): extract ModbusDriverOptions to .Contracts

Move ModbusDriverOptions (and companion option types) to a new
Driver.Modbus.Contracts sibling project. The contracts project
references only Driver.Modbus.Addressing (itself zero-dep and
Admin-safe) because ModbusDriverOptions.Probe/Family/Region
properties use enum types that live there.

Drop 'using ZB.MOM.WW.OtOpcUa.Core.Abstractions' and replace
<see cref="IHostConnectivityProbe"/> with <c>IHostConnectivityProbe</c>
per the approved decision — the using was doc-comment-only.

The runtime Driver.Modbus project gains a ProjectReference back to
.Contracts; the .slnx is updated accordingly.
This commit is contained in:
Joseph Doherty
2026-05-28 08:50:17 -04:00
parent dc12c3732e
commit 5058a56645
4 changed files with 20 additions and 3 deletions
@@ -1,289 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus TCP driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
/// <c>DriverHost.RegisterAsync</c>. Every register the driver exposes appears in
/// <see cref="Tags"/>; names become the OPC UA browse name + full reference.
/// </summary>
public sealed class ModbusDriverOptions
{
/// <summary>Gets the Modbus TCP server host address.</summary>
public string Host { get; init; } = "127.0.0.1";
/// <summary>Gets the Modbus TCP server port.</summary>
public int Port { get; init; } = 502;
/// <summary>Gets the Modbus unit ID (slave ID).</summary>
public byte UnitId { get; init; } = 1;
/// <summary>Gets the communication timeout duration.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these.</summary>
public IReadOnlyList<ModbusTagDefinition> Tags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When <see cref="ModbusProbeOptions.Enabled"/>
/// is true the driver runs a tick loop that issues a cheap FC03 at register 0 every
/// <see cref="ModbusProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
/// Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through
/// <see cref="IHostConnectivityProbe"/>.
/// </summary>
public ModbusProbeOptions Probe { get; init; } = new();
/// <summary>
/// Maximum registers per FC03 (Read Holding Registers) / FC04 (Read Input Registers)
/// transaction. Modbus-TCP spec allows 125; many device families impose lower caps:
/// AutomationDirect DL205/DL260 cap at <c>128</c>, Mitsubishi Q/FX3U cap at <c>64</c>,
/// Omron CJ/CS cap at <c>125</c>. Set to the lowest cap across the devices this driver
/// instance talks to; the driver auto-chunks larger reads into consecutive requests.
/// Default <c>125</c> — the spec maximum, safe against any conforming server. Setting
/// to <c>0</c> disables the cap (discouraged — the spec upper bound still applies).
/// </summary>
public ushort MaxRegistersPerRead { get; init; } = 125;
/// <summary>
/// Maximum registers per FC16 (Write Multiple Registers) transaction. Spec maximum is
/// <c>123</c>; DL205/DL260 cap at <c>100</c>. Matching caller-vs-device semantics:
/// exceeding the cap currently throws (writes aren't auto-chunked because a partial
/// write across two FC16 calls is no longer atomic — caller must explicitly opt in
/// by shortening the tag's <c>StringLength</c> or splitting it into multiple tags).
/// </summary>
public ushort MaxRegistersPerWrite { get; init; } = 123;
/// <summary>
/// Maximum coils per FC01 (Read Coils) / FC02 (Read Discrete Inputs) transaction. Modbus
/// spec allows up to <c>2000</c> bits per request — separate from
/// <see cref="MaxRegistersPerRead"/> because the underlying packing is different
/// (1 bit per coil vs 16 bits per register). Default <c>2000</c>; setting to <c>0</c>
/// disables the cap. The driver auto-chunks coil-array reads above the cap.
/// </summary>
public ushort MaxCoilsPerRead { get; init; } = 2000;
/// <summary>
/// When <c>true</c>, single-element coil writes use FC15 (Write Multiple Coils) with
/// <c>quantity=1</c> instead of the default FC05 (Write Single Coil). Safety / audit
/// PLCs that only accept the multi-write function codes need this. Default <c>false</c>
/// preserves the existing FC05 path.
/// </summary>
public bool UseFC15ForSingleCoilWrites { get; init; } = false;
/// <summary>
/// When <c>true</c>, single-element holding-register writes use FC16 (Write Multiple
/// Registers) with <c>quantity=1</c> instead of the default FC06 (Write Single
/// Register). Same use-case as <see cref="UseFC15ForSingleCoilWrites"/>. Default
/// <c>false</c>.
/// </summary>
public bool UseFC16ForSingleRegisterWrites { get; init; } = false;
/// <summary>
/// <b>Reserved / no-op</b> kill-switch for FC23 (Read/Write Multiple Registers). The
/// driver does not currently emit FC23 — toggling this option has no observable effect
/// today. The slot exists so a future block-read-coalescing enhancement that opts into
/// FC23 can be disabled per-deployment without a code change. Track Driver.Modbus-007
/// for the wiring follow-up. Default <c>false</c>.
/// </summary>
public bool DisableFC23 { get; init; } = false;
/// <summary>
/// #151 — interval for the background re-probe loop that retries auto-prohibited
/// coalesced ranges (#148). When non-null, every <c>AutoProhibitReprobeInterval</c>
/// the driver attempts each prohibition's coalesced read once. If the re-probe
/// succeeds, the prohibition clears and the planner resumes coalescing across the
/// range on the next scan. Default <c>null</c> = re-probe disabled (prohibitions
/// persist until <c>ReinitializeAsync</c>; preserves pre-#151 behaviour).
/// </summary>
public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null;
/// <summary>
/// Block-read coalescing budget (#143). When non-zero, the read planner combines tags
/// in the same (UnitId, Region) group whose addresses are at most this many registers
/// apart into a single FC03/FC04/FC01/FC02 read. The sliced response is then dispatched
/// back to per-tag values. Default <c>0</c> = no coalescing — every tag gets its own
/// PDU (preserves pre-#143 behaviour). Typical opt-in values are 5..32 — large enough
/// to bridge a few unused registers, small enough to avoid trampling protected holes.
/// </summary>
public ushort MaxReadGap { get; init; } = 0;
/// <summary>
/// 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
/// <see cref="ModbusFamily.Generic"/> = Modicon-only behaviour preserved from #137.
/// </summary>
public ModbusFamily Family { get; init; } = ModbusFamily.Generic;
/// <summary>
/// MELSEC sub-family selector — only consulted when <see cref="Family"/> = MELSEC.
/// Default Q/L/iQR (hex X/Y interpretation). Set F_iQF for FX-series PLCs.
/// </summary>
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
/// <summary>
/// When <c>true</c>, 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
/// write returns Good without touching the wire. Saves PLC bandwidth on clients that
/// re-publish the same setpoint every scan. The cached "last written" is invalidated
/// on the next read that returns a different value, so HMI-side changes don't get
/// masked. Default <c>false</c> preserves the historical "every write goes to the wire"
/// behaviour. Per-tag deadband lives on <c>ModbusTagDefinition.Deadband</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Driver.Modbus-010 — write-only-tag caveat:</b> the suppression cache is only
/// invalidated by a <i>read</i> that returns a divergent value. A tag that is never
/// subscribed or polled (write-only setpoints, command registers) never sees its
/// cache entry refreshed — so a value the operator believes was re-asserted is
/// silently suppressed forever after the first write. There is no time- or
/// count-based expiry. If you set <see cref="WriteOnChangeOnly"/> = <c>true</c>,
/// either subscribe / poll every tag that needs deterministic re-write, or leave
/// this option <c>false</c> for the affected driver instance.
/// </para>
/// </remarks>
public bool WriteOnChangeOnly { get; init; } = false;
/// <summary>
/// When <c>true</c> (default) the built-in <see cref="ModbusTcpTransport"/> detects
/// mid-transaction socket failures (<see cref="System.IO.EndOfStreamException"/>,
/// <see cref="System.Net.Sockets.SocketException"/>) and transparently reconnects +
/// retries the PDU exactly once. Required for DL205/DL260 because the H2-ECOM100
/// does not send TCP keepalives — intermediate NAT / firewall devices silently close
/// idle sockets and the first send after the drop would otherwise surface as a
/// connection error to the caller even though the PLC is up.
/// </summary>
public bool AutoReconnect { get; init; } = true;
/// <summary>
/// Per-driver TCP keep-alive settings. Defaults are the historical PR 53 values
/// (KeepAliveEnabled=true, Time=30s, Interval=10s, RetryCount=3) so existing
/// deployments see no behaviour change. Set <see cref="ModbusKeepAliveOptions.Enabled"/>
/// to <c>false</c> to disable OS-level keep-alive entirely (some PLCs don't tolerate it).
/// </summary>
public ModbusKeepAliveOptions KeepAlive { get; init; } = new();
/// <summary>
/// If non-null, the transport tracks the time of the last successful PDU and proactively
/// closes + reconnects the socket on the next request after this idle threshold elapses.
/// Defends against silent NAT / firewall reaping of long-idle sockets — the explicit
/// close-and-reopen turns the failure mode from "first-send-after-X-minutes errors" into
/// "first-send-after-X-minutes pays one reconnect cost."
/// </summary>
public TimeSpan? IdleDisconnectTimeout { get; init; } = null;
/// <summary>
/// Reconnect backoff settings used by the auto-reconnect path. Default is no backoff
/// (immediate retry — preserves the historical pre-#139 behaviour). Set to a non-zero
/// <see cref="ModbusReconnectOptions.InitialDelay"/> to sleep before the first reconnect
/// attempt; <see cref="ModbusReconnectOptions.MaxDelay"/> caps the geometric growth.
/// </summary>
public ModbusReconnectOptions Reconnect { get; init; } = new();
}
/// <summary>OS-level TCP keep-alive knobs. Set <see cref="Enabled"/>=false to skip entirely.</summary>
public sealed class ModbusKeepAliveOptions
{
/// <summary>Gets a value indicating whether keep-alive is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Idle time before the first probe (seconds, mapped to <c>TcpKeepAliveTime</c>).</summary>
public TimeSpan Time { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>Interval between probes once started (seconds, mapped to <c>TcpKeepAliveInterval</c>).</summary>
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>Probes before declaring the socket dead (mapped to <c>TcpKeepAliveRetryCount</c>).</summary>
public int RetryCount { get; init; } = 3;
}
/// <summary>Geometric-backoff settings for the post-drop reconnect loop.</summary>
public sealed class ModbusReconnectOptions
{
/// <summary>Delay before the first reconnect attempt. Default zero = immediate.</summary>
public TimeSpan InitialDelay { get; init; } = TimeSpan.Zero;
/// <summary>Upper bound on the geometric backoff sequence.</summary>
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>Multiplier applied each retry. Default 2.0 doubles each step.</summary>
public double BackoffMultiplier { get; init; } = 2.0;
}
public sealed class ModbusProbeOptions
{
/// <summary>Gets a value indicating whether probing is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Gets the interval between probe requests.</summary>
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Gets the probe request timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Register to read for the probe. Zero is usually safe; override for PLCs that lock register 0.</summary>
public ushort ProbeAddress { get; init; } = 0;
}
/// <summary>
/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
/// the documentation's 1-based coil/register conventions). Multi-register types
/// (Int32/UInt32/Float32 = 2 regs; Int64/UInt64/Float64 = 4 regs) respect the
/// <see cref="ByteOrder"/> field — real-world PLCs disagree on word ordering.
/// </summary>
/// <param name="Name">
/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
/// unique within the driver.
/// </param>
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
/// <param name="Address">Zero-based address within the region.</param>
/// <param name="DataType">
/// Logical data type. See <see cref="ModbusDataType"/> for the register count each encodes.
/// </param>
/// <param name="Writable">When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here.</param>
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
/// <param name="StringByteOrder">
/// Per-register byte order for <c>DataType = String</c>. Standard Modbus packs the first
/// character in the high byte (<see cref="ModbusStringByteOrder.HighByteFirst"/>).
/// AutomationDirect DirectLOGIC (DL205/DL260) and a few legacy families pack the first
/// character in the low byte instead — see <c>docs/v2/dl205.md</c> §strings.
/// </param>
/// <param name="WriteIdempotent">
/// Per <c>docs/v2/plan.md</c> decisions #44, #45, #143 — flag a tag as safe to replay on
/// write timeout / failure. Default <c>false</c>; writes do not auto-retry. Safe candidates:
/// holding-register set-points for analog values and configuration registers where the same
/// value can be written again without side-effects. Unsafe: coils that drive edge-triggered
/// actions (pulse outputs), counter-increment addresses on PLCs that treat writes as deltas,
/// any BCD / counter register where repeat-writes advance state.
/// </param>
/// <param name="ArrayCount">
/// When non-null, the tag is exposed as an OPC UA array of this many elements. Total
/// registers consumed = ArrayCount * registers-per-element. Bit + array is rejected at
/// bind time (no use case). Default null = scalar (existing behavior).
/// </param>
/// <param name="Deadband">
/// When non-null, the subscribe path suppresses a publish whenever
/// <c>|new - last_published| &lt; Deadband</c>. Reduces wire traffic on noisy analog
/// signals (flow meters, temperatures). Only meaningful for numeric scalar types
/// (Int*, UInt*, Float32, Float64, Bcd*); ignored for Bool / BitInRegister / String /
/// array tags. Default null = no deadband (every change publishes).
/// </param>
/// <param name="UnitId">
/// Per-tag UnitId override for multi-slave gateway topology (#142). When non-null this
/// UnitId is used in the MBAP header instead of the driver-level <c>ModbusDriverOptions.UnitId</c>.
/// Defaults to null = use the driver-level value (preserves single-slave deployments).
/// Tags with different UnitIds belong to different physical slaves and the read planner
/// must NOT coalesce them across slaves — even at the same address.
/// </param>
/// <param name="CoalesceProhibited">
/// Escape hatch for #143 block-read coalescing. When <c>true</c>, the planner reads this
/// tag in isolation regardless of <c>ModbusDriverOptions.MaxReadGap</c>. Use when the
/// surrounding registers are write-only or fault on read (some Schneider Premium / Siemens
/// PNs have protected holes). Default <c>false</c>.
/// </param>
public sealed record ModbusTagDefinition(
string Name,
ModbusRegion Region,
ushort Address,
ModbusDataType DataType,
bool Writable = true,
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
byte BitIndex = 0,
ushort StringLength = 0,
ModbusStringByteOrder StringByteOrder = ModbusStringByteOrder.HighByteFirst,
bool WriteIdempotent = false,
int? ArrayCount = null,
double? Deadband = null,
byte? UnitId = null,
bool CoalesceProhibited = false);
@@ -15,6 +15,7 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\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"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj"/>
</ItemGroup>
<ItemGroup>