TwinCAT XAR integration fixture scaffold (#221) #166
@@ -38,6 +38,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||
|
||||
@@ -45,7 +45,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
|
||||
- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped
|
||||
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
|
||||
- [AB Legacy](AbLegacy-Test-Fixture.md) — no integration fixture, unit-only via `FakeAbLegacyTag` (libplctag PCCC)
|
||||
- [TwinCAT](TwinCAT-Test-Fixture.md) — no integration fixture, unit-only via `FakeTwinCATClient` with native-notification harness
|
||||
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
|
||||
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
|
||||
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step
|
||||
- [Galaxy](Galaxy-Test-Fixture.md) — richest harness: E2E Host subprocess + ZB SQL live-smoke + MXAccess opt-in
|
||||
|
||||
@@ -2,24 +2,62 @@
|
||||
|
||||
Coverage map + gap inventory for the Beckhoff TwinCAT ADS driver.
|
||||
|
||||
**TL;DR: there is no integration fixture.** Every test uses a
|
||||
`FakeTwinCATClient` injected via `ITwinCATClientFactory`. Beckhoff's ADS
|
||||
library has no open-source simulator; ADS traffic against real TwinCAT
|
||||
runtimes is trusted from field deployments.
|
||||
**TL;DR:** Integration-test scaffolding lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` (task #221).
|
||||
`TwinCATXarFixture` probes TCP 48898 on an operator-supplied VM; three
|
||||
smoke tests (read / write / native notification) run end-to-end through
|
||||
the real ADS stack when the VM is reachable, skip cleanly otherwise.
|
||||
**Remaining operational work**: stand up a TwinCAT 3 XAR runtime in a
|
||||
Hyper-V VM, author the `.tsproj` project documented at
|
||||
`TwinCatProject/README.md`, rotate the 7-day trial license (or buy a
|
||||
paid runtime). Unit tests via `FakeTwinCATClient` still carry the
|
||||
exhaustive contract coverage.
|
||||
|
||||
The silver lining: TwinCAT is the only driver outside Galaxy that uses
|
||||
**native notifications** (no polling) for `ISubscribable`, and the fake
|
||||
exposes a fire-event harness so notification routing is contract-tested
|
||||
rigorously — just not on the wire.
|
||||
TwinCAT is the only driver outside Galaxy that uses **native
|
||||
notifications** (no polling) for `ISubscribable`, and the fake exposes a
|
||||
fire-event harness so notification routing is contract-tested rigorously
|
||||
at the unit layer.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
Nothing at the integration layer.
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is unit-only.
|
||||
`FakeTwinCATClient` also fakes the `AddDeviceNotification` flow so tests can
|
||||
trigger callbacks without a running runtime.
|
||||
**Integration layer** (task #221, scaffolded):
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` —
|
||||
`TwinCATXarFixture` TCP-probes ADS port 48898 on the host specified by
|
||||
`TWINCAT_TARGET_HOST` + requires `TWINCAT_TARGET_NETID` (AmsNetId of the
|
||||
VM). No fixture-owned lifecycle — XAR can't run in Docker because it
|
||||
bypasses the Windows kernel scheduler, so the VM stays
|
||||
operator-managed. `TwinCatProject/README.md` documents the required
|
||||
`.tsproj` project state; the file itself lands once the XAR VM is up +
|
||||
the project is authored. Three smoke tests:
|
||||
`Driver_reads_seeded_DINT_through_real_ADS`,
|
||||
`Driver_write_then_read_round_trip_on_scratch_REAL`, and
|
||||
`Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
|
||||
— all skip cleanly via `[TwinCATFact]` when the runtime isn't
|
||||
reachable.
|
||||
|
||||
## What it actually covers (unit only)
|
||||
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is
|
||||
still the primary coverage. `FakeTwinCATClient` also fakes the
|
||||
`AddDeviceNotification` flow so tests can trigger callbacks without a
|
||||
running runtime.
|
||||
|
||||
## What it actually covers
|
||||
|
||||
### Integration (XAR VM, task #221 — code scaffolded, needs VM + project)
|
||||
|
||||
- `TwinCAT3SmokeTests.Driver_reads_seeded_DINT_through_real_ADS` — real AMS
|
||||
handshake + ADS read of `GVL_Fixture.nCounter` (seeded at 1234, MAIN
|
||||
increments each cycle)
|
||||
- `TwinCAT3SmokeTests.Driver_write_then_read_round_trip_on_scratch_REAL` —
|
||||
real ADS write + read on `GVL_Fixture.rSetpoint`
|
||||
- `TwinCAT3SmokeTests.Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
|
||||
— real `AddDeviceNotification` against the cycle-incrementing counter;
|
||||
observes `OnDataChange` firing within 3 s of subscribe
|
||||
|
||||
All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env
|
||||
vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or
|
||||
vars are unset.
|
||||
|
||||
### Unit
|
||||
|
||||
- `TwinCATAmsAddressTests` — `ads://<netId>:<port>` parsing + routing
|
||||
- `TwinCATCapabilityTests` — data-type mapping (primitives + declared UDTs),
|
||||
@@ -89,21 +127,30 @@ back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **TwinCAT 3 runtime on CI** — Beckhoff ships a free developer runtime
|
||||
(7-day trial, restartable). Could run on a Windows CI runner with a
|
||||
helper that auto-restarts the runtime every 6 days. Works but operational
|
||||
overhead.
|
||||
2. **AdsSimulator** — Beckhoff has a closed-source "ADS simulator" library
|
||||
used internally; not publicly available.
|
||||
3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network; the
|
||||
only route that covers TC2 + real notification behavior + EtherCAT I/O
|
||||
effects.
|
||||
|
||||
Without a rig, TwinCAT correctness is trusted from the fake matching
|
||||
reality, which has held across field deployments so far.
|
||||
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
||||
remaining work is operational: stand up the Hyper-V VM, install XAR,
|
||||
author the `.tsproj` per `TwinCatProject/README.md`, configure the
|
||||
bilateral ADS route, set `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID`
|
||||
on the dev box. Then the three smoke tests transition skip → pass.
|
||||
Tracked as #221.
|
||||
2. **License-rotation automation** — XAR's 7-day trial expires on
|
||||
schedule. Either automate `TcActivate.exe /reactivate` via a
|
||||
scheduled task on the VM (not officially supported; reportedly works
|
||||
for some TC3 builds), or buy a paid runtime license (~$1k one-time
|
||||
per runtime per CPU) to kill the rotation. The doc at
|
||||
`TwinCatProject/README.md` §License rotation walks through both.
|
||||
3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network;
|
||||
the only route that covers TC2 + real EtherCAT I/O timing + cycle
|
||||
jitter under CPU load.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs`
|
||||
— TCP probe + skip-attributes + env-var parsing
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs`
|
||||
— three wire-level smoke tests
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md`
|
||||
— project spec + VM setup + license-rotation notes
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs` —
|
||||
in-process fake with the notification-fire harness used by
|
||||
`TwinCATNativeNotificationTests`
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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>
|
||||
/// End-to-end smoke tests against a live TwinCAT 3 XAR runtime. Skipped via
|
||||
/// <see cref="TwinCATFactAttribute"/> when the VM isn't reachable / the AmsNetId
|
||||
/// isn't set. Proves the driver's AMS route setup, ADS read/write, symbol browse,
|
||||
/// and native <c>AddDeviceNotification</c> subscription all work on the wire —
|
||||
/// coverage the <c>FakeTwinCATClient</c>-backed unit suite can only contract-test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required VM project state</b> (see <c>TwinCatProject/README.md</c>):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>GVL <c>GVL_Fixture</c> with <c>nCounter : DINT</c> (seed <c>1234</c>),
|
||||
/// <c>rSetpoint : REAL</c> (scratch; smoke writes + reads), <c>bFlag : BOOL</c>
|
||||
/// (seed <c>TRUE</c>).</item>
|
||||
/// <item>PLC program <c>MAIN</c> that increments <c>GVL_Fixture.nCounter</c>
|
||||
/// every cycle (so the native-notification test can observe monotonic changes
|
||||
/// without writing).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
[Collection("TwinCATXar")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "TwinCAT-XAR")]
|
||||
public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim)
|
||||
{
|
||||
[TwinCATFact]
|
||||
public async Task Driver_reads_seeded_DINT_through_real_ADS()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = BuildOptions(sim);
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-read");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["Counter"], TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(1);
|
||||
snapshots[0].StatusCode.ShouldBe(0u,
|
||||
"ADS read against GVL_Fixture.nCounter must succeed end-to-end");
|
||||
// MAIN increments the counter every cycle, so the seed value (1234) is only the
|
||||
// minimum we can assert — value grows monotonically.
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBeGreaterThanOrEqualTo(1234);
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_write_then_read_round_trip_on_scratch_REAL()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = BuildOptions(sim);
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-write");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
const float probe = 42.5f;
|
||||
var writeResults = await drv.WriteAsync(
|
||||
[new WriteRequest("Setpoint", probe)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
var readResults = await drv.ReadAsync(
|
||||
["Setpoint"], TestContext.Current.CancellationToken);
|
||||
readResults.Count.ShouldBe(1);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToSingle(readResults[0].Value).ShouldBe(probe, tolerance: 0.001f);
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_subscribe_receives_native_ADS_notifications_on_counter_changes()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = BuildOptions(sim);
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-sub");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var observed = new List<DataChangeEventArgs>();
|
||||
var gate = new SemaphoreSlim(0);
|
||||
drv.OnDataChange += (_, e) =>
|
||||
{
|
||||
lock (observed) observed.Add(e);
|
||||
gate.Release();
|
||||
};
|
||||
|
||||
var handle = await drv.SubscribeAsync(
|
||||
["Counter"], TimeSpan.FromMilliseconds(250),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// MAIN increments the counter every PLC cycle (default 10 ms task tick).
|
||||
// Native ADS notifications fire on cycle boundaries so 3 s is generous for
|
||||
// at least one OnDataChange to land.
|
||||
var got = await gate.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||
got.ShouldBeTrue("native ADS notification on GVL_Fixture.nCounter must fire within 3 s of subscribe");
|
||||
|
||||
int observedCount;
|
||||
lock (observed) observedCount = observed.Count;
|
||||
observedCount.ShouldBeGreaterThan(0);
|
||||
|
||||
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private static TwinCATDriverOptions BuildOptions(TwinCATXarFixture sim) => new()
|
||||
{
|
||||
Devices = [
|
||||
new TwinCATDeviceOptions(
|
||||
HostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
DeviceName: "XAR-VM"),
|
||||
],
|
||||
Tags = [
|
||||
new TwinCATTagDefinition(
|
||||
Name: "Counter",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Fixture.nCounter",
|
||||
DataType: TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition(
|
||||
Name: "Setpoint",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Fixture.rSetpoint",
|
||||
DataType: TwinCATDataType.Real,
|
||||
Writable: true),
|
||||
],
|
||||
UseNativeNotifications = true,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
// Disable the probe loop — the smoke tests run their own reads; a background
|
||||
// probe against GVL_Fixture.nCounter would race with them for the ADS client
|
||||
// gate + inject flakiness unrelated to the code under test.
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Net.Sockets;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for a TwinCAT 3 XAR runtime on a Hyper-V VM or dedicated
|
||||
/// Windows box. TCP-probes ADS port 48898 on the operator-supplied host. Tests
|
||||
/// skip via <see cref="TwinCATFactAttribute"/> / <see cref="TwinCATTheoryAttribute"/>
|
||||
/// when the runtime isn't reachable, so <c>dotnet test</c> on a fresh clone without
|
||||
/// a TwinCAT VM stays green. Matches the
|
||||
/// <see cref="Modbus.IntegrationTests.ModbusSimulatorFixture"/> /
|
||||
/// <see cref="S7.IntegrationTests.Snap7ServerFixture"/> /
|
||||
/// <c>OpcPlcFixture</c> / <c>AbServerFixture</c> patterns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why a VM, not a container</b>: TwinCAT XAR bypasses the Windows
|
||||
/// kernel scheduler to hit real-time PLC cycles. It can't run inside Docker, and
|
||||
/// on bare metal it conflicts with Hyper-V / WSL 2 — that's why this repo's dev
|
||||
/// environment puts XAR in a dedicated Hyper-V VM per
|
||||
/// <c>docs/v2/dev-environment.md</c> §Integration host. The fixture treats the VM
|
||||
/// as a black-box ADS endpoint reachable over TCP.</para>
|
||||
///
|
||||
/// <para><b>License rotation</b>: the free XAR trial license expires every 7 days.
|
||||
/// When it lapses the runtime goes silent + the fixture's TCP probe fails; tests
|
||||
/// skip with the reason message until the operator renews via
|
||||
/// <c>TcActivate.exe /reactivate</c> (or buys a paid runtime). Intentionally surfaces
|
||||
/// as a skip rather than a hang because "trial expired" is operator action, not a
|
||||
/// test failure.</para>
|
||||
///
|
||||
/// <para><b>Env var overrides</b>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TWINCAT_TARGET_HOST</c> — IP or hostname of the XAR VM (default
|
||||
/// <c>localhost</c>, assumed to be unset on the average dev box + result in a
|
||||
/// clean skip).</item>
|
||||
/// <item><c>TWINCAT_TARGET_NETID</c> — AMS NetId the tests address (e.g.
|
||||
/// <c>5.23.91.23.1.1</c>). Seeded on the target VM via TwinCAT System
|
||||
/// Manager → Routes; the dev box's AmsNetId also needs a bilateral route
|
||||
/// entry on the VM side. No sensible default — tests skip if unset.</item>
|
||||
/// <item><c>TWINCAT_TARGET_PORT</c> — ADS target port (default <c>851</c> =
|
||||
/// TC3 PLC runtime 1). Set to <c>852</c> for runtime 2, etc.</item>
|
||||
/// </list></para>
|
||||
/// </remarks>
|
||||
public sealed class TwinCATXarFixture : IAsyncLifetime
|
||||
{
|
||||
private const string HostEnvVar = "TWINCAT_TARGET_HOST";
|
||||
private const string NetIdEnvVar = "TWINCAT_TARGET_NETID";
|
||||
private const string PortEnvVar = "TWINCAT_TARGET_PORT";
|
||||
|
||||
/// <summary>ADS-over-TCP port on the XAR host. Not the PLC runtime port (that's
|
||||
/// <see cref="AmsPort"/>).</summary>
|
||||
public const int AdsTcpPort = 48898;
|
||||
|
||||
/// <summary>TC3 PLC runtime 1. Override via <see cref="PortEnvVar"/>.</summary>
|
||||
public const int DefaultAmsPort = 851;
|
||||
|
||||
public string TargetHost { get; }
|
||||
public string? TargetNetId { get; }
|
||||
public int AmsPort { get; }
|
||||
public string? SkipReason { get; }
|
||||
|
||||
public TwinCATXarFixture()
|
||||
{
|
||||
TargetHost = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost";
|
||||
TargetNetId = Environment.GetEnvironmentVariable(NetIdEnvVar);
|
||||
AmsPort = int.TryParse(Environment.GetEnvironmentVariable(PortEnvVar), out var p)
|
||||
? p : DefaultAmsPort;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(TargetNetId))
|
||||
{
|
||||
SkipReason = $"TwinCAT XAR unreachable: {NetIdEnvVar} is not set. " +
|
||||
$"Start the XAR VM + set {HostEnvVar}=<vm-ip> and {NetIdEnvVar}=<vm-ams-netid>.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TcpProbe(TargetHost, AdsTcpPort, TimeSpan.FromSeconds(2)))
|
||||
{
|
||||
SkipReason = $"TwinCAT XAR at {TargetHost}:{AdsTcpPort} not reachable within 2 s. " +
|
||||
$"Verify the XAR VM is running, its trial license hasn't expired " +
|
||||
$"(run TcActivate.exe /reactivate on the VM), and {HostEnvVar}/{NetIdEnvVar} point at it.";
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary><c>true</c> when the XAR runtime is reachable + the AmsNetId is set.
|
||||
/// Used by the skip attributes to avoid spinning up the fixture for every test
|
||||
/// class.</summary>
|
||||
public static bool IsRuntimeAvailable()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(NetIdEnvVar))) return false;
|
||||
var host = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost";
|
||||
return TcpProbe(host, AdsTcpPort, TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
private static bool TcpProbe(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(host, port);
|
||||
return task.Wait(timeout) && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class TwinCATXarCollection : Xunit.ICollectionFixture<TwinCATXarFixture>
|
||||
{
|
||||
public const string Name = "TwinCATXar";
|
||||
}
|
||||
|
||||
/// <summary><c>[Fact]</c>-equivalent gated on <see cref="TwinCATXarFixture.IsRuntimeAvailable"/>.</summary>
|
||||
public sealed class TwinCATFactAttribute : FactAttribute
|
||||
{
|
||||
public TwinCATFactAttribute()
|
||||
{
|
||||
if (!TwinCATXarFixture.IsRuntimeAvailable())
|
||||
Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " +
|
||||
"for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>[Theory]</c>-equivalent with the same gate as <see cref="TwinCATFactAttribute"/>.</summary>
|
||||
public sealed class TwinCATTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public TwinCATTheoryAttribute()
|
||||
{
|
||||
if (!TwinCATXarFixture.IsRuntimeAvailable())
|
||||
Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " +
|
||||
"for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
# TwinCAT XAR fixture project
|
||||
|
||||
This folder holds the TwinCAT 3 XAE project that the XAR VM runs for the
|
||||
integration-tests suite (`tests/.../TwinCAT.IntegrationTests/*.cs`).
|
||||
|
||||
**Status today**: stub. The `.tsproj` isn't committed yet; once the XAR
|
||||
VM is up + a project with the required state exists, export via
|
||||
File → Export + drop it here as `OtOpcUaTwinCatFixture.tsproj` + its
|
||||
PLC `.library` / `.plcproj` companions.
|
||||
|
||||
## Why `.tsproj`, not the binary bootproject
|
||||
|
||||
TwinCAT ships two project forms: the XAE `.tsproj` (XML, source of
|
||||
truth) and the compiled bootproject that the XAR runtime actually
|
||||
loads. Ship the `.tsproj` because:
|
||||
|
||||
- Text format — reviewable in PR diffs, diffable in git
|
||||
- Rebuildable across TC3 engineering versions (the XAE tool rebuilds
|
||||
the bootproject from `.tsproj` on "Activate Configuration")
|
||||
- Doesn't carry per-install state (target AmsNetId, source licensing)
|
||||
|
||||
Reconstruction workflow on the VM:
|
||||
|
||||
1. Open TC3 XAE (Visual Studio shell)
|
||||
2. File → Open → `OtOpcUaTwinCatFixture.tsproj`
|
||||
3. Target system → the VM's AmsNetId (set in System Manager → Routes)
|
||||
4. Build → Build Solution (produces the bootproject)
|
||||
5. Activate Configuration → Run Mode (deploys to XAR + starts the
|
||||
runtime)
|
||||
|
||||
## Required project state
|
||||
|
||||
The smoke tests in `TwinCAT3SmokeTests.cs` depend on this exact GVL +
|
||||
PLC setup. Missing or renamed symbols surface as ADS `DeviceSymbolNotFound`
|
||||
or wrong-type read failures, not silent skips.
|
||||
|
||||
### Global Variable List: `GVL_Fixture`
|
||||
|
||||
```st
|
||||
VAR_GLOBAL
|
||||
// Monotonically-increasing counter; MAIN increments each cycle.
|
||||
// Seed value 1234 picked so the smoke test can assert ">= 1234" without
|
||||
// synchronising with the initial cycle.
|
||||
nCounter : DINT := 1234;
|
||||
|
||||
// Scratch REAL for write-then-read round-trip test. Smoke test writes
|
||||
// 42.5 + reads back.
|
||||
rSetpoint : REAL := 0.0;
|
||||
|
||||
// Readable boolean with seed value TRUE. Reserved for future
|
||||
// expansion (e.g. discovery / symbol-browse tests).
|
||||
bFlag : BOOL := TRUE;
|
||||
END_VAR
|
||||
```
|
||||
|
||||
### PLC program: `MAIN`
|
||||
|
||||
```st
|
||||
PROGRAM MAIN
|
||||
VAR
|
||||
END_VAR
|
||||
|
||||
// One-line program: increment the fixture counter every cycle.
|
||||
// The native-notification smoke test subscribes to GVL_Fixture.nCounter
|
||||
// + observes the monotonic changes without a write path.
|
||||
GVL_Fixture.nCounter := GVL_Fixture.nCounter + 1;
|
||||
```
|
||||
|
||||
### Task
|
||||
|
||||
- `PlcTask` — cyclic, 10 ms interval, priority 20
|
||||
- Assigned to `MAIN`
|
||||
|
||||
### Runtime ID
|
||||
|
||||
- TC3 PLC runtime 1 (AMS port `851`) — the smoke-test fixture defaults
|
||||
to this. Use runtime 2 / port `852` only if the single runtime is
|
||||
already taken by another project on the same VM.
|
||||
|
||||
## XAR VM setup (one-time)
|
||||
|
||||
Full bootstrap lives in `docs/v2/dev-environment.md`. The TwinCAT-specific
|
||||
steps:
|
||||
|
||||
1. **Create the Hyper-V VM** — Gen 2, Windows 10/11 64-bit, 4 GB RAM,
|
||||
2 CPUs. External virtual switch so the dev box can reach
|
||||
`<vm-ip>:48898`.
|
||||
2. **Install TwinCAT 3 XAE + XAR** — free download from Beckhoff
|
||||
(`www.beckhoff.com/en-en/products/automation/twincat/`). Activate the
|
||||
7-day trial on first boot.
|
||||
3. **Note the VM's AmsNetId** — shown in the TwinCAT system tray icon →
|
||||
Properties → AMS NetId (format like `5.23.91.23.1.1`).
|
||||
4. **Configure bilateral ADS route**:
|
||||
- On the VM: System Manager → Routes → Add Route → dev box's
|
||||
AmsNetId + IP
|
||||
- On the dev box: edit `%TC_INSTALLPATH%\Target\StaticRoutes.xml` (or
|
||||
use the dev box's own TwinCAT System Manager if installed) to add
|
||||
the VM's AmsNetId + IP
|
||||
5. **Import this project** per the reconstruction workflow above.
|
||||
6. **Hit Activate Configuration + Run Mode**. The runtime starts; the
|
||||
system tray icon goes green; port `48898` is live.
|
||||
|
||||
## License rotation
|
||||
|
||||
The XAR trial expires every 7 days. When it lapses:
|
||||
|
||||
1. The runtime goes silent (red tray icon, ADS port `48898` stops
|
||||
responding to new connections).
|
||||
2. Integration tests skip with the reason message pointing at this
|
||||
folder's README.
|
||||
3. Operator runs `C:\TwinCAT\3.1\Target\StartUp\TcActivate.exe /reactivate`
|
||||
on the VM console (not RDP — the trial activation wants the
|
||||
interactive-login desktop).
|
||||
|
||||
Options to eliminate the manual step:
|
||||
|
||||
- **Scheduled task** that runs the reactivate every 6 days at 02:00 —
|
||||
documented in the Beckhoff forums as working for some TC3 builds,
|
||||
not officially supported.
|
||||
- **Paid runtime license** (~$1k one-time per runtime, per CPU) — kills
|
||||
the rotation permanently, worth it if the integration host is
|
||||
long-lived.
|
||||
|
||||
## How to run the TwinCAT-tier tests
|
||||
|
||||
On the dev box:
|
||||
|
||||
```powershell
|
||||
$env:TWINCAT_TARGET_HOST = '10.0.0.42' # replace with the VM IP
|
||||
$env:TWINCAT_TARGET_NETID = '5.23.91.23.1.1' # replace with the VM AmsNetId
|
||||
# $env:TWINCAT_TARGET_PORT = '852' # only if not using PLC runtime 1
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests
|
||||
```
|
||||
|
||||
With any of those env vars unset, all three smoke tests skip cleanly via
|
||||
`[TwinCATFact]`; unit suite (`TwinCAT.Tests`) runs unchanged.
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/drivers/TwinCAT-Test-Fixture.md`](../../../docs/drivers/TwinCAT-Test-Fixture.md)
|
||||
— coverage map
|
||||
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
|
||||
§Integration host — VM + route + license-rotation notes
|
||||
- Beckhoff Information System → TwinCAT 3 → Product overview + ADS +
|
||||
PLC reference (licensed; internal link only)
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="TwinCatProject\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user