Merge b640-s7 (S7 live codec coverage + STRING/DT BadOutOfRange #632)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:57:59 -04:00
10 changed files with 376 additions and 13 deletions
+7
View File
@@ -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.)
+4 -1
View File
@@ -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.
@@ -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);
@@ -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=<n>` at startup — check `<n>` matches the seed count
`seeded DB1 size=2048 seeds=<n>` at startup — check `<n>` 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
@@ -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 }
]
},
{
@@ -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.
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// #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
/// <c>S7_SIM_ALLOW_WRITES=1</c>.
/// </summary>
@@ -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;
/// <summary>
/// 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 (<c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c>) and the negotiated
/// PDU size. Wide/date-time types are byte-anchored (DBB) by driver rule. Read seeds live at DB1 800..960 in <c>Docker/profiles/s7_1500.json</c> 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 <c>S7_SIM_ALLOW_WRITES=1</c>. Everything
/// skips cleanly when <c>S7_SIM_ENDPOINT</c> (default the shared Docker host) is unreachable.
/// </summary>
[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 ----
/// <summary>Every codec decodes its seeded, distinct non-zero wire bytes through real S7comm.</summary>
[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));
}
/// <summary>Undecodable buffers surface BadOutOfRange per tag, leave siblings Good, and do not degrade health.</summary>
[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),
];
/// <summary>Write each codec's value, read it back through the driver (distinct non-zero values).</summary>
[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);
}
}
/// <summary>
/// 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.
/// </summary>
[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<string, object>
{
["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 ----
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>The negotiated PDU size is a real S7comm value after connect and 0 after shutdown.</summary>
[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);
}
}
@@ -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<ArgumentException>(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF"));
Should.Throw<S7ValueRangeException>(() => 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<ArgumentOutOfRangeException>(() => S7Driver.EncodeScalarBlock(tag, value));
Should.Throw<S7ValueRangeException>(() => S7Driver.EncodeScalarBlock(tag, value));
}
// ── DateTime round-trip identity (encode → decode) ────────────────────────────────────
@@ -407,6 +407,35 @@ public sealed class S7ValueCodecTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// #632 live-coverage finding: classic STRING and DT writes use S7.Net's own encoders, which throw
/// <see cref="ArgumentException"/> / <see cref="ArgumentOutOfRangeException"/> 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.
/// </summary>
[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