Auto: twincat-3.2 — cycle-time / jitter / PLC-state diagnostics

Closes #314
This commit is contained in:
Joseph Doherty
2026-04-26 01:59:56 -04:00
parent 30e39a752a
commit 24a3cda56a
8 changed files with 642 additions and 1 deletions

View File

@@ -0,0 +1,78 @@
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.IntegrationTests;
/// <summary>
/// PR 3.2 / issue #314 — wire-level coverage for the cycle-time / jitter / online-change
/// diagnostics surfaced through the probe loop. Skipped via <see cref="TwinCATFactAttribute"/>
/// when the XAR VM isn't reachable. The four well-known system symbols
/// (<c>TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt</c>, <c>_AppInfo.AppName</c>,
/// <c>_TaskInfo[1].CycleTime</c>, <c>_TaskInfo[1].LastExecTime</c>) are always exported by
/// a live TC3 PLC runtime — no extra fixture state required beyond the existing
/// <c>GVL_Fixture</c> / <c>MAIN</c> setup documented in <c>TwinCatProject/README.md</c>.
/// </summary>
[Collection("TwinCATXar")]
[Trait("Category", "Integration")]
[Trait("Simulator", "TwinCAT-XAR")]
public sealed class TwinCATDiagnosticsIntegrationTests(TwinCATXarFixture sim)
{
[TwinCATFact]
public async Task Probe_loop_surfaces_cycle_time_and_online_change_count()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
// Probe interval kept tight (250 ms) so the test doesn't have to wait the default 5 s
// between probe ticks. The four system symbols are scalar UDINTs / a STRING(80), so
// the diagnostics sample completes well within the 250 ms budget on any healthy
// runtime.
var options = new TwinCATDriverOptions
{
Devices = [
new TwinCATDeviceOptions(
HostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
DeviceName: "XAR-VM"),
],
// No Tags — diagnostics doesn't need any user-declared symbols.
Tags = [],
UseNativeNotifications = false,
Timeout = TimeSpan.FromSeconds(5),
Probe = new TwinCATProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(250),
},
};
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-diag-probe");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Wait up to 5 s for the probe loop to fire at least once + populate the snapshot.
// 250 ms cycle × 20 attempts ≈ 5 s budget — generous enough for AMS handshake on a
// freshly-restarted runtime.
var hostAddress = $"ads://{sim.TargetNetId}:{sim.AmsPort}";
TwinCATDeviceDiagnostics? snap = null;
for (var i = 0; i < 20 && snap is null; i++)
{
await Task.Delay(250, TestContext.Current.CancellationToken);
snap = drv.GetDeviceDiagnostics(hostAddress);
}
snap.ShouldNotBeNull(
"probe loop must populate TwinCATDeviceDiagnostics within ~5 s on a reachable XAR runtime");
snap.CycleTimeMs.ShouldBeGreaterThan(0,
"TwinCAT_SystemInfoVarList._TaskInfo[1].CycleTime is non-zero on every running PLC task");
snap.OnlineChangeCnt.ShouldBeGreaterThanOrEqualTo(0u,
"OnlineChangeCnt is a UDINT counter — non-negative by type, present on every TC3 runtime");
// AppName is best-effort but on a project-loaded runtime it should be non-empty.
snap.AppName.ShouldNotBeNull("running PLC project always reports an AppName");
// The cross-driver DriverHealth.Diagnostics dictionary should also reflect the same
// values so the driver-diagnostics RPC has a consistent surface.
var dict = drv.GetHealth().DiagnosticsOrEmpty;
dict["TwinCAT.CycleTimeMs"].ShouldBe(snap.CycleTimeMs);
dict["TwinCAT.OnlineChangeCnt"].ShouldBe(snap.OnlineChangeCnt);
}
}

View File

@@ -71,6 +71,13 @@ GVL_Fixture.nCounter := GVL_Fixture.nCounter + 1;
- `PlcTask` — cyclic, 10 ms interval, priority 20
- Assigned to `MAIN`
> **Note (PR 3.2 / #314)**: the probe loop also reads the four
> `TwinCAT_SystemInfoVarList` system symbols (`_AppInfo.AppName`,
> `_AppInfo.OnlineChangeCnt`, `_TaskInfo[1].CycleTime`, `_TaskInfo[1].LastExecTime`)
> per tick — they're exported by every TC3 PLC runtime, so no extra fixture state
> is required. `TwinCATDiagnosticsIntegrationTests` asserts they surface on
> `DriverHealth.Diagnostics`.
> **Note (PR 3.1 / #313)**: `GVL_Fixture.nCounter` doubles as the
> coalescing-test driver for `TwinCATMaxDelayTests`. The 10 ms cycle +
> per-cycle increment in `MAIN` means a no-coalescing subscriber sees ~100

View File

@@ -14,6 +14,28 @@ internal class FakeTwinCATClient : ITwinCATClient
public bool ThrowOnProbe { get; set; }
public Exception? Exception { get; set; }
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Convenience seed for the well-known TwinCAT system symbols
/// (<c>TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt</c>,
/// <c>_AppInfo.AppName</c>, <c>_TaskInfo[1].CycleTime</c>,
/// <c>_TaskInfo[1].LastExecTime</c>) used by PR 3.2's probe-loop diagnostics.
/// Internally just writes to <see cref="Values"/>; provided as a named helper so tests
/// read clearly + so future schema changes (e.g. wrapping the system-symbol surface in
/// a typed snapshot) have one place to update.
/// </summary>
public void SetSystemSymbolValue(string name, object? value) => Values[name] = value;
/// <summary>
/// Force the next read of <paramref name="symbolPath"/> to fail with the supplied
/// <paramref name="status"/>. Subsequent reads after that fall back to the default
/// Good behaviour. Used by the PR 3.2 probe-loop tests to simulate a runtime that
/// doesn't expose <c>_TaskInfo[1]</c>.
/// </summary>
public void FailNextReadOf(string symbolPath, uint status = TwinCATStatusMapper.BadCommunicationError)
{
ReadStatuses[symbolPath] = status;
}
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();

View File

@@ -0,0 +1,223 @@
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>
/// PR 3.2 / issue #314 — unit-level coverage for the cycle-time / jitter / online-change
/// diagnostics surfaced by the probe loop. The probe-loop scheduler is hard to drive
/// deterministically (it has its own <see cref="Task.Delay"/>), so these tests invoke the
/// internal <see cref="TwinCATDriver.SampleDeviceDiagnosticsAsync"/> directly with seeded
/// values on the <see cref="FakeTwinCATClient"/>. Wire-level coverage lives in the
/// integration suite (<c>TwinCATDiagnosticsIntegrationTests</c>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATDeviceDiagnosticsTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
[Fact]
public async Task SampleDeviceDiagnosticsAsync_populates_LastDiagnostics_with_seeded_values()
{
var fake = new FakeTwinCATClient();
// Seed: AppName "Plc1", OnlineChangeCnt 7, CycleTime 100_000 ticks (10 ms),
// LastExecTime 35_000 ticks (3.5 ms) → JitterMs = 3.5 - 10.0 = -6.5 (healthy).
fake.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 7u);
fake.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 100_000u);
fake.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 35_000u);
await using var drv = BuildDriverWithClient(fake);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var state = drv.GetDeviceState(Host)!;
await fake.ConnectAsync(state.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
var diags = drv.GetDeviceDiagnostics(Host).ShouldNotBeNull();
diags.AppName.ShouldBe("Plc1");
diags.OnlineChangeCnt.ShouldBe(7u);
diags.CycleTimeMs.ShouldBe(10.0, tolerance: 0.001);
diags.LastExecTimeMs.ShouldBe(3.5, tolerance: 0.001);
diags.JitterMs.ShouldBe(-6.5, tolerance: 0.001);
}
[Fact]
public async Task SampleDeviceDiagnosticsAsync_skips_failing_TaskInfo_read_without_failing_probe()
{
// Older TC2 / soft-PLC builds may not surface _TaskInfo[1]; the probe loop should still
// produce a partial snapshot rather than failing the whole tick.
var fake = new FakeTwinCATClient();
fake.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 3u);
// CycleTime + LastExecTime not seeded; force them to surface as Bad so the probe path
// exercises the per-symbol try/catch.
fake.FailNextReadOf(TwinCATDriver.SymCycleTime);
fake.FailNextReadOf(TwinCATDriver.SymLastExecTime);
await using var drv = BuildDriverWithClient(fake);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var state = drv.GetDeviceState(Host)!;
await fake.ConnectAsync(state.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
var diags = drv.GetDeviceDiagnostics(Host).ShouldNotBeNull();
diags.AppName.ShouldBe("Plc1");
diags.OnlineChangeCnt.ShouldBe(3u);
// Failed reads leave the previous-tick value (or 0 on the first tick) — both are
// acceptable since the API guarantees the field is best-effort.
diags.CycleTimeMs.ShouldBe(0.0);
diags.LastExecTimeMs.ShouldBe(0.0);
}
[Fact]
public async Task SampleDeviceDiagnosticsAsync_OnlineChangeCnt_increment_surfaces_in_diagnostics_dictionary()
{
var fake = new FakeTwinCATClient();
fake.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 2u);
fake.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 100_000u);
fake.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 50_000u);
await using var drv = BuildDriverWithClient(fake);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var state = drv.GetDeviceState(Host)!;
await fake.ConnectAsync(state.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
drv.OnlineChangeIncrementsObserved.ShouldBe(0,
"first tick has no prior snapshot to diff against");
// Operator reloads the project; runtime increments OnlineChangeCnt by 3.
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 5u);
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
drv.OnlineChangeIncrementsObserved.ShouldBe(3);
var diagsAfter = drv.GetDeviceDiagnostics(Host).ShouldNotBeNull();
diagsAfter.OnlineChangeCnt.ShouldBe(5u);
// Aggregate dict on _health surfaces both the latest snapshot + the cumulative
// increment counter so the cross-driver RPC can render either view.
var health = drv.GetHealth();
var dict = health.DiagnosticsOrEmpty;
dict["TwinCAT.OnlineChangeCnt"].ShouldBe(5.0);
dict["TwinCAT.OnlineChangeIncrements"].ShouldBe(3.0);
}
[Fact]
public async Task SampleDeviceDiagnosticsAsync_folds_snapshot_into_DriverHealth_diagnostics()
{
var fake = new FakeTwinCATClient();
fake.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
fake.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 11u);
// 2 ms cycle / 5 ms exec → 3 ms jitter (overrunning).
fake.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 20_000u);
fake.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 50_000u);
await using var drv = BuildDriverWithClient(fake);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var state = drv.GetDeviceState(Host)!;
await fake.ConnectAsync(state.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await drv.SampleDeviceDiagnosticsAsync(state, fake, TestContext.Current.CancellationToken);
var dict = drv.GetHealth().DiagnosticsOrEmpty;
dict["TwinCAT.OnlineChangeCnt"].ShouldBe(11.0);
dict["TwinCAT.CycleTimeMs"].ShouldBe(2.0, tolerance: 0.001);
dict["TwinCAT.LastExecTimeMs"].ShouldBe(5.0, tolerance: 0.001);
dict["TwinCAT.JitterMs"].ShouldBe(3.0, tolerance: 0.001);
dict.ShouldContainKey("TwinCAT.DiagnosticsUpdatedAtTicks");
}
[Fact]
public async Task SampleDeviceDiagnosticsAsync_multiple_devices_get_host_prefixed_keys()
{
const string Host2 = "ads://10.0.0.1.1.1:852";
var fake1 = new FakeTwinCATClient();
fake1.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc1");
fake1.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 1u);
fake1.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 10_000u);
fake1.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 5_000u);
var fake2 = new FakeTwinCATClient();
fake2.SetSystemSymbolValue(TwinCATDriver.SymAppName, "Plc2");
fake2.SetSystemSymbolValue(TwinCATDriver.SymOnlineChangeCnt, 9u);
fake2.SetSystemSymbolValue(TwinCATDriver.SymCycleTime, 100_000u);
fake2.SetSystemSymbolValue(TwinCATDriver.SymLastExecTime, 80_000u);
var queue = new Queue<FakeTwinCATClient>([fake1, fake2]);
var factory = new FakeTwinCATClientFactory { Customise = () => queue.Dequeue() };
await using var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [
new TwinCATDeviceOptions(Host),
new TwinCATDeviceOptions(Host2),
],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-multi", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var s1 = drv.GetDeviceState(Host)!;
var s2 = drv.GetDeviceState(Host2)!;
await fake1.ConnectAsync(s1.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await fake2.ConnectAsync(s2.ParsedAddress, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await drv.SampleDeviceDiagnosticsAsync(s1, fake1, TestContext.Current.CancellationToken);
await drv.SampleDeviceDiagnosticsAsync(s2, fake2, TestContext.Current.CancellationToken);
var dict = drv.GetHealth().DiagnosticsOrEmpty;
// Multi-device: keys are prefixed with the host address so the cross-driver RPC
// can disambiguate.
dict[$"TwinCAT.{Host}.OnlineChangeCnt"].ShouldBe(1.0);
dict[$"TwinCAT.{Host2}.OnlineChangeCnt"].ShouldBe(9.0);
dict[$"TwinCAT.{Host}.CycleTimeMs"].ShouldBe(1.0, tolerance: 0.001);
dict[$"TwinCAT.{Host2}.CycleTimeMs"].ShouldBe(10.0, tolerance: 0.001);
}
[Fact]
public void TicksToMilliseconds_converts_100ns_units()
{
TwinCATDriver.TicksToMilliseconds(0u).ShouldBe(0.0);
TwinCATDriver.TicksToMilliseconds(10_000u).ShouldBe(1.0, tolerance: 0.001);
TwinCATDriver.TicksToMilliseconds(100_000u).ShouldBe(10.0, tolerance: 0.001);
TwinCATDriver.TicksToMilliseconds(uint.MaxValue).ShouldBe(429_496.7295, tolerance: 0.001);
}
[Fact]
public async Task GetDeviceDiagnostics_returns_null_before_first_probe()
{
await using var drv = BuildDriverWithClient(new FakeTwinCATClient());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.GetDeviceDiagnostics(Host).ShouldBeNull();
drv.GetDeviceDiagnostics("ads://does.not.exist:851").ShouldBeNull();
}
/// <summary>
/// Build a driver wired to the supplied <paramref name="fake"/>. Inserts the fake into a
/// factory's <c>Customise</c> hook so the first <see cref="ITwinCATClientFactory.Create"/>
/// call hands back the seeded fake. Caller drives <see cref="TwinCATDriver.SampleDeviceDiagnosticsAsync"/>
/// directly.
/// </summary>
private static TwinCATDriver BuildDriverWithClient(FakeTwinCATClient fake)
{
var dispensed = false;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
if (dispensed) return new FakeTwinCATClient();
dispensed = true;
return fake;
},
};
return new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-diag", factory);
}
}