TwinCAT XAR integration fixture scaffold (#221) #166

Merged
dohertj2 merged 1 commits from twincat-xar-fixture-scaffold into v2 2026-04-20 13:11:55 -04:00
7 changed files with 525 additions and 26 deletions

View File

@@ -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"/>

View File

@@ -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

View File

@@ -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`

View File

@@ -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 },
};
}

View File

@@ -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.";
}
}

View File

@@ -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)

View File

@@ -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>