perf(s7): span-grouped reads — one PDU per contiguous block instead of one per tag

This commit is contained in:
Joseph Doherty
2026-08-16 04:15:37 -04:00
parent 63081d52d8
commit 39f67a0fce
6 changed files with 1188 additions and 37 deletions
@@ -79,6 +79,56 @@ public sealed class S7DriverOptions
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 5;
/// <summary>
/// Largest hole, in bytes, the read planner will read THROUGH when merging two neighbouring
/// tags into one block request. It is a budget for deliberately wasted bytes: raising it
/// merges more aggressively (fewer wire requests, more bytes nobody asked for), lowering it
/// to <c>0</c> merges only tags that are strictly adjacent or overlapping.
/// </summary>
/// <remarks>
/// <para>
/// <b>Any value <c>&lt;= -1</c> disables span grouping entirely</b> — every tag is read
/// on its own, exactly as the driver behaved before grouping existed. That is the
/// escape hatch for a CPU that dislikes wide block reads, and the control arm the
/// parity tests read through.
/// </para>
/// <para>
/// Grouping cannot make a read fail where the per-tag reads would have succeeded: a
/// span never extends past its LAST member's end, so every byte it requests lies inside
/// a range some tag was already going to read. Timer and Counter tags are never grouped
/// — their offset is a device number, not a byte address.
/// </para>
/// </remarks>
[Display(Name = "Span read max gap (bytes)",
Description = "Largest hole read through when merging neighbouring tags into one block request. -1 disables grouping.",
GroupName = "Performance")]
public int SpanReadMaxGapBytes { get; init; } = DefaultSpanReadMaxGapBytes;
/// <summary>Default for <see cref="SpanReadMaxGapBytes"/>, shared with the config DTO so the two can't drift.</summary>
public const int DefaultSpanReadMaxGapBytes = 16;
/// <summary>
/// Largest single planned span, in bytes. A run of tags that would exceed it is broken into
/// several spans; a single tag wider than the cap still gets its own span, because one tag
/// is never split across requests.
/// </summary>
/// <remarks>
/// This is <b>not</b> a correctness bound: S7.Net transparently chunks a <c>ReadBytes</c>
/// call larger than the negotiated PDU into several wire requests, so an oversized span
/// reads correctly either way. The cap exists to bound wasted-gap amplification — without
/// it, a long sparse run of tags each separated by an under-budget hole would coalesce into
/// one enormous request that is mostly padding. The 220-byte default sits just under the
/// usable payload of the common 240-byte S7-300/400 PDU, so a typical span is one request
/// on the wire.
/// </remarks>
[Display(Name = "Span read max span (bytes)",
Description = "Largest single planned block read. Bounds wasted-gap amplification, not correctness.",
GroupName = "Performance")]
public int SpanReadMaxSpanBytes { get; init; } = DefaultSpanReadMaxSpanBytes;
/// <summary>Default for <see cref="SpanReadMaxSpanBytes"/>, shared with the config DTO so the two can't drift.</summary>
public const int DefaultSpanReadMaxSpanBytes = 220;
}
public sealed class S7ProbeOptions
@@ -500,6 +500,25 @@ public sealed class S7Driver
// ---- IReadable ----
/// <inheritdoc />
/// <remarks>
/// <para>
/// The batch is served in two passes under ONE gate acquisition. First
/// <see cref="S7ReadSpanPlanner"/> groups the resolved tags into contiguous
/// <c>(area, DB)</c> byte spans and each span is read with a single
/// <c>ReadBytesAsync</c>, 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 <see cref="PrefersPerTagRead"/>) then goes through the unchanged
/// per-tag <see cref="ReadOneAsync"/> path.
/// </para>
/// <para>
/// Setting <c>SpanReadMaxGapBytes</c> below zero skips the first pass entirely, 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 <see cref="RecordReadFailure"/> so a fault
/// stamps the same status whichever path observed it.
/// </para>
/// </remarks>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
@@ -534,17 +553,69 @@ public sealed class S7Driver
return results;
}
// Resolve every reference first. An unresolvable one is a miss, stamped here and never
// offered to the planner — grouping must not be able to change what "unknown tag" means.
var resolved = new S7TagDefinition?[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
var name = fullReferences[i];
if (!_resolver.TryResolve(name, out var tag))
{
if (_resolver.TryResolve(fullReferences[i], out var tag))
resolved[i] = tag;
else
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue;
}
// Indexes the per-tag path must serve: everything, when grouping is off; otherwise the
// planner's leftovers (Timer/Counter) plus any tag whose address won't parse.
var perTag = new List<int>();
if (_options.SpanReadMaxGapBytes >= 0)
{
List<(int OriginalIndex, S7TagDefinition Tag, S7ParsedAddress Addr)>? plannable = null;
for (var i = 0; i < fullReferences.Count; i++)
{
if (resolved[i] is not { } tag) continue;
if (!TryGetParsedAddress(tag, out var addr))
{
// Defensive: every tag pre-parses at init. A tag that somehow didn't goes
// to the per-tag path, where ReadOneAsync's Parse throws the FormatException
// the catch ladder has always mapped to BadCommunicationError.
perTag.Add(i);
continue;
}
(plannable ??= []).Add((i, tag, addr));
}
if (plannable is not null)
{
var plan = S7ReadSpanPlanner.Build(
plannable, _options.SpanReadMaxGapBytes, _options.SpanReadMaxSpanBytes);
foreach (var span in plan.Spans)
{
// A span that saves no round-trip is not worth changing the wire shape for.
if (PrefersPerTagRead(span))
{
perTag.Add(span.Members[0].OriginalIndex);
continue;
}
await ReadSpanAsync(plc, span, results, now, cancellationToken).ConfigureAwait(false);
}
perTag.AddRange(plan.LeftoverIndexes);
}
}
else
{
for (var i = 0; i < fullReferences.Count; i++)
if (resolved[i] is not null) perTag.Add(i);
}
// 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();
foreach (var i in perTag)
{
try
{
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
var value = await ReadOneAsync(plc, resolved[i]!, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
@@ -555,42 +626,14 @@ public sealed class S7Driver
// dead (we hold _gate) and propagate: the caller cancelled, nobody consumes the
// batch. Marking is unconditional on OCE (not routed through the type classifier):
// whether bytes moved is unknowable, and a false positive costs one cheap reopen
// versus permanent desync for a false negative.
// versus permanent desync for a false negative. Kept AT the call site rather
// than inside RecordReadFailure so the rethrow preserves the original stack.
_plcDead = true;
throw;
}
catch (NotSupportedException)
{
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
}
catch (PlcException pex) when (IsAccessDenied(pex))
{
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent
// configuration fault, NOT a transient one. Blind retry is wasted effort,
// so map it to BadNotSupported and flag the driver as a config alert
// (Faulted) rather than Degraded — per driver-specs.md §5.
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
_health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
}
catch (PlcException pex)
{
// A genuine device-layer fault (CPU error, hardware fault) — transient
// enough to keep retrying; report BadDeviceFailure and degrade health. A
// connection-fatal PlcException (socket drop / ConnectionError) additionally
// marks the handle dead so the next read reopens it.
MarkConnectionDeadIfFatal(pex);
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
}
catch (Exception ex)
{
// Socket / timeout / transport fault — mark the handle dead when it's a
// connection loss so EnsureConnectedAsync reopens on the next call.
MarkConnectionDeadIfFatal(ex);
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex), null, now);
}
}
}
@@ -598,6 +641,182 @@ public sealed class S7Driver
return results;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The point of grouping is to replace N wire requests with one. A lone narrow tag is one
/// wire request either way — but a block read reaches the CPU differently from S7.Net's
/// per-address string read, so converting it would change the observable wire shape of every
/// single-tag read on the server for zero benefit. A lone ARRAY or wide/buffer scalar is NOT
/// demoted: those already read a block on the per-tag path, so serving them as a
/// single-member span is byte-for-byte the request they always issued.
/// </remarks>
/// <param name="span">The planned span to judge.</param>
/// <returns><c>true</c> when the span's single member should go through <see cref="ReadOneAsync"/>.</returns>
private static bool PrefersPerTagRead(S7ReadSpanPlanner.Span span)
=> span.Members.Count == 1
&& span.Members[0].Tag.ArrayCount is not >= 1
&& !IsBufferType(span.Members[0].Tag, span.Members[0].Addr);
/// <summary>
/// Reads one planned span — a SINGLE <c>ReadBytesAsync</c> serving every member — and
/// decodes each member from its slice of the returned block.
/// </summary>
/// <remarks>
/// A span read is one wire request standing in for many per-tag requests, so a transport
/// fault that used to spoil one tag now spoils all of them: every member is stamped with
/// the SAME status <see cref="RecordReadFailure"/> would have produced for that exception on
/// the per-tag path, and health degrades identically. Decoding, by contrast, is per member
/// inside its own guard — a single mis-authored tag (declared type vs. address size) must
/// not spoil the neighbours that shared its block.
/// </remarks>
/// <param name="plc">The live connection (caller holds <see cref="_gate"/>).</param>
/// <param name="span">The planned block read.</param>
/// <param name="results">The batch's result array, written in place at each member's original index.</param>
/// <param name="now">The batch timestamp, shared by every tag in the batch.</param>
/// <param name="ct">Cancellation token for the read.</param>
/// <returns>A task that completes once every member of the span has been stamped.</returns>
private async Task ReadSpanAsync(
IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
byte[] block;
try
{
block = await plc.ReadBytesAsync(
ToS7NetArea(span.Area), span.DbNumber, span.StartByte, span.LengthBytes, ct)
.ConfigureAwait(false)
?? throw new System.IO.InvalidDataException(
$"S7.Net returned null block for {span.Area} db{span.DbNumber} " +
$"@{span.StartByte}+{span.LengthBytes}");
}
catch (OperationCanceledException)
{
// Same reasoning as the per-tag path: a cancelled block read leaves the single gated
// stream at an untrustworthy position, so the handle must be reopened (STAB-15a).
_plcDead = true;
throw;
}
catch (Exception ex)
{
var status = RecordReadFailure(ex);
foreach (var member in span.Members)
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
return;
}
foreach (var member in span.Members)
{
try
{
var value = DecodeMember(member, block);
results[member.OriginalIndex] = new DataValueSnapshot(value, 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 block-read one above.
results[member.OriginalIndex] = new DataValueSnapshot(null, RecordReadFailure(ex), null, now);
}
}
}
/// <summary>
/// Decodes one span member from its slice of the block, routing to exactly the decoder
/// <see cref="ReadOneAsync"/> would have chosen for that tag, in the same order.
/// </summary>
/// <param name="member">The member to decode, carrying its slice position and width.</param>
/// <param name="block">The block the span read.</param>
/// <returns>The decoded value, boxed identically to the per-tag path's.</returns>
private static object DecodeMember(S7ReadSpanPlanner.Member member, byte[] block)
{
// The two block decoders take byte[] (they hand the buffer to S7.Net's own
// S7String/DateTime/Timer/Counter FromByteArray helpers, which are byte[]-only), so a
// member that is not the whole block is copied out. Narrow scalars need no copy at all —
// DecodeNarrowScalar reads straight from a ReadOnlySpan positioned at the tag's own byte.
if (member.Tag.ArrayCount is >= 1)
return DecodeArrayBlock(member.Tag, member.Addr, SliceForMember(member, block));
if (IsBufferType(member.Tag, member.Addr))
return DecodeScalarBlock(member.Tag, member.Addr, SliceForMember(member, block));
return DecodeNarrowScalar(member.Tag, member.Addr, block.AsSpan(member.RelativeOffset, member.WidthBytes));
}
/// <summary>
/// The member's own bytes as a <c>byte[]</c> for the block decoders. Returns the block
/// itself — no copy — when the member IS the whole block, which is the single-member span
/// every array / wide scalar produces today.
/// </summary>
/// <param name="member">The member whose bytes are wanted.</param>
/// <param name="block">The block the span read.</param>
/// <returns>The member's bytes, starting at its own first byte.</returns>
private static byte[] SliceForMember(S7ReadSpanPlanner.Member member, byte[] block)
=> member.RelativeOffset == 0 && member.WidthBytes == block.Length
? block
: block[member.RelativeOffset..(member.RelativeOffset + member.WidthBytes)];
/// <summary>
/// The tag's parsed address: the table built at init, with an on-demand parse as the
/// defensive fallback for a tag that isn't in it. Mirrors <see cref="ReadOneAsync"/>'s
/// lookup, but never throws — an unparseable address returns <c>false</c> so the caller can
/// route the tag to the per-tag path, where the <see cref="FormatException"/> surfaces
/// through the ladder exactly as it always has.
/// </summary>
/// <param name="tag">The tag whose address is wanted.</param>
/// <param name="addr">The parsed address, valid only when this returns <c>true</c>.</param>
/// <returns><c>true</c> when the address is known or parses.</returns>
private bool TryGetParsedAddress(S7TagDefinition tag, out S7ParsedAddress addr)
=> _parsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr);
/// <summary>
/// The read-failure classification ladder, shared by the per-tag path and the span path so
/// a failed span stamps every member with exactly the status the same exception would have
/// produced on a single tag. Applies the health / dead-handle side effects and returns the
/// status code the caller must stamp.
/// </summary>
/// <remarks>
/// <see cref="OperationCanceledException"/> is deliberately NOT handled here: it is control
/// flow rather than a per-tag status (it marks the handle dead and propagates), and
/// rethrowing it from a helper would rewrite the stack. Both call sites catch it first.
/// </remarks>
/// <param name="ex">The exception observed on a read.</param>
/// <returns>The OPC UA status code every affected tag must be stamped with.</returns>
private uint RecordReadFailure(Exception ex)
{
switch (ex)
{
case NotSupportedException:
return StatusBadNotSupported;
case PlcException pex when IsAccessDenied(pex):
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration
// fault, NOT a transient one. Blind retry is wasted effort, so map it to
// BadNotSupported and flag the driver as a config alert (Faulted) rather than
// Degraded — per driver-specs.md §5.
_health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
return StatusBadNotSupported;
case PlcException pex:
// A genuine device-layer fault (CPU error, hardware fault) — transient enough to
// keep retrying; report BadDeviceFailure and degrade health. A connection-fatal
// PlcException (socket drop / ConnectionError) additionally marks the handle dead
// so the next read reopens it.
MarkConnectionDeadIfFatal(pex);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
return StatusBadDeviceFailure;
default:
// Socket / timeout / transport fault — mark the handle dead when it's a connection
// loss so EnsureConnectedAsync reopens on the next call.
MarkConnectionDeadIfFatal(ex);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return StatusBadCommunicationError;
}
}
private async Task<object> ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct)
{
// Every tag pre-parses its address at init (_parsedByRawPath); the on-demand
@@ -681,7 +900,7 @@ public sealed class S7Driver
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address — its <see cref="S7Area"/> routes Timer/Counter into the seam regardless of <see cref="S7DataType"/>.</param>
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
private static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
internal static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
addr.Area is S7Area.Timer or S7Area.Counter
|| tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
or S7DataType.String or S7DataType.DateTime;
@@ -69,6 +69,11 @@ public static class S7DriverFactoryExtensions
// tags. The driver maps each RawTagEntry's TagConfig blob through S7TagDefinitionFactory
// at Initialize; the legacy pre-declared DTO tag path (Name/Address → BuildTag) is retired.
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
// Block-read grouping knobs. Mapped explicitly like every other key on this DTO — the
// options object is never deserialised directly — so omitting them here would leave the
// documented "-1 disables grouping" escape hatch unreachable from a deployed config.
SpanReadMaxGapBytes = dto.SpanReadMaxGapBytes ?? S7DriverOptions.DefaultSpanReadMaxGapBytes,
SpanReadMaxSpanBytes = dto.SpanReadMaxSpanBytes ?? S7DriverOptions.DefaultSpanReadMaxSpanBytes,
Probe = new S7ProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
@@ -122,6 +127,11 @@ public static class S7DriverFactoryExtensions
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>Gets the probe configuration.</summary>
public S7ProbeDto? Probe { get; init; }
/// <summary>Gets the largest hole the read planner reads through when merging tags into one
/// block request; any value below zero disables block-read grouping. Null keeps the default.</summary>
public int? SpanReadMaxGapBytes { get; init; }
/// <summary>Gets the largest single planned block read in bytes. Null keeps the default.</summary>
public int? SpanReadMaxSpanBytes { get; init; }
}
/// <summary>Data transfer object for S7 probe configuration.</summary>
@@ -0,0 +1,198 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Pure planner that turns one read batch into contiguous <c>(area, DB)</c> byte spans, so the
/// driver can serve many tags with one <c>ReadBytesAsync</c> instead of one wire request per
/// tag. No I/O, no driver state — the grouping rules are decided here and executed by
/// <see cref="S7Driver.ReadAsync"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The safety invariant is that a span never extends past its last member's end.</b> A
/// span may deliberately read bytes nobody asked for BETWEEN its members (that is what the
/// gap budget buys), but every byte it requests lies inside a range some member was going
/// to read anyway. A grouped read therefore cannot fault (area overrun, DB overrun) where
/// the individual per-tag reads would have succeeded.
/// </para>
/// <para>
/// <b>Timer and Counter tags are never grouped.</b> Their
/// <see cref="S7ParsedAddress.ByteOffset"/> holds the timer/counter DEVICE NUMBER rather
/// than a byte address, and S7.Net addresses those areas in per-device units — merging one
/// with a byte-addressed neighbour, or even with another Timer, would read from a
/// completely unrelated place. They come back as leftovers for the per-tag path.
/// </para>
/// </remarks>
internal static class S7ReadSpanPlanner
{
/// <summary>One tag inside a planned span, positioned relative to the span's start byte.</summary>
/// <param name="OriginalIndex">The tag's index in the caller's read batch — where its result must be written.</param>
/// <param name="Tag">The resolved tag definition (carries the data type, array count and string length).</param>
/// <param name="Addr">The tag's parsed address (carries the area, DB, access size and bit position).</param>
/// <param name="WidthBytes">Bytes this tag occupies — the exact number its decoder consumes.</param>
/// <param name="RelativeOffset">The tag's first byte expressed against <see cref="Span.StartByte"/>; the slice offset into the block.</param>
internal sealed record Member(
int OriginalIndex,
S7TagDefinition Tag,
S7ParsedAddress Addr,
int WidthBytes,
int RelativeOffset);
/// <summary>One planned block read: a contiguous byte range in one area, serving several tags.</summary>
/// <param name="Area">The S7 area the block is read from.</param>
/// <param name="DbNumber">Data-block number (0 for non-DB areas).</param>
/// <param name="StartByte">First byte of the block — the lowest member's byte offset.</param>
/// <param name="LengthBytes">Block length — always exactly the furthest member's end minus <paramref name="StartByte"/>.</param>
/// <param name="Members">The tags this block serves, in ascending byte order.</param>
internal sealed record Span(
S7Area Area,
int DbNumber,
int StartByte,
int LengthBytes,
IReadOnlyList<Member> Members);
/// <summary>The planned grouping of one read batch.</summary>
/// <param name="Spans">Block reads to issue, one wire request each.</param>
/// <param name="LeftoverIndexes">Batch indexes the planner declined — the caller reads these one at a time.</param>
internal sealed record Plan(IReadOnlyList<Span> Spans, IReadOnlyList<int> LeftoverIndexes);
/// <summary>
/// Groups the resolved tags of one read batch into block-read spans.
/// </summary>
/// <param name="resolved">
/// The batch's resolved tags, each paired with its parsed address and its index in the
/// caller's reference list. Unresolvable references must be handled by the caller and left
/// out of this list.
/// </param>
/// <param name="maxGapBytes">
/// Largest hole to read through when merging (inclusive). <c>0</c> merges only strictly
/// adjacent or overlapping tags; any value below zero disables grouping and returns every
/// index as a leftover.
/// </param>
/// <param name="maxSpanBytes">
/// Largest planned span. A run that would exceed it is broken; a single tag wider than it
/// still forms its own span, because a tag is never split across requests.
/// </param>
/// <returns>The spans to read plus the indexes the caller must read individually.</returns>
internal static Plan Build(
IReadOnlyList<(int OriginalIndex, S7TagDefinition Tag, S7ParsedAddress Addr)> resolved,
int maxGapBytes,
int maxSpanBytes)
{
ArgumentNullException.ThrowIfNull(resolved);
var leftovers = new List<int>();
// The off switch. Checked here (as well as at the call site) so the disabled behaviour is
// a property of the planner and can be proven without standing up a driver.
if (maxGapBytes < 0)
{
foreach (var (index, _, _) in resolved) leftovers.Add(index);
return new Plan([], leftovers);
}
List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr, int Width)>? eligible = null;
foreach (var (index, tag, addr) in resolved)
{
// Timer/Counter offsets are device numbers, not byte addresses — see the type remarks.
if (addr.Area is S7Area.Timer or S7Area.Counter)
{
leftovers.Add(index);
continue;
}
(eligible ??= []).Add((index, tag, addr, MemberWidthBytes(tag, addr)));
}
if (eligible is null) return new Plan([], leftovers);
var spans = new List<Span>();
// Group by (area, DB): two areas — or two data blocks — are separate address spaces, so a
// byte range in one says nothing about the same range in the other.
foreach (var group in eligible.GroupBy(e => (e.Addr.Area, e.Addr.DbNumber)))
{
OpenSpan? open = null;
// Ascending byte order. OrderBy is stable, so tags sharing a byte (two Bools at
// different bit positions) keep their batch order inside the span.
foreach (var (index, tag, addr, width) in group.OrderBy(e => e.Addr.ByteOffset))
{
var start = addr.ByteOffset;
var end = start + width; // exclusive
if (open is not null)
{
// A negative gap means the tags overlap (a word inside an already-open dword,
// or a second Bool in the same byte) — that is a gap of zero or better, so it
// always passes a non-negative budget without any clamping.
var gap = start - open.EndByte;
var mergedEnd = Math.Max(open.EndByte, end);
if (gap <= maxGapBytes && mergedEnd - open.StartByte <= maxSpanBytes)
{
open.Members.Add(new Member(index, tag, addr, width, start - open.StartByte));
// Max, not `end`: an overlapping member must never pull the span's end back.
open.EndByte = mergedEnd;
continue;
}
spans.Add(Seal(group.Key.Area, group.Key.DbNumber, open));
}
// Start a fresh span. Deliberately unconditional — a tag wider than maxSpanBytes
// gets its own oversized span rather than being split, and because the merge test
// above measures the RESULTING length, that oversized span can never absorb a
// neighbour afterwards.
open = new OpenSpan(start, end);
open.Members.Add(new Member(index, tag, addr, width, 0));
}
if (open is not null) spans.Add(Seal(group.Key.Area, group.Key.DbNumber, open));
}
return new Plan(spans, leftovers);
}
/// <summary>
/// Bytes one tag occupies inside a contiguous block. The three arms are checked in exactly
/// the order <see cref="S7Driver.ReadAsync"/> routes a tag to its decoder, so the width
/// planned here is always the width the decoder that runs will consume.
/// </summary>
/// <param name="tag">The resolved tag definition.</param>
/// <param name="addr">The tag's parsed address.</param>
/// <returns>The tag's byte width.</returns>
internal static int MemberWidthBytes(S7TagDefinition tag, S7ParsedAddress addr)
{
if (tag.ArrayCount is >= 1)
return tag.ArrayCount.Value * S7Driver.ElementByteSize(addr.Size);
if (S7Driver.IsBufferType(tag, addr))
return S7Driver.ScalarByteWidth(tag, addr);
return S7Driver.NarrowScalarWidth(addr);
}
/// <summary>Freezes an in-progress span into its immutable <see cref="Span"/> form.</summary>
/// <param name="area">The area the span reads from.</param>
/// <param name="dbNumber">The data-block number.</param>
/// <param name="open">The in-progress span.</param>
/// <returns>The sealed span; its length is exactly the furthest member's end minus the start.</returns>
private static Span Seal(S7Area area, int dbNumber, OpenSpan open)
=> new(area, dbNumber, open.StartByte, open.EndByte - open.StartByte, open.Members);
/// <summary>
/// Mutable accumulator for a span still being extended. <see cref="StartByte"/> is fixed at
/// creation (members arrive in ascending order, so nothing can ever start earlier);
/// <see cref="EndByte"/> is exclusive and only ever grows.
/// </summary>
/// <param name="startByte">First byte of the span.</param>
/// <param name="endByte">Exclusive end byte of the span so far.</param>
private sealed class OpenSpan(int startByte, int endByte)
{
/// <summary>First byte of the span — fixed for the span's lifetime.</summary>
public int StartByte { get; } = startByte;
/// <summary>Exclusive end byte of the furthest member seen so far.</summary>
public int EndByte { get; set; } = endByte;
/// <summary>Members accumulated so far, in ascending byte order.</summary>
public List<Member> Members { get; } = [];
}
}