Auto: s7-c2 — TSAP / Connection Type selector

Closes #295
This commit is contained in:
Joseph Doherty
2026-04-26 00:49:10 -04:00
parent bcf83bf39b
commit 3b98e4d366
7 changed files with 513 additions and 1 deletions

View File

@@ -163,7 +163,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
_parsedByName[t.Name] = parsed;
}
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
var plc = BuildPlc();
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
// honours the bound.
@@ -889,6 +889,58 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
private global::S7.Net.Plc RequirePlc() =>
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
/// <summary>
/// Construct the underlying S7netplus <see cref="Plc"/> honouring
/// <see cref="S7DriverOptions.TsapMode"/>, <see cref="S7DriverOptions.LocalTsap"/>,
/// and <see cref="S7DriverOptions.RemoteTsap"/>. <see cref="TsapMode.Auto"/> falls
/// back to the existing <c>(CpuType, host, port, rack, slot)</c> constructor so the
/// change is opt-in for sites that don't need a non-default class. Other modes go
/// through the raw-TSAP-pair overload, computing the pair from
/// <see cref="S7TsapDefaults"/> and the configured rack/slot, then layering the
/// caller-supplied <see cref="S7DriverOptions.LocalTsap"/> /
/// <see cref="S7DriverOptions.RemoteTsap"/> on top.
/// </summary>
private Plc BuildPlc()
{
if (_options.TsapMode == TsapMode.Auto)
{
// Existing behaviour: S7netplus picks the TSAP pair via TsapPair.GetDefaultTsapPair
// from CpuType + rack + slot. An explicit LocalTsap / RemoteTsap under Auto is
// ignored on purpose — Auto means "let the library decide". Document this in s7.md.
return new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
}
ushort localTsap;
ushort remoteTsap;
if (_options.TsapMode == TsapMode.Other)
{
if (_options.LocalTsap is not ushort lt || _options.RemoteTsap is not ushort rt)
{
throw new InvalidOperationException(
"S7DriverOptions.TsapMode = Other requires both LocalTsap and RemoteTsap to be set " +
"(no class default exists for Other). Set both, or pick Pg / Op / S7Basic.");
}
localTsap = lt;
remoteTsap = rt;
}
else
{
var classByte = S7TsapDefaults.HighByteFor(_options.TsapMode);
// Compute defaults from the class + configured rack/slot, then let explicit
// overrides win — so e.g. "TsapMode = Pg, LocalTsap = 0x0142" produces a PG-class
// remote with a custom local for sites that need a fixed source-TSAP.
localTsap = _options.LocalTsap ?? S7TsapDefaults.BuildLocalTsap(classByte);
remoteTsap = _options.RemoteTsap ?? S7TsapDefaults.BuildRemoteTsap(
classByte, _options.Rack, _options.Slot);
}
var pair = new global::S7.Net.Protocol.TsapPair(
new global::S7.Net.Protocol.Tsap((byte)(localTsap >> 8), (byte)(localTsap & 0xFF)),
new global::S7.Net.Protocol.Tsap((byte)(remoteTsap >> 8), (byte)(remoteTsap & 0xFF)));
return new Plc(_options.Host, _options.Port, pair);
}
// ---- ITagDiscovery ----
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)

View File

@@ -53,6 +53,10 @@ public static class S7DriverFactoryExtensions
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
ProbeAddress = dto.Probe?.ProbeAddress ?? "MW0",
},
TsapMode = ParseEnum<TsapMode>(dto.TsapMode, driverInstanceId, "TsapMode",
fallback: TsapMode.Auto),
LocalTsap = dto.LocalTsap,
RemoteTsap = dto.RemoteTsap,
};
return new S7Driver(options, driverInstanceId);
@@ -103,6 +107,21 @@ public static class S7DriverFactoryExtensions
public int? TimeoutMs { get; init; }
public List<S7TagDto>? Tags { get; init; }
public S7ProbeDto? Probe { get; init; }
/// <summary>
/// Optional connection-class selector — one of <c>Auto</c> (default),
/// <c>Pg</c>, <c>Op</c>, <c>S7Basic</c>, <c>Other</c>. When omitted the driver
/// keeps the existing <c>Auto</c> behaviour (S7netplus picks the TSAP pair
/// from <see cref="CpuType"/>). See <c>docs/v2/s7.md</c> "TSAP / Connection
/// Type" section.
/// </summary>
public string? TsapMode { get; init; }
/// <summary>Optional 16-bit local TSAP override. Required (with <see cref="RemoteTsap"/>) when <c>TsapMode = Other</c>.</summary>
public ushort? LocalTsap { get; init; }
/// <summary>Optional 16-bit remote TSAP override. Required (with <see cref="LocalTsap"/>) when <c>TsapMode = Other</c>.</summary>
public ushort? RemoteTsap { get; init; }
}
internal sealed class S7TagDto

View File

@@ -81,6 +81,111 @@ public sealed class S7DriverOptions
/// 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>
/// 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