Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs
T

272 lines
14 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 System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
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 S7CpuType CpuType { get; init; } = S7CpuType.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>
/// Optional override of the LOCAL ISO-TSAP, as two hex bytes <c>"01.00"</c> (also accepted:
/// <c>"0100"</c>, <c>"0x0100"</c>). Null/blank keeps S7.Net's per-CPU default (<c>01.00</c>).
/// Needed for CPUs / gateways that reserve connection resources by TSAP (S7-400H, CP343, LOGO!).
/// </summary>
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
public string? LocalTsap { get; init; }
/// <summary>
/// Optional override of the REMOTE ISO-TSAP, same text form as <see cref="LocalTsap"/>
/// (e.g. <c>"03.01"</c> = S7-basic resource, rack 0 slot 1). Null/blank keeps the slot-derived default.
/// Mutually exclusive with <see cref="ConnectionType"/>.
/// </summary>
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
public string? RemoteTsap { get; init; }
/// <summary>
/// Optional connection resource (PG / OP / S7Basic) written into the first byte of the slot-derived
/// remote TSAP, keeping <c>rack × 0x20 + slot</c> as the second byte. Null keeps the default.
/// Mutually exclusive with <see cref="RemoteTsap"/>.
/// </summary>
public S7ConnectionType? ConnectionType { get; init; }
/// <summary>The authoring/load error for setting both <see cref="RemoteTsap"/> and <see cref="ConnectionType"/>; shared by the factory and the AdminUI form so they never disagree.</summary>
public const string TsapConflictDetail =
"sets both RemoteTsap and ConnectionType; ConnectionType only fills the first byte of the " +
"slot-derived remote TSAP, so choose one.";
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Authored raw tags this driver serves. The deploy artifact hands each authored raw
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
/// WriteIdempotent flag); the driver maps each through
/// <see cref="S7TagDefinitionFactory.FromTagConfig"/> 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).
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When enabled, the driver runs a tick loop
/// that issues <c>S7.Net.Plc.ReadStatusAsync</c> (a CPU-status PDU) every
/// <see cref="S7ProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
/// Running ↔ Stopped transitions.
/// </summary>
public S7ProbeOptions Probe { get; init; } = new();
/// <summary>
/// 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.
/// </summary>
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 5;
/// <summary>
/// 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 <c>0</c> merges only tags that are strictly adjacent or overlapping.
/// </summary>
/// <remarks>
/// <para>
/// <b><c>-1</c> disables span grouping entirely</b> — 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.
/// <c>-1</c> is the floor: <c>S7DriverFactoryExtensions.ParseOptions</c> rejects anything
/// below it, so a config cannot express "disabled" by accident with an arbitrary negative.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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;
/// <summary>Default for <see cref="SpanReadMaxGapBytes"/>, shared with the config DTO so the two can't drift.</summary>
public const int DefaultSpanReadMaxGapBytes = 16;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This is <b>not</b> a correctness bound: S7.Net transparently chunks a <c>ReadBytes</c>
/// 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.
/// <para>
/// There is deliberately <b>no upper bound</b>: 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. <c>S7DriverFactoryExtensions.ParseOptions</c> therefore validates only the
/// lower bound (<c>&gt;= 1</c>); a non-positive cap can never admit a single block read.
/// </para>
/// </remarks>
[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;
/// <summary>Default for <see cref="SpanReadMaxSpanBytes"/>, shared with the config DTO so the two can't drift.</summary>
public const int DefaultSpanReadMaxSpanBytes = 220;
}
/// <summary>
/// Reads a TSAP as either hex text (<c>"03.01"</c>) or a JSON number (<c>769</c>, 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.
/// </summary>
public sealed class S7TsapJsonConverter : System.Text.Json.Serialization.JsonConverter<string?>
{
/// <inheritdoc />
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.");
}
/// <inheritdoc />
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
{
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Gets or sets the probe interval.</summary>
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Gets or sets the probe timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}
/// <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">
/// 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="ArrayCount">
/// Element count when the tag is a 1-D array; <c>null</c> for a scalar. Any value <c>&gt;= 1</c>
/// (including 1) surfaces as a 1-D OPC UA array node.
/// For an equipment tag this is threaded from the <c>TagConfig</c> JSON's <c>arrayLength</c>
/// (honoured only when <c>isArray</c> is true) by <see cref="S7TagDefinitionFactory"/>. When
/// set, the driver issues a single contiguous block read of
/// <c>ArrayCount × element-bytes</c> from the tag's start address and decodes each element
/// into an element-typed CLR array (<c>short[]</c> / <c>int[]</c> / <c>float[]</c> / etc.).
/// </param>
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,
/// <summary>S7 DATE_AND_TIME (DT, 8-byte BCD) → DateTime. The other date/time members below were appended after it.</summary>
DateTime,
/// <summary>S7 WSTRING (UTF-16BE, 4-byte header) → String. <c>StringLength</c> is the declared max in characters (≤ 16382).</summary>
WString,
/// <summary>S7 CHAR (1 byte, Latin-1) → one-character String.</summary>
Char,
/// <summary>S7 WCHAR (UTF-16BE code unit) → one-character String.</summary>
WChar,
/// <summary>S7 DTL (12 bytes, ns resolution) → DateTime.</summary>
Dtl,
/// <summary>S7 DATE (UInt16 days since 1990-01-01) → DateTime.</summary>
Date,
/// <summary>S7 TIME_OF_DAY (UInt32 ms since midnight) → Int32 milliseconds.</summary>
Tod,
/// <summary>S7 TIME (Int32 ms, signed duration) → Int32 milliseconds.</summary>
Time,
/// <summary>S7 S5TIME (BCD + time base) → Int32 milliseconds.</summary>
S5Time,
}