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, }