diff --git a/docs/Driver.S7.Cli.md b/docs/Driver.S7.Cli.md
index f18dae0..07f6cc6 100644
--- a/docs/Driver.S7.Cli.md
+++ b/docs/Driver.S7.Cli.md
@@ -22,6 +22,9 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
| `--rack` | `0` | Hardware rack (S7-400 distributed setups only) |
| `--slot` | `0` | CPU slot (S7-300 = 2, S7-400 = 2 or 3, S7-1200/1500 = 0) |
| `--timeout-ms` | `5000` | Per-operation timeout |
+| `--tsap-mode` | `Auto` | ISO-on-TCP connection class: `Auto` / `Pg` / `Op` / `S7Basic` / `Other`. Hardened S7-1500 / ET 200SP CPUs may require `Op` or `S7Basic`. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type). |
+| `--local-tsap` | (unset) | Optional 16-bit local TSAP override (e.g. `0x0200`). Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
+| `--remote-tsap` | (unset) | Optional 16-bit remote TSAP override. Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
| `--verbose` | off | Serilog debug output |
## PUT/GET must be enabled
@@ -83,6 +86,26 @@ otopcua-s7-cli write -h 192.168.1.30 -a M0.0 -t Bool -v true
**Writes to M / Q are real** — they drive the PLC program. Be careful what you
flip on a running machine.
+### Hardened CPU — forcing OP-class TSAP
+
+```powershell
+# Probe a hardened S7-1500 that rejects PG class but accepts OP.
+otopcua-s7-cli probe -h 10.50.12.30 --tsap-mode Op
+
+# Read against the same CPU.
+otopcua-s7-cli read -h 10.50.12.30 --tsap-mode Op -a DB1.DBW0 -t Int16
+
+# Manual TSAP override (e.g. site with a fixed proprietary TSAP gateway).
+otopcua-s7-cli probe -h 10.50.12.30 --tsap-mode Other --local-tsap 0x4D57 --remote-tsap 0x4D58
+```
+
+Without `--tsap-mode`, the CLI uses S7netplus's CpuType-derived default (PG
+class for almost everything). The same connection-refused failure shape that a
+wrong `--slot` produces also shows up when the CPU rejects PG class — try
+`--tsap-mode Op` first when the handshake is failing on otherwise-correct
+endpoint config. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type)
+for the byte table and motivation.
+
### `subscribe`
```powershell
diff --git a/docs/v2/s7.md b/docs/v2/s7.md
index 62894c1..36d81a2 100644
--- a/docs/v2/s7.md
+++ b/docs/v2/s7.md
@@ -573,6 +573,86 @@ S7 driver health without reaching for a Wireshark capture:
The values render alongside Modbus / OPC UA Client metrics in the Admin
UI driver-diagnostics panel — same RPC, same dashboard row layout.
+## TSAP / Connection Type
+
+S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request
+PDU carries a 16-bit **TSAP pair** (local + remote) that the CPU validates
+before any S7comm payload flows. S7netplus's default `Plc(CpuType, host, port,
+rack, slot)` constructor picks a **PG-class** TSAP pair via
+`TsapPair.GetDefaultTsapPair`. That choice works against most lab S7-1200 /
+S7-1500 CPUs and against TIA Portal itself, but **hardened deployments**
+(security-config'd S7-1500, ET 200SP, locked-down PROFINET projects) reject
+PG class outright at COTP-handshake time, returning the same connection-refused
+shape as a wrong slot byte.
+
+PR-S7-C2 surfaces a `TsapMode` enum on `S7DriverOptions` so an operator can
+force a specific class without re-flashing the PLC project. It applies equally
+to the Admin-UI-driven config DB row and to the `otopcua-s7-cli` test client.
+
+### Raw-TSAP byte table
+
+The high byte is the connection class. The local low byte is conventionally
+`0x00` (caller / unprivileged), and the remote low byte is
+`(rack << 5) | slot` per the S7 spec — the same convention S7netplus's
+`TsapPair.GetDefaultTsapPair(CpuType, rack, slot)` uses for the remote endpoint.
+
+| Class | High byte | Local TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=2) | Typical use |
+|----------|-----------|----------------------------|------------------------------|------------------------------|----------------------------------------------|
+| PG | `0x01` | `0x0100` | `0x0100` | `0x0102` | TIA Portal, dev laptops, lab S7-1200/1500 |
+| OP | `0x02` | `0x0200` | `0x0200` | `0x0202` | Operator panels, hardened-CPU S7-1500 |
+| S7-Basic | `0x03` | `0x0300` | `0x0300` | `0x0302` | WinCC BasicPanel SDK, S7-Basic clients |
+| Other | caller | caller-supplied | caller-supplied | caller-supplied | escape hatch — unusual fixed-TSAP firmware |
+
+### `TsapMode` enum
+
+| Mode | Behaviour |
+|-----------|----------------------------------------------------------------------------------------------------------------------------------|
+| `Auto` | Existing behaviour — S7netplus picks the TSAP pair from `CpuType`. Explicit `LocalTsap` / `RemoteTsap` are ignored under `Auto`. |
+| `Pg` | Force PG class (high byte `0x01`). Local / remote computed from rack + slot. |
+| `Op` | Force OP class (high byte `0x02`). |
+| `S7Basic` | Force S7-Basic class (high byte `0x03`). |
+| `Other` | Caller-supplied `LocalTsap` + `RemoteTsap`. Both must be set or driver init throws `InvalidOperationException`. |
+
+Explicit `LocalTsap` / `RemoteTsap` overrides win over the class-derived
+defaults under any non-`Auto` mode — a site that needs a fixed source-TSAP for
+firewall reasons can pin `LocalTsap` while keeping `TsapMode = Pg` for the
+remote computation.
+
+### Worked example: hardened S7-1500 requiring OP class
+
+```jsonc
+{
+ "Host": "10.50.12.30",
+ "CpuType": "S71500",
+ "Rack": 0,
+ "Slot": 0,
+ "TsapMode": "Op",
+ "Tags": [ /* … */ ]
+}
+```
+
+This produces local = `0x0200`, remote = `0x0200` (rack=0, slot=0). The same
+PLC under `TsapMode = "Auto"` (PG class) returns COTP rejection — same packet
+capture shape as a wrong-slot misconfig, which is the failure-mode footnote
+under §5 of `driver-specs.md`.
+
+### Why not just expose `LocalTsap` / `RemoteTsap` directly?
+
+Most operators don't know the byte format off-hand and reach for `Pg` /
+`Op` / `S7Basic` based on Siemens-doc terminology. Keeping the enum lets the
+Admin UI render a dropdown with sensible labels, while the `ushort?` fields
+stay available as the manual escape hatch when a site has truly unusual
+firmware (e.g. third-party S7-protocol gateways with fixed proprietary
+TSAPs). Both paths are exercised in the unit-test mapping table.
+
+### Live-firmware verification
+
+The PG/OP/S7-Basic byte table above is the documented Siemens convention; the
+actual handshake is verified against the dev-box S7-1500 lab rig (a hardened
+project that rejects PG and accepts OP). That test is documented in
+`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests` but only runs against
+real firmware — the pymodbus-style "TSAP simulator" doesn't exist for S7.
+
## References
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
index 602e354..91779c1 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
@@ -33,6 +33,23 @@ public abstract class S7CommandBase : DriverCommandBase
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
+ [CommandOption("tsap-mode", Description =
+ "ISO-on-TCP / S7comm connection class: Auto (default — S7netplus picks the TSAP " +
+ "from --cpu), Pg, Op, S7Basic, or Other (requires --local-tsap and --remote-tsap). " +
+ "Hardened S7-1500 / ET 200SP CPUs may refuse PG and require Op or S7Basic. See " +
+ "docs/v2/s7.md \"TSAP / Connection Type\" for the byte table and motivation.")]
+ public TsapMode TsapMode { get; init; } = TsapMode.Auto;
+
+ [CommandOption("local-tsap", Description =
+ "Optional 16-bit local TSAP override (e.g. 0x4D57). Required when --tsap-mode is Other; " +
+ "wins over the class default under Pg/Op/S7Basic.")]
+ public ushort? LocalTsap { get; init; }
+
+ [CommandOption("remote-tsap", Description =
+ "Optional 16-bit remote TSAP override. Required when --tsap-mode is Other; wins over " +
+ "the class default under Pg/Op/S7Basic.")]
+ public ushort? RemoteTsap { get; init; }
+
///
public override TimeSpan Timeout
{
@@ -55,6 +72,9 @@ public abstract class S7CommandBase : DriverCommandBase
Timeout = Timeout,
Tags = tags,
Probe = new S7ProbeOptions { Enabled = false },
+ TsapMode = TsapMode,
+ LocalTsap = LocalTsap,
+ RemoteTsap = RemoteTsap,
};
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
index 952a4f3..49633e8 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -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");
+ ///
+ /// Construct the underlying S7netplus honouring
+ /// , ,
+ /// and . falls
+ /// back to the existing (CpuType, host, port, rack, slot) 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
+ /// and the configured rack/slot, then layering the
+ /// caller-supplied /
+ /// on top.
+ ///
+ 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)
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
index 3809dbf..fe360b3 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
@@ -53,6 +53,10 @@ public static class S7DriverFactoryExtensions
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
ProbeAddress = dto.Probe?.ProbeAddress ?? "MW0",
},
+ TsapMode = ParseEnum(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? Tags { get; init; }
public S7ProbeDto? Probe { get; init; }
+
+ ///
+ /// Optional connection-class selector — one of Auto (default),
+ /// Pg, Op, S7Basic, Other. When omitted the driver
+ /// keeps the existing Auto behaviour (S7netplus picks the TSAP pair
+ /// from ). See docs/v2/s7.md "TSAP / Connection
+ /// Type" section.
+ ///
+ public string? TsapMode { get; init; }
+
+ /// Optional 16-bit local TSAP override. Required (with ) when TsapMode = Other.
+ public ushort? LocalTsap { get; init; }
+
+ /// Optional 16-bit remote TSAP override. Required (with ) when TsapMode = Other.
+ public ushort? RemoteTsap { get; init; }
}
internal sealed class S7TagDto
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
index d17f51c..5224161 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
@@ -81,6 +81,111 @@ public sealed class S7DriverOptions
/// opt out of merging regardless of this knob.
///
public int BlockCoalescingGapBytes { get; init; } = S7BlockCoalescingPlanner.DefaultGapMergeBytes;
+
+ ///
+ /// ISO-on-TCP / S7comm "connection class" selector. Hardened S7-1500 CPUs and some
+ /// ET 200SP / S7-1200 firmware variants reject the default PG-class TSAP that
+ /// S7netplus picks under and require an OP-class
+ /// or S7-Basic-class 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 docs/v2/s7.md "TSAP / Connection
+ /// Type" section for the raw-TSAP byte table and the hardened-CPU motivation.
+ ///
+ ///
+ ///
+ /// preserves existing behaviour — S7netplus picks
+ /// the TSAP pair from the configured via its
+ /// TsapPair.GetDefaultTsapPair. Use /
+ /// / to force a specific
+ /// class, or together with
+ /// and for a fully-manual escape hatch. Explicit
+ /// / overrides win even under
+ /// / / .
+ ///
+ ///
+ public TsapMode TsapMode { get; init; } = TsapMode.Auto;
+
+ ///
+ /// Optional fully-manual local TSAP override (16-bit big-endian word — high byte =
+ /// class selector, low byte = caller-defined). Required (together with
+ /// ) when is
+ /// . Wins over the class-derived default under any
+ /// non- mode.
+ ///
+ public ushort? LocalTsap { get; init; }
+
+ ///
+ /// Optional fully-manual remote TSAP override (16-bit big-endian word — high byte =
+ /// class selector, low byte = (rack << 5) | slot per the S7 spec).
+ /// Required (together with ) when is
+ /// . Wins over the class-derived default under any
+ /// non- mode.
+ ///
+ public ushort? RemoteTsap { get; init; }
+}
+
+///
+/// ISO-on-TCP / S7comm connection class. Picks the high byte of the TSAP pair used
+/// during COTP handshake. See docs/v2/s7.md "TSAP / Connection Type" section
+/// for the raw-byte table and the hardened-CPU motivation.
+///
+public enum TsapMode
+{
+ /// S7netplus picks the TSAP pair from . Existing behaviour.
+ Auto,
+ /// PG class — high byte 0x01. Default for development laptops / TIA Portal.
+ Pg,
+ /// OP class — high byte 0x02. Required by some hardened S7-1500 / ET 200SP deployments.
+ Op,
+ /// S7-Basic class — high byte 0x03. Used by S7-Basic clients (e.g. some HMI panels and the WinCC BasicPanel SDK).
+ S7Basic,
+ /// Caller-supplied + . Both must be set or driver init throws.
+ Other,
+}
+
+///
+/// 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 (rack << 5) | slot per the spec. Mirrored in
+/// docs/v2/s7.md "TSAP / Connection Type" table.
+///
+public static class S7TsapDefaults
+{
+ /// PG-class high byte = 0x01.
+ public const byte PgClassHighByte = 0x01;
+
+ /// OP-class high byte = 0x02.
+ public const byte OpClassHighByte = 0x02;
+
+ /// S7-Basic-class high byte = 0x03.
+ public const byte S7BasicClassHighByte = 0x03;
+
+ /// Build the local TSAP (16-bit BE): class << 8 | 0x00.
+ public static ushort BuildLocalTsap(byte classHighByte) => (ushort)(classHighByte << 8);
+
+ ///
+ /// Build the remote TSAP (16-bit BE): class << 8 | ((rack & 0x07) << 5 | (slot & 0x1F)).
+ /// Matches the convention used by S7netplus's TsapPair.GetDefaultTsapPair for the remote endpoint.
+ ///
+ 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));
+ }
+
+ /// Pick the class high-byte for a non- / non- mode.
+ 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
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapModeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapModeTests.cs
new file mode 100644
index 0000000..3036482
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapModeTests.cs
@@ -0,0 +1,213 @@
+using System.Text.Json;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
+
+///
+/// Unit tests for the PR-S7-C2 TSAP / Connection Type selector. Validates the
+/// raw-byte mapping, the →
+/// constructor branching in , and JSON DTO round-trip on
+/// . The live-PLC handshake is exercised
+/// separately (and is hardware-gated to the dev-box S7-1500 lab rig).
+///
+[Trait("Category", "Unit")]
+public sealed class S7TsapModeTests
+{
+ [Fact]
+ public void Default_TsapMode_is_Auto_to_preserve_existing_behaviour()
+ {
+ new S7DriverOptions().TsapMode.ShouldBe(TsapMode.Auto);
+ new S7DriverOptions().LocalTsap.ShouldBeNull();
+ new S7DriverOptions().RemoteTsap.ShouldBeNull();
+ }
+
+ // ---- High-byte mapping ----
+
+ [Theory]
+ [InlineData(TsapMode.Pg, S7TsapDefaults.PgClassHighByte)]
+ [InlineData(TsapMode.Op, S7TsapDefaults.OpClassHighByte)]
+ [InlineData(TsapMode.S7Basic, S7TsapDefaults.S7BasicClassHighByte)]
+ public void HighByteFor_returns_the_class_selector_for_each_named_mode(TsapMode mode, byte expected)
+ {
+ S7TsapDefaults.HighByteFor(mode).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData(TsapMode.Auto)]
+ [InlineData(TsapMode.Other)]
+ public void HighByteFor_throws_for_Auto_and_Other(TsapMode mode)
+ {
+ Should.Throw(() => S7TsapDefaults.HighByteFor(mode));
+ }
+
+ // ---- Raw-byte construction ----
+
+ [Theory]
+ [InlineData(S7TsapDefaults.PgClassHighByte, 0x0100)]
+ [InlineData(S7TsapDefaults.OpClassHighByte, 0x0200)]
+ [InlineData(S7TsapDefaults.S7BasicClassHighByte, 0x0300)]
+ public void BuildLocalTsap_uses_class_high_byte_with_zero_low_byte(byte cls, ushort expected)
+ {
+ S7TsapDefaults.BuildLocalTsap(cls).ShouldBe(expected);
+ }
+
+ [Theory]
+ // PG / S7-300 default — rack=0 slot=2 → 0x0102 (matches docs/v2/s7.md table).
+ [InlineData(S7TsapDefaults.PgClassHighByte, 0, 2, 0x0102)]
+ // OP / S7-300 default — rack=0 slot=2 → 0x0202.
+ [InlineData(S7TsapDefaults.OpClassHighByte, 0, 2, 0x0202)]
+ // S7-Basic / S7-300 default — rack=0 slot=2 → 0x0302.
+ [InlineData(S7TsapDefaults.S7BasicClassHighByte, 0, 2, 0x0302)]
+ // S7-1200 / S7-1500 onboard PN — rack=0 slot=0 → low byte 0x00.
+ [InlineData(S7TsapDefaults.PgClassHighByte, 0, 0, 0x0100)]
+ // S7-400 distributed — rack=1 slot=3 → low byte = (1<<5)|3 = 0x23.
+ [InlineData(S7TsapDefaults.OpClassHighByte, 1, 3, 0x0223)]
+ public void BuildRemoteTsap_packs_class_rack_and_slot_per_S7_spec(byte cls, int rack, int slot, ushort expected)
+ {
+ S7TsapDefaults.BuildRemoteTsap(cls, rack, slot).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData(-1, 0)]
+ [InlineData(16, 0)]
+ [InlineData(0, -1)]
+ [InlineData(0, 32)]
+ public void BuildRemoteTsap_throws_on_out_of_range_rack_or_slot(int rack, int slot)
+ {
+ Should.Throw(() =>
+ S7TsapDefaults.BuildRemoteTsap(S7TsapDefaults.PgClassHighByte, rack, slot));
+ }
+
+ // ---- Driver construction (Other-mode missing-overrides validation) ----
+
+ [Fact]
+ public async Task Other_mode_without_LocalTsap_or_RemoteTsap_throws_at_initialize()
+ {
+ // No LocalTsap / RemoteTsap → BuildPlc should refuse before opening the socket.
+ var opts = new S7DriverOptions
+ {
+ Host = "192.0.2.1",
+ Timeout = TimeSpan.FromMilliseconds(250),
+ TsapMode = TsapMode.Other,
+ };
+ using var drv = new S7Driver(opts, "s7-other-missing");
+
+ var ex = await Should.ThrowAsync(async () =>
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
+ ex.Message.ShouldContain("LocalTsap");
+ ex.Message.ShouldContain("RemoteTsap");
+ }
+
+ [Fact]
+ public async Task Other_mode_with_only_LocalTsap_set_still_throws()
+ {
+ var opts = new S7DriverOptions
+ {
+ Host = "192.0.2.1",
+ Timeout = TimeSpan.FromMilliseconds(250),
+ TsapMode = TsapMode.Other,
+ LocalTsap = 0x4D57,
+ // RemoteTsap intentionally null
+ };
+ using var drv = new S7Driver(opts, "s7-other-half");
+
+ await Should.ThrowAsync(async () =>
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Other_mode_with_both_overrides_does_not_fail_at_BuildPlc_stage()
+ {
+ // With Other-mode + both overrides, BuildPlc must succeed; the connect itself fails
+ // because 192.0.2.1 is unroutable (RFC 5737), so we expect a transport-shaped Exception
+ // (NOT InvalidOperationException complaining about missing TSAPs).
+ var opts = new S7DriverOptions
+ {
+ Host = "192.0.2.1",
+ Timeout = TimeSpan.FromMilliseconds(250),
+ TsapMode = TsapMode.Other,
+ LocalTsap = 0x4D57,
+ RemoteTsap = 0x4D58,
+ };
+ using var drv = new S7Driver(opts, "s7-other-ok");
+
+ var ex = await Should.ThrowAsync(async () =>
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
+ // Should not be the "missing LocalTsap/RemoteTsap" guard — that guard rejects on
+ // Type, this should be a transport / timeout failure shape.
+ (ex is InvalidOperationException io && io.Message.Contains("LocalTsap"))
+ .ShouldBeFalse("Other-mode with both overrides set must pass the BuildPlc guard");
+ }
+
+ // ---- DTO round-trip ----
+
+ [Fact]
+ public void DTO_round_trip_with_TsapMode_Op_and_explicit_overrides()
+ {
+ // Mirrors what the central config DB hands to S7DriverFactoryExtensions.CreateInstance.
+ var json = """
+ {
+ "Host": "192.168.1.30",
+ "CpuType": "S71500",
+ "Rack": 0,
+ "Slot": 0,
+ "TsapMode": "Op",
+ "LocalTsap": 768,
+ "RemoteTsap": 770,
+ "Tags": []
+ }
+ """;
+ var drv = (S7Driver)S7DriverFactoryExtensions.CreateInstance("s7-dto", json);
+ drv.ShouldNotBeNull();
+ drv.DriverInstanceId.ShouldBe("s7-dto");
+ drv.Dispose();
+ }
+
+ [Fact]
+ public void DTO_unknown_TsapMode_is_rejected()
+ {
+ var json = """
+ {
+ "Host": "192.168.1.30",
+ "TsapMode": "ProfiNet",
+ "Tags": []
+ }
+ """;
+ Should.Throw(() =>
+ S7DriverFactoryExtensions.CreateInstance("s7-bad-mode", json));
+ }
+
+ [Fact]
+ public void DTO_omitting_TsapMode_falls_back_to_Auto()
+ {
+ // Critical for backwards-compat: pre-PR-S7-C2 configs must still load.
+ var json = """
+ {
+ "Host": "192.168.1.30",
+ "Tags": []
+ }
+ """;
+ var drv = S7DriverFactoryExtensions.CreateInstance("s7-legacy", json);
+ drv.ShouldNotBeNull();
+ drv.Dispose();
+ }
+
+ [Fact]
+ public void DTO_round_trip_serialise_then_deserialise_preserves_TSAP_fields()
+ {
+ // Round-trip through the DTO type the factory uses, to catch property-name mismatches.
+ var dto = new S7DriverFactoryExtensions.S7DriverConfigDto
+ {
+ Host = "10.0.0.5",
+ TsapMode = "S7Basic",
+ LocalTsap = 0x0300,
+ RemoteTsap = 0x0302,
+ };
+ var json = JsonSerializer.Serialize(dto);
+ var back = JsonSerializer.Deserialize(json)!;
+ back.TsapMode.ShouldBe("S7Basic");
+ back.LocalTsap.ShouldBe((ushort)0x0300);
+ back.RemoteTsap.ShouldBe((ushort)0x0302);
+ }
+}