From 20022083df5184f7b07cab18ad1ea1c81fe0f7f5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:05:42 -0400 Subject: [PATCH] perf(s7): span leftovers batch through multi-var read PDUs with per-chunk fallback --- .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 224 ++++++++++++++++- .../S7SpanReadParityTests.cs | 232 +++++++++++++++++- 2 files changed, 444 insertions(+), 12 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index d9c7c2ae..6dd2a123 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using S7.Net; using S7NetDataType = global::S7.Net.DataType; +using S7DataItem = global::S7.Net.Types.DataItem; using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -514,11 +515,19 @@ public sealed class S7Driver /// ReadBytesAsync, its members decoded by slicing the returned block — this is /// what turns one wire request per tag into one per contiguous block. Whatever the /// planner declines (Timer/Counter, whose offsets are device numbers) plus any span not - /// worth issuing (see ) then goes through the unchanged + /// worth issuing (see ) falls to the leftover list. + /// + /// + /// Those leftovers get a SECOND grouping pass: the ones a + /// expresses cleanly — the narrow scalars, which is what a + /// demoted single-member span is — are batched into multi-var requests by + /// , which crosses (area, DB) + /// boundaries a block read cannot. Whatever remains (Timer/Counter, unparseable + /// addresses, and any chunk whose multi-var request faulted) goes through the unchanged /// per-tag path. /// /// - /// Setting SpanReadMaxGapBytes below zero skips the first pass entirely, which + /// Setting SpanReadMaxGapBytes below zero skips BOTH passes, which /// restores the pre-grouping behaviour exactly. Either way the result array is written /// by original index, so the caller sees its own ordering regardless of how the batch /// was partitioned, and both passes share so a fault @@ -617,6 +626,18 @@ public sealed class S7Driver // Sorted so a batch is served in reference order regardless of how it was partitioned — // the per-tag tail is otherwise ordered by the planner's grouping, not by the caller's. perTag.Sort(); + + // Second grouping pass: the leftovers a DataItem can express ride ONE multi-var request + // per chunk instead of a wire request each. Deliberately gated on the SAME switch as the + // span pass — SpanReadMaxGapBytes < 0 is documented to restore the pre-grouping wire + // shape exactly, and batching the (then complete) per-tag list would break that. What + // comes back is the tags still owed an individual read, still in reference order. + if (_options.SpanReadMaxGapBytes >= 0) + { + perTag = await ReadLeftoversByMultiVarAsync( + plc, resolved, perTag, results, now, cancellationToken).ConfigureAwait(false); + } + foreach (var i in perTag) { try @@ -647,6 +668,205 @@ public sealed class S7Driver return results; } + /// + /// Items one multi-var request may carry. The caller owns chunking — S7.Net does NOT + /// split a multi-var read, it throws when the request or the projected response exceeds + /// the negotiated PDU (AssertPduSizeForRead). + /// + /// + /// The request costs a 19-byte header plus 12 bytes per item; the response costs a + /// 14-byte header plus 4 bytes of item header plus the item's own data. Against the common + /// 240-byte PDU that permits 18 items request-side and — for the 4-byte-or-narrower scalars + /// this pass batches — 28 response-side. 12 sits well inside both, leaving headroom for a + /// CPU that negotiates a smaller PDU than 240, which is the failure this constant exists to + /// avoid: an oversized request is a hard throw, not a paged read. It is deliberately NOT a + /// config key — the safe value is a property of the protocol, not of a deployment. + /// + private const int MultiVarReadMaxItems = 12; + + /// + /// Fewest batchable leftovers worth issuing a multi-var request for. ONE leftover is one + /// wire request either way, so batching it would change the observable wire shape of every + /// single-tag read for zero benefit — the same reasoning + /// applies to a lone narrow span. + /// + /// + /// The gate is on the PASS, not on each chunk: a batch of 13 still splits into 12 + 1, and + /// that trailing single is left in the multi-var path. It costs one wire request either way, + /// so re-routing it would buy nothing and would make the chunk loop's index bookkeeping + /// answer a second question. + /// + private const int MultiVarReadMinItems = 2; + + /// + /// Serves the span planner's leftovers that a can express by + /// batching them into multi-var requests, and returns the indexes still owed an individual + /// — in ascending reference order. + /// + /// + /// + /// Only a narrow scalar is batched. A carrying the same + /// area / DB / offset / bit / VarType that S7.Net's own address parser derives + /// from the tag's address string, with Count == 1, is decoded by the SAME + /// ParseBytes call the per-address read path uses — so its boxed value is + /// byte-for-byte and type-for-type what would have produced, + /// and applies to it unchanged. + /// + /// + /// Timer and Counter deliberately stay on the per-tag path even though S7.Net's + /// VarType names them. Their per-tag path does not go through ParseBytes + /// at all — it block-reads the 2-byte word and decodes it in + /// , which surfaces a Counter as int where + /// S7.Net's own Counter.FromByteArray returns ushort, and which carries a + /// documented BCD follow-up for legacy C-area counters. Batching them would fork that + /// decode into a second site that must be kept in step by hand. Correctness beats the + /// round-trip. Arrays, wide/buffer scalars and unparseable addresses are likewise left + /// alone — the first two already read a block apiece, and the last has no address to + /// build an item from. + /// + /// + /// A multi-var fault is never a tag's fault. The whole chunk falls back to the + /// per-tag path, which is what decides the tag's status — batching must not be able to + /// stamp a tag Bad that reading it one at a time would have served, so + /// is deliberately NOT called for the request itself. + /// S7.Net offers no per-item error surface to be finer-grained with: a + /// carries only its Value, and the response parser + /// throws on the FIRST item the CPU refused (before populating any later item), so + /// per-chunk fallback is the finest granularity the library actually admits. Per-ITEM + /// decoding, by contrast, is guarded individually — exactly as the span path guards its + /// members — because a mis-authored tag must not spoil the neighbours it shared a + /// request with, and it fails identically on the per-tag path anyway. + /// + /// + /// The live connection (caller holds ). + /// The batch's resolved tags, indexed by reference position. + /// Indexes the span pass left for the per-tag path, ascending. + /// The batch's result array, written in place at each tag's original index. + /// The batch timestamp, shared by every tag in the batch. + /// Cancellation token for the reads. + /// The indexes still owed an individual read, ascending. + private async Task> ReadLeftoversByMultiVarAsync( + IS7Plc plc, + S7TagDefinition?[] resolved, + List leftovers, + DataValueSnapshot[] results, + DateTime now, + CancellationToken ct) + { + var stillPerTag = new List(); + List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr)>? batchable = null; + + foreach (var i in leftovers) + { + var tag = resolved[i]!; + if (tag.ArrayCount is >= 1 + || !TryGetParsedAddress(tag, out var addr) + || IsBufferType(tag, addr)) + { + stillPerTag.Add(i); + continue; + } + (batchable ??= []).Add((i, tag, addr)); + } + + // Nothing to gain: leave the (at most one) batchable tag exactly where it was. + if (batchable is null || batchable.Count < MultiVarReadMinItems) + { + if (batchable is not null) + foreach (var (index, _, _) in batchable) stillPerTag.Add(index); + stillPerTag.Sort(); + return stillPerTag; + } + + for (var start = 0; start < batchable.Count; start += MultiVarReadMaxItems) + { + var chunk = batchable.GetRange(start, Math.Min(MultiVarReadMaxItems, batchable.Count - start)); + + IReadOnlyList answered; + try + { + var items = new List(chunk.Count); + foreach (var (_, _, addr) in chunk) items.Add(ToDataItem(addr)); + + answered = await plc.ReadMultipleVarsAsync(items, ct).ConfigureAwait(false); + + // The contract is "the same items back, in the supplied order". Anything else is + // unmappable to the batch's indexes, so treat it as a failed chunk rather than + // guessing which tag got which value. + if (answered is null || answered.Count != chunk.Count) + { + throw new System.IO.InvalidDataException( + $"S7.Net returned {answered?.Count.ToString() ?? "no"} multi-var item(s) " + + $"for a {chunk.Count}-item request"); + } + } + catch (OperationCanceledException) + { + // Same reasoning as the per-tag and span paths: a cancelled read abandons a half-read + // response on the single gated stream, so the handle must be reopened (STAB-15a). + _plcDead = true; + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, + "S7 driver {DriverInstanceId}: multi-var read of {ItemCount} leftover tag(s) failed; " + + "falling back to per-tag reads for that chunk", _driverInstanceId, chunk.Count); + foreach (var (index, _, _) in chunk) stillPerTag.Add(index); + continue; + } + + for (var k = 0; k < chunk.Count; k++) + { + var (index, tag, addr) = chunk[k]; + try + { + var raw = answered[k].Value + ?? throw new System.IO.InvalidDataException( + $"S7.Net returned null multi-var value for '{tag.Address}'"); + results[index] = new DataValueSnapshot(ReinterpretRawValue(tag, addr, raw), 0u, now, now); + _health = new DriverHealth(DriverState.Healthy, now, null); + } + catch (Exception ex) + { + // Decoding is pure and synchronous — no cancellation can surface here, so there + // is deliberately no OperationCanceledException arm to shadow the request one. + results[index] = new DataValueSnapshot(null, RecordReadFailure(ex), null, now); + } + } + } + + // The fallback chunks were appended out of order behind the non-batchable tags. + stillPerTag.Sort(); + return stillPerTag; + } + + /// + /// Builds the multi-var request item for one narrow scalar — the exact + /// (DataType, DB, StartByteAdr, BitAdr, VarType, Count) tuple S7.Net's own address + /// parser derives from the equivalent address string, so the item reads and decodes + /// identically to 's per-address read. + /// + /// The tag's parsed address; its picks the var type. + /// The request item, with no value yet. + private static S7DataItem ToDataItem(S7ParsedAddress addr) => new() + { + DataType = ToS7NetArea(addr.Area), + DB = addr.DbNumber, + StartByteAdr = addr.ByteOffset, + // Non-Bit sizes parse to a bit offset of 0, which S7.Net ignores for those var types. + BitAdr = (byte)addr.BitOffset, + VarType = addr.Size switch + { + S7Size.Bit => VarType.Bit, + S7Size.Byte => VarType.Byte, + S7Size.Word => VarType.Word, + S7Size.DWord => VarType.DWord, + _ => throw new NotSupportedException($"S7 size {addr.Size} has no multi-var VarType mapping"), + }, + Count = 1, + }; + /// /// True when a planned span is better served by the per-tag path: it holds exactly ONE /// narrow scalar, so issuing it as a block read saves no round-trip and buys nothing. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs index 4c82e3b2..00160f68 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs @@ -74,6 +74,44 @@ public sealed class S7SpanReadParityTests new S7TagDefinition("B", "DB1.DBW2", S7DataType.UInt16, Writable: false), new S7TagDefinition("C", "DB1.DBW4", S7DataType.UInt16, Writable: false)); + /// + /// narrow words spaced 8 bytes apart. With a gap budget below 6 + /// none of them can merge, so every one becomes a single-member span the driver demotes to + /// the leftover path — which is exactly the population the multi-var batch serves. + /// + /// How many scattered words to author. + /// The raw-tag entries. + private static IReadOnlyList ScatteredWords(int count) => S7RawTags.Entries( + [.. Enumerable.Range(0, count).Select(i => + new S7TagDefinition($"S{i}", $"DB1.DBW{i * 8}", S7DataType.UInt16, Writable: false))]); + + /// References for , in authoring order. + /// How many scattered words the batch reads. + /// The reference names. + private static string[] ScatteredRefs(int count) => [.. Enumerable.Range(0, count).Select(i => $"S{i}")]; + + /// + /// One of every narrow access width, spaced far enough apart that each is its own + /// single-member span — so the whole set reaches the multi-var pass and every box type + /// (bool / byte / ushort / short / uint / int / + /// float) is exercised through it. + /// + private static IReadOnlyList ScatteredWidths() => S7RawTags.Entries( + new S7TagDefinition("WBit", "DB1.DBX0.3", S7DataType.Bool, Writable: false), + new S7TagDefinition("WByte", "DB1.DBB8", S7DataType.Byte, Writable: false), + new S7TagDefinition("WU16", "DB1.DBW16", S7DataType.UInt16, Writable: false), + new S7TagDefinition("WI16", "DB1.DBW24", S7DataType.Int16, Writable: false), + new S7TagDefinition("WU32", "DB1.DBD32", S7DataType.UInt32, Writable: false), + new S7TagDefinition("WI32", "DB1.DBD40", S7DataType.Int32, Writable: false), + new S7TagDefinition("WF32", "DB1.DBD48", S7DataType.Float32, Writable: false), + // A second AREA in the same batch — a multi-var request carries each item's own area/DB, + // which is precisely what a block read cannot do. + new S7TagDefinition("WMem", "MW0", S7DataType.UInt16, Writable: false)); + + /// References for , in authoring order. + private static readonly string[] ScatteredWidthRefs = + ["WBit", "WByte", "WU16", "WI16", "WU32", "WI32", "WF32", "WMem"]; + // ── Parity ─────────────────────────────────────────────────────────────────────────── /// @@ -120,11 +158,11 @@ public sealed class S7SpanReadParityTests /// read. /// /// - /// 14 tags collapse to 4 wire requests: ONE block read covering the eleven DB1 tags from - /// byte 0 to byte 68, the Counter's own unavoidable block read, and two per-address string - /// reads — the far DB1 word (past the gap budget) and the lone Memory word, each of which - /// the driver deliberately leaves on the per-tag path because a single-member narrow span - /// saves no round-trip. + /// 14 tags collapse to 3 wire requests: ONE block read covering the eleven DB1 tags from + /// byte 0 to byte 68, the Counter's own unavoidable block read, and ONE multi-var request + /// carrying the two tags a span can't help — the far DB1 word (past the gap budget) and the + /// lone Memory word, which is in a different AREA entirely. Neither is worth a block read of + /// its own, and batching them together is something only a multi-var request can do. /// [Fact] public async Task Grouping_issues_far_fewer_wire_requests_than_there_are_tags() @@ -136,12 +174,15 @@ public sealed class S7SpanReadParityTests _ = await driver.ReadAsync(MixedRefs, ct); - (factory.ReadBytesCalls + factory.StringReadCalls).ShouldBeLessThan(MixedRefs.Length); + (factory.ReadBytesCalls + factory.StringReadCalls + factory.MultiVarCalls) + .ShouldBeLessThan(MixedRefs.Length); factory.ReadBytesCalls.ShouldBeLessThan(MixedRefs.Length); factory.ReadBytesCalls.ShouldBe(2); - // The nine narrow tags inside the big span issue NO string reads — only the two tags that - // ended up alone in a span of their own do. - factory.StringReadCalls.ShouldBe(2); + // NO per-address reads at all: the nine narrow tags inside the big span are served by its + // block read, and the two that ended up alone in a span of their own are batched together + // into a single multi-var request. + factory.StringReadCalls.ShouldBe(0); + factory.MultiVarCalls.ShouldBe(1); } // ── Failure parity ─────────────────────────────────────────────────────────────────── @@ -205,6 +246,133 @@ public sealed class S7SpanReadParityTests results[2].StatusCode.ShouldBe(0u); } + // ── Multi-var leftovers ────────────────────────────────────────────────────────────── + + /// + /// The batchable leftovers ride ONE multi-var request per chunk of + /// MultiVarReadMaxItems and issue no per-address reads at all. 13 scattered words + /// that the span planner cannot merge used to cost 13 wire requests; they now cost 2. + /// + [Fact] + public async Task Batchable_leftovers_ride_multi_var_requests_instead_of_one_read_each() + { + var ct = TestContext.Current.CancellationToken; + var factory = new ImageS7PlcFactory(Images()); + using var driver = new S7Driver(Options(ScatteredWords(13), maxGap: 2), "s7-multivar", factory); + await driver.InitializeAsync("{}", ct); + + var results = await driver.ReadAsync(ScatteredRefs(13), ct); + + results.ShouldAllBe(s => s.StatusCode == 0u); + results.ShouldAllBe(s => s.Value != null); + // ⌈13 / 12⌉ = 2 requests, and NOT ONE per-address read — the whole point of the batch. + factory.MultiVarCalls.ShouldBe(2); + factory.StringReadCalls.ShouldBe(0); + factory.ReadBytesCalls.ShouldBe(0); + // The chunking is a real split, not one oversized request plus an empty one. + factory.MultiVarItems.Select(c => c.Count).ShouldBe([12, 1]); + } + + /// + /// THE multi-var gate: a tag served by a multi-var request decodes to the same value, the + /// same CLR box type and the same status as the per-tag path reading the same image. + /// + [Fact] + public async Task Multi_var_leftovers_match_the_per_tag_path_snapshot_for_snapshot() + { + var images = Images(); + var ct = TestContext.Current.CancellationToken; + + var batching = new ImageS7PlcFactory(images); + var perTag = new ImageS7PlcFactory(images); + + using var batchingDriver = new S7Driver(Options(ScatteredWidths(), maxGap: 2), "s7-mv-parity", batching); + using var perTagDriver = new S7Driver(Options(ScatteredWidths(), maxGap: -1), "s7-mv-control", perTag); + await batchingDriver.InitializeAsync("{}", ct); + await perTagDriver.InitializeAsync("{}", ct); + + var batched = await batchingDriver.ReadAsync(ScatteredWidthRefs, ct); + var individual = await perTagDriver.ReadAsync(ScatteredWidthRefs, ct); + + for (var i = 0; i < ScatteredWidthRefs.Length; i++) + { + var name = ScatteredWidthRefs[i]; + batched[i].StatusCode.ShouldBe(0u, $"'{name}' should have read Good"); + batched[i].StatusCode.ShouldBe(individual[i].StatusCode, $"status differs for '{name}'"); + batched[i].Value.ShouldNotBeNull($"'{name}' returned no value"); + batched[i].Value!.GetType().ShouldBe(individual[i].Value!.GetType(), $"box type differs for '{name}'"); + batched[i].Value.ShouldBe(individual[i].Value, $"value differs for '{name}'"); + } + + // Proves the batching side really took the multi-var path (otherwise this file's parity + // claim would be vacuous), and the control side really did not. + batching.MultiVarCalls.ShouldBe(1); + batching.StringReadCalls.ShouldBe(0); + perTag.MultiVarCalls.ShouldBe(0); + perTag.StringReadCalls.ShouldBe(ScatteredWidthRefs.Length); + } + + /// + /// A multi-var request that faults falls back to the per-tag path for that chunk's tags and + /// still serves them Good. Batching is an optimisation: it must never be able to turn a tag + /// Bad that reading it one at a time would have served. + /// + [Fact] + public async Task A_failing_multi_var_chunk_falls_back_to_per_tag_reads_and_still_serves_good() + { + var ct = TestContext.Current.CancellationToken; + var factory = new ImageS7PlcFactory(Images()) + { + // A device-layer fault — the shape that WOULD stamp BadDeviceFailure had it come from + // the per-tag path. Through the multi-var pass it must cost nothing but a retry. + MultiVarFault = new PlcException(ErrorCode.ReadData, "S7 CPU rejected the multi-var request"), + }; + using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-fallback", factory); + await driver.InitializeAsync("{}", ct); + + var results = await driver.ReadAsync(ScatteredRefs(5), ct); + + results.ShouldAllBe(s => s.StatusCode == 0u); + results.ShouldAllBe(s => s.Value != null); + factory.MultiVarCalls.ShouldBe(1); + // Every tag of the failed chunk was re-read individually. + factory.StringReadCalls.ShouldBe(5); + // And the failed batch attempt did NOT degrade the driver — nothing actually failed to read. + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// Tags the per-tag path owns — Timer and Counter, whose offsets are device numbers rather + /// than byte addresses — are never handed to a multi-var request, even though they share the + /// leftover list with the narrow scalars that are. + /// + [Fact] + public async Task Timer_and_counter_leftovers_never_enter_a_multi_var_request() + { + var ct = TestContext.Current.CancellationToken; + var tags = S7RawTags.Entries( + new S7TagDefinition("Tmr", "T2", S7DataType.Float64, Writable: false), + new S7TagDefinition("Cnt", "C1", S7DataType.Int32, Writable: false), + new S7TagDefinition("N0", "DB1.DBW0", S7DataType.UInt16, Writable: false), + new S7TagDefinition("N1", "DB1.DBW100", S7DataType.UInt16, Writable: false)); + var factory = new ImageS7PlcFactory(Images()); + using var driver = new S7Driver(Options(tags, maxGap: 2), "s7-mv-partition", factory); + await driver.InitializeAsync("{}", ct); + + var results = await driver.ReadAsync(["Tmr", "Cnt", "N0", "N1"], ct); + + results.ShouldAllBe(s => s.StatusCode == 0u); + results[0].Value.ShouldBeOfType(); + results[1].Value.ShouldBeOfType(); + + // One multi-var request carrying ONLY the two narrow words; the Timer and the Counter kept + // their own block reads. + factory.MultiVarCalls.ShouldBe(1); + factory.MultiVarItems[0].Count.ShouldBe(2); + factory.MultiVarItems[0].ShouldAllBe(d => d.DataType == S7NetDataType.DataBlock); + factory.ReadBytesCalls.ShouldBe(2); + } + // ── Configurability ────────────────────────────────────────────────────────────────── /// @@ -265,11 +433,15 @@ public sealed class S7SpanReadParityTests var counters = new byte[64]; for (var i = 0; i < counters.Length; i++) counters[i] = (byte)((i * 29 + 3) & 0xFF); + var timers = new byte[64]; + for (var i = 0; i < timers.Length; i++) timers[i] = (byte)((i * 17 + 5) & 0xFF); + return new Dictionary<(S7NetDataType, int), byte[]> { [(S7NetDataType.DataBlock, 1)] = db1, [(S7NetDataType.Memory, 0)] = mem, [(S7NetDataType.Counter, 0)] = counters, + [(S7NetDataType.Timer, 0)] = timers, }; } @@ -289,9 +461,18 @@ public sealed class S7SpanReadParityTests /// Number of per-address string reads issued — the un-grouped narrow path. public int StringReadCalls; + /// Number of multi-var requests issued — one per leftover chunk. + public int MultiVarCalls; + + /// The items of every multi-var request, in issue order, so a test can assert what was batched. + public List> MultiVarItems { get; } = new(); + /// When set, every read throws this instead of serving the image. public Exception? ReadFault { get; init; } + /// When set, only the MULTI-VAR read throws this — the per-tag fallback still serves the image. + public Exception? MultiVarFault { get; init; } + /// public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => new ImageS7Plc(this); @@ -324,9 +505,40 @@ public sealed class S7SpanReadParityTests } /// + /// + /// Decodes each item from the same byte image the other two read shapes serve, boxing + /// exactly as S7.Net's ParseBytes does for Count == 1: Bit a + /// bool at BitAdr, Byte a byte, Word a + /// ushort, DWord a uint. Like the real library it populates each + /// item's Value in place and hands the same list back. + /// public Task> ReadMultipleVarsAsync( List items, CancellationToken ct) - => throw new NotSupportedException("ImageS7Plc: multi-var reads not served by the parity image fake"); + { + // Counted (and captured) BEFORE the injected fault so a fallback test can assert the + // batch was ATTEMPTED — counting only successes would read zero. + Interlocked.Increment(ref owner.MultiVarCalls); + lock (owner.MultiVarItems) owner.MultiVarItems.Add([.. items]); + if (owner.MultiVarFault is { } mvFault) throw mvFault; + if (owner.ReadFault is { } fault) throw fault; + + foreach (var item in items) + { + var image = owner.Images[(item.DataType, item.DB)]; + var at = item.StartByteAdr; + item.Value = item.VarType switch + { + VarType.Bit => (object)((image[at] & (1 << item.BitAdr)) != 0), + VarType.Byte => image[at], + VarType.Word => (ushort)((image[at] << 8) | image[at + 1]), + VarType.DWord => ((uint)image[at] << 24) | ((uint)image[at + 1] << 16) + | ((uint)image[at + 2] << 8) | image[at + 3], + _ => throw new NotSupportedException($"fake cannot serve VarType {item.VarType}"), + }; + } + + return Task.FromResult>(items); + } /// public Task ReadAsync(string address, CancellationToken ct)