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