Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
2026-04-26 01:03:00 -04:00

299 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Siemens S7 native (S7comm / ISO-on-TCP port 102) driver configuration. Bound from the
/// driver's <c>DriverConfig</c> JSON at <c>DriverHost.RegisterAsync</c>. 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.
/// </summary>
/// <remarks>
/// <para>
/// The driver requires <b>PUT/GET communication enabled</b> 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 <c>Plc.ReadAsync</c> returning <c>ErrorCode.Accessing</c>).
/// The driver maps that specifically to <c>BadNotSupported</c> 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.
/// </para>
/// <para>
/// See <c>docs/v2/driver-specs.md</c> §5 for the full specification.
/// </para>
/// </remarks>
public sealed class S7DriverOptions
{
/// <summary>PLC IP address or hostname.</summary>
public string Host { get; init; } = "127.0.0.1";
/// <summary>TCP port. ISO-on-TCP is 102 on every S7 model; override only for unusual NAT setups.</summary>
public int Port { get; init; } = 102;
/// <summary>
/// CPU family. Determines the ISO-TSAP slot byte that S7.Net uses during connection
/// setup — pick the family that matches the target PLC exactly.
/// </summary>
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
/// <summary>
/// Hardware rack number. Almost always 0; relevant only for distributed S7-400 racks
/// with multiple CPUs.
/// </summary>
public short Rack { get; init; } = 0;
/// <summary>
/// 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.
/// </summary>
public short Slot { get; init; } = 0;
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Pre-declared tag map. S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off a static tag list configured per-site. Address grammar documented in S7AddressParser (PR 63).</summary>
public IReadOnlyList<S7TagDefinition> Tags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When enabled, the driver runs a tick loop
/// that issues a cheap read against <see cref="S7ProbeOptions.ProbeAddress"/> every
/// <see cref="S7ProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
/// Running ↔ Stopped transitions.
/// </summary>
public S7ProbeOptions Probe { get; init; } = new();
/// <summary>
/// Block-read coalescing gap-merge threshold (bytes). When two same-DB tags are
/// within this many bytes of each other the planner folds them into a single
/// <c>Plc.ReadBytesAsync</c> request and slices the response client-side. The
/// default <see cref="S7BlockCoalescingPlanner.DefaultGapMergeBytes"/> = 16 bytes
/// trades a minor over-fetch for one fewer PDU round-trip — over-fetching 16
/// bytes is cheaper than the ~30-byte S7 request frame.
/// </summary>
/// <remarks>
/// Raise the threshold for chatty PLCs where PDU round-trips dominate latency
/// (S7-1200 with default 240-byte PDU); lower it when DBs are sparsely populated
/// so the over-fetch cost outweighs the saved PDU. Setting to 0 disables gap
/// merging entirely — only literally adjacent ranges (gap == 0) coalesce.
/// STRING / WSTRING / CHAR / WCHAR / structured-timestamp / array tags always
/// opt out of merging regardless of this knob.
/// </remarks>
public int BlockCoalescingGapBytes { get; init; } = S7BlockCoalescingPlanner.DefaultGapMergeBytes;
/// <summary>
/// ISO-on-TCP / S7comm "connection class" selector. Hardened S7-1500 CPUs and some
/// ET 200SP / S7-1200 firmware variants reject the default <b>PG-class</b> TSAP that
/// S7netplus picks under <see cref="TsapMode.Auto"/> and require an <b>OP-class</b>
/// or <b>S7-Basic-class</b> TSAP instead. Picking the wrong class produces the same
/// failure shape as picking the wrong slot — connection refused at COTP handshake
/// time, before any S7comm PDU is sent. See <c>docs/v2/s7.md</c> "TSAP / Connection
/// Type" section for the raw-TSAP byte table and the hardened-CPU motivation.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="TsapMode.Auto"/> preserves existing behaviour — S7netplus picks
/// the TSAP pair from the configured <see cref="CpuType"/> via its
/// <c>TsapPair.GetDefaultTsapPair</c>. Use <see cref="TsapMode.Pg"/> /
/// <see cref="TsapMode.Op"/> / <see cref="TsapMode.S7Basic"/> to force a specific
/// class, or <see cref="TsapMode.Other"/> together with <see cref="LocalTsap"/>
/// and <see cref="RemoteTsap"/> for a fully-manual escape hatch. Explicit
/// <see cref="LocalTsap"/> / <see cref="RemoteTsap"/> overrides win even under
/// <see cref="TsapMode.Pg"/> / <see cref="TsapMode.Op"/> / <see cref="TsapMode.S7Basic"/>.
/// </para>
/// </remarks>
public TsapMode TsapMode { get; init; } = TsapMode.Auto;
/// <summary>
/// Optional fully-manual local TSAP override (16-bit big-endian word — high byte =
/// class selector, low byte = caller-defined). Required (together with
/// <see cref="RemoteTsap"/>) when <see cref="TsapMode"/> is
/// <see cref="TsapMode.Other"/>. Wins over the class-derived default under any
/// non-<see cref="TsapMode.Auto"/> mode.
/// </summary>
public ushort? LocalTsap { get; init; }
/// <summary>
/// Optional fully-manual remote TSAP override (16-bit big-endian word — high byte =
/// class selector, low byte = <c>(rack &lt;&lt; 5) | slot</c> per the S7 spec).
/// Required (together with <see cref="LocalTsap"/>) when <see cref="TsapMode"/> is
/// <see cref="TsapMode.Other"/>. Wins over the class-derived default under any
/// non-<see cref="TsapMode.Auto"/> mode.
/// </summary>
public ushort? RemoteTsap { get; init; }
/// <summary>
/// PR-S7-C3 — per-tag scan-group → publishing-interval map. When a tag declares a
/// <see cref="S7TagDefinition.ScanGroup"/>, <see cref="S7Driver.SubscribeAsync"/>
/// resolves its publishing rate by looking the group up in this dictionary; tags
/// without a group, or with a group not present here, fall back to the
/// subscription-default <c>publishingInterval</c> argument. Keys are matched
/// case-insensitively. See <c>docs/v2/s7.md</c> "Per-tag scan groups" section.
/// </summary>
/// <remarks>
/// The driver still owns one <c>Plc</c> connection serialized through the per-driver
/// <c>_gate</c>, so partitioning into N poll loops does NOT parallelise wire-level
/// reads — every partition queues against the same semaphore. Operator value is
/// decoupling tick cadence: a 100 ms HMI tag isn't blocked behind a 10 s slow-poll
/// batch any more, because the slow batch's <c>Task.Delay</c> isn't holding the gate.
/// </remarks>
public IReadOnlyDictionary<string, TimeSpan>? ScanGroupIntervals { get; init; }
}
/// <summary>
/// ISO-on-TCP / S7comm connection class. Picks the high byte of the TSAP pair used
/// during COTP handshake. See <c>docs/v2/s7.md</c> "TSAP / Connection Type" section
/// for the raw-byte table and the hardened-CPU motivation.
/// </summary>
public enum TsapMode
{
/// <summary>S7netplus picks the TSAP pair from <see cref="S7DriverOptions.CpuType"/>. Existing behaviour.</summary>
Auto,
/// <summary>PG class — high byte 0x01. Default for development laptops / TIA Portal.</summary>
Pg,
/// <summary>OP class — high byte 0x02. Required by some hardened S7-1500 / ET 200SP deployments.</summary>
Op,
/// <summary>S7-Basic class — high byte 0x03. Used by S7-Basic clients (e.g. some HMI panels and the WinCC BasicPanel SDK).</summary>
S7Basic,
/// <summary>Caller-supplied <see cref="S7DriverOptions.LocalTsap"/> + <see cref="S7DriverOptions.RemoteTsap"/>. Both must be set or driver init throws.</summary>
Other,
}
/// <summary>
/// Raw-TSAP byte constants per ISO-on-TCP / S7comm connection class. The "high byte"
/// is the class selector documented in the Siemens function manual; the local TSAP's
/// low byte is conventionally 0x00 (caller / unprivileged) and the remote TSAP's low
/// byte is <c>(rack &lt;&lt; 5) | slot</c> per the spec. Mirrored in
/// <c>docs/v2/s7.md</c> "TSAP / Connection Type" table.
/// </summary>
public static class S7TsapDefaults
{
/// <summary>PG-class high byte = 0x01.</summary>
public const byte PgClassHighByte = 0x01;
/// <summary>OP-class high byte = 0x02.</summary>
public const byte OpClassHighByte = 0x02;
/// <summary>S7-Basic-class high byte = 0x03.</summary>
public const byte S7BasicClassHighByte = 0x03;
/// <summary>Build the local TSAP (16-bit BE): <c>class &lt;&lt; 8 | 0x00</c>.</summary>
public static ushort BuildLocalTsap(byte classHighByte) => (ushort)(classHighByte << 8);
/// <summary>
/// Build the remote TSAP (16-bit BE): <c>class &lt;&lt; 8 | ((rack &amp; 0x07) &lt;&lt; 5 | (slot &amp; 0x1F))</c>.
/// Matches the convention used by S7netplus's <c>TsapPair.GetDefaultTsapPair</c> for the remote endpoint.
/// </summary>
public static ushort BuildRemoteTsap(byte classHighByte, int rack, int slot)
{
if (rack < 0 || rack > 15)
throw new ArgumentOutOfRangeException(nameof(rack), rack, "rack must be 0..15");
if (slot < 0 || slot > 31)
throw new ArgumentOutOfRangeException(nameof(slot), slot, "slot must be 0..31");
return (ushort)((classHighByte << 8) | ((rack & 0x07) << 5) | (slot & 0x1F));
}
/// <summary>Pick the class high-byte for a non-<see cref="TsapMode.Auto"/> / non-<see cref="TsapMode.Other"/> mode.</summary>
public static byte HighByteFor(TsapMode mode) => mode switch
{
TsapMode.Pg => PgClassHighByte,
TsapMode.Op => OpClassHighByte,
TsapMode.S7Basic => S7BasicClassHighByte,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode,
"HighByteFor only handles Pg / Op / S7Basic; Auto and Other are caller-handled"),
};
}
public sealed class S7ProbeOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Address to probe for liveness. DB1.DBW0 is the convention if the PLC project
/// reserves a small fingerprint DB for health checks (per <c>docs/v2/s7.md</c>);
/// if not, pick any valid Merker word like <c>MW0</c>.
/// </summary>
public string ProbeAddress { get; init; } = "MW0";
}
/// <summary>
/// One S7 variable as exposed by the driver. Addresses use S7.Net syntax — see
/// <c>S7AddressParser</c> (PR 63) for the grammar.
/// </summary>
/// <param name="Name">Tag name; OPC UA browse name + driver full reference.</param>
/// <param name="Address">S7 address string, e.g. <c>DB1.DBW0</c>, <c>M0.0</c>, <c>I0.0</c>, <c>QD4</c>. Grammar documented in <c>S7AddressParser</c> (PR 63).</param>
/// <param name="DataType">Logical data type — drives the underlying S7.Net read/write width.</param>
/// <param name="Writable">When true the driver accepts writes for this tag.</param>
/// <param name="StringLength">For <c>DataType = String</c>: S7-string max length. Default 254 (S7 max).</param>
/// <param name="WriteIdempotent">
/// Per <c>docs/v2/plan.md</c> decisions #44, #45, #143 — flag a tag as safe to replay on
/// write timeout / failure. Default <c>false</c>; 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.
/// </param>
/// <param name="ElementCount">
/// Optional 1-D array length. <c>null</c> (or <c>1</c>) = scalar tag; <c>&gt; 1</c> = array.
/// The driver issues one byte-range read covering <c>ElementCount × bytes-per-element</c>
/// and slices client-side via the existing scalar codec. Multi-dim arrays are deferred;
/// array-of-UDT lands with PR-S7-D2. Variable-width element types
/// (STRING/WSTRING/CHAR/WCHAR) and BOOL (packed bits) are rejected at init time —
/// they need bespoke layout handling and are tracked as a follow-up. Capped at 8000 to
/// keep the byte-range request inside a single S7 PDU envelope.
/// </param>
/// <param name="ScanGroup">
/// PR-S7-C3 — optional scan-group identifier. When set, <c>SubscribeAsync</c> looks up
/// the group's publishing interval in <see cref="S7DriverOptions.ScanGroupIntervals"/>
/// and partitions the input tag list so all tags sharing that interval poll together
/// in a dedicated background loop. Tags with no <c>ScanGroup</c>, or with a group not
/// present in the map, fall back to the subscription's default publishing interval
/// (legacy single-rate behaviour). Group names are matched case-insensitively. See
/// <c>docs/v2/s7.md</c> "Per-tag scan groups" section.
/// </param>
public sealed record S7TagDefinition(
string Name,
string Address,
S7DataType DataType,
bool Writable = true,
int StringLength = 254,
bool WriteIdempotent = false,
int? ElementCount = null,
string? ScanGroup = null);
public enum S7DataType
{
Bool,
Byte,
Int16,
UInt16,
Int32,
UInt32,
Int64,
UInt64,
Float32,
Float64,
String,
/// <summary>S7 WSTRING: 4-byte header (max-len + actual-len, both UInt16 big-endian) followed by N×2 UTF-16BE bytes; total wire length = 4 + 2 × StringLength.</summary>
WString,
/// <summary>S7 CHAR: single ASCII byte.</summary>
Char,
/// <summary>S7 WCHAR: two bytes UTF-16 big-endian.</summary>
WChar,
DateTime,
/// <summary>S7 DTL — 12-byte structured timestamp with year/mon/day/dow/h/m/s/ns; year range 1970-2554.</summary>
Dtl,
/// <summary>S7 DATE_AND_TIME (DT) — 8-byte BCD timestamp; year range 1990-2089.</summary>
DateAndTime,
/// <summary>S7 S5TIME — 16-bit BCD duration with 2-bit timebase; range 0..9990s. Surfaced as Int32 ms.</summary>
S5Time,
/// <summary>S7 TIME — signed Int32 ms big-endian. Surfaced as Int32 ms (negative durations allowed).</summary>
Time,
/// <summary>S7 TIME_OF_DAY (TOD) — UInt32 ms since midnight big-endian; range 0..86399999. Surfaced as Int32 ms.</summary>
TimeOfDay,
/// <summary>S7 DATE — UInt16 days since 1990-01-01 big-endian. Surfaced as DateTime.</summary>
Date,
}