Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs
T

620 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Live gate for the read-batching layer: the <see cref="S7ReadSpanPlanner"/> 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>Live-gate record.</b> Run 2026-08-17 against <c>10.100.0.35:1102</c>
/// (<c>otopcua-python-snap7-s7_1500</c>, profile <c>Docker/profiles/s7_1500.json</c>):
/// <b>PASSED</b> — 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.
/// </para>
/// <para>
/// Re-run the same day against the same endpoint after the fixture gained its TM0 / CT0
/// areas: <b>PASSED</b> — 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
/// <c>T0</c> (decoded 456.0 s from S5TIME <c>0x2456</c>) and <c>C0</c> (decoded count 4711).
/// </para>
/// <para>
/// <b>What the layout buys.</b> The ten tags are positioned so the planner's rules produce a
/// known, non-trivial partition rather than one that would pass by accident:
/// </para>
/// <list type="bullet">
/// <item>
/// <b>Cluster A</b> (<c>DB1.DBW300 … DB1.DBD316</c>) — 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 (<c>0xDEAD</c>, <c>0xDEADBEEF</c>) so a mis-computed
/// slice offset yields garbage rather than a zero that could coincidentally match.
/// </item>
/// <item>
/// <b>Cluster B</b> (<c>DB1.DBW400</c> + <c>DB1.DBW402</c>) — 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 <c>PrefersPerTagRead</c> demotion, so it is a real block read.
/// </item>
/// <item>
/// <b>Leftovers</b> (<c>DB1.DBW500</c>, <c>DB1.DBW600</c>, <c>MW10</c>) — each isolated
/// enough to form its own single-member span, each therefore demoted by
/// <c>PrefersPerTagRead</c>, and three of them is past <c>MultiVarReadMinItems</c> — so
/// they ride ONE multi-var request. <c>MW10</c> is deliberately in a different area:
/// a block read can never merge across <c>(area, DB)</c>, and the multi-var pass can,
/// so its presence in the single request is what pins that property live.
/// </item>
/// </list>
/// <para>
/// <b>Timer / Counter ARE covered here now.</b> The fixture profile grew TM0 + CT0 (64
/// bytes each = 32 device cells), so
/// <see cref="Timer_and_counter_tags_never_enter_a_multi_var_request"/> 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
/// <c>ReadBytes</c> 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
/// <c>IsBufferType</c>.
/// </para>
/// <para>
/// <b>⚠️ Fixture limitation — only device 0 is addressable.</b> Live-probed 2026-08-17:
/// snap7's server resolves the TM/CT request address as a BIT address (it serves byte
/// <c>addr &gt;&gt; 3</c> of the area) where S7.Net — and a real S7 CPU — put the device
/// NUMBER in that field. Measured directly: <c>ReadBytes(Timer, db 0, start 5, count 2)</c>
/// returns TM bytes 0..1, and it takes <c>start 80</c> (= 10 × 8) to reach the timer seeded
/// at TM byte 10. Device <b>0</b> is the single fixed point of the two conventions
/// (<c>0 &gt;&gt; 3 == 2 × 0 == 0</c>), so it is the only device this fixture can pin a
/// VALUE for, and that is what the mixed batch uses. The spec-correct <c>2 × N</c> cells
/// are seeded too and
/// <see cref="Timer_and_counter_decode_the_seeded_values_at_their_device_numbers"/> asserts
/// them — skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a real
/// CPU. The device-number offset arithmetic itself stays unit-covered
/// (<c>S7ScalarBlockTests</c>, <c>S7SpanReadParityTests</c>).
/// </para>
/// <para>
/// <b>Why a counting factory rather than logs.</b> 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.
/// <see cref="CountingPlcFactory"/> decorates the REAL <see cref="S7PlcFactory"/>, so the
/// shipped <c>S7PlcAdapter</c> (and its <c>S7OperationDeadline</c> ceiling) stays on the
/// wire — the gate measures production's path, not a re-implementation of it.
/// </para>
/// </remarks>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
{
/// <summary>
/// Verifies the grouped batch returns exactly what reading every tag individually returns —
/// same values, same boxed CLR types, all Good.
/// </summary>
/// <remarks>
/// Boxed equality is the assertion on purpose: the three paths decode through different code
/// (block slicing, <c>S7.Net.ParseBytes</c> 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.
/// </remarks>
[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");
}
}
/// <summary>
/// Verifies the grouped batch decodes the fixture's seeded values — so both paths agreeing
/// cannot mean "both wrong the same way".
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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
/// <c>SpanReadMaxGapBytes &lt; 0</c> is documented to restore the pre-grouping wire shape
/// EXACTLY, and "exactly" is a claim about requests, not values.
/// </remarks>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// The unit suite pins this against a fake (<c>S7SpanReadParityTests</c>
/// <c>Timer_and_counter_leftovers_never_enter_a_multi_var_request</c>); 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.
/// </para>
/// <para>
/// The Timer/Counter values are asserted at device <b>0</b> only — see the type remarks
/// for why this fixture cannot address any other device.
/// </para>
/// </remarks>
[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");
}
/// <summary>
/// Verifies a Timer and a Counter at NON-zero device numbers decode their seeded values —
/// the assertion the shared fixture cannot serve.
/// </summary>
/// <remarks>
/// Skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> 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 <c>T5</c> reads TM byte 0 rather than TM byte 10
/// (measured 2026-08-17 — see the type remarks). Set the variable alongside
/// <c>S7_SIM_ENDPOINT</c> when pointing this suite at a real S7 CPU seeded to match
/// <c>Docker/profiles/s7_1500.json</c>'s TM/CT areas.
/// </remarks>
[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");
}
/// <summary>Reads the whole span layout once through a freshly-built driver and reports the wire shape.</summary>
/// <param name="maxGapBytes">The span budget: the shipped default, or a negative value to disable grouping.</param>
/// <param name="driverInstanceId">Instance id, distinct per call so log lines stay attributable.</param>
/// <returns>The batch's snapshots plus the wire-call counts observed for the read itself.</returns>
private Task<(IReadOnlyList<DataValueSnapshot> Snapshots, CountingPlcFactory Counters)>
ReadBatchAsync(int maxGapBytes, string driverInstanceId)
=> ReadBatchAsync(
SpanLayout.BuildOptions(sim.Host, sim.Port, maxGapBytes), SpanLayout.TagNames, driverInstanceId);
/// <summary>Reads one batch through a freshly-built driver and reports the wire shape.</summary>
/// <param name="options">Driver options carrying the layout's raw tags and span budget.</param>
/// <param name="tagNames">The references to read, in the order the driver is asked for them.</param>
/// <param name="driverInstanceId">Instance id, distinct per call so log lines stay attributable.</param>
/// <returns>The batch's snapshots plus the wire-call counts observed for the read itself.</returns>
private static async Task<(IReadOnlyList<DataValueSnapshot> Snapshots, CountingPlcFactory Counters)>
ReadBatchAsync(S7DriverOptions options, IReadOnlyList<string> 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);
}
/// <summary>
/// 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 <c>Docker/profiles/s7_1500.json</c>, so a seed drift there surfaces here as a value
/// mismatch rather than a mystery failure.
/// </summary>
private static class SpanLayout
{
/// <summary>The shipped gap budget — the value this gate exists to validate.</summary>
public const int DefaultGapBytes = S7DriverOptions.DefaultSpanReadMaxGapBytes;
/// <summary>Any negative gap disables BOTH grouping passes; the documented off switch.</summary>
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),
];
/// <summary>The batch's references, in the order the driver is asked for them.</summary>
public static readonly IReadOnlyList<string> TagNames = [.. Definitions.Select(d => d.Name)];
/// <summary>Builds driver options for the layout at the requested span budget.</summary>
/// <param name="host">The fixture hostname or IP address.</param>
/// <param name="port">The fixture port.</param>
/// <param name="maxGapBytes">Span gap budget; negative disables grouping.</param>
/// <returns>The driver options.</returns>
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),
};
}
/// <summary>
/// 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
/// <c>Docker/profiles/s7_1500.json</c>.
/// </summary>
private static class TimerCounterLayout
{
/// <summary>Declares the endpoint a real S7 CPU, which addresses TM/CT by device number.</summary>
public const string RealCpuEnvVar = "S7_TIMER_NUMBER_ADDRESSING";
/// <summary>
/// The one device number snap7's bit-address resolution and a CPU's device-number
/// resolution both land on: <c>0 &gt;&gt; 3 == 2 × 0 == 0</c>.
/// </summary>
public const int Device0 = 0;
/// <summary>T0's seed 0x2456 — S5TIME base 2 (×1 s) over BCD 456.</summary>
public const double Device0TimerSeconds = 456.0d;
/// <summary>C0's seed — the raw big-endian count word.</summary>
public const int Device0CounterCount = 4711;
/// <summary>
/// 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).
/// </summary>
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),
];
/// <summary>The non-zero device numbers, reachable only from a device number-addressing CPU.</summary>
public static readonly S7TagDefinition[] DeviceNumbered =
[
new("TC_Timer5", "T5", S7DataType.Float64, Writable: false),
new("TC_Counter7", "C7", S7DataType.Int32, Writable: false),
];
/// <summary>The batch's references, in the order the driver is asked for them.</summary>
/// <param name="defs">The definition set.</param>
/// <returns>The tag names.</returns>
public static IReadOnlyList<string> Names(IReadOnlyList<S7TagDefinition> defs)
=> [.. defs.Select(d => d.Name)];
/// <summary>Builds driver options for one definition set at the shipped span budget.</summary>
/// <param name="host">The fixture hostname or IP address.</param>
/// <param name="port">The fixture port.</param>
/// <param name="defs">The definition set.</param>
/// <returns>The driver options.</returns>
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),
};
}
/// <summary>
/// <see cref="IS7PlcFactory"/> 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.
/// </summary>
/// <remarks>
/// <b>Not thread-safe, by design.</b> Every <c>ReadBatchAsync</c> builds its own factory and
/// the driver serialises all wire calls on its <c>_gate</c>, 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.
/// </remarks>
/// <param name="inner">The factory producing the real connection.</param>
private sealed class CountingPlcFactory(IS7PlcFactory inner) : IS7PlcFactory
{
private readonly List<(S7NetDataType Area, int Db, int Start, int Count)> _blockReadShapes = [];
private readonly List<S7NetDataType> _multiVarItemAreas = [];
/// <summary>Block reads issued — one per planned span.</summary>
public int BlockReads { get; private set; }
/// <summary>
/// The <c>(area, DB, start, count)</c> 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.
/// </summary>
public IReadOnlyList<(S7NetDataType Area, int Db, int Start, int Count)> BlockReadShapes
=> _blockReadShapes;
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<S7NetDataType> MultiVarItemAreas => _multiVarItemAreas;
/// <summary>Multi-var requests issued — one per leftover chunk.</summary>
public int MultiVarReads { get; private set; }
/// <summary>Items carried across all multi-var requests.</summary>
public int MultiVarItems { get; private set; }
/// <summary>Per-address reads issued — the unchanged per-tag path.</summary>
public int AddressReads { get; private set; }
/// <summary>Total read round-trips, whichever shape they took.</summary>
public int TotalReadRequests => BlockReads + MultiVarReads + AddressReads;
/// <summary>Zeroes the counters, so a batch can be measured without initialization's calls.</summary>
public void Reset()
{
BlockReads = 0;
MultiVarReads = 0;
MultiVarItems = 0;
AddressReads = 0;
_blockReadShapes.Clear();
_multiVarItemAreas.Clear();
}
/// <inheritdoc />
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<S7DataItem>? items)
{
MultiVarReads++;
MultiVarItems += items?.Count ?? 0;
if (items is null) return;
foreach (var item in items) _multiVarItemAreas.Add(item.DataType);
}
private void CountAddressRead() => AddressReads++;
/// <summary>Counting pass-through over one real connection.</summary>
/// <param name="inner">The real connection.</param>
/// <param name="owner">The factory holding the counters.</param>
private sealed class CountingPlc(IS7Plc inner, CountingPlcFactory owner) : IS7Plc
{
/// <inheritdoc />
public bool IsConnected => inner.IsConnected;
/// <inheritdoc />
public Task OpenAsync(CancellationToken cancellationToken) => inner.OpenAsync(cancellationToken);
/// <inheritdoc />
public void Close() => inner.Close();
/// <inheritdoc />
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
{
owner.CountAddressRead();
return inner.ReadAsync(address, cancellationToken);
}
/// <inheritdoc />
public Task<byte[]> 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);
}
/// <inheritdoc />
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(
List<S7DataItem> 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);
}
/// <inheritdoc />
public Task WriteAsync(string address, object value, CancellationToken cancellationToken)
=> inner.WriteAsync(address, value, cancellationToken);
/// <inheritdoc />
public Task WriteBytesAsync(
S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken)
=> inner.WriteBytesAsync(area, db, startByteAdr, value, cancellationToken);
/// <inheritdoc />
public Task ReadStatusAsync(CancellationToken cancellationToken) => inner.ReadStatusAsync(cancellationToken);
/// <inheritdoc />
public void Dispose() => inner.Dispose();
}
}
}