using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
///
/// Siemens S7 native (S7comm / ISO-on-TCP port 102) driver configuration. Bound from the
/// driver's DriverConfig JSON at DriverHost.RegisterAsync. Unlike the Modbus
/// driver the S7 driver uses the PLC's *native* protocol — port 102 ISO-on-TCP rather
/// than Modbus's 502, and S7-specific area codes (DB, M, I, Q) rather than holding-
/// register / coil tables.
///
///
///
/// The driver requires PUT/GET communication enabled in the TIA Portal
/// hardware config for S7-1200/1500. The factory default disables PUT/GET access,
/// so a driver configured against a freshly-flashed CPU will see a hard error
/// (S7.Net surfaces it as Plc.ReadAsync returning ErrorCode.Accessing).
/// The driver maps that specifically to BadNotSupported and flags it as a
/// configuration alert rather than a transient fault — blind Polly retry is wasted
/// effort when the PLC will keep refusing every request.
///
///
/// See docs/v2/driver-specs.md §5 for the full specification.
///
///
public sealed class S7DriverOptions
{
/// PLC IP address or hostname.
public string Host { get; init; } = "127.0.0.1";
/// TCP port. ISO-on-TCP is 102 on every S7 model; override only for unusual NAT setups.
public int Port { get; init; } = 102;
///
/// CPU family. Determines the ISO-TSAP slot byte that S7.Net uses during connection
/// setup — pick the family that matches the target PLC exactly.
///
public S7CpuType CpuType { get; init; } = S7CpuType.S71500;
///
/// Hardware rack number. Almost always 0; relevant only for distributed S7-400 racks
/// with multiple CPUs.
///
public short Rack { get; init; } = 0;
///
/// CPU slot. Conventions per family: S7-300 = slot 2, S7-400 = slot 2 or 3,
/// S7-1200 / S7-1500 = slot 0 (onboard PN). S7.Net uses this to build the remote
/// TSAP. Wrong slot → connection refused during handshake.
///
public short Slot { get; init; } = 0;
///
/// Optional override of the LOCAL ISO-TSAP, as two hex bytes "01.00" (also accepted:
/// "0100", "0x0100"). Null/blank keeps S7.Net's per-CPU default (01.00).
/// Needed for CPUs / gateways that reserve connection resources by TSAP (S7-400H, CP343, LOGO!).
///
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
public string? LocalTsap { get; init; }
///
/// Optional override of the REMOTE ISO-TSAP, same text form as
/// (e.g. "03.01" = S7-basic resource, rack 0 slot 1). Null/blank keeps the slot-derived default.
/// Mutually exclusive with .
///
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
public string? RemoteTsap { get; init; }
///
/// Optional connection resource (PG / OP / S7Basic) written into the first byte of the slot-derived
/// remote TSAP, keeping rack × 0x20 + slot as the second byte. Null keeps the default.
/// Mutually exclusive with .
///
public S7ConnectionType? ConnectionType { get; init; }
/// The authoring/load error for setting both and ; shared by the factory and the AdminUI form so they never disagree.
public const string TsapConflictDetail =
"sets both RemoteTsap and ConnectionType; ConnectionType only fills the first byte of the " +
"slot-derived remote TSAP, so choose one.";
/// Connect + per-operation timeout.
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
///
/// 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.
/// S7 has a symbol-table protocol but S7.Net does not expose it, so the driver serves exactly
/// these authored tags. Address grammar documented in S7AddressParser (PR 63).
///
public IReadOnlyList RawTags { get; init; } = [];
///
/// Background connectivity-probe settings. When enabled, the driver runs a tick loop
/// that issues S7.Net.Plc.ReadStatusAsync (a CPU-status PDU) every
/// and raises OnHostStatusChanged on
/// Running ↔ Stopped transitions.
///
public S7ProbeOptions Probe { 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;
///
/// Largest hole, in bytes, the read planner will read THROUGH when merging two neighbouring
/// tags into one block request. It is a budget for deliberately wasted bytes: raising it
/// merges more aggressively (fewer wire requests, more bytes nobody asked for), lowering it
/// to 0 merges only tags that are strictly adjacent or overlapping.
///
///
///
/// -1 disables span grouping entirely — every tag is read on its own,
/// exactly as the driver behaved before grouping existed. That is the escape hatch for a
/// CPU that dislikes wide block reads, and the control arm the parity tests read through.
/// -1 is the floor: S7DriverFactoryExtensions.ParseOptions rejects anything
/// below it, so a config cannot express "disabled" by accident with an arbitrary negative.
///
///
/// Grouping cannot make a read fail where the per-tag reads would have succeeded: a
/// span never extends past its LAST member's end, so every byte it requests lies inside
/// a range some tag was already going to read. Timer and Counter tags are never grouped
/// — their offset is a device number, not a byte address.
///
///
[Display(Name = "Span read max gap (bytes)",
Description = "Largest hole read through when merging neighbouring tags into one block request. -1 disables grouping.",
GroupName = "Performance")]
public int SpanReadMaxGapBytes { get; init; } = DefaultSpanReadMaxGapBytes;
/// Default for , shared with the config DTO so the two can't drift.
public const int DefaultSpanReadMaxGapBytes = 16;
///
/// Largest single planned span, in bytes. A run of tags that would exceed it is broken into
/// several spans; a single tag wider than the cap still gets its own span, because one tag
/// is never split across requests.
///
///
/// This is not a correctness bound: S7.Net transparently chunks a ReadBytes
/// call larger than the negotiated PDU into several wire requests, so an oversized span
/// reads correctly either way. The cap exists to bound wasted-gap amplification — without
/// it, a long sparse run of tags each separated by an under-budget hole would coalesce into
/// one enormous request that is mostly padding. The 220-byte default sits just under the
/// usable payload of the common 240-byte S7-300/400 PDU, so a typical span is one request
/// on the wire.
///
/// There is deliberately no upper bound: the negotiated PDU size is opaque inside
/// S7.Net, so a value above it cannot be detected — it stays legal and simply degrades to
/// multi-request chunking inside S7.Net, costing extra wire round-trips per span rather
/// than failing. S7DriverFactoryExtensions.ParseOptions therefore validates only the
/// lower bound (>= 1); a non-positive cap can never admit a single block read.
///
///
[Display(Name = "Span read max span (bytes)",
Description = "Largest single planned block read. Bounds wasted-gap amplification, not correctness.",
GroupName = "Performance")]
public int SpanReadMaxSpanBytes { get; init; } = DefaultSpanReadMaxSpanBytes;
/// Default for , shared with the config DTO so the two can't drift.
public const int DefaultSpanReadMaxSpanBytes = 220;
}
///
/// Reads a TSAP as either hex text ("03.01") or a JSON number (769, the form the driver
/// factory also accepts) and always writes the hex text. Without it a numeric TSAP would fail to bind to
/// the string property, and a typed consumer (the AdminUI form, the Test Connect probe) would fall back
/// to defaults — a form Save would then silently erase the authored config.
///
public sealed class S7TsapJsonConverter : System.Text.Json.Serialization.JsonConverter
{
///
public override string? Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options)
{
if (reader.TokenType == System.Text.Json.JsonTokenType.Null) return null;
if (reader.TokenType == System.Text.Json.JsonTokenType.Number && reader.TryGetUInt16(out var n))
return $"{n >> 8:X2}.{n & 0xFF:X2}";
if (reader.TokenType == System.Text.Json.JsonTokenType.String) return reader.GetString();
throw new System.Text.Json.JsonException("S7 TSAP must be hex text such as \"03.01\" or a 16-bit number.");
}
///
public override void Write(System.Text.Json.Utf8JsonWriter writer, string? value, System.Text.Json.JsonSerializerOptions options)
{
if (value is null) writer.WriteNullValue(); else writer.WriteStringValue(value);
}
}
public sealed class S7ProbeOptions
{
/// Gets or sets a value indicating whether probing is enabled.
public bool Enabled { get; init; } = true;
/// Gets or sets the probe interval.
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
/// Gets or sets the probe timeout.
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}
///
/// One S7 variable as exposed by the driver. Addresses use S7.Net syntax — see
/// S7AddressParser (PR 63) for the grammar.
///
/// Tag name; OPC UA browse name + driver full reference.
/// S7 address string, e.g. DB1.DBW0, M0.0, I0.0, QD4. Grammar documented in S7AddressParser (PR 63).
/// Logical data type — drives the underlying S7.Net read/write width.
/// When true the driver accepts writes for this tag.
/// For DataType = String: S7-string max length. Default 254 (S7 max).
///
/// Flag a tag as safe to replay on
/// write timeout / failure. Default false; writes do not auto-retry. Safe candidates
/// on S7: DB word/dword set-points holding analog values, configuration DBs where the same
/// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output)
/// coils that drive edge-triggered routines in the PLC program.
///
///
/// Element count when the tag is a 1-D array; null for a scalar. Any value >= 1
/// (including 1) surfaces as a 1-D OPC UA array node.
/// For an equipment tag this is threaded from the TagConfig JSON's arrayLength
/// (honoured only when isArray is true) by . When
/// set, the driver issues a single contiguous block read of
/// ArrayCount × element-bytes from the tag's start address and decodes each element
/// into an element-typed CLR array (short[] / int[] / float[] / etc.).
///
public sealed record S7TagDefinition(
string Name,
string Address,
S7DataType DataType,
bool Writable = true,
int StringLength = 254,
bool WriteIdempotent = false,
int? ArrayCount = null);
public enum S7DataType
{
Bool,
Byte,
Int16,
UInt16,
Int32,
UInt32,
Int64,
UInt64,
Float32,
Float64,
String,
/// S7 DATE_AND_TIME (DT, 8-byte BCD) → DateTime. The other date/time members below were appended after it.
DateTime,
/// S7 WSTRING (UTF-16BE, 4-byte header) → String. StringLength is the declared max in characters (≤ 16382).
WString,
/// S7 CHAR (1 byte, Latin-1) → one-character String.
Char,
/// S7 WCHAR (UTF-16BE code unit) → one-character String.
WChar,
/// S7 DTL (12 bytes, ns resolution) → DateTime.
Dtl,
/// S7 DATE (UInt16 days since 1990-01-01) → DateTime.
Date,
/// S7 TIME_OF_DAY (UInt32 ms since midnight) → Int32 milliseconds.
Tod,
/// S7 TIME (Int32 ms, signed duration) → Int32 milliseconds.
Time,
/// S7 S5TIME (BCD + time base) → Int32 milliseconds.
S5Time,
}