AB Legacy ab_server PCCC Docker fixture scaffold (#224) — Docker infrastructure + test-class shape in place; wire-level round-trip currently blocked by an ab_server-side PCCC coverage gap documented honestly in the fixture + coverage docs. Closes the Docker-infrastructure piece of #224; the remaining work is upstream (patch ab_server's PCCC server opcodes) or sideways (RSEmulate 500 golden-box tier, lab rig).
New project tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/ with four pieces. AbLegacyServerFixture — TCP probe against localhost:44818 (or AB_LEGACY_ENDPOINT override), distinct from AB_SERVER_ENDPOINT so both CIP + PCCC containers can run simultaneously. Single-public-ctor to satisfy xunit collection-fixture constraint. AbLegacyServerProfile + KnownProfiles carry the per-family (SLC500 / MicroLogix / PLC-5) ComposeProfile + Notes; drives per-theory parameterisation. AbLegacyFactAttribute / AbLegacyTheoryAttribute match the AB CIP skip-attribute pattern. Docker/docker-compose.yml reuses the AB CIP otopcua-ab-server:libplctag-release image — `build:` block points at ../../AbCip.IntegrationTests/Docker context so `docker compose build` from here produces / reuses the same multi-stage build. Three compose profiles (slc500 / micrologix / plc5) with per-family `--plc` + `--tag=<file>[<size>]` flags matching the PCCC tag syntax (different from CIP's `Name:Type[size]`). AbLegacyReadSmokeTests — one parametric theory reading N7:0 across all three families + one SLC500 write-then-read on N7:5. Targets the shape the driver would use against real hardware. Verified 2026-04-20 against a live SLC500 container: TCP probe passes + container accepts connections + libplctag negotiates session, but read/write returns BadCommunicationError (libplctag status 0x80050000). Root-caused to ab_server's PCCC server-side opcode coverage being narrower than libplctag's PCCC client expects — not a driver-side bug, not a scaffold bug, just an ab_server upstream limitation. Documented honestly in Docker/README.md + AbLegacy-Test-Fixture.md rather than skipping the tests or weakening assertions; tests now skip cleanly when container is absent, fail with clear message when container is up but the protocol gap surfaces. Operator resolves by filing an ab_server upstream patch, pointing AB_LEGACY_ENDPOINT at real hardware, or scaffolding an RSEmulate 500 golden-box tier. Docker/README.md — Known limitations section leads with the PCCC round-trip gap (test date, failure signature, possible root causes, three resolution paths) before the pre-existing limitations (T/C file decomposition, ST file quirks, indirect addressing, DF1 serial). Reader can't miss the "scaffolded but blocked on upstream" framing. docs/drivers/AbLegacy-Test-Fixture.md — TL;DR flipped from "no integration fixture" to "Docker scaffold in place; wire-level round-trip currently blocked by ab_server PCCC gap". What-the-fixture-is gains an Integration section. Follow-up candidates rewritten: #1 is now "fix ab_server PCCC upstream", #2 is RSEmulate 500 golden-box (with cost callouts matching our existing Logix Emulate + TwinCAT XAR scaffolds — license + Hyper-V conflict + binary project format), #3 is lab rig. Key-files list adds the four new files. docs/drivers/README.md coverage-map row updated from "no integration fixture" to "Docker scaffold via ab_server PCCC; wire-level round-trip currently blocked, docs call out resolution paths". Solution file picks up the new tests/.../AbLegacy.IntegrationTests entry. AbLegacyDataType.Int used throughout (not Int16 — the enum uses SLC file-type naming). Build 0 errors; 2 smoke tests skip cleanly without container + fail with clear errors when container up (proving the infrastructure works end-to-end + the gap is specifically the ab_server protocol coverage, not the scaffold). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
||||||
<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.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.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests.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.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.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.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
||||||
|
|||||||
@@ -3,18 +3,34 @@
|
|||||||
Coverage map + gap inventory for the AB Legacy (PCCC) driver — SLC 500 /
|
Coverage map + gap inventory for the AB Legacy (PCCC) driver — SLC 500 /
|
||||||
MicroLogix / PLC-5 / LogixPccc-mode.
|
MicroLogix / PLC-5 / LogixPccc-mode.
|
||||||
|
|
||||||
**TL;DR: there is no integration fixture.** Everything runs through a
|
**TL;DR:** Docker integration-test scaffolding lives at
|
||||||
`FakeAbLegacyTag` injected via `IAbLegacyTagFactory`. libplctag powers the
|
`tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/` (task #224),
|
||||||
real wire path but ships no in-process fake, and `ab_server` has no PCCC
|
reusing the AB CIP `ab_server` image in PCCC mode with per-family
|
||||||
emulation either — so PCCC behavior against real hardware is trusted from
|
compose profiles (`slc500` / `micrologix` / `plc5`). Scaffold passes
|
||||||
field deployments, not from CI.
|
the skip-when-absent contract cleanly. **Wire-level round-trip against
|
||||||
|
`ab_server` PCCC mode currently fails** with `BadCommunicationError`
|
||||||
|
on read/write (verified 2026-04-20) — ab_server's PCCC server-side
|
||||||
|
coverage is narrower than libplctag's PCCC client expects. The smoke
|
||||||
|
tests target the correct shape for real hardware + should pass when
|
||||||
|
`AB_LEGACY_ENDPOINT` points at a real SLC 5/05 / MicroLogix. Unit tests
|
||||||
|
via `FakeAbLegacyTag` still carry the contract coverage.
|
||||||
|
|
||||||
## What the fixture is
|
## What the fixture is
|
||||||
|
|
||||||
Nothing at the integration layer.
|
**Integration layer** (task #224, scaffolded with a known ab_server
|
||||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` is unit-only, all tests
|
gap):
|
||||||
tagged `[Trait("Category", "Unit")]`. The driver accepts
|
`tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/` with
|
||||||
`IAbLegacyTagFactory` via ctor DI; every test supplies a `FakeAbLegacyTag`.
|
`AbLegacyServerFixture` (TCP-probes `localhost:44818`) + three smoke
|
||||||
|
tests (parametric read across families, SLC500 write-then-read). Reuses
|
||||||
|
the AB CIP `otopcua-ab-server:libplctag-release` image via a relative
|
||||||
|
`build:` context in `Docker/docker-compose.yml` — one image, different
|
||||||
|
`--plc` flags. See `Docker/README.md` §Known limitations for the
|
||||||
|
ab_server PCCC round-trip gap + resolution paths.
|
||||||
|
|
||||||
|
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` is
|
||||||
|
still the primary coverage. All tests tagged `[Trait("Category", "Unit")]`.
|
||||||
|
The driver accepts `IAbLegacyTagFactory` via ctor DI; every test
|
||||||
|
supplies a `FakeAbLegacyTag`.
|
||||||
|
|
||||||
## What it actually covers (unit only)
|
## What it actually covers (unit only)
|
||||||
|
|
||||||
@@ -77,20 +93,32 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
|
|||||||
|
|
||||||
## Follow-up candidates
|
## Follow-up candidates
|
||||||
|
|
||||||
1. **Nothing open-source** — libplctag's test suite runs against real
|
1. **Fix ab_server PCCC coverage upstream** — the scaffold lands the
|
||||||
hardware; there is no public PCCC simulator comparable to `pymodbus` or
|
Docker infrastructure; the wire-level round-trip gap is in ab_server
|
||||||
`ab_server`.
|
itself. Filing a patch to `libplctag/libplctag` to expand PCCC
|
||||||
2. **Lab rig** — cheapest path is a used SLC 5/05 or MicroLogix 1100 on a
|
server-side opcode coverage would make the scaffolded smoke tests
|
||||||
dedicated network; the parts are end-of-life but still available. PLC-5
|
pass without a golden-box tier.
|
||||||
and LogixPccc-mode behavior require those specific controllers.
|
2. **Rockwell RSEmulate 500 golden-box tier** — Rockwell's real emulator
|
||||||
3. **libplctag upstream test harness** — the project's own `tests/` folder
|
for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file
|
||||||
has PCCC cases we could try to adapt, but they assume specific hardware.
|
indirection), timer/counter decomposition, and real ladder execution
|
||||||
|
gaps. Costs: RSLinx OEM license, Windows-only, Hyper-V conflict
|
||||||
AB Legacy is inherently a trust-the-library driver until someone stands up
|
matching TwinCAT XAR + Logix Emulate, no clean PR-diffable project
|
||||||
a rig.
|
format (SLC/ML save as binary `.RSS`). Scaffold like the Logix
|
||||||
|
Emulate tier when operationally worth it.
|
||||||
|
3. **Lab rig** — used SLC 5/05 or MicroLogix 1100 on a dedicated
|
||||||
|
network; parts are end-of-life but still available. PLC-5 +
|
||||||
|
LogixPccc-mode behaviour + DF1 serial need specific controllers.
|
||||||
|
|
||||||
## Key fixture / config files
|
## Key fixture / config files
|
||||||
|
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs`
|
||||||
|
— TCP probe + skip attributes + env-var parsing
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs`
|
||||||
|
— three wire-level smoke tests (currently blocked by ab_server PCCC gap)
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml`
|
||||||
|
— compose profiles reusing AB CIP Dockerfile
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md`
|
||||||
|
— known-limitations write-up + resolution paths
|
||||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs` —
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs` —
|
||||||
in-process fake + factory
|
in-process fake + factory
|
||||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — scope remarks
|
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — scope remarks
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
|
|||||||
- [AB CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only
|
- [AB CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only
|
||||||
- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped
|
- [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`
|
- [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)
|
- [AB Legacy](AbLegacy-Test-Fixture.md) — Docker scaffold via `ab_server` PCCC mode (task #224); wire-level round-trip currently blocked by ab_server's PCCC coverage gap, docs call out RSEmulate 500 + lab-rig resolution paths
|
||||||
- [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
|
- [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
|
- [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
|
- [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
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end smoke tests against the <c>ab_server</c> PCCC Docker container.
|
||||||
|
/// Promotes the AB Legacy driver from unit-only coverage (<c>FakeAbLegacyTag</c>)
|
||||||
|
/// to wire-level: real libplctag PCCC stack over real TCP against the ab_server
|
||||||
|
/// simulator. Parametrised over all three families (SLC 500 / MicroLogix / PLC-5)
|
||||||
|
/// via <c>[AbLegacyTheory]</c> + <c>[MemberData]</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(AbLegacyServerCollection.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Simulator", "ab_server-PCCC")]
|
||||||
|
public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
|
||||||
|
{
|
||||||
|
public static IEnumerable<object[]> Profiles =>
|
||||||
|
KnownProfiles.All.Select(p => new object[] { p });
|
||||||
|
|
||||||
|
[AbLegacyTheory]
|
||||||
|
[MemberData(nameof(Profiles))]
|
||||||
|
public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile)
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
// PCCC PLCs use empty cip-path, but AbLegacyHostAddress still requires the
|
||||||
|
// /cip-path suffix to parse.
|
||||||
|
var deviceUri = $"ab://{sim.Host}:{sim.Port}/";
|
||||||
|
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
|
||||||
|
Tags = [
|
||||||
|
new AbLegacyTagDefinition(
|
||||||
|
Name: "IntCounter",
|
||||||
|
DeviceHostAddress: deviceUri,
|
||||||
|
Address: "N7:0",
|
||||||
|
DataType: AbLegacyDataType.Int),
|
||||||
|
],
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, driverInstanceId: $"ablegacy-smoke-{profile.Family}");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(
|
||||||
|
["IntCounter"], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
|
||||||
|
$"N7:0 read must succeed against the {profile.Family} compose profile");
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AbLegacyFact]
|
||||||
|
public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
// PCCC PLCs use empty cip-path, but AbLegacyHostAddress still requires the
|
||||||
|
// /cip-path suffix to parse.
|
||||||
|
var deviceUri = $"ab://{sim.Host}:{sim.Port}/";
|
||||||
|
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
|
||||||
|
Tags = [
|
||||||
|
new AbLegacyTagDefinition(
|
||||||
|
Name: "Scratch",
|
||||||
|
DeviceHostAddress: deviceUri,
|
||||||
|
Address: "N7:5",
|
||||||
|
DataType: AbLegacyDataType.Int,
|
||||||
|
Writable: true),
|
||||||
|
],
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, driverInstanceId: "ablegacy-smoke-rw");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
const short probe = 0x1234;
|
||||||
|
var writeResults = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Scratch", probe)],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
writeResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
|
||||||
|
"PCCC N7:5 write must succeed end-to-end");
|
||||||
|
|
||||||
|
var readResults = await drv.ReadAsync(
|
||||||
|
["Scratch"], TestContext.Current.CancellationToken);
|
||||||
|
readResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
Convert.ToInt32(readResults.Single().Value).ShouldBe(probe);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reachability probe for the <c>ab_server</c> Docker container running in a PCCC
|
||||||
|
/// plc mode (<c>SLC500</c> / <c>Micrologix</c> / <c>PLC/5</c>). Same container image
|
||||||
|
/// the AB CIP integration suite uses — libplctag's <c>ab_server</c> supports both
|
||||||
|
/// CIP + PCCC families from one binary. Tests skip via
|
||||||
|
/// <see cref="AbLegacyFactAttribute"/> / <see cref="AbLegacyTheoryAttribute"/> when
|
||||||
|
/// the port isn't live, so <c>dotnet test</c> stays green on a fresh clone without
|
||||||
|
/// Docker running.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Env-var overrides:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>AB_LEGACY_ENDPOINT</c> — <c>host:port</c> of the PCCC-mode simulator.
|
||||||
|
/// Defaults to <c>localhost:44818</c> (EtherNet/IP port; ab_server's PCCC
|
||||||
|
/// emulation exposes PCCC-over-CIP on the same port as CIP itself).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Distinct from <c>AB_SERVER_ENDPOINT</c> used by the AB CIP fixture so both
|
||||||
|
/// can point at different containers simultaneously during a combined test run.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AbLegacyServerFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
|
||||||
|
|
||||||
|
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
|
||||||
|
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
|
||||||
|
/// with, not a different TCP listener.</summary>
|
||||||
|
public const int DefaultPort = 44818;
|
||||||
|
|
||||||
|
public string Host { get; } = "127.0.0.1";
|
||||||
|
public int Port { get; } = DefaultPort;
|
||||||
|
public string? SkipReason { get; }
|
||||||
|
|
||||||
|
public AbLegacyServerFixture()
|
||||||
|
{
|
||||||
|
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
|
||||||
|
{
|
||||||
|
var parts = raw.Split(':', 2);
|
||||||
|
Host = parts[0];
|
||||||
|
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TcpProbe(Host, Port))
|
||||||
|
{
|
||||||
|
SkipReason =
|
||||||
|
$"AB Legacy PCCC simulator at {Host}:{Port} not reachable within 2 s. " +
|
||||||
|
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
|
||||||
|
$"--profile slc500 up -d) or override {EndpointEnvVar}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
public static bool IsServerAvailable()
|
||||||
|
{
|
||||||
|
var (host, port) = ResolveEndpoint();
|
||||||
|
return TcpProbe(host, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Host, int Port) ResolveEndpoint()
|
||||||
|
{
|
||||||
|
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
|
||||||
|
if (raw is null) return ("127.0.0.1", DefaultPort);
|
||||||
|
var parts = raw.Split(':', 2);
|
||||||
|
var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
|
||||||
|
return (parts[0], port);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TcpProbe(string host, int port)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
var task = client.ConnectAsync(host, port);
|
||||||
|
return task.Wait(TimeSpan.FromSeconds(2)) && client.Connected;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-family marker for the PCCC-mode compose profile a given test targets. The
|
||||||
|
/// compose file (<c>Docker/docker-compose.yml</c>) is the canonical source of truth
|
||||||
|
/// for which <c>--plc</c> mode + tags each family seeds; this record just ties a
|
||||||
|
/// family enum to its compose-profile name + operator-facing notes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AbLegacyServerProfile(
|
||||||
|
AbLegacyPlcFamily Family,
|
||||||
|
string ComposeProfile,
|
||||||
|
string Notes);
|
||||||
|
|
||||||
|
/// <summary>Canonical profiles covering every PCCC family the driver supports.</summary>
|
||||||
|
public static class KnownProfiles
|
||||||
|
{
|
||||||
|
public static readonly AbLegacyServerProfile Slc500 = new(
|
||||||
|
Family: AbLegacyPlcFamily.Slc500,
|
||||||
|
ComposeProfile: "slc500",
|
||||||
|
Notes: "SLC 500 / 5/05 family. ab_server SLC500 mode covers N/F/B/L files.");
|
||||||
|
|
||||||
|
public static readonly AbLegacyServerProfile MicroLogix = new(
|
||||||
|
Family: AbLegacyPlcFamily.MicroLogix,
|
||||||
|
ComposeProfile: "micrologix",
|
||||||
|
Notes: "MicroLogix 1000 / 1100 / 1400. Shares N/F/B file-type coverage with SLC500; ST (ASCII strings) included.");
|
||||||
|
|
||||||
|
public static readonly AbLegacyServerProfile Plc5 = new(
|
||||||
|
Family: AbLegacyPlcFamily.Plc5,
|
||||||
|
ComposeProfile: "plc5",
|
||||||
|
Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only.");
|
||||||
|
|
||||||
|
public static IReadOnlyList<AbLegacyServerProfile> All { get; } =
|
||||||
|
[Slc500, MicroLogix, Plc5];
|
||||||
|
|
||||||
|
public static AbLegacyServerProfile ForFamily(AbLegacyPlcFamily family) =>
|
||||||
|
All.FirstOrDefault(p => p.Family == family)
|
||||||
|
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Xunit.CollectionDefinition(Name)]
|
||||||
|
public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture<AbLegacyServerFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "AbLegacyServer";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>[Fact]</c>-equivalent that skips when the PCCC simulator isn't reachable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AbLegacyFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
public AbLegacyFactAttribute()
|
||||||
|
{
|
||||||
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||||
|
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
||||||
|
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
||||||
|
"or set AB_LEGACY_ENDPOINT.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>[Theory]</c>-equivalent with the same gate as <see cref="AbLegacyFactAttribute"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AbLegacyTheoryAttribute : TheoryAttribute
|
||||||
|
{
|
||||||
|
public AbLegacyTheoryAttribute()
|
||||||
|
{
|
||||||
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||||
|
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
||||||
|
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
||||||
|
"or set AB_LEGACY_ENDPOINT.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# AB Legacy PCCC integration-test fixture — `ab_server` (Docker)
|
||||||
|
|
||||||
|
[libplctag](https://github.com/libplctag/libplctag)'s `ab_server` supports
|
||||||
|
both CIP (ControlLogix / CompactLogix / Micro800) and PCCC (SLC 500 /
|
||||||
|
MicroLogix / PLC-5) families from one binary. This fixture reuses the AB
|
||||||
|
CIP Docker image (`otopcua-ab-server:libplctag-release`) with different
|
||||||
|
`--plc` flags. No new Dockerfile needed — the compose file's `build:`
|
||||||
|
block points at the AB CIP `Docker/` folder so `docker compose build`
|
||||||
|
from here reuses the same multi-stage build.
|
||||||
|
|
||||||
|
**Docker is the only supported launch path**; a fresh clone needs Docker
|
||||||
|
Desktop and nothing else.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [`docker-compose.yml`](docker-compose.yml) | Three per-family services (`slc500` / `micrologix` / `plc5`); all bind `:44818` |
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# SLC 500 family — widest PCCC coverage
|
||||||
|
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests\Docker\docker-compose.yml --profile slc500 up
|
||||||
|
|
||||||
|
# Per-family
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile micrologix up
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile plc5 up
|
||||||
|
```
|
||||||
|
|
||||||
|
Detached + stop:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 up -d
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 down
|
||||||
|
```
|
||||||
|
|
||||||
|
First run builds the `otopcua-ab-server:libplctag-release` image (~3-5
|
||||||
|
min — clones libplctag + compiles `ab_server`). If the AB CIP fixture
|
||||||
|
already built the image locally, docker reuses the cached layers + this
|
||||||
|
runs in seconds. Only one family binds `:44818` at a time; to switch
|
||||||
|
families stop the current service + start another.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
- Default: `localhost:44818` (EtherNet/IP standard)
|
||||||
|
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
|
||||||
|
MicroLogix / PLC-5 PLC on its native port.
|
||||||
|
|
||||||
|
## Run the integration tests
|
||||||
|
|
||||||
|
In a separate shell with a container up:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||||
|
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
`AbLegacyServerFixture` TCP-probes `localhost:44818` at collection init +
|
||||||
|
records a skip reason when unreachable. Tests use `[AbLegacyFact]` /
|
||||||
|
`[AbLegacyTheory]` which check the same probe.
|
||||||
|
|
||||||
|
## What each family seeds
|
||||||
|
|
||||||
|
PCCC tag format is `<file>[<size>]` without a type suffix — file letter
|
||||||
|
implies type:
|
||||||
|
|
||||||
|
- `N` = 16-bit signed integer
|
||||||
|
- `F` = 32-bit IEEE 754 float
|
||||||
|
- `B` = 1-bit boolean (stored as uint16, bit-addressable via `/n`)
|
||||||
|
- `L` = 32-bit signed integer (SLC 5/05 V15+ only)
|
||||||
|
- `ST` = 82-byte ASCII string (MicroLogix-specific extension)
|
||||||
|
|
||||||
|
| Family | Seeded tags | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| SLC 500 | `N7[10]`, `F8[10]`, `B3[10]`, `L19[10]` | Baseline; covers the four numeric file types a typical SLC project uses |
|
||||||
|
| MicroLogix | `B3[10]`, `N7[10]`, `L19[10]` | No `F8` — MicroLogix 1000 has no float file; use L19 when scaled integers aren't enough |
|
||||||
|
| PLC-5 | `N7[10]`, `F8[10]`, `B3[10]` | No `L` — PLC-5 predates the L file type; DINT equivalents went in integer files |
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
### ab_server PCCC read/write round-trip (verified 2026-04-20)
|
||||||
|
|
||||||
|
**Scaffold is in place; wire-level round-trip does NOT currently pass
|
||||||
|
against `ab_server --plc=SLC500`.** With the SLC500 compose profile up,
|
||||||
|
TCP 44818 accepts connections and libplctag negotiates the session,
|
||||||
|
but the three smoke tests in `AbLegacyReadSmokeTests.cs` all fail at
|
||||||
|
read/write with `BadCommunicationError` (libplctag status `0x80050000`).
|
||||||
|
Possible root causes:
|
||||||
|
|
||||||
|
- ab_server's PCCC server-side opcode coverage may be narrower than
|
||||||
|
libplctag's PCCC client expects — the tool is primarily a CIP
|
||||||
|
server; PCCC was added later + is noted in libplctag docs as less
|
||||||
|
mature.
|
||||||
|
- libplctag's PCCC-over-CIP encapsulation may assume a real SLC 5/05
|
||||||
|
EtherNet/IP NIC's framing that ab_server doesn't emit.
|
||||||
|
|
||||||
|
The scaffold ships **as-is** because:
|
||||||
|
|
||||||
|
1. The Docker infrastructure + fixture pattern works cleanly (probe
|
||||||
|
passes, container lifecycle is clean, tests skip when absent).
|
||||||
|
2. The test classes target the correct shape for what the AB Legacy
|
||||||
|
driver would do against real hardware.
|
||||||
|
3. Pointing `AB_LEGACY_ENDPOINT` at a real SLC 5/05 / MicroLogix
|
||||||
|
1100 / 1400 should make the tests pass outright — the failure
|
||||||
|
mode is ab_server-specific, not driver-specific.
|
||||||
|
|
||||||
|
Resolution paths (pick one):
|
||||||
|
|
||||||
|
1. **File an ab_server bug** in `libplctag/libplctag` to expand PCCC
|
||||||
|
server-side coverage.
|
||||||
|
2. **Golden-box tier** via Rockwell RSEmulate 500 — closer to real
|
||||||
|
firmware, but license-gated + RSLinx-dependent.
|
||||||
|
3. **Lab rig** — used SLC 5/05 / MicroLogix 1100 on a dedicated
|
||||||
|
network; the authoritative path.
|
||||||
|
|
||||||
|
### Other known gaps (unchanged from ab_server)
|
||||||
|
|
||||||
|
- **Timer / Counter file decomposition** — PCCC T4 / C5 files contain
|
||||||
|
three-field structs (`.ACC` / `.PRE` / `.DN`). Not in ab_server's
|
||||||
|
scope; tests targeting `T4:0.ACC` stay unit-only.
|
||||||
|
- **ST (ASCII string) files** — real MicroLogix ST files have a length
|
||||||
|
field plus CRLF-sensitive semantics that don't round-trip cleanly.
|
||||||
|
- **Indirect addressing** (`N7:[N10:5]`) — not in ab_server's scope.
|
||||||
|
- **DF1 serial wire behaviour** — the whole ab_server path is TCP;
|
||||||
|
DF1 radio / serial fidelity needs real hardware.
|
||||||
|
|
||||||
|
See [`docs/drivers/AbLegacy-Test-Fixture.md`](../../../docs/drivers/AbLegacy-Test-Fixture.md)
|
||||||
|
for the full coverage map.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [libplctag on GitHub](https://github.com/libplctag/libplctag) — `ab_server`
|
||||||
|
lives under `src/tools/ab_server/`
|
||||||
|
- [`docs/drivers/AbLegacy-Test-Fixture.md`](../../../docs/drivers/AbLegacy-Test-Fixture.md)
|
||||||
|
— coverage map + gap inventory
|
||||||
|
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
|
||||||
|
§Docker fixtures — full fixture inventory
|
||||||
|
- [`../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/`](../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/)
|
||||||
|
— the shared Dockerfile this compose file's `build:` block references
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# AB Legacy PCCC integration-test fixture — ab_server in PCCC mode.
|
||||||
|
#
|
||||||
|
# Same image as the AB CIP fixture (otopcua-ab-server:libplctag-release).
|
||||||
|
# The build context points at the AB CIP Docker folder one directory over
|
||||||
|
# so `docker compose build` from here produces the same image if it
|
||||||
|
# doesn't already exist; if it does, docker's cache reuses the layer.
|
||||||
|
#
|
||||||
|
# One service per PCCC family. All bind :44818 on the host; run one at a
|
||||||
|
# time. PCCC tag format differs from CIP: `<file>[<size>]` without a
|
||||||
|
# type suffix since the type is implicit in the file letter (N = INT,
|
||||||
|
# F = REAL, B = bit-packed, L = DINT).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose --profile slc500 up
|
||||||
|
# docker compose --profile micrologix up
|
||||||
|
# docker compose --profile plc5 up
|
||||||
|
services:
|
||||||
|
slc500:
|
||||||
|
profiles: ["slc500"]
|
||||||
|
build:
|
||||||
|
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: otopcua-ab-server:libplctag-release
|
||||||
|
container_name: otopcua-ab-server-slc500
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "44818:44818"
|
||||||
|
command: [
|
||||||
|
"ab_server",
|
||||||
|
"--plc=SLC500",
|
||||||
|
"--port=44818",
|
||||||
|
"--tag=N7[10]",
|
||||||
|
"--tag=F8[10]",
|
||||||
|
"--tag=B3[10]",
|
||||||
|
"--tag=L19[10]"
|
||||||
|
]
|
||||||
|
|
||||||
|
micrologix:
|
||||||
|
profiles: ["micrologix"]
|
||||||
|
image: otopcua-ab-server:libplctag-release
|
||||||
|
build:
|
||||||
|
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: otopcua-ab-server-micrologix
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "44818:44818"
|
||||||
|
command: [
|
||||||
|
"ab_server",
|
||||||
|
"--plc=Micrologix",
|
||||||
|
"--port=44818",
|
||||||
|
"--tag=B3[10]",
|
||||||
|
"--tag=N7[10]",
|
||||||
|
"--tag=L19[10]"
|
||||||
|
]
|
||||||
|
|
||||||
|
plc5:
|
||||||
|
profiles: ["plc5"]
|
||||||
|
image: otopcua-ab-server:libplctag-release
|
||||||
|
build:
|
||||||
|
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: otopcua-ab-server-plc5
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "44818:44818"
|
||||||
|
command: [
|
||||||
|
"ab_server",
|
||||||
|
"--plc=PLC/5",
|
||||||
|
"--port=44818",
|
||||||
|
"--tag=N7[10]",
|
||||||
|
"--tag=F8[10]",
|
||||||
|
"--tag=B3[10]"
|
||||||
|
]
|
||||||
@@ -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.AbLegacy.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.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Docker\**\*" 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