From 3b98e4d366ee9841ac84a67d26ecf47c59410ee0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 00:49:10 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20s7-c2=20=E2=80=94=20TSAP=20/=20Connecti?= =?UTF-8?q?on=20Type=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #295 --- docs/Driver.S7.Cli.md | 23 ++ docs/v2/s7.md | 80 +++++++ .../S7CommandBase.cs | 20 ++ src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 54 ++++- .../S7DriverFactoryExtensions.cs | 19 ++ .../S7DriverOptions.cs | 105 +++++++++ .../S7TsapModeTests.cs | 213 ++++++++++++++++++ 7 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapModeTests.cs 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); + } +} -- 2.49.1