test(ablegacy): deploy the PCCC fixture, one port per family (#534)
The AbLegacy integration suite has never run against a simulator. /opt/otopcua-ablegacy/ did not exist on the Docker host — the stack was written but never deployed — and every ab_server across BOTH ab fixtures bound :44818. So the AB CIP fixture's ControlLogix container (up for two weeks) answered this suite's TCP reachability probe, the suite declined to skip, and it ran SLC500-mode PCCC against a ControlLogix. Four failures that read as "fixture missing". Two structural problems, both fixed here: - The theory parameterises over all three PCCC families, but only one container could ever exist on the shared port, so at least two cases failed on every conceivable run. The suite was un-greenable by construction, and AB_LEGACY_COMPOSE_PROFILE existed to paper over it by filtering the theory down to whichever container happened to be up. - A reachability probe that cannot tell one simulator from another is not a skip gate. It answered "is some ab_server listening", which is a different question from the one the caller is asking, and reported the difference as a test failure. Each family now has its own host port — 44819 slc500, 44820 micrologix, 44821 plc5 — so all three run together and alongside AbCip on 44818 (ab_server still listens on 44818 inside each container; only the host mapping differs). Skip is per-family via SkipReasonFor(profile); the class-level gate runs if ANY family is up so one down container cannot silently skip the other two. AB_LEGACY_COMPOSE_PROFILE now applies only in AB_LEGACY_ENDPOINT-pinned real-hardware mode, where the family genuinely is a property of the one device. This finally makes true what the fixture's XML doc has claimed from the start — that it and the AB CIP fixture can point at different containers simultaneously. Deployed to /opt/otopcua-ablegacy/ on 10.100.0.35; all three containers running. The 4 tests still FAIL, and are left red on purpose — see #534. Now aimed at correct-family simulators, the PCCC exchange itself returns BadCommunicationError for every server-path / client-cip-path permutation, reproduced outside the test harness via the driver CLI. That is a true signal about the driver (or about libplctag's PCCC support against ab_server) that skipping would hide. What changed here is that the failure is now diagnostic instead of an artifact of a port collision. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
+11
-9
@@ -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)],
|
||||
|
||||
+82
-30
@@ -17,9 +17,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
/// <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>10.100.0.35:44818</c> — the shared Docker host (EtherNet/IP port; ab_server's PCCC
|
||||
/// emulation exposes PCCC-over-CIP on the same port as CIP itself).</item>
|
||||
/// <item><c>AB_LEGACY_ENDPOINT</c> — <c>host:port</c> of a single PCCC-mode device, pinning
|
||||
/// <b>every</b> 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: <c>10.100.0.35:44819</c> slc500, <c>:44820</c> micrologix,
|
||||
/// <c>:44821</c> plc5.</item>
|
||||
/// <item><c>AB_LEGACY_CIP_PATH</c> — routing path appended to the <c>ab://host:port/</c>
|
||||
/// URI. Defaults to <c>1,0</c> (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 <c>AB_LEGACY_CIP_PATH=</c> (empty) when pointing at
|
||||
/// real hardware.</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.
|
||||
/// 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 — 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 <c>:44818</c>.
|
||||
/// </remarks>
|
||||
public sealed class AbLegacyServerFixture : IAsyncLifetime
|
||||
{
|
||||
@@ -56,15 +60,58 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
|
||||
/// <summary>Gets the server host address.</summary>
|
||||
public string Host { get; } = "10.100.0.35";
|
||||
|
||||
/// <summary>Gets the server TCP port.</summary>
|
||||
/// <summary>
|
||||
/// The port every family shares when <c>AB_LEGACY_ENDPOINT</c> pins one, or
|
||||
/// <see cref="DefaultPort"/>. Prefer <see cref="PortFor"/> — against the Docker fixture each
|
||||
/// family listens on its own port and this value addresses none of them.
|
||||
/// </summary>
|
||||
public int Port { get; } = DefaultPort;
|
||||
|
||||
/// <summary>True when <c>AB_LEGACY_ENDPOINT</c> pinned a single endpoint (real-hardware mode).</summary>
|
||||
public bool EndpointPinned { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The port to reach <paramref name="profile"/>'s simulator on: the family's own port against the
|
||||
/// Docker fixture, or the pinned endpoint's port when <c>AB_LEGACY_ENDPOINT</c> is set.
|
||||
/// </summary>
|
||||
/// <param name="profile">The family profile under test.</param>
|
||||
/// <returns>The TCP port.</returns>
|
||||
public int PortFor(AbLegacyServerProfile profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
return EndpointPinned ? Port : profile.Port;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Why <paramref name="profile"/>'s simulator cannot be reached, or null when it can.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Per-family, not per-suite (2026-08-01).</b> Every family used to share <c>:44818</c>, 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.
|
||||
/// </remarks>
|
||||
/// <param name="profile">The family profile under test.</param>
|
||||
/// <returns>A skip reason, or <see langword="null"/>.</returns>
|
||||
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}.";
|
||||
}
|
||||
|
||||
/// <summary>CIP routing path portion of the device URI (after the <c>/</c> separator).
|
||||
/// May be empty when targeting real hardware; non-empty against ab_server.</summary>
|
||||
public string CipPath { get; } = DefaultCipPath;
|
||||
|
||||
/// <summary>Gets the skip reason if the server is unavailable, or null if available.</summary>
|
||||
public string? SkipReason { get; }
|
||||
|
||||
/// <summary>Initializes the AB Legacy server fixture by reading environment overrides.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Initializes the fixture asynchronously.</summary>
|
||||
@@ -97,29 +144,19 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
|
||||
/// exists.
|
||||
/// </summary>
|
||||
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 <c>--plc</c> mode + tags each family seeds; this record just ties a
|
||||
/// family enum to its compose-profile name + operator-facing notes.
|
||||
/// </summary>
|
||||
/// <param name="Family">The PCCC family.</param>
|
||||
/// <param name="ComposeProfile">The compose profile name that starts this family's container.</param>
|
||||
/// <param name="Port">
|
||||
/// The family's own host port. Each family gets one so all three run together, and alongside the
|
||||
/// AB CIP fixture on <c>:44818</c>. Overridden wholesale by <c>AB_LEGACY_ENDPOINT</c> (real hardware,
|
||||
/// where there is one device and the family is a property of it, not of the port).
|
||||
/// </param>
|
||||
/// <param name="Notes">Operator-facing description of what the family's container seeds.</param>
|
||||
public sealed record AbLegacyServerProfile(
|
||||
AbLegacyPlcFamily Family,
|
||||
string ComposeProfile,
|
||||
int Port,
|
||||
string Notes);
|
||||
|
||||
/// <summary>Canonical profiles covering every PCCC family the driver supports.</summary>
|
||||
/// <remarks>
|
||||
/// The <c>Port</c> values must match <c>Docker/docker-compose.yml</c>'s host-side mappings.
|
||||
/// </remarks>
|
||||
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.");
|
||||
|
||||
/// <summary>Gets all known profiles.</summary>
|
||||
|
||||
@@ -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/<path>` 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
|
||||
|
||||
|
||||
+16
-11
@@ -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: `<file>[<size>]` 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",
|
||||
|
||||
Reference in New Issue
Block a user