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/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 43a5dc08..6d95a8aa 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -1555,14 +1555,21 @@ public sealed class S7Driver // ReadScalarBlockAsync read, so WriteBytesAsync writes the whole reserved STRING. A value // longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT // silently truncate). A null value encodes as the empty string. - return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength); + // #632: surfaced as S7ValueRangeException (→ BadOutOfRange) like the WSTRING codec — a + // client value error, not a comms fault (the raw ArgumentException used to land in the + // generic catch as BadCommunicationError + Degraded). + try { return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength); } + catch (ArgumentException ex) { throw new S7ValueRangeException($"S7 STRING value does not fit the declared {tag.StringLength}-character field: {ex.Message}"); } case S7DataType.DateTime: // S7.Net's DateTime.ToByteArray builds the 8-byte DT BCD block. It validates the // 1990–2089 DT range and throws ArgumentOutOfRangeException for an out-of-range year — // we surface that (no silent clamp). Value coerced via Convert.ToDateTime (accepts a // System.DateTime or a parseable timestamp string). - return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value)); + // #632: the range refusal maps to BadOutOfRange (see STRING above). + var dt = Convert.ToDateTime(value); + try { return global::S7.Net.Types.DateTime.ToByteArray(dt); } + catch (ArgumentOutOfRangeException ex) { throw new S7ValueRangeException($"S7 DATE_AND_TIME value {dt:O} is outside the DT range (1990–2089): {ex.Message}"); } // Writes refuse (S7ValueRangeException → BadOutOfRange) rather than truncate or clamp. case S7DataType.WString: return S7ValueCodecs.EncodeWString(value, tag.StringLength); 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..aae870ab 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 @@ -39,7 +39,7 @@ docker compose --profile s7_1500 up -d --build --force-recreate ``` Confirm it took: `docker logs otopcua-python-snap7-s7_1500` prints -`seeded DB1 size=1024 seeds=` at startup — check `` matches the seed count +`seeded DB1 size=2048 seeds=` at startup — check `` matches the seed count you just wrote. This is the fastest way to tell a stale image from a bad address. On the shared Docker host the deployment mirror at `/opt/otopcua-s7/` is @@ -78,7 +78,7 @@ without Docker running. ## What's encoded in `profiles/s7_1500.json` -DB1 (1024 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at +DB1 (2048 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at known offsets: | Address | Type | Seed | Purpose | @@ -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..86c0714c 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 @@ -1,10 +1,10 @@ { - "_description": "S7-1500 profile — DB1 (1024 bytes) + MB (256 bytes) + TM/CT (64 bytes each) with well-known seeds at named offsets for the smoke + byte-order + string + Timer/Counter tests. Big-endian Siemens wire order throughout.", + "_description": "S7-1500 profile — DB1 (2048 bytes) + MB (256 bytes) + TM/CT (64 bytes each) with well-known seeds at named offsets for the smoke + byte-order + string + Timer/Counter tests. Big-endian Siemens wire order throughout.", "areas": [ { "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_1500ArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500ArrayWriteTests.cs index e831942c..5c96c11d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500ArrayWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500ArrayWriteTests.cs @@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; /// /// #610 item 2 (S7-016): array write round trip against the python-snap7 S7-1500 profile. Skips -/// cleanly when the sim is absent. Uses DB1 bytes 700+ (the profile's 1024-byte DB1 has no seed +/// cleanly when the sim is absent. Uses DB1 bytes 700+ (the profile's 2048-byte DB1 has no seed /// there), so the writes cannot disturb another test's seeds. Writes also require /// S7_SIM_ALLOW_WRITES=1. /// 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..2deec614 --- /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. Wide/date-time types are byte-anchored (DBB) by driver rule. 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.DBB900", S7DataType.Date), + new S7TagDefinition("Tod", "DB1.DBB904", S7DataType.Tod), + new S7TagDefinition("TimePos", "DB1.DBB910", S7DataType.Time), + new S7TagDefinition("TimeNeg", "DB1.DBB914", S7DataType.Time), + new S7TagDefinition("S5Sec", "DB1.DBB920", S7DataType.S5Time), + new S7TagDefinition("S5Cs", "DB1.DBB922", S7DataType.S5Time), + new S7TagDefinition("DateMax", "DB1.DBB960", 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.DBB940", S7DataType.S5Time), + new S7TagDefinition("TodPastMidnight", "DB1.DBB944", S7DataType.Tod), + new S7TagDefinition("WStrCurOverMax", "DB1.DBB950", S7DataType.WString, StringLength: 3), + new S7TagDefinition("Good", "DB1.DBB904", 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.DBB1180", S7DataType.Date), + new("Tod", "DB1.DBB1184", S7DataType.Tod), + new("Time", "DB1.DBB1188", S7DataType.Time), + new("S5", "DB1.DBB1192", 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); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs index 583d658e..83b911e8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs @@ -299,7 +299,7 @@ public sealed class S7ScalarBlockTests { var tag = Tag(S7DataType.String, stringLength: 5); // 6 chars into a 5-char reserved field — S7.Net throws ArgumentException. - Should.Throw(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF")); + Should.Throw(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF")); } // ── String round-trip identity (encode → decode) ────────────────────────────────────── @@ -376,7 +376,7 @@ public sealed class S7ScalarBlockTests { var tag = Tag(S7DataType.DateTime); var value = new System.DateTime(year, 1, 1, 0, 0, 0); - Should.Throw(() => S7Driver.EncodeScalarBlock(tag, value)); + Should.Throw(() => S7Driver.EncodeScalarBlock(tag, value)); } // ── DateTime round-trip identity (encode → decode) ──────────────────────────────────── diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs index bb285f52..506d5d67 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs @@ -407,6 +407,35 @@ public sealed class S7ValueCodecTests drv.GetHealth().State.ShouldBe(DriverState.Healthy); } + /// + /// #632 live-coverage finding: classic STRING and DT writes use S7.Net's own encoders, which throw + /// / for an unrepresentable + /// value. That fell into the generic catch (BadCommunicationError + Degraded health) while the + /// sibling WSTRING/DATE codecs return BadOutOfRange — a client error must not look like a comms fault. + /// + [Fact] + public async Task Classic_STRING_and_DT_unrepresentable_writes_are_BadOutOfRange_without_degrading_health() + { + var plc = new MemoryS7Plc(); + var opts = new S7DriverOptions + { + Host = "192.0.2.1", + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries( + new S7TagDefinition("Str", "DB1.DBB0", S7DataType.String, StringLength: 4), + new S7TagDefinition("Dt", "DB1.DBB20", S7DataType.DateTime)), + }; + using var drv = new S7Driver(opts, "s7-classic-range", new MemoryS7PlcFactory(plc)); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + var ct = TestContext.Current.CancellationToken; + + (await drv.WriteAsync([new WriteRequest("Str", "abcd")], ct))[0].StatusCode.ShouldBe(0u); + (await drv.WriteAsync([new WriteRequest("Str", "abcde")], ct))[0].StatusCode.ShouldBe(0x803C0000u); + (await drv.WriteAsync([new WriteRequest("Dt", new DateTime(1980, 1, 1))], ct))[0].StatusCode.ShouldBe(0x803C0000u); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + (await drv.ReadAsync(["Str"], ct))[0].Value.ShouldBe("abcd"); + } + // ---- fakes ---- private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory