299 lines
16 KiB
C#
299 lines
16 KiB
C#
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 << 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 << 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 << 8 | 0x00</c>.</summary>
|
||
public static ushort BuildLocalTsap(byte classHighByte) => (ushort)(classHighByte << 8);
|
||
|
||
/// <summary>
|
||
/// Build the remote TSAP (16-bit BE): <c>class << 8 | ((rack & 0x07) << 5 | (slot & 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>> 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,
|
||
}
|