fbcb521d1b
Strict width+kind eligibility for sum-batched scalars (others read singly); latch only on ADS 1793/1794, split 1792/1797/1798 without latching; 32 KiB per-command budget and 500-item ceiling; short sum response handled per item. Diagnostics: opt-in diagnosticsEnabled (default off), reuse the probe's ReadState, sample after the host-state transition, JitterMs renamed HeadroomMs, compact per-device line so many devices fit the 16-entry cap, unavailable-symbol latch cleared on reconnect. AdminUI: min/max on sum-read max, diagnostics checkbox. Pure planner extracted and unit-tested. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
224 lines
9.5 KiB
C#
224 lines
9.5 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
|
|
|
/// <summary>Gitea #645 / #310 — a poll's reads batched into ADS sum-read commands, with per-item status,
|
|
/// a chunk cap and a fallback to individual reads when the target refuses the command.</summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class TwinCATSumReadTests
|
|
{
|
|
private const string Host = "ads://5.23.91.23.1.1:851";
|
|
|
|
private static TwinCATTagDefinition Tag(string name, TwinCATDataType type = TwinCATDataType.DInt) =>
|
|
new(name, Host, "MAIN." + name, type);
|
|
|
|
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(
|
|
bool useSumRead, int maxItems, params TwinCATTagDefinition[] tags)
|
|
{
|
|
var factory = new FakeTwinCATClientFactory();
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions(Host)],
|
|
RawTags = TwinCATRawTags.Entries(tags),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
UseSumRead = useSumRead,
|
|
SumReadMaxItems = maxItems,
|
|
}, "drv-sum", factory);
|
|
return (drv, factory);
|
|
}
|
|
|
|
private static FakeTwinCATClient Seeded(params (string symbol, object value)[] values)
|
|
{
|
|
var c = new FakeTwinCATClient();
|
|
foreach (var (symbol, value) in values) c.Values[symbol] = value;
|
|
return c;
|
|
}
|
|
|
|
/// <summary>Verifies that sum-read is opt-in: off, every tag is read on its own.</summary>
|
|
[Fact]
|
|
public async Task Disabled_reads_each_tag_individually()
|
|
{
|
|
var (drv, factory) = NewDriver(false, 200, Tag("A"), Tag("B"), Tag("C"));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => Seeded(("MAIN.A", 1), ("MAIN.B", 2), ("MAIN.C", 3));
|
|
|
|
var snaps = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
|
|
|
snaps.Select(s => s.Value).ShouldBe([1, 2, 3]);
|
|
factory.Clients[0].SumReadBatches.ShouldBeEmpty();
|
|
factory.Clients[0].SingleReadCount.ShouldBe(3);
|
|
}
|
|
|
|
/// <summary>Verifies that one poll's tags go out as a single sum command, values in request order.</summary>
|
|
[Fact]
|
|
public async Task Enabled_batches_a_poll_into_one_sum_command()
|
|
{
|
|
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"), Tag("C"));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => Seeded(("MAIN.A", 1), ("MAIN.B", 2), ("MAIN.C", 3));
|
|
|
|
var snaps = await drv.ReadAsync(["C", "A", "B"], CancellationToken.None);
|
|
|
|
snaps.Select(s => s.Value).ShouldBe([3, 1, 2]);
|
|
snaps.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good);
|
|
factory.Clients[0].SumReadBatches.ShouldBe([3]);
|
|
factory.Clients[0].SingleReadCount.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>Verifies that one item's error code lands on that tag only.</summary>
|
|
[Fact]
|
|
public async Task Per_item_error_code_is_mapped_to_that_tag_only()
|
|
{
|
|
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"), Tag("C"));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () =>
|
|
{
|
|
var c = Seeded(("MAIN.A", 1), ("MAIN.C", 3));
|
|
c.ReadStatuses["MAIN.B"] = TwinCATStatusMapper.BadNodeIdUnknown; // ADS 1808 DeviceSymbolNotFound
|
|
return c;
|
|
};
|
|
|
|
var snaps = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
|
|
|
snaps[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
snaps[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
|
snaps[1].Value.ShouldBeNull();
|
|
snaps[2].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
snaps[2].Value.ShouldBe(3);
|
|
}
|
|
|
|
/// <summary>Verifies the size cap: a bigger poll splits into chunks and a lone remainder reads singly.</summary>
|
|
[Fact]
|
|
public async Task Poll_larger_than_the_cap_is_split_into_chunks()
|
|
{
|
|
var tags = Enumerable.Range(0, 5).Select(i => Tag("T" + i)).ToArray();
|
|
var (drv, factory) = NewDriver(true, 2, tags);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () =>
|
|
{
|
|
var c = new FakeTwinCATClient();
|
|
for (var i = 0; i < 5; i++) c.Values["MAIN.T" + i] = i * 10;
|
|
return c;
|
|
};
|
|
|
|
var snaps = await drv.ReadAsync([.. tags.Select(t => t.Name)], CancellationToken.None);
|
|
|
|
snaps.Select(s => s.Value).ShouldBe([0, 10, 20, 30, 40]);
|
|
factory.Clients[0].SumReadBatches.ShouldBe([2, 2]);
|
|
factory.Clients[0].SingleReadCount.ShouldBe(1); // the 5th tag: a one-item "batch" is just a read
|
|
}
|
|
|
|
/// <summary>Verifies the refusal fallback: the poll still completes and the device stops asking.</summary>
|
|
[Fact]
|
|
public async Task Refused_sum_command_falls_back_to_individual_reads_and_sticks()
|
|
{
|
|
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"), Tag("C"));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () =>
|
|
{
|
|
var c = Seeded(("MAIN.A", 1), ("MAIN.B", 2), ("MAIN.C", 3));
|
|
c.SumReadException = new TwinCATSumReadRefusedException(1793);
|
|
return c;
|
|
};
|
|
|
|
var first = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
|
var second = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
|
|
|
first.Select(s => s.Value).ShouldBe([1, 2, 3]);
|
|
first.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good);
|
|
second.Select(s => s.Value).ShouldBe([1, 2, 3]);
|
|
factory.Clients[0].SumReadBatches.ShouldBe([3]); // tried once, never again
|
|
factory.Clients[0].SingleReadCount.ShouldBe(6);
|
|
drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>Verifies a comms failure is not mistaken for a refusal: the chunk goes Bad, sum stays on.</summary>
|
|
[Fact]
|
|
public async Task Comms_failure_marks_the_chunk_bad_and_does_not_disable_sum_read()
|
|
{
|
|
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () =>
|
|
{
|
|
var c = Seeded(("MAIN.A", 1), ("MAIN.B", 2));
|
|
c.SumReadException = new InvalidOperationException("router gone");
|
|
return c;
|
|
};
|
|
|
|
var failed = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
|
factory.Clients[0].SumReadException = null;
|
|
var recovered = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
|
|
|
failed.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.BadCommunicationError);
|
|
recovered.Select(s => s.Value).ShouldBe([1, 2]);
|
|
factory.Clients[0].SumReadBatches.ShouldBe([2, 2]);
|
|
}
|
|
|
|
/// <summary>Verifies unknown references do not disturb result order.</summary>
|
|
[Fact]
|
|
public async Task Unknown_reference_keeps_its_slot_in_a_batched_poll()
|
|
{
|
|
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => Seeded(("MAIN.A", 1), ("MAIN.B", 2));
|
|
|
|
var snaps = await drv.ReadAsync(["A", "nope", "B"], CancellationToken.None);
|
|
|
|
snaps[0].Value.ShouldBe(1);
|
|
snaps[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
|
snaps[2].Value.ShouldBe(2);
|
|
factory.Clients[0].SumReadBatches.ShouldBe([2]);
|
|
}
|
|
|
|
/// <summary>Verifies the batch carries bit index and array length so the client decodes like a single read.</summary>
|
|
[Fact]
|
|
public async Task Bulk_items_carry_array_length_and_bit_index()
|
|
{
|
|
var captured = new List<TwinCATBulkReadItem>();
|
|
var factory = new FakeTwinCATClientFactory { Customise = () => new CapturingClient(captured) };
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions(Host)],
|
|
RawTags = TwinCATRawTags.Entries([
|
|
new TwinCATTagDefinition("Arr", Host, "MAIN.Arr", TwinCATDataType.Int, ArrayLength: 4),
|
|
new TwinCATTagDefinition("Bit", Host, "MAIN.Word.3", TwinCATDataType.Bool),
|
|
]),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
UseSumRead = true,
|
|
}, "drv-sum", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.ReadAsync(["Arr", "Bit"], CancellationToken.None);
|
|
|
|
captured.Count.ShouldBe(2);
|
|
captured[0].ArrayCount.ShouldBe(4);
|
|
captured[1].BitIndex.ShouldBe(3);
|
|
}
|
|
|
|
/// <summary>Verifies the JSON keys reach the options.</summary>
|
|
[Fact]
|
|
public void Options_parse_from_driver_config_json()
|
|
{
|
|
var on = TwinCATDriverFactoryExtensions.ParseOptionsForTests(
|
|
"""{"useSumRead": true, "sumReadMaxItems": 50}""", "d");
|
|
on.UseSumRead.ShouldBeTrue();
|
|
on.SumReadMaxItems.ShouldBe(50);
|
|
|
|
var off = TwinCATDriverFactoryExtensions.ParseOptionsForTests("{}", "d");
|
|
off.UseSumRead.ShouldBeFalse();
|
|
off.SumReadMaxItems.ShouldBe(200);
|
|
}
|
|
|
|
private sealed class CapturingClient(List<TwinCATBulkReadItem> sink) : FakeTwinCATClient
|
|
{
|
|
public override Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
|
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken ct)
|
|
{
|
|
sink.AddRange(reads);
|
|
return base.ReadValuesAsync(reads, ct);
|
|
}
|
|
}
|
|
}
|