Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs
T
Joseph Doherty 92749402b5 fix(modbus): review r3 - one address authority (ModbusTagAddressResolution)
N3: a keyless blob carrying the addressResolved marker was served from its
string by the driver but saved as HR[0] by the editor model / CSV path. The
precedence rule now lives in ONE pure resolver in Driver.Modbus.Contracts
(ModbusTagAddressResolution.Classify/Resolve): keys when region+address are
both present, else a string-only addressString resolved with the driver's
family (refused when it cannot parse), else refused. The marker never decides.

Consumers that now ask it instead of restating it: the driver's tag reader
(ModbusTagDefinitionFactory.FromTagConfig), the deploy gate (Inspect /
DescribeRejection), the editor model (AddressShape / IsStringOnly /
HasKeyAddress / ResolveWith, ToJson stamping, Validate), the CSV export cells
and the editor hint. The model writes keys only when it loaded them or a field
was authored - never defaults it did not resolve (also closes two divergences
the new matrix found: malformed-string+marker and non-string addressString were
saved as HR[0]).

One table-driven test (ModbusAddressAuthorityAgreementTests) runs 15 persisted
shapes through driver, deploy gate, model load/save/Validate and the real CSV
export->import under all four families; breaking the CSV export or reverting
the model to the marker rule fails it.

Minors: m1 CSV address cells keep the string+marker while it still describes
them (lossless unchanged round trip), drop it when contradicted, and the review
grid reports the drop; m2 the pre-lane test uses the real export residual; m3
unmarked string-only + typed-cell case added; m4 stale docs/summary and the
unused factory parameter removed.

Refs #741

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:46:21 -04:00

341 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.ComponentModel.DataAnnotations;
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>. The tags the driver serves are delivered as authored raw
/// tags in <see cref="RawTags"/>; the driver maps each through
/// <see cref="ModbusTagDefinitionFactory.FromTagConfig"/> to build its RawPath → definition table.
/// </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>
/// Authored raw tags this driver serves. The deploy artifact hands each authored raw
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
/// WriteIdempotent flag); the driver maps each through
/// <see cref="ModbusTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition table.
/// Modbus has no discovery protocol — the driver serves exactly these.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { 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
/// <c>IHostConnectivityProbe</c>.
/// </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> resets to the spec maximum (<c>125</c>) without requiring the operator
/// to hard-code the limit. Values above <c>125</c> are spec-illegal for conforming
/// servers; only use them when the target device's documentation explicitly allows it.
/// </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>
/// resets to the spec maximum (<c>2000</c>). Values above <c>2000</c> are spec-illegal
/// and will cause most PLCs to reject the request with exception 03. The driver
/// auto-chunks coil-array reads above the cap.
/// </summary>
[Range(0, 2000)]
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>
/// Hard kill-switch for FC23 (Read/Write Multiple Registers): when <c>true</c> the driver never
/// emits FC23, even with <see cref="UseFC23ForMultiRegisterWrites"/> set. Default <c>false</c>.
/// FC23 is OPT-IN — a config without <see cref="UseFC23ForMultiRegisterWrites"/> keeps the FC16 wire
/// behaviour, so the persisted <c>"disableFC23": false</c> every form-saved config carries changes
/// nothing on the wire.
/// </summary>
public bool DisableFC23 { get; init; } = false;
/// <summary>
/// Opt-in: send a multi-register holding write (2–121 registers: 32/64-bit values, arrays, strings)
/// as ONE FC23 that writes the block and reads the same block back, so the device confirms what it
/// now holds. A unit that answers FC23 with exception 01 (MELSEC QJ71MT91, S7 Modbus servers) moves
/// to FC16 for that write and every later one until the next initialize; one that fails FC23 at the
/// transport moves too (that one write is reported as a comms failure). Ignored when
/// <see cref="DisableFC23"/> is set. Default <c>false</c> = FC16, the historical behaviour.
/// </summary>
public bool UseFC23ForMultiRegisterWrites { get; init; } = false;
/// <summary>
/// Interval for the background re-probe loop that retries auto-prohibited
/// coalesced ranges. 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 the previous behaviour).
/// </summary>
public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null;
/// <summary>
/// Block-read coalescing budget. When non-zero, the read planner combines tags in the same
/// (UnitId, Region) group whose addresses are at most this many registers (Coils / DiscreteInputs:
/// bits) apart into a single FC03/FC04/FC01/FC02 read, then slices the response back into per-tag
/// values (several <c>BitInRegister</c> tags on one status word fold into one read). <c>0</c> = no
/// coalescing — an explicitly authored 0 keeps that historical meaning. Default <c>8</c> applies
/// only when the key is ABSENT. A range the device refuses is auto-prohibited and read per tag.
/// <see cref="DisableReadCoalescing"/> also turns it off; <c>coalesceProhibited</c> exempts one tag.
/// </summary>
public ushort MaxReadGap { get; init; } = DefaultMaxReadGap;
/// <summary>The default <see cref="MaxReadGap"/> (shared by the factory DTO and the AdminUI form).</summary>
public const ushort DefaultMaxReadGap = 8;
/// <summary>
/// When <c>true</c>, every tag is read with its own PDU (no block coalescing at all) — for a device
/// that mis-answers merged reads in a way the auto-prohibition cannot catch. Default <c>false</c>.
/// </summary>
public bool DisableReadCoalescing { get; init; } = false;
/// <summary>
/// PLC family hint that drives the parser's family-native branch. A STRING-ONLY tag (an
/// <c>addressString</c> with no region/address keys — see <see cref="ModbusTagAddressResolution"/>) is
/// resolved with this family at every initialize, so with a non-Generic
/// value that family's native syntax (DL205 V2000 octal / MELSEC D100) parses to the right
/// region + offset. Defaults to <see cref="ModbusFamily.Generic"/> (Modicon / mnemonic grammar).
/// Tags authored by region + numeric address are unaffected.
/// </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>
/// Wire transport selector. <see cref="ModbusTransportMode.Tcp"/> (default) is the
/// historical Modbus TCP/MBAP framing. <see cref="ModbusTransportMode.RtuOverTcp"/>
/// frames Modbus RTU PDUs (CRC16, no MBAP header) over the same TCP socket — for
/// serial-to-Ethernet gateways that bridge RS-485 without re-encapsulating to MBAP.
/// </summary>
public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;
/// <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>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 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>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
/// </summary>
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 5;
}
/// <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">
/// Flags 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. 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 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);