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. /// /// /// Re-run the same day against the same endpoint after the fixture gained its TM0 / CT0 /// areas: PASSED — 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 /// T0 (decoded 456.0 s from S5TIME 0x2456) and C0 (decoded count 4711). /// /// /// 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 covered here now. The fixture profile grew TM0 + CT0 (64 /// bytes each = 32 device cells), so /// pins the planner's /// per-tag exclusion against real wire rather than a fake: a Timer and a Counter batched /// alongside three multi-var-eligible scalars each take their own area-typed /// ReadBytes round-trip, and the one multi-var request the scalars share carries /// 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 /// IsBufferType. /// /// /// ⚠️ Fixture limitation — only device 0 is addressable. 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) where S7.Net — and a real S7 CPU — put the device /// NUMBER in that field. Measured directly: ReadBytes(Timer, db 0, start 5, count 2) /// returns TM bytes 0..1, and it takes start 80 (= 10 × 8) to reach the timer seeded /// at TM byte 10. Device 0 is the single fixed point of the two conventions /// (0 >> 3 == 2 × 0 == 0), so it is the only device this fixture can pin a /// VALUE for, and that is what the mixed batch uses. The spec-correct 2 × N cells /// are seeded too and /// asserts /// them — skipped unless S7_TIMER_NUMBER_ADDRESSING=1 declares the endpoint a real /// CPU. The device-number offset arithmetic itself stays unit-covered /// (S7ScalarBlockTests, S7SpanReadParityTests). /// /// /// 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); } /// /// 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. /// /// /// /// The unit suite pins this against a fake (S7SpanReadParityTests /// Timer_and_counter_leftovers_never_enter_a_multi_var_request); 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. /// /// /// The Timer/Counter values are asserted at device 0 only — see the type remarks /// for why this fixture cannot address any other device. /// /// [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"); } /// /// Verifies a Timer and a Counter at NON-zero device numbers decode their seeded values — /// the assertion the shared fixture cannot serve. /// /// /// Skipped unless S7_TIMER_NUMBER_ADDRESSING=1 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 T5 reads TM byte 0 rather than TM byte 10 /// (measured 2026-08-17 — see the type remarks). Set the variable alongside /// S7_SIM_ENDPOINT when pointing this suite at a real S7 CPU seeded to match /// Docker/profiles/s7_1500.json's TM/CT areas. /// [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"); } /// Reads the whole span 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 Task<(IReadOnlyList Snapshots, CountingPlcFactory Counters)> ReadBatchAsync(int maxGapBytes, string driverInstanceId) => ReadBatchAsync( SpanLayout.BuildOptions(sim.Host, sim.Port, maxGapBytes), SpanLayout.TagNames, driverInstanceId); /// Reads one batch through a freshly-built driver and reports the wire shape. /// Driver options carrying the layout's raw tags and span budget. /// The references to read, in the order the driver is asked for them. /// 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 static async Task<(IReadOnlyList Snapshots, CountingPlcFactory Counters)> ReadBatchAsync(S7DriverOptions options, IReadOnlyList tagNames, string driverInstanceId) { var counters = new CountingPlcFactory(new S7PlcFactory()); await using var drv = new S7Driver(options, 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(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), }; } /// /// 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 /// Docker/profiles/s7_1500.json. /// private static class TimerCounterLayout { /// Declares the endpoint a real S7 CPU, which addresses TM/CT by device number. public const string RealCpuEnvVar = "S7_TIMER_NUMBER_ADDRESSING"; /// /// The one device number snap7's bit-address resolution and a CPU's device-number /// resolution both land on: 0 >> 3 == 2 × 0 == 0. /// public const int Device0 = 0; /// T0's seed 0x2456 — S5TIME base 2 (×1 s) over BCD 456. public const double Device0TimerSeconds = 456.0d; /// C0's seed — the raw big-endian count word. public const int Device0CounterCount = 4711; /// /// 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). /// 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), ]; /// The non-zero device numbers, reachable only from a device number-addressing CPU. public static readonly S7TagDefinition[] DeviceNumbered = [ new("TC_Timer5", "T5", S7DataType.Float64, Writable: false), new("TC_Counter7", "C7", S7DataType.Int32, Writable: false), ]; /// The batch's references, in the order the driver is asked for them. /// The definition set. /// The tag names. public static IReadOnlyList Names(IReadOnlyList defs) => [.. defs.Select(d => d.Name)]; /// Builds driver options for one definition set at the shipped span budget. /// The fixture hostname or IP address. /// The fixture port. /// The definition set. /// The driver options. 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), }; } /// /// 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. /// /// /// Not thread-safe, by design. Every ReadBatchAsync builds its own factory and /// the driver serialises all wire calls on its _gate, so nothing here is ever touched /// concurrently today. A future caller that shared one factory across parallel reads would /// silently corrupt the counts and shape lists rather than fail loudly — give it a lock, or /// a factory per batch, before doing that. /// /// The factory producing the real connection. private sealed class CountingPlcFactory(IS7PlcFactory inner) : IS7PlcFactory { private readonly List<(S7NetDataType Area, int Db, int Start, int Count)> _blockReadShapes = []; private readonly List _multiVarItemAreas = []; /// Block reads issued — one per planned span. public int BlockReads { get; private set; } /// /// The (area, DB, start, count) 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. /// public IReadOnlyList<(S7NetDataType Area, int Db, int Start, int Count)> BlockReadShapes => _blockReadShapes; /// /// 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. /// public IReadOnlyList MultiVarItemAreas => _multiVarItemAreas; /// 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; _blockReadShapes.Clear(); _multiVarItemAreas.Clear(); } /// 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(S7NetDataType area, int db, int start, int count) { BlockReads++; _blockReadShapes.Add((area, db, start, count)); } private void CountMultiVarRead(IReadOnlyList? items) { MultiVarReads++; MultiVarItems += items?.Count ?? 0; if (items is null) return; foreach (var item in items) _multiVarItemAreas.Add(item.DataType); } 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(area, db, startByteAdr, count); return inner.ReadBytesAsync(area, db, startByteAdr, count, cancellationToken); } /// public Task> ReadMultipleVarsAsync( List items, CancellationToken cancellationToken) { // Counted before the call: the items ARE the request's shape, and S7.Net hands the // same list back with values filled in, so reading them afterwards would measure // the same thing later. owner.CountMultiVarRead(items); 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(); } } }