Merge b640-s7 (S7 live codec coverage + STRING/DT BadOutOfRange #632)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -49,6 +49,13 @@ The driver ctor change that made this possible:
|
|||||||
- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` —
|
- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` —
|
||||||
`DB1.DBW100` write → read-back; proves write path + buffer visibility
|
`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
|
### Unit
|
||||||
|
|
||||||
- `S7AddressParserTests` — S7 address syntax (`DB1.DBD0`, `M10.3`, `IW4`, etc.)
|
- `S7AddressParserTests` — S7 address syntax (`DB1.DBD0`, `M10.3`, `IW4`, etc.)
|
||||||
|
|||||||
+4
-1
@@ -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
|
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` cover the
|
||||||
address parser, the reinterpret/box conversions, and the driver lifecycle.
|
address parser, the reinterpret/box conversions, and the driver lifecycle.
|
||||||
- **Integration fixture** — a Docker S7 simulator on the shared docker host; see
|
- **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
|
- **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.
|
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
|
// ReadScalarBlockAsync read, so WriteBytesAsync writes the whole reserved STRING. A value
|
||||||
// longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT
|
// longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT
|
||||||
// silently truncate). A null value encodes as the empty string.
|
// 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:
|
case S7DataType.DateTime:
|
||||||
// S7.Net's DateTime.ToByteArray builds the 8-byte DT BCD block. It validates the
|
// 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 —
|
// 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
|
// we surface that (no silent clamp). Value coerced via Convert.ToDateTime (accepts a
|
||||||
// System.DateTime or a parseable timestamp string).
|
// 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.
|
// Writes refuse (S7ValueRangeException → BadOutOfRange) rather than truncate or clamp.
|
||||||
case S7DataType.WString: return S7ValueCodecs.EncodeWString(value, tag.StringLength);
|
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
|
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.
|
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
|
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`
|
## 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:
|
known offsets:
|
||||||
|
|
||||||
| Address | Type | Seed | Purpose |
|
| Address | Type | Seed | Purpose |
|
||||||
@@ -93,7 +93,7 @@ known offsets:
|
|||||||
| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default |
|
| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default |
|
||||||
|
|
||||||
Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`,
|
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`)
|
### 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
|
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.
|
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
|
### Timer / Counter (TM / CT) areas
|
||||||
|
|
||||||
`TM0` and `CT0` are 64 bytes each — **32 device cells**, since a timer is a 2-byte
|
`TM0` and `CT0` are 64 bytes each — **32 device cells**, since a timer is a 2-byte
|
||||||
|
|||||||
+26
-3
@@ -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": [
|
"areas": [
|
||||||
{
|
{
|
||||||
"area": "DB",
|
"area": "DB",
|
||||||
"index": 1,
|
"index": 1,
|
||||||
"size": 1024,
|
"size": 2048,
|
||||||
"seeds": [
|
"seeds": [
|
||||||
{ "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it",
|
{ "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it",
|
||||||
"offset": 0, "type": "u16", "value": 4242 },
|
"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",
|
{ "_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 },
|
"offset": 500, "type": "u16", "value": 60001 },
|
||||||
{ "_desc": "DB1.DBW600 — second isolated narrow scalar, likewise a multi-var leftover",
|
{ "_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.
|
"""Poke seed values into the area buffer at declared byte offsets.
|
||||||
|
|
||||||
Each seed is {"offset": int, "type": str, "value": int|float|bool|str}
|
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).
|
big-endian (Siemens wire format).
|
||||||
"""
|
"""
|
||||||
for seed in seeds:
|
for seed in seeds:
|
||||||
@@ -73,6 +73,11 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
|
|||||||
buf[off] |= (1 << bit)
|
buf[off] |= (1 << bit)
|
||||||
else:
|
else:
|
||||||
buf[off] &= ~(1 << bit) & 0xFF
|
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":
|
elif t == "ascii":
|
||||||
# Siemens STRING type: byte 0 = max length, byte 1 = current length,
|
# Siemens STRING type: byte 0 = max length, byte 1 = current length,
|
||||||
# bytes 2+ = payload. Seeds supply the payload text; we fill max/cur.
|
# bytes 2+ = payload. Seeds supply the payload text; we fill max/cur.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #610 item 2 (S7-016): array write round trip against the python-snap7 S7-1500 profile. Skips
|
/// #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
|
/// there), so the writes cannot disturb another test's seeds. Writes also require
|
||||||
/// <c>S7_SIM_ALLOW_WRITES=1</c>.
|
/// <c>S7_SIM_ALLOW_WRITES=1</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+263
@@ -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);
|
var tag = Tag(S7DataType.String, stringLength: 5);
|
||||||
// 6 chars into a 5-char reserved field — S7.Net throws ArgumentException.
|
// 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) ──────────────────────────────────────
|
// ── String round-trip identity (encode → decode) ──────────────────────────────────────
|
||||||
@@ -376,7 +376,7 @@ public sealed class S7ScalarBlockTests
|
|||||||
{
|
{
|
||||||
var tag = Tag(S7DataType.DateTime);
|
var tag = Tag(S7DataType.DateTime);
|
||||||
var value = new System.DateTime(year, 1, 1, 0, 0, 0);
|
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) ────────────────────────────────────
|
// ── DateTime round-trip identity (encode → decode) ────────────────────────────────────
|
||||||
|
|||||||
@@ -407,6 +407,35 @@ public sealed class S7ValueCodecTests
|
|||||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
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 ----
|
// ---- fakes ----
|
||||||
|
|
||||||
private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory
|
private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory
|
||||||
|
|||||||
Reference in New Issue
Block a user