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);