diff --git a/docs/drivers/S7-Test-Fixture.md b/docs/drivers/S7-Test-Fixture.md index 511d4510..68e058dc 100644 --- a/docs/drivers/S7-Test-Fixture.md +++ b/docs/drivers/S7-Test-Fixture.md @@ -49,6 +49,13 @@ The driver ctor change that made this possible: - `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` — `DB1.DBW100` write → read-back; proves write path + buffer visibility +- `S7_1500CodecTests` (#632/#638) — live python-snap7 coverage of the STRING / WSTRING / CHAR / WCHAR / + DTL / DATE / TOD / TIME / S5TIME codecs: seeded reads (DB1 800..960, distinct non-zero), BadOutOfRange + reads of undecodable buffers, write round-trips and BadOutOfRange refused writes (scratch DB1 1100..1199; + needs `S7_SIM_ALLOW_WRITES=1`), the `LocalTsap`/`RemoteTsap`/`ConnectionType` override keys (proves they + reach the wire; snap7 accepts any TSAP, so TSAP semantics remain hardware-only), and the negotiated PDU + size (`NegotiatedPduSize` / `GetDiagnostics`). Seed map in `Docker/README.md`. + ### Unit - `S7AddressParserTests` — S7 address syntax (`DB1.DBD0`, `M10.3`, `IW4`, etc.) diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md index 23ce7de8..457367cd 100644 --- a/docs/drivers/S7.md +++ b/docs/drivers/S7.md @@ -286,7 +286,10 @@ fresh drop after a healthy period reconnects immediately; worst-case recovery la - **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` cover the address parser, the reinterpret/box conversions, and the driver lifecycle. - **Integration fixture** — a Docker S7 simulator on the shared docker host; see - [S7-Test-Fixture.md](S7-Test-Fixture.md) for the coverage map and endpoint. + [S7-Test-Fixture.md](S7-Test-Fixture.md) for the coverage map and endpoint. The wide/date-time + codecs (STRING, WSTRING, CHAR, WCHAR, DTL, DATE, TOD, TIME, S5TIME) and the TSAP override keys have + live snap7 coverage in `S7_1500CodecTests`; an unrepresentable STRING/DT write is `BadOutOfRange` + (not a comms fault) like the other codecs. - **CLI** — [Driver.S7.Cli.md](../Driver.S7.Cli.md) documents the standalone read/write/probe CLI for manual checks against a real or simulated CPU. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md index 4a864762..d019ba0c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md @@ -93,7 +93,7 @@ known offsets: | `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default | Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`, -`bool` (with `"bit": 0..7`), `ascii` (S7 STRING). +`bool` (with `"bit": 0..7`), `ascii` (S7 STRING), `hex` (raw bytes). ### Span-planner gate seeds (offsets 300+, `S7_1500SpanReadTests`) @@ -119,6 +119,32 @@ inside the holes cluster A's span reads through. No tag maps to them; they are there so a mis-computed slice offset returns garbage instead of a zero that could match by luck. Every seed above is distinct and non-zero for the same reason. +### Codec gate seeds (offsets 800+, `S7_1500CodecTests`, #632/#638) + +DB1 is 2048 bytes. Seeds use the `hex` seed type (raw big-endian bytes) for WSTRING/DTL/undecodable +headers. All distinct and non-zero. Writes go to the **unseeded** 1100..1199 scratch area +(`S7_SIM_ALLOW_WRITES=1` required). + +| Address | Type | Seed | Decodes to | +|---|---|---|---| +| `DB1.DBB800` | STRING max 32 | `Siemens S7` | `"Siemens S7"` | +| `DB1.DBB840` | WSTRING max 8 | `0008 0005 0047 0072 00fc 00df 0065` | `"Grüße"` | +| `DB1.DBB870` | CHAR | `0x51` | `"Q"` | +| `DB1.DBB872` | WCHAR | `0x20AC` | `"€"` | +| `DB1.DBB880` | DTL | `07e8 03 0f 06 0d 2d 38 2f075f4c` | 2024-03-15 13:45:56.7890123 | +| `DB1.DBW900` | DATE | `11488` | 2021-06-15 | +| `DB1.DBD904` | TOD | `49556789` | 49 556 789 ms (13:45:56.789) | +| `DB1.DBD910` / `914` | TIME | `123456789` / `-86400123` | same, ms | +| `DB1.DBW920` / `922` | S5TIME | `0x2789` / `0x0123` | 789 000 ms / 1 230 ms | +| `DB1.DBW960` | DATE | `0xFFFF` | 2169-06-06 | +| `DB1.DBB930` | DTL | all zero (unseeded) | BadOutOfRange (uninitialised) | +| `DB1.DBW940` | S5TIME | `0x0A5F` | BadOutOfRange (non-BCD nibble) | +| `DB1.DBD944` | TOD | `0xFFFFFFFF` | BadOutOfRange (past midnight) | +| `DB1.DBB950` | WSTRING max 3 | `0003 0009` | BadOutOfRange (cur > max) | + +snap7 does not validate STRING/WSTRING headers or TSAPs, so the TSAP-override theory proves the keys +reach the wire and the connection still works — not TSAP semantics (hardware only). + ### Timer / Counter (TM / CT) areas `TM0` and `CT0` are 64 bytes each — **32 device cells**, since a timer is a 2-byte diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json index 37820a15..c0953c7c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json @@ -4,7 +4,7 @@ { "area": "DB", "index": 1, - "size": 1024, + "size": 2048, "seeds": [ { "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it", "offset": 0, "type": "u16", "value": 4242 }, @@ -46,7 +46,30 @@ { "_desc": "DB1.DBW500 — isolated narrow scalar: its own single-member span, demoted by PrefersPerTagRead into a leftover that rides the multi-var pass", "offset": 500, "type": "u16", "value": 60001 }, { "_desc": "DB1.DBW600 — second isolated narrow scalar, likewise a multi-var leftover", - "offset": 600, "type": "i16", "value": -32001 } + "offset": 600, "type": "i16", "value": -32001 }, + + { "_desc": "CODEC GATE (S7_1500CodecTests, #632/#638). Every seed from 800 to 969 is distinct and non-zero so a decode that returns zero/default can never pass. 800: STRING max 32 cur 10 'Siemens S7'", + "offset": 800, "type": "ascii", "value": "Siemens S7", "max_len": 32 }, + { "_desc": "DB1 @840 — WSTRING max 8 cur 5 'Grüße' (ü=00FC, ß=00DF) — non-ASCII proves UTF-16BE, not Latin-1", + "offset": 840, "type": "hex", "value": "0008 0005 0047 0072 00fc 00df 0065" }, + { "_desc": "DB1 @870 — CHAR 'Q'", "offset": 870, "type": "u8", "value": 81 }, + { "_desc": "DB1 @872 — WCHAR U+20AC '€' (does not fit Latin-1)", "offset": 872, "type": "u16", "value": 8364 }, + { "_desc": "DB1 @880 — DTL 2024-03-15 13:45:56.7890123 (weekday 6 = Friday, ns 789012300)", + "offset": 880, "type": "hex", "value": "07e8 03 0f 06 0d 2d 38 2f075f4c" }, + { "_desc": "DB1 @900 — DATE 2021-06-15 = 11488 days since 1990-01-01", "offset": 900, "type": "u16", "value": 11488 }, + { "_desc": "DB1 @904 — TOD 13:45:56.789 = 49556789 ms", "offset": 904, "type": "u32", "value": 49556789 }, + { "_desc": "DB1 @910 — TIME +123456789 ms", "offset": 910, "type": "i32", "value": 123456789 }, + { "_desc": "DB1 @914 — TIME -86400123 ms (negative duration)", "offset": 914, "type": "i32", "value": -86400123 }, + { "_desc": "DB1 @920 — S5TIME 0x2789: base 2 (x1 s), BCD 789 = 789000 ms", "offset": 920, "type": "u16", "value": 10121 }, + { "_desc": "DB1 @922 — S5TIME 0x0123: base 0 (x10 ms), BCD 123 = 1230 ms", "offset": 922, "type": "u16", "value": 291 }, + + { "_desc": "UNDECODABLE buffers — reads must surface BadOutOfRange, not a value. @930 DTL stays all-zero (uninitialised) on purpose. @940: S5TIME 0x0A5F has a non-BCD nibble", + "offset": 940, "type": "u16", "value": 2655 }, + { "_desc": "DB1 @944 — TOD 0xFFFFFFFF, past midnight", "offset": 944, "type": "u32", "value": 4294967295 }, + { "_desc": "DB1 @950 — WSTRING max 3 cur 9 (current length exceeds reserved)", + "offset": 950, "type": "hex", "value": "0003 0009" }, + { "_desc": "DB1 @960 — DATE 0xFFFF = 65535 days = 2169-06-06, the far end of the DATE range (still a valid decode)", + "offset": 960, "type": "u16", "value": 65535 } ] }, { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py index ce1824b7..f83bfacc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py @@ -45,7 +45,7 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None: """Poke seed values into the area buffer at declared byte offsets. Each seed is {"offset": int, "type": str, "value": int|float|bool|str} - where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is + where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii, hex}. Endianness is big-endian (Siemens wire format). """ for seed in seeds: @@ -73,6 +73,11 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None: buf[off] |= (1 << bit) else: buf[off] &= ~(1 << bit) & 0xFF + elif t == "hex": + # Raw bytes, big-endian wire order as written. Used for the multi-byte S7 types the + # scalar seeds cannot express (WSTRING, DTL, ...): {"type": "hex", "value": "07e8030f..."}. + raw = bytes.fromhex(str(v).replace(" ", "")) + buf[off:off + len(raw)] = raw elif t == "ascii": # Siemens STRING type: byte 0 = max length, byte 1 = current length, # bytes 2+ = payload. Seeds supply the payload text; we fill max/cur. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500CodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500CodecTests.cs new file mode 100644 index 00000000..9ed3fe9f --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500CodecTests.cs @@ -0,0 +1,263 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; + +/// +/// Gitea #632 / #638 live coverage: the S7 STRING / WSTRING / CHAR / WCHAR / DTL / DATE / TOD / TIME / +/// S5TIME codecs against the python-snap7 S7-1500 profile, over real ISO-on-TCP + S7comm, plus the +/// TSAP override keys (LocalTsap / RemoteTsap / ConnectionType) and the negotiated +/// PDU size. Read seeds live at DB1 800..960 in Docker/profiles/s7_1500.json and are all +/// distinct and non-zero, so a decode that returned zero/default cannot pass. Write tests use the +/// unseeded DB1 1100..1199 scratch area and additionally need S7_SIM_ALLOW_WRITES=1. Everything +/// skips cleanly when S7_SIM_ENDPOINT (default the shared Docker host) is unreachable. +/// +[Collection(Snap7ServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "S7_1500")] +public sealed class S7_1500CodecTests(Snap7ServerFixture sim) +{ + private const uint BadOutOfRange = 0x803C0000u; + + private static S7DriverOptions Options(string host, int port, params S7TagDefinition[] tags) => new() + { + Host = host, + Port = port, + CpuType = S7CpuType.S71500, + Rack = 0, + Slot = 0, + Timeout = TimeSpan.FromSeconds(5), + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries(tags), + }; + + private static S7DriverOptions WithTsap(S7DriverOptions o, string? local, string? remote, S7ConnectionType? ct) => new() + { + Host = o.Host, Port = o.Port, CpuType = o.CpuType, Rack = o.Rack, Slot = o.Slot, Timeout = o.Timeout, + Probe = o.Probe, RawTags = o.RawTags, + LocalTsap = local, RemoteTsap = remote, ConnectionType = ct, + }; + + private void RequireSim() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + } + + private static void RequireWrites() + { + if (Environment.GetEnvironmentVariable("S7_SIM_ALLOW_WRITES") != "1") + Assert.Skip("Write test: set S7_SIM_ALLOW_WRITES=1 to allow writes to the endpoint under test (simulator only)."); + } + + // ---- reads ---- + + /// Every codec decodes its seeded, distinct non-zero wire bytes through real S7comm. + [Fact] + public async Task Codecs_decode_seeded_bytes_through_real_S7comm() + { + RequireSim(); + var o = Options(sim.Host, sim.Port, + new S7TagDefinition("Str", "DB1.DBB800", S7DataType.String, StringLength: 32), + new S7TagDefinition("WStr", "DB1.DBB840", S7DataType.WString, StringLength: 8), + new S7TagDefinition("Chr", "DB1.DBB870", S7DataType.Char), + new S7TagDefinition("WChr", "DB1.DBB872", S7DataType.WChar), + new S7TagDefinition("Dtl", "DB1.DBB880", S7DataType.Dtl), + new S7TagDefinition("Date", "DB1.DBW900", S7DataType.Date), + new S7TagDefinition("Tod", "DB1.DBD904", S7DataType.Tod), + new S7TagDefinition("TimePos", "DB1.DBD910", S7DataType.Time), + new S7TagDefinition("TimeNeg", "DB1.DBD914", S7DataType.Time), + new S7TagDefinition("S5Sec", "DB1.DBW920", S7DataType.S5Time), + new S7TagDefinition("S5Cs", "DB1.DBW922", S7DataType.S5Time), + new S7TagDefinition("DateMax", "DB1.DBW960", S7DataType.Date)); + await using var drv = new S7Driver(o, "s7-codec-read"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var names = o.RawTags.Select(t => t.RawPath).ToArray(); + var r = await drv.ReadAsync(names, TestContext.Current.CancellationToken); + r.Count.ShouldBe(names.Length); + for (var i = 0; i < r.Count; i++) r[i].StatusCode.ShouldBe(0u, names[i]); + + object? V(string n) => r[Array.IndexOf(names, n)].Value; + V("Str").ShouldBe("Siemens S7"); + V("WStr").ShouldBe("Grüße"); + V("Chr").ShouldBe("Q"); + V("WChr").ShouldBe("€"); + V("Dtl").ShouldBe(new DateTime(2024, 3, 15, 13, 45, 56).AddTicks(7_890_123)); + V("Date").ShouldBe(new DateTime(2021, 6, 15)); + V("Tod").ShouldBe(49_556_789); + V("TimePos").ShouldBe(123_456_789); + V("TimeNeg").ShouldBe(-86_400_123); + V("S5Sec").ShouldBe(789_000); + V("S5Cs").ShouldBe(1_230); + V("DateMax").ShouldBe(new DateTime(2169, 6, 6)); + } + + /// Undecodable buffers surface BadOutOfRange per tag, leave siblings Good, and do not degrade health. + [Fact] + public async Task Undecodable_buffers_read_BadOutOfRange_without_degrading_health() + { + RequireSim(); + var o = Options(sim.Host, sim.Port, + new S7TagDefinition("DtlUninit", "DB1.DBB930", S7DataType.Dtl), + new S7TagDefinition("S5NonBcd", "DB1.DBW940", S7DataType.S5Time), + new S7TagDefinition("TodPastMidnight", "DB1.DBD944", S7DataType.Tod), + new S7TagDefinition("WStrCurOverMax", "DB1.DBB950", S7DataType.WString, StringLength: 3), + new S7TagDefinition("Good", "DB1.DBD904", S7DataType.Tod)); + await using var drv = new S7Driver(o, "s7-codec-bad"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var r = await drv.ReadAsync( + ["DtlUninit", "S5NonBcd", "TodPastMidnight", "WStrCurOverMax", "Good"], + TestContext.Current.CancellationToken); + for (var i = 0; i < 4; i++) r[i].StatusCode.ShouldBe(BadOutOfRange, $"tag #{i}"); + r[4].StatusCode.ShouldBe(0u); + r[4].Value.ShouldBe(49_556_789); + drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a PLC buffer the codec refuses is data, not a comms fault"); + } + + // ---- writes ---- + + private static readonly S7TagDefinition[] ScratchTags = + [ + new("Str", "DB1.DBB1100", S7DataType.String, StringLength: 8), + new("WStr", "DB1.DBB1120", S7DataType.WString, StringLength: 6), + new("Chr", "DB1.DBB1150", S7DataType.Char), + new("WChr", "DB1.DBB1152", S7DataType.WChar), + new("Dtl", "DB1.DBB1160", S7DataType.Dtl), + new("Date", "DB1.DBW1180", S7DataType.Date), + new("Tod", "DB1.DBD1184", S7DataType.Tod), + new("Time", "DB1.DBD1188", S7DataType.Time), + new("S5", "DB1.DBW1192", S7DataType.S5Time), + ]; + + /// Write each codec's value, read it back through the driver (distinct non-zero values). + [Fact] + public async Task Codec_writes_round_trip_through_real_S7comm() + { + RequireSim(); + RequireWrites(); + await using var drv = new S7Driver(Options(sim.Host, sim.Port, ScratchTags), "s7-codec-write"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var dtl = new DateTime(2031, 11, 30, 23, 59, 58).AddTicks(1_234_567); + (string Tag, object Value)[] cases = + [ + ("Str", "Plant-7"), + ("WStr", "Ünï€9"), + ("Chr", "Z"), + ("WChr", "ß"), + ("Dtl", dtl), + ("Date", new DateTime(2077, 2, 28)), + ("Tod", 3_723_456), + ("Time", -7_654_321), + ("S5", 456_000), + ]; + var res = await drv.WriteAsync( + [.. cases.Select(c => new WriteRequest(c.Tag, c.Value))], TestContext.Current.CancellationToken); + for (var i = 0; i < res.Count; i++) res[i].StatusCode.ShouldBe(0u, cases[i].Tag); + + var read = await drv.ReadAsync([.. cases.Select(c => c.Tag)], TestContext.Current.CancellationToken); + for (var i = 0; i < read.Count; i++) + { + read[i].StatusCode.ShouldBe(0u, cases[i].Tag); + read[i].Value.ShouldBe(cases[i].Value, cases[i].Tag); + } + } + + /// + /// Unrepresentable writes are refused BadOutOfRange with nothing sent (the previously written value + /// survives) and without degrading driver health. Covers classic STRING + DT (fixed in this change: + /// they used to map to BadCommunicationError) and every #632 codec. + /// + [Fact] + public async Task Unrepresentable_writes_are_BadOutOfRange_and_leave_the_PLC_untouched() + { + RequireSim(); + RequireWrites(); + var tags = ScratchTags.Append(new S7TagDefinition("Dt", "DB1.DBB1196", S7DataType.DateTime)).ToArray(); + await using var drv = new S7Driver(Options(sim.Host, sim.Port, tags), "s7-codec-range"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + var ct = TestContext.Current.CancellationToken; + + // Known-good baseline so "nothing was sent" is observable as an unchanged value. + var good = new Dictionary + { + ["Str"] = "Keep-8ch", ["WStr"] = "Keep-6", ["Chr"] = "K", ["WChr"] = "W", + ["Dtl"] = new DateTime(2025, 5, 6, 7, 8, 9), ["Date"] = new DateTime(2000, 1, 2), + ["Tod"] = 1_000_001, ["Time"] = 2_000_002, ["S5"] = 3_000, + }; + (await drv.WriteAsync([.. good.Select(g => new WriteRequest(g.Key, g.Value))], ct)) + .ShouldAllBe(x => x.StatusCode == 0u); + + (string Tag, object Bad)[] bad = + [ + ("Str", "123456789"), // 9 > max 8 + ("WStr", "1234567"), // 7 > max 6 + ("Chr", "€"), // U+20AC does not fit Latin-1 + ("Dtl", new DateTime(1960, 1, 1)), // before 1970 + ("Date", new DateTime(1980, 1, 1)), // before 1990 + ("Tod", 86_400_000), // past midnight + ("Time", 3_000_000_000L), // > Int32 + ("S5", 15), // fits no S5TIME time base exactly + ("Dt", new DateTime(1980, 1, 1)), // outside the 1990-2089 DT range + ]; + var res = await drv.WriteAsync([.. bad.Select(b => new WriteRequest(b.Tag, b.Bad))], ct); + for (var i = 0; i < res.Count; i++) res[i].StatusCode.ShouldBe(BadOutOfRange, bad[i].Tag); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + + var back = await drv.ReadAsync([.. good.Keys], ct); + var keys = good.Keys.ToArray(); + for (var i = 0; i < back.Count; i++) + { + back[i].StatusCode.ShouldBe(0u, keys[i]); + back[i].Value.ShouldBe(good[keys[i]], $"{keys[i]} must be unchanged after the refused write"); + } + } + + // ---- TSAP override + PDU ---- + + /// + /// The TSAP override keys reach the wire. snap7's server accepts any TSAP, so this proves the + /// override is applied and the connection still works — NOT TSAP semantics (a real CPU rejects a + /// wrong TSAP; only hardware can prove the values themselves). + /// + [Theory] + [InlineData("01.00", "03.01", null)] + [InlineData("4D.57", null, null)] + [InlineData(null, "02.01", null)] + [InlineData(null, null, S7ConnectionType.PG)] + [InlineData(null, null, S7ConnectionType.OP)] + public async Task TSAP_override_keys_connect_and_read_through_real_S7comm( + string? local, string? remote, S7ConnectionType? connType) + { + RequireSim(); + var o = WithTsap( + Options(sim.Host, sim.Port, new S7TagDefinition("Probe", "DB1.DBW0", S7DataType.UInt16)), + local, remote, connType); + await using var drv = new S7Driver(o, "s7-tsap"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var r = await drv.ReadAsync(["Probe"], TestContext.Current.CancellationToken); + r[0].StatusCode.ShouldBe(0u); + Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue); + } + + /// The negotiated PDU size is a real S7comm value after connect and 0 after shutdown. + [Fact] + public async Task Negotiated_PDU_size_is_observed_after_connect() + { + RequireSim(); + var o = Options(sim.Host, sim.Port, new S7TagDefinition("Probe", "DB1.DBW0", S7DataType.UInt16)); + await using var drv = new S7Driver(o, "s7-pdu"); + drv.NegotiatedPduSize.ShouldBe(0, "no connect yet"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + // S7comm PDUs are 240 (S7-300) / 480 (S7-1200/1500 default) / up to 960 bytes. + drv.NegotiatedPduSize.ShouldBeInRange(240, 960); + drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(drv.NegotiatedPduSize); + + await drv.ShutdownAsync(TestContext.Current.CancellationToken); + drv.NegotiatedPduSize.ShouldBe(0); + } +}