aafb9d4929
Wave-B EXEMPLAR. Retire the pre-declared/blob-parse tag model; the driver now builds its RawPath -> definition table from the artifact's RawTagEntry set via a pure mapper. - Contracts: rename ModbusEquipmentTagParser -> ModbusTagDefinitionFactory; TryParse(reference) -> FromTagConfig(tagConfig, rawPath) (def.Name = rawPath, no leading-brace heuristic); add inverse ToTagConfig(def) serializer; keep all strict-enum reads + guards; also read stringByteOrder/deadband/coalesceProhibited so a RawTagEntry round-trips the full authored def. Inspect() unchanged. - Options: Tags(IReadOnlyList<ModbusTagDefinition>) -> RawTags(IReadOnlyList<RawTagEntry>). - Driver: _tagsByName -> _tagsByRawPath (Ordinal); resolver byRawPath-only; build the table from RawTags at Initialize threading entry.WriteIdempotent onto each def; Discover/Teardown updated. - Factory: remove ModbusTagDto/BuildTag/ValidateStringLength + ConfigDto.Tags; bind RawTags from driver-config JSON. - CLI ModbusCommandBase.BuildOptions: serialise typed defs -> RawTagEntry via ToTagConfig. - Tests: migrate Tags= -> RawTags via ModbusRawTags.Entries helper; parser tests -> FromTagConfig(rawPath); factory String-length/enum tests re-seated on the mapper seam. - Propagated rename to ControlPlane EquipmentTagConfigInspector (outside Modbus projects). Modbus.Tests 315/315, Addressing.Tests 161/161, Cli.Tests 72/72 green. Docker-gated Driver.Modbus.IntegrationTests left untouched (won't compile against the new API; expected).
310 lines
18 KiB
C#
310 lines
18 KiB
C#
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>
|
|
/// <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. Default <c>false</c>.
|
|
/// </summary>
|
|
public bool DisableFC23 { 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
|
|
/// 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 the previous 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. 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 by default.
|
|
/// </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>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| < 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);
|