test(s7): live-pin the Timer/Counter multi-var exclusion against a TM/CT-seeded s7_1500 fixture
This commit is contained in:
@@ -76,7 +76,8 @@ 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) with typed seeds at known offsets:
|
DB1 (1024 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at
|
||||||
|
known offsets:
|
||||||
|
|
||||||
| Address | Type | Seed | Purpose |
|
| Address | Type | Seed | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -116,6 +117,58 @@ 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.
|
||||||
|
|
||||||
|
### Timer / Counter (TM / CT) areas
|
||||||
|
|
||||||
|
`TM0` and `CT0` are 64 bytes each — **32 device cells**, since a timer is a 2-byte
|
||||||
|
S5TIME word and a counter a 2-byte count word, with device number `N` at byte
|
||||||
|
offset `2 × N`. They exist so `S7_1500SpanReadTests` can pin, against real wire,
|
||||||
|
that `S7ReadSpanPlanner` excludes Timer/Counter from every span and the multi-var
|
||||||
|
pass excludes them again on `IsBufferType` — each takes its own area-typed
|
||||||
|
`ReadBytes` round-trip instead.
|
||||||
|
|
||||||
|
| Address | Byte offset | Seed | Decodes to |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `T0` | 0 | `0x2456` | `456.0` s (S5TIME base 2 = ×1 s, BCD 456) |
|
||||||
|
| `T5` | 10 | `0x1123` | `12.3` s (base 1 = ×0.1 s, BCD 123) |
|
||||||
|
| `T9` | 18 | `0x0250` | `2.5` s (base 0 = ×0.01 s, BCD 250) |
|
||||||
|
| `C0` | 0 | `4711` | `4711` (raw big-endian count word) |
|
||||||
|
| `C7` | 14 | `31337` | `31337` |
|
||||||
|
| `C11` | 22 | `1234` | `1234` |
|
||||||
|
|
||||||
|
A timer decodes to seconds as a `double` (S7.Net `Timer.FromByteArray`) and a
|
||||||
|
counter to an `int` count (`Counter.FromByteArray`, raw word — **not** BCD).
|
||||||
|
|
||||||
|
#### ⚠️ Only device **0** is addressable on this fixture
|
||||||
|
|
||||||
|
Live-probed 2026-08-17: snap7's server resolves the TM/CT request address as a
|
||||||
|
**bit address** — it serves byte `addr >> 3` of the area — whereas S7.Net, and a
|
||||||
|
real S7 CPU, put the device **number** in that field. Measured directly against
|
||||||
|
`10.100.0.35:1102`:
|
||||||
|
|
||||||
|
```
|
||||||
|
ReadBytes(Timer,db=0,start=0,count=8) => [00 00 00 00 24 56 00 00] # bytes 0..7 (T2 seeded at byte 4)
|
||||||
|
ReadBytes(Timer,db=0,start=2,count=2) => [00 00] # 2>>3 = byte 0, NOT timer 2 at byte 4
|
||||||
|
ReadBytes(Timer,db=0,start=4,count=2) => [00 00] # 4>>3 = byte 0
|
||||||
|
ReadBytes(Timer,db=0,start=32,count=2) => [24 56] # 32>>3 = byte 4 ← the timer-2 cell
|
||||||
|
ReadBytes(Counter,db=0,start=48,count=2) => [12 67] # 48>>3 = byte 6 ← the counter-3 cell
|
||||||
|
```
|
||||||
|
|
||||||
|
No error is raised — the reads succeed, they just land on the wrong cell. Device
|
||||||
|
**0** is the single fixed point of the two conventions (`0 >> 3 == 2 × 0 == 0`),
|
||||||
|
so `T0` / `C0` are the only cells whose *value* the fixture can pin; that is what
|
||||||
|
`Timer_and_counter_tags_never_enter_a_multi_var_request` asserts, and the
|
||||||
|
wire-shape half of that test is unaffected by the quirk. The `2 × N` cells above
|
||||||
|
are seeded for a real CPU: run
|
||||||
|
`Timer_and_counter_decode_the_seeded_values_at_their_device_numbers` against one
|
||||||
|
with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
S7_SIM_ENDPOINT=<cpu>:102 S7_TIMER_NUMBER_ADDRESSING=1 \
|
||||||
|
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
It skips (naming this limitation) whenever that variable is unset.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
From the `snap7.server.Server` docstring upstream:
|
From the `snap7.server.Server` docstring upstream:
|
||||||
@@ -130,6 +183,9 @@ Not exercised here — needs a lab rig:
|
|||||||
- S7-1500 Optimized-DB symbolic access
|
- S7-1500 Optimized-DB symbolic access
|
||||||
- PG / OP / S7-Basic session-type differentiation
|
- PG / OP / S7-Basic session-type differentiation
|
||||||
- PUT/GET-disabled-by-default enforcement
|
- PUT/GET-disabled-by-default enforcement
|
||||||
|
- Device-number addressing of the TM / CT areas (see the ⚠️ above — the reads are
|
||||||
|
served, but resolved as bit addresses, so only device 0 lands where a CPU
|
||||||
|
would put it)
|
||||||
|
|
||||||
See [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md)
|
See [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md)
|
||||||
for the full coverage map.
|
for the full coverage map.
|
||||||
|
|||||||
+29
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_description": "S7-1500 profile — single DB1 (1024 bytes) + MB (256 bytes) with well-known seeds at named offsets for the smoke + byte-order + string tests. Big-endian Siemens wire order throughout.",
|
"_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.",
|
||||||
"areas": [
|
"areas": [
|
||||||
{
|
{
|
||||||
"area": "DB",
|
"area": "DB",
|
||||||
@@ -59,6 +59,34 @@
|
|||||||
{ "_desc": "MW10 — span-planner gate: a narrow scalar in a DIFFERENT area from the DB1 leftovers. A block read can never merge across (area, DB); the multi-var pass can, so this pins that the leftover batching crosses the boundary.",
|
{ "_desc": "MW10 — span-planner gate: a narrow scalar in a DIFFERENT area from the DB1 leftovers. A block read can never merge across (area, DB); the multi-var pass can, so this pins that the leftover batching crosses the boundary.",
|
||||||
"offset": 10, "type": "u16", "value": 4097 }
|
"offset": 10, "type": "u16", "value": 4097 }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_desc": "TM (timers) — 64 bytes = 32 timer cells. A timer is a 2-byte S5TIME word and timer number N lives at byte offset 2*N, so the offsets below ARE the timer numbers doubled. Values are S5TIME-encoded (bits 12..13 = time base, the remaining three nibbles = a BCD 0..999 count) and each decodes to a distinct NON-ZERO duration, because a zero-valued timer reads the same as a timer that was never served at all (the #536 trap). ⚠️ Only T0 is readable through this fixture — see the CT note below and Docker/README.md §'Timer / Counter (TM / CT) areas'.",
|
||||||
|
"area": "TM",
|
||||||
|
"index": 0,
|
||||||
|
"size": 64,
|
||||||
|
"seeds": [
|
||||||
|
{ "_desc": "T0 @ byte 0 — 0x2456: base 2 (×1s), BCD 456 ⇒ 456.0 seconds. Device 0 is the one cell where snap7's address resolution and a real CPU's agree, so this is the seed S7_1500SpanReadTests asserts on.",
|
||||||
|
"offset": 0, "type": "u16", "value": 9302 },
|
||||||
|
{ "_desc": "T5 @ byte 10 — 0x1123: base 1 (×0.1s), BCD 123 ⇒ 12.3 seconds. Reachable only from a real CPU (S7_TIMER_NUMBER_ADDRESSING=1).",
|
||||||
|
"offset": 10, "type": "u16", "value": 4387 },
|
||||||
|
{ "_desc": "T9 @ byte 18 — 0x0250: base 0 (×0.01s), BCD 250 ⇒ 2.5 seconds. Real CPU only, as above.",
|
||||||
|
"offset": 18, "type": "u16", "value": 592 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_desc": "CT (counters) — 64 bytes = 32 counter cells, counter number N at byte offset 2*N. The driver surfaces a counter as the RAW big-endian word (S7Driver.DecodeScalarBlock via S7.Net's Counter.FromByteArray), so these seeds are plain u16 counts; distinct + non-zero for the same reason as the timers. ⚠️ snap7's server resolves the TM/CT request address as a BIT address (byte = addr>>3) instead of as a device NUMBER, while S7.Net (and a real CPU) put the device number there — so a read of C{n} lands on byte n>>3, and device 0 is the only n where the two conventions coincide. Live-probed 2026-08-17; see Docker/README.md.",
|
||||||
|
"area": "CT",
|
||||||
|
"index": 0,
|
||||||
|
"size": 64,
|
||||||
|
"seeds": [
|
||||||
|
{ "_desc": "C0 @ byte 0 — count 4711. The device-0 cell both conventions agree on; asserted by S7_1500SpanReadTests.",
|
||||||
|
"offset": 0, "type": "u16", "value": 4711 },
|
||||||
|
{ "_desc": "C7 @ byte 14 — count 31337. Reachable only from a real CPU (S7_TIMER_NUMBER_ADDRESSING=1).",
|
||||||
|
"offset": 14, "type": "u16", "value": 31337 },
|
||||||
|
{ "_desc": "C11 @ byte 22 — count 1234. Real CPU only, as above.",
|
||||||
|
"offset": 22, "type": "u16", "value": 1234 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+267
-19
@@ -23,6 +23,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
|||||||
/// analytic model and real wire was found.
|
/// analytic model and real wire was found.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// Re-run the same day against the same endpoint after the fixture gained its TM0 / CT0
|
||||||
|
/// areas: <b>PASSED</b> — 4 passed, 0 failed, 1 skipped (the device number-addressing test,
|
||||||
|
/// see below). The Timer/Counter batch was served by 4 requests for 7 tags: one DB1 span,
|
||||||
|
/// one 3-item multi-var carrying only the scalars, and one 2-byte area read apiece for
|
||||||
|
/// <c>T0</c> (decoded 456.0 s from S5TIME <c>0x2456</c>) and <c>C0</c> (decoded count 4711).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// <b>What the layout buys.</b> The ten tags are positioned so the planner's rules produce a
|
/// <b>What the layout buys.</b> The ten tags are positioned so the planner's rules produce a
|
||||||
/// known, non-trivial partition rather than one that would pass by accident:
|
/// known, non-trivial partition rather than one that would pass by accident:
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -48,13 +55,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
|||||||
/// </item>
|
/// </item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Timer / Counter are NOT covered here.</b> The fixture profile declares only DB1 and
|
/// <b>Timer / Counter ARE covered here now.</b> The fixture profile grew TM0 + CT0 (64
|
||||||
/// MK; python-snap7's server can register TM/CT areas but S7.Net's timer/counter read is a
|
/// bytes each = 32 device cells), so
|
||||||
/// distinct wire shape that the fixture has never served, so pinning the planner's per-tag
|
/// <see cref="Timer_and_counter_tags_never_enter_a_multi_var_request"/> pins the planner's
|
||||||
/// exclusion live would mean standing up and validating a new fixture area. That exclusion
|
/// per-tag exclusion against real wire rather than a fake: a Timer and a Counter batched
|
||||||
/// stays unit-covered. Nothing else in this suite depends on it — Timer/Counter tags are
|
/// alongside three multi-var-eligible scalars each take their own area-typed
|
||||||
/// rejected by the planner on AREA and by the multi-var pass on <c>IsBufferType</c>, two
|
/// <c>ReadBytes</c> round-trip, and the one multi-var request the scalars share carries
|
||||||
/// independent guards.
|
/// neither of them. Two independent guards produce that — the planner rejects them on AREA
|
||||||
|
/// (their offsets are device numbers, not byte addresses) and the multi-var pass on
|
||||||
|
/// <c>IsBufferType</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>⚠️ Fixture limitation — only device 0 is addressable.</b> Live-probed 2026-08-17:
|
||||||
|
/// snap7's server resolves the TM/CT request address as a BIT address (it serves byte
|
||||||
|
/// <c>addr >> 3</c> of the area) where S7.Net — and a real S7 CPU — put the device
|
||||||
|
/// NUMBER in that field. Measured directly: <c>ReadBytes(Timer, db 0, start 5, count 2)</c>
|
||||||
|
/// returns TM bytes 0..1, and it takes <c>start 80</c> (= 10 × 8) to reach the timer seeded
|
||||||
|
/// at TM byte 10. Device <b>0</b> is the single fixed point of the two conventions
|
||||||
|
/// (<c>0 >> 3 == 2 × 0 == 0</c>), so it is the only device this fixture can pin a
|
||||||
|
/// VALUE for, and that is what the mixed batch uses. The spec-correct <c>2 × N</c> cells
|
||||||
|
/// are seeded too and
|
||||||
|
/// <see cref="Timer_and_counter_decode_the_seeded_values_at_their_device_numbers"/> asserts
|
||||||
|
/// them — skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a real
|
||||||
|
/// CPU. The device-number offset arithmetic itself stays unit-covered
|
||||||
|
/// (<c>S7ScalarBlockTests</c>, <c>S7SpanReadParityTests</c>).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Why a counting factory rather than logs.</b> The driver emits no log on a successful
|
/// <b>Why a counting factory rather than logs.</b> The driver emits no log on a successful
|
||||||
@@ -178,16 +202,137 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
|
|||||||
ungrouped.Counters.TotalReadRequests.ShouldBe(SpanLayout.TagNames.Count);
|
ungrouped.Counters.TotalReadRequests.ShouldBe(SpanLayout.TagNames.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reads the whole layout once through a freshly-built driver and reports the wire shape.</summary>
|
/// <summary>
|
||||||
|
/// Live pin for the planner's Timer/Counter exclusion: batched alongside three
|
||||||
|
/// multi-var-eligible scalars, a Timer and a Counter each take their own area-typed block
|
||||||
|
/// read and neither ever appears as an item of the multi-var request the scalars share.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The unit suite pins this against a fake (<c>S7SpanReadParityTests</c>
|
||||||
|
/// <c>Timer_and_counter_leftovers_never_enter_a_multi_var_request</c>); what only a live
|
||||||
|
/// run can add is that the excluded reads are ones a PLC actually answers — a driver
|
||||||
|
/// that put a Timer into a multi-var request would fault the WHOLE chunk on real wire,
|
||||||
|
/// taking the three innocent scalars down with it into the per-tag fallback. So the
|
||||||
|
/// scalars' values and the single 3-item request are asserted here too: they are the
|
||||||
|
/// evidence the exclusion is what kept the batch intact.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The Timer/Counter values are asserted at device <b>0</b> only — see the type remarks
|
||||||
|
/// for why this fixture cannot address any other device.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task Timer_and_counter_tags_never_enter_a_multi_var_request()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var (snapshots, counters) = await ReadBatchAsync(
|
||||||
|
TimerCounterLayout.BuildOptions(sim.Host, sim.Port, TimerCounterLayout.MixedBatch),
|
||||||
|
TimerCounterLayout.Names(TimerCounterLayout.MixedBatch),
|
||||||
|
"s7-timer-counter");
|
||||||
|
|
||||||
|
var byName = TimerCounterLayout.Names(TimerCounterLayout.MixedBatch)
|
||||||
|
.Select((n, i) => (n, snapshots[i]))
|
||||||
|
.ToDictionary(x => x.n, x => x.Item2);
|
||||||
|
|
||||||
|
foreach (var (name, snapshot) in byName)
|
||||||
|
snapshot.StatusCode.ShouldBe(0u, $"'{name}' must be Good");
|
||||||
|
|
||||||
|
// (a) Correct decode off real wire. A Timer surfaces as the S5TIME word's duration in
|
||||||
|
// seconds (double) and a Counter as the raw count (int) — two different areas, two
|
||||||
|
// different values, two different CLR types, so a read that reached the wrong area or
|
||||||
|
// decoded through the wrong arm cannot pass.
|
||||||
|
byName["TC_Timer"].Value.ShouldBe(TimerCounterLayout.Device0TimerSeconds);
|
||||||
|
byName["TC_Counter"].Value.ShouldBe(TimerCounterLayout.Device0CounterCount);
|
||||||
|
|
||||||
|
// The co-batched scalars still decode to their own seeds — the exclusion protected them.
|
||||||
|
byName["TC_SpanB_U16a"].Value.ShouldBe((ushort)40201);
|
||||||
|
byName["TC_SpanB_U16b"].Value.ShouldBe((ushort)40202);
|
||||||
|
byName["TC_Lone_U16"].Value.ShouldBe((ushort)60001);
|
||||||
|
byName["TC_Lone_I16"].Value.ShouldBe((short)-32001);
|
||||||
|
byName["TC_Lone_Mk"].Value.ShouldBe((ushort)4097);
|
||||||
|
|
||||||
|
// (b) The wire shape. This is the pin: not one multi-var item carries a Timer or Counter.
|
||||||
|
counters.MultiVarReads.ShouldBe(1,
|
||||||
|
"the three isolated narrow scalars are past MultiVarReadMinItems, so they share one request");
|
||||||
|
counters.MultiVarItems.ShouldBe(3,
|
||||||
|
"exactly the three scalars — a Timer or Counter joining them would make this 4 or 5");
|
||||||
|
counters.MultiVarItemAreas.ShouldNotContain(S7NetDataType.Timer,
|
||||||
|
"a Timer must never be expressed as a multi-var item: its per-tag path decodes the S5TIME " +
|
||||||
|
"word itself and does not go through S7.Net's ParseBytes at all");
|
||||||
|
counters.MultiVarItemAreas.ShouldNotContain(S7NetDataType.Counter,
|
||||||
|
"a Counter must never be expressed as a multi-var item — same reason, plus its per-tag " +
|
||||||
|
"decode surfaces int where S7.Net's own Counter.FromByteArray returns ushort");
|
||||||
|
|
||||||
|
// …and they arrive instead as their own area-typed block reads, two bytes apiece (one
|
||||||
|
// S5TIME / counter word), with the device number in the start field. Asserting the shape
|
||||||
|
// rather than just the count is what distinguishes "excluded to a block read" from
|
||||||
|
// "excluded to the per-address string path", which is a different wire request entirely.
|
||||||
|
counters.BlockReadShapes.ShouldContain((S7NetDataType.Timer, 0, TimerCounterLayout.Device0, 2));
|
||||||
|
counters.BlockReadShapes.ShouldContain((S7NetDataType.Counter, 0, TimerCounterLayout.Device0, 2));
|
||||||
|
counters.BlockReadShapes.ShouldContain((S7NetDataType.DataBlock, 1, 400, 4),
|
||||||
|
"the two adjacent DB1 words still merge into one span, so this batch is a real three-way split");
|
||||||
|
counters.BlockReads.ShouldBe(3, "one span + one Timer + one Counter");
|
||||||
|
counters.AddressReads.ShouldBe(0,
|
||||||
|
"the Timer/Counter exclusion routes to the block-read seam, never to the per-address path");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a Timer and a Counter at NON-zero device numbers decode their seeded values —
|
||||||
|
/// the assertion the shared fixture cannot serve.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a device
|
||||||
|
/// number-addressing CPU, because python-snap7's server is not one: it resolves the TM/CT
|
||||||
|
/// request address as a bit address, so <c>T5</c> reads TM byte 0 rather than TM byte 10
|
||||||
|
/// (measured 2026-08-17 — see the type remarks). Set the variable alongside
|
||||||
|
/// <c>S7_SIM_ENDPOINT</c> when pointing this suite at a real S7 CPU seeded to match
|
||||||
|
/// <c>Docker/profiles/s7_1500.json</c>'s TM/CT areas.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task Timer_and_counter_decode_the_seeded_values_at_their_device_numbers()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
if (Environment.GetEnvironmentVariable(TimerCounterLayout.RealCpuEnvVar) != "1")
|
||||||
|
{
|
||||||
|
Assert.Skip(
|
||||||
|
$"python-snap7's server resolves a TM/CT request address as a BIT address (byte = addr>>3), " +
|
||||||
|
$"not as a device number, so only device 0 is reachable on the fixture. " +
|
||||||
|
$"Set {TimerCounterLayout.RealCpuEnvVar}=1 (with S7_SIM_ENDPOINT pointing at a real S7 CPU " +
|
||||||
|
$"seeded like Docker/profiles/s7_1500.json) to run this.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var (snapshots, _) = await ReadBatchAsync(
|
||||||
|
TimerCounterLayout.BuildOptions(sim.Host, sim.Port, TimerCounterLayout.DeviceNumbered),
|
||||||
|
TimerCounterLayout.Names(TimerCounterLayout.DeviceNumbered),
|
||||||
|
"s7-timer-counter-devnum");
|
||||||
|
|
||||||
|
snapshots[0].StatusCode.ShouldBe(0u, "'TC_Timer5' must be Good");
|
||||||
|
snapshots[1].StatusCode.ShouldBe(0u, "'TC_Counter7' must be Good");
|
||||||
|
snapshots[0].Value.ShouldBe(12.3d, "T5 holds S5TIME 0x1123 — base 0.1 s, BCD 123");
|
||||||
|
snapshots[1].Value.ShouldBe(31337, "C7 holds the raw count word 31337");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the whole span layout once through a freshly-built driver and reports the wire shape.</summary>
|
||||||
/// <param name="maxGapBytes">The span budget: the shipped default, or a negative value to disable grouping.</param>
|
/// <param name="maxGapBytes">The span budget: the shipped default, or a negative value to disable grouping.</param>
|
||||||
/// <param name="driverInstanceId">Instance id, distinct per call so log lines stay attributable.</param>
|
/// <param name="driverInstanceId">Instance id, distinct per call so log lines stay attributable.</param>
|
||||||
/// <returns>The batch's snapshots plus the wire-call counts observed for the read itself.</returns>
|
/// <returns>The batch's snapshots plus the wire-call counts observed for the read itself.</returns>
|
||||||
private async Task<(IReadOnlyList<DataValueSnapshot> Snapshots, CountingPlcFactory Counters)>
|
private Task<(IReadOnlyList<DataValueSnapshot> Snapshots, CountingPlcFactory Counters)>
|
||||||
ReadBatchAsync(int maxGapBytes, string driverInstanceId)
|
ReadBatchAsync(int maxGapBytes, string driverInstanceId)
|
||||||
|
=> ReadBatchAsync(
|
||||||
|
SpanLayout.BuildOptions(sim.Host, sim.Port, maxGapBytes), SpanLayout.TagNames, driverInstanceId);
|
||||||
|
|
||||||
|
/// <summary>Reads one batch through a freshly-built driver and reports the wire shape.</summary>
|
||||||
|
/// <param name="options">Driver options carrying the layout's raw tags and span budget.</param>
|
||||||
|
/// <param name="tagNames">The references to read, in the order the driver is asked for them.</param>
|
||||||
|
/// <param name="driverInstanceId">Instance id, distinct per call so log lines stay attributable.</param>
|
||||||
|
/// <returns>The batch's snapshots plus the wire-call counts observed for the read itself.</returns>
|
||||||
|
private static async Task<(IReadOnlyList<DataValueSnapshot> Snapshots, CountingPlcFactory Counters)>
|
||||||
|
ReadBatchAsync(S7DriverOptions options, IReadOnlyList<string> tagNames, string driverInstanceId)
|
||||||
{
|
{
|
||||||
var counters = new CountingPlcFactory(new S7PlcFactory());
|
var counters = new CountingPlcFactory(new S7PlcFactory());
|
||||||
await using var drv = new S7Driver(
|
await using var drv = new S7Driver(options, driverInstanceId, counters);
|
||||||
SpanLayout.BuildOptions(sim.Host, sim.Port, maxGapBytes), driverInstanceId, counters);
|
|
||||||
|
|
||||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
@@ -195,7 +340,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
|
|||||||
// subject here, so the counters start from zero at the read.
|
// subject here, so the counters start from zero at the read.
|
||||||
counters.Reset();
|
counters.Reset();
|
||||||
|
|
||||||
var snapshots = await drv.ReadAsync(SpanLayout.TagNames, TestContext.Current.CancellationToken);
|
var snapshots = await drv.ReadAsync(tagNames, TestContext.Current.CancellationToken);
|
||||||
return (snapshots, counters);
|
return (snapshots, counters);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +405,84 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Timer/Counter layouts. Two sets: a mixed batch that provokes all three read shapes at
|
||||||
|
/// once (block span, multi-var, per-tag Timer/Counter), and a device-numbered pair only a
|
||||||
|
/// real CPU can serve. Addresses map 1:1 to the TM0 / CT0 seeds in
|
||||||
|
/// <c>Docker/profiles/s7_1500.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static class TimerCounterLayout
|
||||||
|
{
|
||||||
|
/// <summary>Declares the endpoint a real S7 CPU, which addresses TM/CT by device number.</summary>
|
||||||
|
public const string RealCpuEnvVar = "S7_TIMER_NUMBER_ADDRESSING";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one device number snap7's bit-address resolution and a CPU's device-number
|
||||||
|
/// resolution both land on: <c>0 >> 3 == 2 × 0 == 0</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const int Device0 = 0;
|
||||||
|
|
||||||
|
/// <summary>T0's seed 0x2456 — S5TIME base 2 (×1 s) over BCD 456.</summary>
|
||||||
|
public const double Device0TimerSeconds = 456.0d;
|
||||||
|
|
||||||
|
/// <summary>C0's seed — the raw big-endian count word.</summary>
|
||||||
|
public const int Device0CounterCount = 4711;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Timer and a Counter batched with scalars laid out to produce every other read shape:
|
||||||
|
/// two adjacent DB1 words (one merged span) and three isolated narrow scalars, one of
|
||||||
|
/// them in another area (one 3-item multi-var request).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly S7TagDefinition[] MixedBatch =
|
||||||
|
[
|
||||||
|
new("TC_SpanB_U16a", "DB1.DBW400", S7DataType.UInt16),
|
||||||
|
new("TC_SpanB_U16b", "DB1.DBW402", S7DataType.UInt16),
|
||||||
|
new("TC_Lone_U16", "DB1.DBW500", S7DataType.UInt16),
|
||||||
|
new("TC_Lone_I16", "DB1.DBW600", S7DataType.Int16),
|
||||||
|
new("TC_Lone_Mk", "MW10", S7DataType.UInt16),
|
||||||
|
// Timer tags are typed Float64 (decoded to seconds) and Counter tags Int32 (decoded to
|
||||||
|
// a count) — the init guard rejects any other typing. Writable:false is mandatory, not
|
||||||
|
// stylistic: S7TagDefinition defaults Writable to TRUE and the same guard rejects a
|
||||||
|
// writable Timer/Counter outright (they are read-only this phase).
|
||||||
|
new("TC_Timer", "T0", S7DataType.Float64, Writable: false),
|
||||||
|
new("TC_Counter", "C0", S7DataType.Int32, Writable: false),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>The non-zero device numbers, reachable only from a device number-addressing CPU.</summary>
|
||||||
|
public static readonly S7TagDefinition[] DeviceNumbered =
|
||||||
|
[
|
||||||
|
new("TC_Timer5", "T5", S7DataType.Float64, Writable: false),
|
||||||
|
new("TC_Counter7", "C7", S7DataType.Int32, Writable: false),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>The batch's references, in the order the driver is asked for them.</summary>
|
||||||
|
/// <param name="defs">The definition set.</param>
|
||||||
|
/// <returns>The tag names.</returns>
|
||||||
|
public static IReadOnlyList<string> Names(IReadOnlyList<S7TagDefinition> defs)
|
||||||
|
=> [.. defs.Select(d => d.Name)];
|
||||||
|
|
||||||
|
/// <summary>Builds driver options for one definition set at the shipped span budget.</summary>
|
||||||
|
/// <param name="host">The fixture hostname or IP address.</param>
|
||||||
|
/// <param name="port">The fixture port.</param>
|
||||||
|
/// <param name="defs">The definition set.</param>
|
||||||
|
/// <returns>The driver options.</returns>
|
||||||
|
public static S7DriverOptions BuildOptions(string host, int port, S7TagDefinition[] defs) => new()
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = port,
|
||||||
|
CpuType = S7CpuType.S71500,
|
||||||
|
Rack = 0,
|
||||||
|
Slot = 0,
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
// As in SpanLayout: a background probe would race the read for the connection gate and
|
||||||
|
// pollute the wire-shape counts.
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false },
|
||||||
|
SpanReadMaxGapBytes = SpanLayout.DefaultGapBytes,
|
||||||
|
SpanReadMaxSpanBytes = S7DriverOptions.DefaultSpanReadMaxSpanBytes,
|
||||||
|
RawTags = S7RawTags.Entries(defs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <see cref="IS7PlcFactory"/> decorator that counts the read calls the driver makes, so a
|
/// <see cref="IS7PlcFactory"/> decorator that counts the read calls the driver makes, so a
|
||||||
/// test can assert the wire shape rather than infer it. Delegates to the real factory, so
|
/// test can assert the wire shape rather than infer it. Delegates to the real factory, so
|
||||||
@@ -268,9 +491,25 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
|
|||||||
/// <param name="inner">The factory producing the real connection.</param>
|
/// <param name="inner">The factory producing the real connection.</param>
|
||||||
private sealed class CountingPlcFactory(IS7PlcFactory inner) : IS7PlcFactory
|
private sealed class CountingPlcFactory(IS7PlcFactory inner) : IS7PlcFactory
|
||||||
{
|
{
|
||||||
|
private readonly List<(S7NetDataType Area, int Db, int Start, int Count)> _blockReadShapes = [];
|
||||||
|
private readonly List<S7NetDataType> _multiVarItemAreas = [];
|
||||||
|
|
||||||
/// <summary>Block reads issued — one per planned span.</summary>
|
/// <summary>Block reads issued — one per planned span.</summary>
|
||||||
public int BlockReads { get; private set; }
|
public int BlockReads { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>(area, DB, start, count)</c> of every block read, in issue order. Counting
|
||||||
|
/// alone cannot tell a Timer's own 2-byte area read from a span's — the shape can.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<(S7NetDataType Area, int Db, int Start, int Count)> BlockReadShapes
|
||||||
|
=> _blockReadShapes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The area of every item carried by every multi-var request. A Timer or Counter
|
||||||
|
/// appearing here is the exact defect the planner's exclusion exists to prevent.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<S7NetDataType> MultiVarItemAreas => _multiVarItemAreas;
|
||||||
|
|
||||||
/// <summary>Multi-var requests issued — one per leftover chunk.</summary>
|
/// <summary>Multi-var requests issued — one per leftover chunk.</summary>
|
||||||
public int MultiVarReads { get; private set; }
|
public int MultiVarReads { get; private set; }
|
||||||
|
|
||||||
@@ -290,18 +529,26 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
|
|||||||
MultiVarReads = 0;
|
MultiVarReads = 0;
|
||||||
MultiVarItems = 0;
|
MultiVarItems = 0;
|
||||||
AddressReads = 0;
|
AddressReads = 0;
|
||||||
|
_blockReadShapes.Clear();
|
||||||
|
_multiVarItemAreas.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
|
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
|
||||||
=> new CountingPlc(inner.Create(cpuType, host, port, rack, slot, timeout), this);
|
=> new CountingPlc(inner.Create(cpuType, host, port, rack, slot, timeout), this);
|
||||||
|
|
||||||
private void CountBlockRead() => BlockReads++;
|
private void CountBlockRead(S7NetDataType area, int db, int start, int count)
|
||||||
|
{
|
||||||
|
BlockReads++;
|
||||||
|
_blockReadShapes.Add((area, db, start, count));
|
||||||
|
}
|
||||||
|
|
||||||
private void CountMultiVarRead(int items)
|
private void CountMultiVarRead(IReadOnlyList<S7DataItem>? items)
|
||||||
{
|
{
|
||||||
MultiVarReads++;
|
MultiVarReads++;
|
||||||
MultiVarItems += items;
|
MultiVarItems += items?.Count ?? 0;
|
||||||
|
if (items is null) return;
|
||||||
|
foreach (var item in items) _multiVarItemAreas.Add(item.DataType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CountAddressRead() => AddressReads++;
|
private void CountAddressRead() => AddressReads++;
|
||||||
@@ -331,7 +578,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
|
|||||||
public Task<byte[]> ReadBytesAsync(
|
public Task<byte[]> ReadBytesAsync(
|
||||||
S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
|
S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
owner.CountBlockRead();
|
owner.CountBlockRead(area, db, startByteAdr, count);
|
||||||
return inner.ReadBytesAsync(area, db, startByteAdr, count, cancellationToken);
|
return inner.ReadBytesAsync(area, db, startByteAdr, count, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,9 +586,10 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
|
|||||||
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(
|
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(
|
||||||
List<S7DataItem> items, CancellationToken cancellationToken)
|
List<S7DataItem> items, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Counted before the call: the item count is the request's shape, and S7.Net hands
|
// Counted before the call: the items ARE the request's shape, and S7.Net hands the
|
||||||
// the same list back, so reading it afterwards would measure the same thing later.
|
// same list back with values filled in, so reading them afterwards would measure
|
||||||
owner.CountMultiVarRead(items?.Count ?? 0);
|
// the same thing later.
|
||||||
|
owner.CountMultiVarRead(items);
|
||||||
return inner.ReadMultipleVarsAsync(items!, cancellationToken);
|
return inner.ReadMultipleVarsAsync(items!, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user