using System.ComponentModel.DataAnnotations; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; /// /// Modbus TCP driver configuration. Bound from the driver's DriverConfig JSON at /// DriverHost.RegisterAsync. The tags the driver serves are delivered as authored raw /// tags in ; the driver maps each through /// to build its RawPath → definition table. /// public sealed class ModbusDriverOptions { /// Gets the Modbus TCP server host address. public string Host { get; init; } = "127.0.0.1"; /// Gets the Modbus TCP server port. public int Port { get; init; } = 502; /// Gets the Modbus unit ID (slave ID). public byte UnitId { get; init; } = 1; /// Gets the communication timeout duration. public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); /// /// Authored raw tags this driver serves. The deploy artifact hands each authored raw /// Tag as a (RawPath identity + driver TagConfig blob + /// WriteIdempotent flag); the driver maps each through /// into its RawPath → definition table. /// Modbus has no discovery protocol — the driver serves exactly these. /// public IReadOnlyList RawTags { get; init; } = []; /// /// Background connectivity-probe settings. When /// is true the driver runs a tick loop that issues a cheap FC03 at register 0 every /// and raises OnHostStatusChanged on /// Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through /// IHostConnectivityProbe. /// public ModbusProbeOptions Probe { get; init; } = new(); /// /// 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 128, Mitsubishi Q/FX3U cap at 64, /// Omron CJ/CS cap at 125. Set to the lowest cap across the devices this driver /// instance talks to; the driver auto-chunks larger reads into consecutive requests. /// Default 125 — the spec maximum, safe against any conforming server. Setting /// to 0 resets to the spec maximum (125) without requiring the operator /// to hard-code the limit. Values above 125 are spec-illegal for conforming /// servers; only use them when the target device's documentation explicitly allows it. /// public ushort MaxRegistersPerRead { get; init; } = 125; /// /// Maximum registers per FC16 (Write Multiple Registers) transaction. Spec maximum is /// 123; DL205/DL260 cap at 100. 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 StringLength or splitting it into multiple tags). /// public ushort MaxRegistersPerWrite { get; init; } = 123; /// /// Maximum coils per FC01 (Read Coils) / FC02 (Read Discrete Inputs) transaction. Modbus /// spec allows up to 2000 bits per request — separate from /// because the underlying packing is different /// (1 bit per coil vs 16 bits per register). Default 2000; setting to 0 /// resets to the spec maximum (2000). Values above 2000 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. /// [Range(0, 2000)] public ushort MaxCoilsPerRead { get; init; } = 2000; /// /// When true, single-element coil writes use FC15 (Write Multiple Coils) with /// quantity=1 instead of the default FC05 (Write Single Coil). Safety / audit /// PLCs that only accept the multi-write function codes need this. Default false /// preserves the existing FC05 path. /// public bool UseFC15ForSingleCoilWrites { get; init; } = false; /// /// When true, single-element holding-register writes use FC16 (Write Multiple /// Registers) with quantity=1 instead of the default FC06 (Write Single /// Register). Same use-case as . Default /// false. /// public bool UseFC16ForSingleRegisterWrites { get; init; } = false; /// /// Reserved / no-op 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 false. /// public bool DisableFC23 { get; init; } = false; /// /// Interval for the background re-probe loop that retries auto-prohibited /// coalesced ranges. When non-null, every AutoProhibitReprobeInterval /// 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 null = re-probe disabled (prohibitions /// persist until ReinitializeAsync; preserves the previous behaviour). /// public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null; /// /// 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 0 = 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. /// public ushort MaxReadGap { get; init; } = 0; /// /// 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 /// = Modicon-only behaviour preserved by default. /// public ModbusFamily Family { get; init; } = ModbusFamily.Generic; /// /// MELSEC sub-family selector — only consulted when = MELSEC. /// Default Q/L/iQR (hex X/Y interpretation). Set F_iQF for FX-series PLCs. /// public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR; /// /// When true, 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 false preserves the historical "every write goes to the wire" /// behaviour. Per-tag deadband lives on ModbusTagDefinition.Deadband. /// /// /// /// Write-only-tag caveat: the suppression cache is only /// invalidated by a read 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 = true, /// either subscribe / poll every tag that needs deterministic re-write, or leave /// this option false for the affected driver instance. /// /// public bool WriteOnChangeOnly { get; init; } = false; /// /// When true (default) the built-in detects /// mid-transaction socket failures (, /// ) 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. /// public bool AutoReconnect { get; init; } = true; /// /// 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 /// to false to disable OS-level keep-alive entirely (some PLCs don't tolerate it). /// public ModbusKeepAliveOptions KeepAlive { get; init; } = new(); /// /// 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." /// public TimeSpan? IdleDisconnectTimeout { get; init; } = null; /// /// Reconnect backoff settings used by the auto-reconnect path. Default is no backoff /// (immediate retry — preserves the historical behaviour). Set to a non-zero /// to sleep before the first reconnect /// attempt; caps the geometric growth. /// public ModbusReconnectOptions Reconnect { get; init; } = new(); /// /// 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. /// [Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")] [Range(1, 60)] public int ProbeTimeoutSeconds { get; init; } = 5; } /// OS-level TCP keep-alive knobs. Set =false to skip entirely. public sealed class ModbusKeepAliveOptions { /// Gets a value indicating whether keep-alive is enabled. public bool Enabled { get; init; } = true; /// Idle time before the first probe (seconds, mapped to TcpKeepAliveTime). public TimeSpan Time { get; init; } = TimeSpan.FromSeconds(30); /// Interval between probes once started (seconds, mapped to TcpKeepAliveInterval). public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(10); /// Probes before declaring the socket dead (mapped to TcpKeepAliveRetryCount). public int RetryCount { get; init; } = 3; } /// Geometric-backoff settings for the post-drop reconnect loop. public sealed class ModbusReconnectOptions { /// Delay before the first reconnect attempt. Default zero = immediate. public TimeSpan InitialDelay { get; init; } = TimeSpan.Zero; /// Upper bound on the geometric backoff sequence. public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(30); /// Multiplier applied each retry. Default 2.0 doubles each step. public double BackoffMultiplier { get; init; } = 2.0; } public sealed class ModbusProbeOptions { /// Gets a value indicating whether probing is enabled. public bool Enabled { get; init; } = true; /// Gets the interval between probe requests. public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5); /// Gets the probe request timeout. public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); /// Register to read for the probe. Zero is usually safe; override for PLCs that lock register 0. public ushort ProbeAddress { get; init; } = 0; } /// /// 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 /// field — real-world PLCs disagree on word ordering. /// /// /// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be /// unique within the driver. /// /// Coils / DiscreteInputs / InputRegisters / HoldingRegisters. /// Zero-based address within the region. /// /// Logical data type. See for the register count each encodes. /// /// When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here. /// Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String. /// For DataType = BitInRegister: which bit of the holding register (0-15, LSB-first). /// For DataType = String: number of ASCII characters (2 per register, rounded up). /// /// Per-register byte order for DataType = String. Standard Modbus packs the first /// character in the high byte (). /// AutomationDirect DirectLOGIC (DL205/DL260) and a few legacy families pack the first /// character in the low byte instead — see docs/v2/dl205.md §strings. /// /// /// Flags a tag as safe to replay on write timeout / failure. Default false; 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. /// /// /// 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). /// /// /// When non-null, the subscribe path suppresses a publish whenever /// |new - last_published| < Deadband. 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). /// /// /// 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 ModbusDriverOptions.UnitId. /// 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. /// /// /// Escape hatch for block-read coalescing. When true, the planner reads this /// tag in isolation regardless of ModbusDriverOptions.MaxReadGap. Use when the /// surrounding registers are write-only or fault on read (some Schneider Premium / Siemens /// PNs have protected holes). Default false. /// 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);