diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs
index 313892ce..9bbfd8f4 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs
@@ -41,13 +41,13 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
[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);
+ if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
// PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing
// after the `/`), but libplctag's ab_server requires a non-empty path at the
// CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0`
// against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW.
- var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
+ var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
@@ -75,23 +75,25 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
[AbLegacyFact]
public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register()
{
- if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ // Addresses the SLC500 container by its OWN port, so it no longer matters which other
+ // families happen to be running. The old AB_LEGACY_COMPOSE_PROFILE guard existed because every
+ // family shared :44818 and this test could otherwise write SLC500 semantics at whichever
+ // container held the port; it survives below only for real-hardware runs, where
+ // AB_LEGACY_ENDPOINT pins one device and the family IS a property of that device.
+ if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip);
- // Skip when the running compose profile isn't SLC500 — ab_server's `--plc=`
- // flag selects exactly one family per process, so a write against a plc5-mode
- // container with SLC500 semantics always fails at the wire.
var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE");
- if (!string.IsNullOrEmpty(only) &&
+ if (sim.EndpointPinned && !string.IsNullOrEmpty(only) &&
!string.Equals(only, "slc500", StringComparison.OrdinalIgnoreCase))
{
- Assert.Skip($"Test targets the SLC500 compose profile; AB_LEGACY_COMPOSE_PROFILE='{only}'.");
+ Assert.Skip($"Pinned endpoint is not SLC500; AB_LEGACY_COMPOSE_PROFILE='{only}'.");
}
// PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing
// after the `/`), but libplctag's ab_server requires a non-empty path at the
// CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0`
// against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW.
- var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
+ var deviceUri = $"ab://{sim.Host}:{sim.PortFor(KnownProfiles.Slc500)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs
index 7faa0ca8..f0a5a347 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs
@@ -17,9 +17,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
///
/// Env-var overrides:
///
-/// - AB_LEGACY_ENDPOINT — host:port of the PCCC-mode simulator.
-/// Defaults to 10.100.0.35:44818 — the shared Docker host (EtherNet/IP port; ab_server's PCCC
-/// emulation exposes PCCC-over-CIP on the same port as CIP itself).
+/// - AB_LEGACY_ENDPOINT — host:port of a single PCCC-mode device, pinning
+/// every family to it (real-hardware mode, where the family is a property of the device
+/// rather than of the port). Unset — the default — each family is reached on its own port on the
+/// shared Docker host: 10.100.0.35:44819 slc500, :44820 micrologix,
+/// :44821 plc5.
/// - AB_LEGACY_CIP_PATH — routing path appended to the ab://host:port/
/// URI. Defaults to 1,0 (port-1/slot-0 backplane), required by ab_server
/// which rejects unconnected Send_RR_Data with an empty path at the CIP layer
@@ -27,8 +29,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// use an empty path — set AB_LEGACY_CIP_PATH= (empty) when pointing at
/// real hardware.
///
-/// Distinct from AB_SERVER_ENDPOINT used by the AB CIP fixture so both
-/// can point at different containers simultaneously during a combined test run.
+/// Distinct from AB_SERVER_ENDPOINT used by the AB CIP fixture so both can point at different
+/// containers simultaneously during a combined test run — which was the stated intent from the start
+/// but was not true in practice until 2026-08-01, because every ab_server across both fixtures
+/// defaulted to :44818.
///
public sealed class AbLegacyServerFixture : IAsyncLifetime
{
@@ -56,15 +60,58 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
/// Gets the server host address.
public string Host { get; } = "10.100.0.35";
- /// Gets the server TCP port.
+ ///
+ /// The port every family shares when AB_LEGACY_ENDPOINT pins one, or
+ /// . Prefer — against the Docker fixture each
+ /// family listens on its own port and this value addresses none of them.
+ ///
public int Port { get; } = DefaultPort;
+ /// True when AB_LEGACY_ENDPOINT pinned a single endpoint (real-hardware mode).
+ public bool EndpointPinned { get; }
+
+ ///
+ /// The port to reach 's simulator on: the family's own port against the
+ /// Docker fixture, or the pinned endpoint's port when AB_LEGACY_ENDPOINT is set.
+ ///
+ /// The family profile under test.
+ /// The TCP port.
+ public int PortFor(AbLegacyServerProfile profile)
+ {
+ ArgumentNullException.ThrowIfNull(profile);
+ return EndpointPinned ? Port : profile.Port;
+ }
+
+ ///
+ /// Why 's simulator cannot be reached, or null when it can.
+ ///
+ ///
+ /// ⚠️ Per-family, not per-suite (2026-08-01). Every family used to share :44818, so
+ /// this could only ever answer "is some ab_server up" — and the AB CIP fixture's ControlLogix
+ /// container answers there too. The probe therefore reported reachable, the suite declined to
+ /// skip, and SLC500-mode PCCC ran against a ControlLogix and failed at the wire. A reachability
+ /// check that cannot tell one simulator from another is not a skip gate; it is a coin flip that
+ /// reports its result as a test failure.
+ ///
+ /// The family profile under test.
+ /// A skip reason, or .
+ public string? SkipReasonFor(AbLegacyServerProfile profile)
+ {
+ ArgumentNullException.ThrowIfNull(profile);
+ var port = PortFor(profile);
+ if (TcpProbe(Host, port)) return null;
+
+ return EndpointPinned
+ ? $"AB Legacy PCCC endpoint at {Host}:{port} ({EndpointEnvVar}) not reachable within 2 s."
+ : $"AB Legacy {profile.ComposeProfile} simulator at {Host}:{port} not reachable within 2 s. "
+ + $"Start it (docker compose -f Docker/docker-compose.yml --profile {profile.ComposeProfile} "
+ + $"up -d), attach real hardware, or override {EndpointEnvVar}.";
+ }
+
/// CIP routing path portion of the device URI (after the / separator).
/// May be empty when targeting real hardware; non-empty against ab_server.
public string CipPath { get; } = DefaultCipPath;
- /// Gets the skip reason if the server is unavailable, or null if available.
- public string? SkipReason { get; }
/// Initializes the AB Legacy server fixture by reading environment overrides.
public AbLegacyServerFixture()
@@ -74,6 +121,7 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
var parts = raw.Split(':', 2);
Host = parts[0];
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
+ EndpointPinned = true;
}
// Empty override is intentional (real hardware); treat `null` as "not set, use
@@ -81,7 +129,6 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
var cipOverride = Environment.GetEnvironmentVariable(CipPathEnvVar);
if (cipOverride is not null) CipPath = cipOverride;
- SkipReason = ResolveSkipReason(Host, Port);
}
/// Initializes the fixture asynchronously.
@@ -97,29 +144,19 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
/// exists.
///
public static bool IsServerAvailable()
- {
- var (host, port) = ResolveEndpoint();
- return ResolveSkipReason(host, port) is null;
- }
-
- private static string? ResolveSkipReason(string host, int port)
- {
- if (!TcpProbe(host, port))
- {
- return $"AB Legacy PCCC endpoint at {host}:{port} not reachable within 2 s. " +
- $"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
- $"--profile slc500 up -d), attach real hardware, or override {EndpointEnvVar}.";
- }
- return null;
- }
-
- private static (string Host, int Port) ResolveEndpoint()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
- if (raw is null) return ("10.100.0.35", DefaultPort);
- var parts = raw.Split(':', 2);
- var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
- return (parts[0], port);
+ if (raw is { Length: > 0 })
+ {
+ var parts = raw.Split(':', 2);
+ var pinned = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
+ return TcpProbe(parts[0], pinned);
+ }
+
+ // ANY family's simulator being up runs the class; each test then skips itself on its own port
+ // via SkipReasonFor. Gating the whole class on one family would skip the other two whenever it
+ // happened to be down, which reads as coverage that ran.
+ return KnownProfiles.All.Any(profile => TcpProbe("10.100.0.35", profile.Port));
}
private static bool TcpProbe(string host, int port)
@@ -140,27 +177,42 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
/// for which --plc mode + tags each family seeds; this record just ties a
/// family enum to its compose-profile name + operator-facing notes.
///
+/// The PCCC family.
+/// The compose profile name that starts this family's container.
+///
+/// The family's own host port. Each family gets one so all three run together, and alongside the
+/// AB CIP fixture on :44818. Overridden wholesale by AB_LEGACY_ENDPOINT (real hardware,
+/// where there is one device and the family is a property of it, not of the port).
+///
+/// Operator-facing description of what the family's container seeds.
public sealed record AbLegacyServerProfile(
AbLegacyPlcFamily Family,
string ComposeProfile,
+ int Port,
string Notes);
/// Canonical profiles covering every PCCC family the driver supports.
+///
+/// The Port values must match Docker/docker-compose.yml's host-side mappings.
+///
public static class KnownProfiles
{
public static readonly AbLegacyServerProfile Slc500 = new(
Family: AbLegacyPlcFamily.Slc500,
ComposeProfile: "slc500",
+ Port: 44819,
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",
+ Port: 44820,
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",
+ Port: 44821,
Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only.");
/// Gets all known profiles.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md
index 2245bd23..84edcf90 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md
@@ -13,7 +13,7 @@ Desktop and nothing else.
| File | Purpose |
|---|---|
-| [`docker-compose.yml`](docker-compose.yml) | Three per-family services (`slc500` / `micrologix` / `plc5`); all bind `:44818` |
+| [`docker-compose.yml`](docker-compose.yml) | Three per-family services — `slc500` on `:44819`, `micrologix` on `:44820`, `plc5` on `:44821`. Run them together; they no longer collide with each other or with the AB CIP fixture on `:44818`. |
## Run
@@ -38,12 +38,12 @@ 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
+runs in seconds. (Until 2026-08-01 every family bound `:44818`, so only one could run and the theory's other two cases always failed; each now has its own port.) To switch
families stop the current service + start another.
## Endpoint
-- Default: `localhost:44818` (EtherNet/IP standard)
+- Default: `10.100.0.35:44819` / `:44820` / `:44821` per family (the shared Docker host)
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
MicroLogix / PLC-5 PLC on its native port.
@@ -51,9 +51,9 @@ families stop the current service + start another.
| Var | Default | Purpose |
|---|---|---|
-| `AB_LEGACY_ENDPOINT` | `localhost:44818` | `host:port` of the PCCC endpoint. |
+| `AB_LEGACY_ENDPOINT` | *unset* — per-family ports on `10.100.0.35` | `host:port` of a single PCCC device, pinning **every** family to it (real-hardware mode). |
| `AB_LEGACY_CIP_PATH` | `1,0` | CIP routing path portion of the `ab://host:port/` URI. ab_server rejects empty paths at the CIP unconnected-send layer; real SLC/MicroLogix/PLC-5 hardware accepts empty (no backplane). Set to empty (`AB_LEGACY_CIP_PATH=`) when pointing at real hardware. |
-| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | When set (e.g. `slc500`), the parametric theory filters to that profile. Only one compose container binds `:44818` at a time; set this to the profile currently up so the suite doesn't try to hit e.g. the Slc500 family against the PLC-5 container. Leave unset for real-hardware runs (all 3 families parameterize). |
+| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | Only consulted when `AB_LEGACY_ENDPOINT` pins an endpoint: names which family that one device is, so the SLC500-only write test skips against other hardware. Against the Docker fixture it is unnecessary — each family has its own port, and each test skips itself if its own port is down. |
## Run the integration tests
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml
index f0d34323..97e4df73 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml
@@ -5,15 +5,20 @@
# 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: `[]` without a
-# type suffix since the type is implicit in the file letter (N = INT,
-# F = REAL, B = bit-packed, L = DINT).
+# One service per PCCC family, each on its OWN host port — 44819 slc500,
+# 44820 micrologix, 44821 plc5 — so all three run together, and alongside the
+# AB CIP fixture, which owns :44818. They all bound :44818 until 2026-08-01,
+# which made the suite structurally un-greenable: the theory parameterises over
+# all three families, but only one container could ever exist, so at least two
+# cases failed on every run. Worse, the AB CIP ControlLogix container answers
+# on :44818 too, so the fixture's TCP reachability probe saw "up", declined to
+# skip, and ran SLC500-mode PCCC against a ControlLogix.
#
-# Usage:
-# docker compose --profile slc500 up
-# docker compose --profile micrologix up
-# docker compose --profile plc5 up
+# ab_server still listens on 44818 INSIDE the container (--port=44818); only the
+# host mapping differs. Keep these in step with AbLegacyServerProfile.Port.
+#
+# Usage (all three at once — that is the point):
+# docker compose --profile slc500 --profile micrologix --profile plc5 up -d
services:
slc500:
profiles: ["slc500"]
@@ -24,7 +29,7 @@ services:
container_name: otopcua-ab-server-slc500
restart: "no"
ports:
- - "44818:44818"
+ - "44819:44818"
command: [
"ab_server",
"--plc=SLC500",
@@ -44,7 +49,7 @@ services:
container_name: otopcua-ab-server-micrologix
restart: "no"
ports:
- - "44818:44818"
+ - "44820:44818"
command: [
"ab_server",
"--plc=Micrologix",
@@ -63,7 +68,7 @@ services:
container_name: otopcua-ab-server-plc5
restart: "no"
ports:
- - "44818:44818"
+ - "44821:44818"
command: [
"ab_server",
"--plc=PLC/5",