From 57c66416d8728d02d96df111746f4ef35a363205 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:24:39 -0400 Subject: [PATCH] test(s7): live-gate span reads + multi-var leftovers against the s7_1500 fixture --- .../ZB.MOM.WW.OtOpcUa.Driver.S7.csproj | 5 + .../Docker/README.md | 49 +++ .../Docker/profiles/s7_1500.json | 33 +- .../S7_1500/S7_1500SpanReadTests.cs | 364 ++++++++++++++++++ 4 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj index 239b1450..67a0e51c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj @@ -24,6 +24,11 @@ + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md index 50cf4ee5..15cb25e0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md @@ -27,6 +27,31 @@ docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 up -d docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 down ``` +### ⚠️ Editing a profile requires `--build` + +`Dockerfile` **COPYs `profiles/` into the image**, so the seeds are baked in at +build time — they are not bind-mounted. A plain `up -d`, and even +`up -d --force-recreate`, reuses the existing `otopcua-python-snap7:1.0` image +and silently keeps serving the OLD seeds. After editing a profile JSON: + +```bash +docker compose --profile s7_1500 up -d --build --force-recreate +``` + +Confirm it took: `docker logs otopcua-python-snap7-s7_1500` prints +`seeded DB1 size=1024 seeds=` at startup — check `` matches the seed count +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 +**root-owned**, so a plain `rsync` fails with `status 23` and leaves the old +files in place while still printing the file list. Push with: + +```bash +rsync -av --rsync-path="sudo rsync" \ + tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/ \ + dohertj2@10.100.0.35:/opt/otopcua-s7/ +``` + ## Endpoint - Default: `localhost:1102` (non-privileged; sidesteps Windows Firewall @@ -67,6 +92,30 @@ DB1 (1024 bytes) + MB (256 bytes) with typed seeds at known offsets: Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`, `bool` (with `"bit": 0..7`), `ascii` (S7 STRING). +### Span-planner gate seeds (offsets 300+, `S7_1500SpanReadTests`) + +These exist to give `S7ReadSpanPlanner` a layout with a **known** partition under +the shipped 16-byte-gap / 220-byte-span defaults — the offsets are the test, so +moving one changes what the gate proves. + +| Address | Type | Seed | Role in the partition | +|---|---|---|---| +| `DB1.DBW300` | u16 | `51001` | cluster A — merges with the four below into ONE 300..320 span | +| `DB1.DBW302` | i16 | `-21001` | cluster A (gap 0) | +| `DB1.DBD304` | i32 | `305419896` | cluster A (gap 0) | +| `DB1.DBX310.5` | bool | `true` | cluster A (gap 2) | +| `DB1.DBD316` | f32 | `2.7182817` | cluster A (gap 5); seals the span at 20 bytes | +| `DB1.DBW400` | u16 | `40201` | cluster B — 80 bytes on, so the gap budget forces a SECOND span | +| `DB1.DBW402` | u16 | `40202` | cluster B's second member (keeps it off the `PrefersPerTagRead` demotion) | +| `DB1.DBW500` | u16 | `60001` | isolated ⇒ demoted single-member span ⇒ multi-var leftover | +| `DB1.DBW600` | i16 | `-32001` | isolated ⇒ multi-var leftover | +| `MW10` | u16 | `4097` | multi-var leftover in a **different area** — a block read can't cross `(area, DB)`, multi-var can | + +Offsets `308`, `311` and `312` carry **poison** (`0xDEAD`, `0x5A`, `0xDEADBEEF`) +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. + ## Known limitations From the `snap7.server.Server` docstring upstream: diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json index 20d8955a..bca0c603 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json @@ -19,7 +19,34 @@ { "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0", "offset": 100, "type": "u16", "value": 0 }, { "_desc": "DB1.STRING[200] — S7 string 'Hello' (max 32, cur 5)", - "offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 } + "offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 }, + + { "_desc": "DB1.DBW300 — span-planner gate (S7_1500SpanReadTests) starts here. Cluster A spans 300..320 and merges into ONE block read under the 16-byte gap budget. Every seed from 300 on is distinct and non-zero so a mis-sliced block can never accidentally match (the #536 zero-source trap).", + "offset": 300, "type": "u16", "value": 51001 }, + { "_desc": "DB1.DBW302 — cluster A, adjacent to DBW300 (gap 0)", + "offset": 302, "type": "i16", "value": -21001 }, + { "_desc": "DB1.DBD304 — cluster A, adjacent to DBW302 (gap 0)", + "offset": 304, "type": "i32", "value": 305419896 }, + { "_desc": "DB1 @308 — POISON in the 308..310 hole the span reads through; no tag maps here, so a mis-slice yields 0xDEAD rather than a zero that could pass by luck", + "offset": 308, "type": "u16", "value": 57005 }, + { "_desc": "DB1.DBX310.5 — cluster A bool (gap 2 from the DBD304 end)", + "offset": 310, "type": "bool", "value": true, "bit": 5 }, + { "_desc": "DB1 @311 — POISON in the 311..316 hole", + "offset": 311, "type": "u8", "value": 90 }, + { "_desc": "DB1 @312 — POISON in the 311..316 hole (0xDEADBEEF)", + "offset": 312, "type": "u32", "value": 3735928559 }, + { "_desc": "DB1.DBD316 — cluster A f32, gap 5 from the bool byte's end; seals the span at 300..320 = 20 bytes", + "offset": 316, "type": "f32", "value": 2.7182817 }, + + { "_desc": "DB1.DBW400 — cluster B; 80 bytes past cluster A's end, so the 16-byte gap budget forces a SECOND span rather than one merged read", + "offset": 400, "type": "u16", "value": 40201 }, + { "_desc": "DB1.DBW402 — cluster B's second member; two members keep the span off the PrefersPerTagRead demotion", + "offset": 402, "type": "u16", "value": 40202 }, + + { "_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 } ] }, { @@ -28,7 +55,9 @@ "size": 256, "seeds": [ { "_desc": "MW0 — probe target for S7ProbeOptions.ProbeAddress default", - "offset": 0, "type": "u16", "value": 1 } + "offset": 0, "type": "u16", "value": 1 }, + { "_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 } ] } ] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs new file mode 100644 index 00000000..d3084ca3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs @@ -0,0 +1,364 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using S7DataItem = global::S7.Net.Types.DataItem; +using S7NetDataType = global::S7.Net.DataType; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; + +/// +/// Live gate for the read-batching layer: the block-read pass +/// (16-byte gap / 220-byte span defaults) and the multi-var leftover pass, both exercised against +/// the real python-snap7 S7-1500 fixture rather than a fake. The span defaults were chosen +/// analytically and the multi-var batching shipped with unit coverage only; this suite is what +/// proves they behave on real S7comm wire. +/// +/// +/// +/// Live-gate record. Run 2026-08-17 against 10.100.0.35:1102 +/// (otopcua-python-snap7-s7_1500, profile Docker/profiles/s7_1500.json): +/// PASSED — 3 tests, 0 failed, 0 skipped. Grouped batch collapsed 10 tags to 3 wire +/// requests (2 block reads + 1 three-item multi-var) and matched the ungrouped 10-request +/// batch value-for-value and type-for-type. No behavioural difference between the planner's +/// analytic model and real wire was found. +/// +/// +/// What the layout buys. The ten tags are positioned so the planner's rules produce a +/// known, non-trivial partition rather than one that would pass by accident: +/// +/// +/// +/// Cluster A (DB1.DBW300 … DB1.DBD316) — five tags whose largest hole is 5 +/// bytes, so all five merge into ONE 20-byte span under the 16-byte gap budget. The +/// holes are seeded with poison (0xDEAD, 0xDEADBEEF) so a mis-computed +/// slice offset yields garbage rather than a zero that could coincidentally match. +/// +/// +/// Cluster B (DB1.DBW400 + DB1.DBW402) — 80 bytes past cluster A, so +/// the gap budget is what forces a SECOND span instead of one merged read. Two members +/// keep it clear of the PrefersPerTagRead demotion, so it is a real block read. +/// +/// +/// Leftovers (DB1.DBW500, DB1.DBW600, MW10) — each isolated +/// enough to form its own single-member span, each therefore demoted by +/// PrefersPerTagRead, and three of them is past MultiVarReadMinItems — so +/// they ride ONE multi-var request. MW10 is deliberately in a different area: +/// a block read can never merge across (area, DB), and the multi-var pass can, +/// so its presence in the single request is what pins that property live. +/// +/// +/// +/// Timer / Counter are NOT covered here. The fixture profile declares only DB1 and +/// MK; python-snap7's server can register TM/CT areas but S7.Net's timer/counter read is a +/// distinct wire shape that the fixture has never served, so pinning the planner's per-tag +/// exclusion live would mean standing up and validating a new fixture area. That exclusion +/// stays unit-covered. Nothing else in this suite depends on it — Timer/Counter tags are +/// rejected by the planner on AREA and by the multi-var pass on IsBufferType, two +/// independent guards. +/// +/// +/// Why a counting factory rather than logs. The driver emits no log on a successful +/// grouped read, so the only honest way to assert the wire shape is to observe the calls. +/// decorates the REAL , so the +/// shipped S7PlcAdapter (and its S7OperationDeadline ceiling) stays on the +/// wire — the gate measures production's path, not a re-implementation of it. +/// +/// +[Collection(Snap7ServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "S7_1500")] +public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim) +{ + /// + /// Verifies the grouped batch returns exactly what reading every tag individually returns — + /// same values, same boxed CLR types, all Good. + /// + /// + /// Boxed equality is the assertion on purpose: the three paths decode through different code + /// (block slicing, S7.Net.ParseBytes via multi-var, and the per-address read), and a + /// path that returned the right number as the wrong CLR type would be a real defect that a + /// numeric-tolerance comparison would hide. + /// + [Fact] + public async Task Grouped_batch_matches_ungrouped_reads_value_for_value() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var grouped = await ReadBatchAsync(SpanLayout.DefaultGapBytes, "s7-span-on"); + var ungrouped = await ReadBatchAsync(SpanLayout.GroupingDisabled, "s7-span-off"); + + grouped.Snapshots.Count.ShouldBe(SpanLayout.TagNames.Count); + ungrouped.Snapshots.Count.ShouldBe(SpanLayout.TagNames.Count); + + for (var i = 0; i < SpanLayout.TagNames.Count; i++) + { + var name = SpanLayout.TagNames[i]; + grouped.Snapshots[i].StatusCode.ShouldBe(0u, $"grouped read of '{name}' must be Good"); + ungrouped.Snapshots[i].StatusCode.ShouldBe(0u, $"ungrouped read of '{name}' must be Good"); + grouped.Snapshots[i].Value.ShouldBe( + ungrouped.Snapshots[i].Value, + $"'{name}' must decode identically whichever path served it"); + } + } + + /// + /// Verifies the grouped batch decodes the fixture's seeded values — so both paths agreeing + /// cannot mean "both wrong the same way". + /// + /// + /// Every seed is distinct and non-zero (the #536 trap: a zero-valued source proves nothing, + /// because a read that returned nothing at all would also read as zero). Distinctness matters + /// independently: two tags sharing a value would let a swapped slice offset pass. + /// + [Fact] + public async Task Grouped_batch_decodes_the_seeded_values() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var grouped = await ReadBatchAsync(SpanLayout.DefaultGapBytes, "s7-span-seeds"); + var byName = SpanLayout.TagNames + .Select((n, i) => (n, grouped.Snapshots[i])) + .ToDictionary(x => x.n, x => x.Item2); + + foreach (var (name, snapshot) in byName) + snapshot.StatusCode.ShouldBe(0u, $"'{name}' must be Good"); + + // Cluster A — the five members of the single merged span. + byName["SpanA_U16"].Value.ShouldBe((ushort)51001); + byName["SpanA_I16"].Value.ShouldBe((short)-21001); + byName["SpanA_I32"].Value.ShouldBe(305419896); + byName["SpanA_Bool"].Value.ShouldBe(true); + Convert.ToSingle(byName["SpanA_F32"].Value).ShouldBe(2.7182817f, tolerance: 0.000001f); + + // Cluster B — the second span. + byName["SpanB_U16a"].Value.ShouldBe((ushort)40201); + byName["SpanB_U16b"].Value.ShouldBe((ushort)40202); + + // The multi-var leftovers, including the one in a different area. + byName["Lone_U16"].Value.ShouldBe((ushort)60001); + byName["Lone_I16"].Value.ShouldBe((short)-32001); + byName["Lone_MkU16"].Value.ShouldBe((ushort)4097); + } + + /// + /// Verifies the wire shape the batching actually produces against a live PLC: ten tags served + /// by three requests with grouping on, ten with it off. + /// + /// + /// This is the half of the gate that a value-parity test cannot cover — a driver that quietly + /// fell back to per-tag reads for every tag would pass parity perfectly while delivering none + /// of the round-trip saving the feature exists for. The off-case is asserted too, because + /// SpanReadMaxGapBytes < 0 is documented to restore the pre-grouping wire shape + /// EXACTLY, and "exactly" is a claim about requests, not values. + /// + [Fact] + public async Task Grouping_collapses_ten_tags_to_three_wire_requests() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var grouped = await ReadBatchAsync(SpanLayout.DefaultGapBytes, "s7-span-shape-on"); + + grouped.Counters.BlockReads.ShouldBe(2, + "cluster A merges into one span and cluster B into a second; the gap budget separates them"); + grouped.Counters.MultiVarReads.ShouldBe(1, + "the three demoted single-member spans are past MultiVarReadMinItems, so they share one request"); + grouped.Counters.MultiVarItems.ShouldBe(3, + "all three leftovers ride that request — including MW10, which no block read could have joined"); + grouped.Counters.AddressReads.ShouldBe(0, + "nothing in this batch should reach the per-tag path once both grouping passes have run"); + grouped.Counters.TotalReadRequests.ShouldBe(3); + + var ungrouped = await ReadBatchAsync(SpanLayout.GroupingDisabled, "s7-span-shape-off"); + + ungrouped.Counters.AddressReads.ShouldBe(SpanLayout.TagNames.Count, + "grouping off must restore one per-address read per tag"); + ungrouped.Counters.BlockReads.ShouldBe(0); + ungrouped.Counters.MultiVarReads.ShouldBe(0, + "the multi-var pass rides the SAME switch as the span pass — disabling grouping must disable both"); + ungrouped.Counters.TotalReadRequests.ShouldBe(SpanLayout.TagNames.Count); + } + + /// Reads the whole layout once through a freshly-built driver and reports the wire shape. + /// The span budget: the shipped default, or a negative value to disable grouping. + /// Instance id, distinct per call so log lines stay attributable. + /// The batch's snapshots plus the wire-call counts observed for the read itself. + private async Task<(IReadOnlyList Snapshots, CountingPlcFactory Counters)> + ReadBatchAsync(int maxGapBytes, string driverInstanceId) + { + var counters = new CountingPlcFactory(new S7PlcFactory()); + await using var drv = new S7Driver( + SpanLayout.BuildOptions(sim.Host, sim.Port, maxGapBytes), driverInstanceId, counters); + + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + // Initialization may open the connection and probe; only the batch's own wire calls are the + // subject here, so the counters start from zero at the read. + counters.Reset(); + + var snapshots = await drv.ReadAsync(SpanLayout.TagNames, TestContext.Current.CancellationToken); + return (snapshots, counters); + } + + /// + /// The tag layout under test, positioned against the shipped 16-byte gap / 220-byte span + /// defaults so the planner produces a known partition. Addresses map 1:1 to the seed offsets + /// in Docker/profiles/s7_1500.json, so a seed drift there surfaces here as a value + /// mismatch rather than a mystery failure. + /// + private static class SpanLayout + { + /// The shipped gap budget — the value this gate exists to validate. + public const int DefaultGapBytes = S7DriverOptions.DefaultSpanReadMaxGapBytes; + + /// Any negative gap disables BOTH grouping passes; the documented off switch. + public const int GroupingDisabled = -1; + + private static readonly S7TagDefinition[] Definitions = + [ + // Cluster A: holes of 0, 0, 2 and 5 bytes — all inside the 16-byte budget, so these five + // merge into one 300..320 span (20 bytes, far under the 220-byte ceiling). + new("SpanA_U16", "DB1.DBW300", S7DataType.UInt16), + new("SpanA_I16", "DB1.DBW302", S7DataType.Int16), + new("SpanA_I32", "DB1.DBD304", S7DataType.Int32), + new("SpanA_Bool", "DB1.DBX310.5", S7DataType.Bool), + new("SpanA_F32", "DB1.DBD316", S7DataType.Float32), + + // Cluster B: 80 bytes past cluster A's end, so the budget forces a second span. Two + // members, so PrefersPerTagRead cannot demote it to the per-tag path. + new("SpanB_U16a", "DB1.DBW400", S7DataType.UInt16), + new("SpanB_U16b", "DB1.DBW402", S7DataType.UInt16), + + // Leftovers: each far enough from every neighbour to form a single-member span, hence + // demoted, hence batched by the multi-var pass. MW10 is in another area entirely. + new("Lone_U16", "DB1.DBW500", S7DataType.UInt16), + new("Lone_I16", "DB1.DBW600", S7DataType.Int16), + new("Lone_MkU16", "MW10", S7DataType.UInt16), + ]; + + /// The batch's references, in the order the driver is asked for them. + public static readonly IReadOnlyList TagNames = [.. Definitions.Select(d => d.Name)]; + + /// Builds driver options for the layout at the requested span budget. + /// The fixture hostname or IP address. + /// The fixture port. + /// Span gap budget; negative disables grouping. + /// The driver options. + public static S7DriverOptions BuildOptions(string host, int port, int maxGapBytes) => new() + { + Host = host, + Port = port, + CpuType = S7CpuType.S71500, + Rack = 0, + Slot = 0, + Timeout = TimeSpan.FromSeconds(5), + // Same reasoning as the smoke profile: a background probe would race these reads for the + // connection gate and would also pollute the wire-shape counts this suite asserts on. + Probe = new S7ProbeOptions { Enabled = false }, + SpanReadMaxGapBytes = maxGapBytes, + SpanReadMaxSpanBytes = S7DriverOptions.DefaultSpanReadMaxSpanBytes, + RawTags = S7RawTags.Entries(Definitions), + }; + } + + /// + /// 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 + /// every counted call is the one production would have issued. + /// + /// The factory producing the real connection. + private sealed class CountingPlcFactory(IS7PlcFactory inner) : IS7PlcFactory + { + /// Block reads issued — one per planned span. + public int BlockReads { get; private set; } + + /// Multi-var requests issued — one per leftover chunk. + public int MultiVarReads { get; private set; } + + /// Items carried across all multi-var requests. + public int MultiVarItems { get; private set; } + + /// Per-address reads issued — the unchanged per-tag path. + public int AddressReads { get; private set; } + + /// Total read round-trips, whichever shape they took. + public int TotalReadRequests => BlockReads + MultiVarReads + AddressReads; + + /// Zeroes the counters, so a batch can be measured without initialization's calls. + public void Reset() + { + BlockReads = 0; + MultiVarReads = 0; + MultiVarItems = 0; + AddressReads = 0; + } + + /// + 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); + + private void CountBlockRead() => BlockReads++; + + private void CountMultiVarRead(int items) + { + MultiVarReads++; + MultiVarItems += items; + } + + private void CountAddressRead() => AddressReads++; + + /// Counting pass-through over one real connection. + /// The real connection. + /// The factory holding the counters. + private sealed class CountingPlc(IS7Plc inner, CountingPlcFactory owner) : IS7Plc + { + /// + public bool IsConnected => inner.IsConnected; + + /// + public Task OpenAsync(CancellationToken cancellationToken) => inner.OpenAsync(cancellationToken); + + /// + public void Close() => inner.Close(); + + /// + public Task ReadAsync(string address, CancellationToken cancellationToken) + { + owner.CountAddressRead(); + return inner.ReadAsync(address, cancellationToken); + } + + /// + public Task ReadBytesAsync( + S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) + { + owner.CountBlockRead(); + return inner.ReadBytesAsync(area, db, startByteAdr, count, cancellationToken); + } + + /// + public Task> ReadMultipleVarsAsync( + List items, CancellationToken cancellationToken) + { + // Counted before the call: the item count is the request's shape, and S7.Net hands + // the same list back, so reading it afterwards would measure the same thing later. + owner.CountMultiVarRead(items?.Count ?? 0); + return inner.ReadMultipleVarsAsync(items!, cancellationToken); + } + + /// + public Task WriteAsync(string address, object value, CancellationToken cancellationToken) + => inner.WriteAsync(address, value, cancellationToken); + + /// + public Task WriteBytesAsync( + S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) + => inner.WriteBytesAsync(area, db, startByteAdr, value, cancellationToken); + + /// + public Task ReadStatusAsync(CancellationToken cancellationToken) => inner.ReadStatusAsync(cancellationToken); + + /// + public void Dispose() => inner.Dispose(); + } + } +}