ff055ebd79
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
272 lines
14 KiB
C#
272 lines
14 KiB
C#
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>>= 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>>= 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,
|
||
}
|