199 lines
10 KiB
C#
199 lines
10 KiB
C#
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; } = [];
|
|
}
|
|
}
|