664ff75ee2
Read-only live gate on Elma presses Z35474 (TC3 3.1.4024) and Z34834 (TC2 2.11): - Sum-read: every chunk came back BadCommunicationError. Beckhoff's SumRead(IList<IgIoSumReadEntity>) ctor casts to IList<SumDataEntity>, which throws InvalidCastException for a List<> (only an array survives via covariance). The fake-client tests never built a real SumRead. New AdsTwinCATClient.CreateSumRead passes an array; TwinCATSumReadConstructionTests pins it (red on the List form) plus a falsifiability control on the library. After the fix: 59 tags, status identical everywhere, values identical on every non-moving tag, ~0.35-0.66 s vs ~2.7 s per poll; TC2 CE 2.11 answers sum reads. - An unexpected sum chunk failure now logs one Warning per device (it was invisible: the health message was overwritten by the next good read). - Diagnostics: TC2 has no TwinCAT_SystemInfoVarList; fall back to .SYSTEMINFO / .SYSTEMTASKINFOARR[1] once the TC3 name answers not-found (live: all six keys). - #612 browse: Flat verified on TC3 (10,770 leaves; VirtualTree 1,459 strict subset) — comment and docs updated, no load-mode option needed. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
257 lines
12 KiB
C#
257 lines
12 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
|
|
|
/// <summary>Gitea #645 / #314 — PLC state / task-cycle facts on the optional IDriverDiagnostics bag.</summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class TwinCATDiagnosticsTests
|
|
{
|
|
private const string Host1 = "ads://5.23.91.23.1.1:851";
|
|
private const string Host2 = "ads://5.23.91.24.1.1:851";
|
|
|
|
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool probe, bool diagnostics, params (string host, string? name)[] devices) =>
|
|
new(new TwinCATDriverOptions
|
|
{
|
|
Devices = [.. devices.Select(d => new TwinCATDeviceOptions(d.host, d.name))],
|
|
Probe = new TwinCATProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) },
|
|
DiagnosticsEnabled = diagnostics,
|
|
}, "drv-diag", factory);
|
|
|
|
private static FakeTwinCATClient PlcClient() => new()
|
|
{
|
|
AdsStateText = "Run",
|
|
Values =
|
|
{
|
|
[TwinCATDriver.SymAppName] = "Plc30 App",
|
|
[TwinCATDriver.SymOnlineChangeCnt] = 7u,
|
|
[TwinCATDriver.SymCycleTime] = 100_000u, // 10.0 ms
|
|
[TwinCATDriver.SymLastExecTime] = 25_000u, // 2.5 ms
|
|
},
|
|
};
|
|
|
|
private static async Task Sample(TwinCATDriver drv, FakeTwinCATClient client, string host)
|
|
{
|
|
var state = drv.GetDeviceState(host)!;
|
|
await drv.SampleDeviceDiagnosticsAsync(state, client, CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>Verifies the driver advertises the optional capability and is empty before any sample.</summary>
|
|
[Fact]
|
|
public async Task Bag_is_empty_before_the_first_sample()
|
|
{
|
|
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var diag = drv.ShouldBeAssignableTo<IDriverDiagnostics>()!;
|
|
diag.GetDiagnostics().ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies a single-device driver publishes flat keys with state, app, cycle, exec and headroom.</summary>
|
|
[Fact]
|
|
public async Task Single_device_publishes_flat_keys()
|
|
{
|
|
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await Sample(drv, PlcClient(), Host1);
|
|
|
|
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
|
|
|
|
bag["TwinCAT.AdsState"].ShouldBe("Run");
|
|
bag["TwinCAT.AppName"].ShouldBe("Plc30 App");
|
|
bag["TwinCAT.OnlineChangeCnt"].ShouldBe("7");
|
|
bag["TwinCAT.CycleTimeMs"].ShouldBe("10.0");
|
|
bag["TwinCAT.LastExecTimeMs"].ShouldBe("2.5");
|
|
bag["TwinCAT.HeadroomMs"].ShouldBe("7.5");
|
|
DriverDiagnostics.Bound(bag).Count.ShouldBe(bag.Count); // within the host's 16-entry cap
|
|
}
|
|
|
|
/// <summary>Verifies several devices are told apart by device name, falling back to host address.</summary>
|
|
[Fact]
|
|
public async Task Multiple_devices_are_prefixed()
|
|
{
|
|
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, "Elma01"), (Host2, null));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await Sample(drv, PlcClient(), Host1);
|
|
await Sample(drv, PlcClient(), Host2);
|
|
|
|
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
|
|
|
|
bag["TwinCAT.Elma01"].ShouldBe("Run; Plc30 App; online-changes 7; cycle 10.0 ms; exec 2.5 ms; headroom 7.5 ms");
|
|
bag[$"TwinCAT.{Host2}"].ShouldContain("cycle 10.0 ms");
|
|
bag.ContainsKey("TwinCAT.AdsState").ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Verifies a runtime without _TaskInfo (older TwinCAT 2) still yields the facts it has, and
|
|
/// the missing symbols are asked for once, not on every probe tick.</summary>
|
|
[Fact]
|
|
public async Task Missing_task_info_gives_a_partial_bag_and_is_not_re_asked()
|
|
{
|
|
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var client = new FakeTwinCATClient { AdsStateText = "Run" };
|
|
client.Values[TwinCATDriver.SymAppName] = "Plc1";
|
|
client.ReadStatuses[TwinCATDriver.SymCycleTime] = TwinCATStatusMapper.BadNodeIdUnknown;
|
|
client.ReadStatuses[TwinCATDriver.SymLastExecTime] = TwinCATStatusMapper.BadNodeIdUnknown;
|
|
client.ReadStatuses[TwinCATDriver.SymOnlineChangeCnt] = TwinCATStatusMapper.BadNodeIdUnknown;
|
|
// ...and the TwinCAT 2 equivalents are absent too.
|
|
client.ReadStatuses[TwinCATDriver.SymTc2CycleTime] = TwinCATStatusMapper.BadNodeIdUnknown;
|
|
client.ReadStatuses[TwinCATDriver.SymTc2LastExecTime] = TwinCATStatusMapper.BadNodeIdUnknown;
|
|
client.ReadStatuses[TwinCATDriver.SymTc2OnlineChangeCount] = TwinCATStatusMapper.BadNodeIdUnknown;
|
|
|
|
await Sample(drv, client, Host1);
|
|
var readsAfterFirst = client.SingleReadCount;
|
|
await Sample(drv, client, Host1);
|
|
|
|
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
|
|
bag["TwinCAT.AdsState"].ShouldBe("Run");
|
|
bag["TwinCAT.AppName"].ShouldBe("Plc1");
|
|
bag.Keys.ShouldNotContain("TwinCAT.CycleTimeMs");
|
|
bag.Keys.ShouldNotContain("TwinCAT.HeadroomMs");
|
|
client.SingleReadCount.ShouldBe(readsAfterFirst + 1); // only AppName is read again
|
|
}
|
|
|
|
/// <summary>hw2 live gate (TC2 2.11 Elma Z34834): a TwinCAT 2 runtime has no TwinCAT_SystemInfoVarList but
|
|
/// serves the same facts as `.SYSTEMINFO` / `.SYSTEMTASKINFOARR[1]`; the bag must be filled from those, and
|
|
/// after the first tick only the TC2 names are read (4 reads, not 8).</summary>
|
|
[Fact]
|
|
public async Task TwinCAT2_system_globals_fill_the_bag_when_the_TC3_names_are_absent()
|
|
{
|
|
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var client = new FakeTwinCATClient { AdsStateText = "Run" };
|
|
foreach (var tc3 in new[] { TwinCATDriver.SymAppName, TwinCATDriver.SymOnlineChangeCnt, TwinCATDriver.SymCycleTime, TwinCATDriver.SymLastExecTime })
|
|
client.ReadStatuses[tc3] = TwinCATStatusMapper.BadNodeIdUnknown;
|
|
client.Values[TwinCATDriver.SymTc2ProjectName] = "ELMA_5STAGE";
|
|
client.Values[TwinCATDriver.SymTc2OnlineChangeCount] = (ushort)3;
|
|
client.Values[TwinCATDriver.SymTc2CycleTime] = 100_000u; // 10.0 ms
|
|
client.Values[TwinCATDriver.SymTc2LastExecTime] = 40_000u; // 4.0 ms
|
|
|
|
await Sample(drv, client, Host1);
|
|
var readsAfterFirst = client.SingleReadCount;
|
|
await Sample(drv, client, Host1);
|
|
|
|
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
|
|
bag["TwinCAT.AppName"].ShouldBe("ELMA_5STAGE");
|
|
bag["TwinCAT.OnlineChangeCnt"].ShouldBe("3");
|
|
bag["TwinCAT.CycleTimeMs"].ShouldBe("10.0");
|
|
bag["TwinCAT.LastExecTimeMs"].ShouldBe("4.0");
|
|
bag["TwinCAT.HeadroomMs"].ShouldBe("6.0");
|
|
readsAfterFirst.ShouldBe(8);
|
|
client.SingleReadCount.ShouldBe(readsAfterFirst + 4);
|
|
}
|
|
|
|
/// <summary>A TC3 target that serves TwinCAT_SystemInfoVarList is never asked for the TC2 names.</summary>
|
|
[Fact]
|
|
public async Task A_TC3_target_never_reads_the_TwinCAT2_names()
|
|
{
|
|
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var client = PlcClient();
|
|
|
|
await Sample(drv, client, Host1);
|
|
|
|
client.SingleReadCount.ShouldBe(4);
|
|
}
|
|
|
|
/// <summary>Verifies a failed sample keeps the previous snapshot rather than blanking the bag.</summary>
|
|
[Fact]
|
|
public async Task Failed_sample_keeps_the_last_values()
|
|
{
|
|
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var client = PlcClient();
|
|
await Sample(drv, client, Host1);
|
|
|
|
client.ThrowOnRead = true;
|
|
client.AdsStateText = null;
|
|
await Sample(drv, client, Host1);
|
|
|
|
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
|
|
bag["TwinCAT.CycleTimeMs"].ShouldBe("10.0");
|
|
bag["TwinCAT.AdsState"].ShouldBe("Run");
|
|
}
|
|
|
|
/// <summary>Verifies the probe loop itself fills the bag — no manual sampling.</summary>
|
|
[Fact]
|
|
public async Task Probe_loop_populates_the_bag()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory { Customise = PlcClient };
|
|
var drv = NewDriver(factory, true, true, (Host1, null));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
try
|
|
{
|
|
IReadOnlyDictionary<string, string> bag = new Dictionary<string, string>();
|
|
for (var i = 0; i < 100 && bag.Count == 0; i++)
|
|
{
|
|
await Task.Delay(50);
|
|
bag = ((IDriverDiagnostics)drv).GetDiagnostics();
|
|
}
|
|
bag["TwinCAT.OnlineChangeCnt"].ShouldBe("7");
|
|
}
|
|
finally { await drv.ShutdownAsync(CancellationToken.None); }
|
|
}
|
|
|
|
/// <summary>Verifies a bag for the maximum number of devices fits the host's cap (one line per device).</summary>
|
|
[Fact]
|
|
public async Task Many_devices_all_fit_the_bag_cap()
|
|
{
|
|
var hosts = Enumerable.Range(1, 12).Select(i => ($"ads://5.23.91.{i}.1.1:851", (string?)$"D{i}")).ToArray();
|
|
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, hosts);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
foreach (var (host, _) in hosts) await Sample(drv, PlcClient(), host);
|
|
|
|
var bag = DriverDiagnostics.Bound(((IDriverDiagnostics)drv).GetDiagnostics());
|
|
|
|
bag.Count.ShouldBe(12);
|
|
}
|
|
|
|
/// <summary>Verifies sampling is opt-in: with it off the probe loop adds no reads and the bag stays empty.</summary>
|
|
[Fact]
|
|
public async Task Disabled_by_default_adds_no_reads()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory { Customise = PlcClient };
|
|
var drv = NewDriver(factory, true, false, (Host1, null));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
try
|
|
{
|
|
for (var i = 0; i < 100 && drv.GetHostStatuses().All(h => h.State != HostState.Running); i++)
|
|
await Task.Delay(20);
|
|
await Task.Delay(100); // several more probe ticks
|
|
}
|
|
finally { await drv.ShutdownAsync(CancellationToken.None); }
|
|
|
|
factory.Clients[0].SingleReadCount.ShouldBe(0);
|
|
((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies a symbol the PLC did not export is asked for again once the client is rebuilt.</summary>
|
|
[Fact]
|
|
public async Task Unavailable_symbols_are_cleared_when_a_new_client_is_installed()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory();
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions(Host1)],
|
|
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("A", Host1, "MAIN.A", TwinCATDataType.DInt)]),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
DiagnosticsEnabled = true,
|
|
}, "drv-diag", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var first = new FakeTwinCATClient();
|
|
first.ReadStatuses[TwinCATDriver.SymCycleTime] = TwinCATStatusMapper.BadNodeIdUnknown;
|
|
factory.Customise = () => first;
|
|
await drv.ReadAsync(["A"], CancellationToken.None); // installs the first client
|
|
await Sample(drv, first, Host1);
|
|
drv.GetDeviceState(Host1)!.UnavailableDiagSymbols.ContainsKey(TwinCATDriver.SymCycleTime).ShouldBeTrue();
|
|
|
|
first.SimulateWireDrop();
|
|
factory.Customise = () => new FakeTwinCATClient();
|
|
await drv.ReadAsync(["A"], CancellationToken.None); // reconnect installs a new client
|
|
|
|
drv.GetDeviceState(Host1)!.UnavailableDiagSymbols.ShouldBeEmpty();
|
|
}
|
|
}
|