Auto: abcip-5.1 — HSBY paired-IP role probing

Closes #242
This commit is contained in:
Joseph Doherty
2026-04-26 07:51:44 -04:00
parent 349aa5c6f4
commit 561b0f9ea9
12 changed files with 1260 additions and 9 deletions

View File

@@ -0,0 +1,35 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// PR abcip-5.1 — integration scaffold for HSBY paired-IP role probing. Skipped by default
/// because <c>ab_server</c> cannot emulate a ControlLogix HSBY pair (it has no second-chassis
/// concept + no <c>WallClockTime.SyncStatus</c> tag). Promoted from skipped to active when
/// the Docker fixture grows the <c>hsby-mux</c> sidecar (planned in PR abcip-5.2 follow-up
/// work) or when a real lab rig is available; the unit-level coverage in
/// <c>AbCipHsbyTests</c> exercises the value-mapping + active-resolution rules in the
/// meantime.
/// <para>
/// The skip lives in the test body so the file still compiles + the trait is discoverable
/// by <c>dotnet test --filter "Category=Hsby"</c>; the body never gets to assert anything
/// against <c>ab_server</c>.
/// </para>
/// </summary>
[Trait("Category", "Hsby")]
[Trait("Requires", "AbServer")]
public sealed class AbCipHsbyRoleProberTests
{
[AbServerFact]
public Task Role_prober_resolves_active_chassis_against_paired_fixture()
{
// ab_server cannot emulate an HSBY pair; the paired-fixture compose service +
// hsby-mux sidecar that PR abcip-5.2 ships will let this body do real wire work.
// For PR abcip-5.1 we keep the file as a scaffold so the integration trait is
// discoverable and a future PR can flip the skip into a real assertion.
Assert.Skip("HSBY paired-fixture (controllogix-secondary + hsby-mux sidecar) not yet wired — PR abcip-5.2 follow-up.");
return Task.CompletedTask;
}
}

View File

@@ -95,3 +95,81 @@ services:
"--tag=TestDINT:DINT[1]",
"--tag=SafetyDINT_S:DINT[1]"
]
# ---- PR abcip-5.1 — paired-fixture for HSBY role probing ------------------
# The "paired" profile spins up two ab_server instances (controllogix-primary
# on :44818, controllogix-secondary on :44819) plus a stub hsby-mux sidecar
# that flips a role bit on demand. The mux is a placeholder — it does NOT
# currently inject role bits because ab_server has no WallClockTime.SyncStatus
# tag concept. PR abcip-5.2 follow-up will land:
# 1. A patched ab_server image (or a separate Python TCP shim) that exposes
# a writable WallClockTime.SyncStatus DINT per chassis.
# 2. A real hsby-mux REST endpoint (POST /flip {"active": "primary"}) that
# writes 1 to the chosen chassis + 0 to the other.
# For now the services exist so the compose file documents the topology + the
# AbCipHsbyRoleProberTests integration test has a place to land its
# [AbServerFact] without breaking the pre-5.1 ab_server profiles.
controllogix-primary:
profiles: ["paired"]
image: otopcua-ab-server:libplctag-release
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-ab-server-controllogix-primary
restart: "no"
ports:
- "44818:44818"
command: [
"ab_server",
"--plc=ControlLogix",
"--path=1,0",
"--port=44818",
"--tag=TestDINT:DINT[1]",
# Stand-in for WallClockTime.SyncStatus until the patched image lands.
"--tag=SyncStatus:DINT[1]"
]
controllogix-secondary:
profiles: ["paired"]
image: otopcua-ab-server:libplctag-release
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-ab-server-controllogix-secondary
restart: "no"
ports:
- "44819:44818"
command: [
"ab_server",
"--plc=ControlLogix",
"--path=1,0",
"--port=44818",
"--tag=TestDINT:DINT[1]",
"--tag=SyncStatus:DINT[1]"
]
# Stub hsby-mux — placeholder. Today's image is a tiny Python script that
# exposes a /health endpoint + nothing else. PR abcip-5.2 will replace this
# with a real role-flip endpoint that writes SyncStatus on either chassis.
hsby-mux:
profiles: ["paired"]
image: python:3.12-alpine
container_name: otopcua-ab-hsby-mux
restart: "no"
ports:
- "8080:8080"
command:
- sh
- -c
- |
python -c "
import http.server, socketserver
class H(http.server.BaseHTTPRequestHandler):
def do_GET(s):
s.send_response(200); s.send_header('Content-Type','text/plain'); s.end_headers()
s.wfile.write(b'hsby-mux stub - PR abcip-5.2 follow-up will wire role flips')
socketserver.TCPServer(('', 8080), H).serve_forever()
"
depends_on:
- controllogix-primary
- controllogix-secondary

View File

@@ -0,0 +1,301 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-5.1 — unit tests for HSBY paired-IP role probing. Drives two fake-runtime
/// gateways (primary + partner), forces each to return a chosen <c>WallClockTime.SyncStatus</c>
/// value, asserts <see cref="AbCipDriver.GetHealth"/> diagnostics + the device-state
/// <c>ActiveAddress</c> resolves to the expected chassis under each split-state combination.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipHsbyTests
{
// ---- Pure value mapping ----
[Theory]
[InlineData(0L, HsbyRole.Standby)] // WallClockTime.SyncStatus matrix
[InlineData(1L, HsbyRole.Active)]
[InlineData(2L, HsbyRole.Disqualified)]
[InlineData(99L, HsbyRole.Unknown)] // out-of-range integer
public void MapValueToRole_handles_WallClockTime_SyncStatus_matrix(long raw, HsbyRole expected)
{
AbCipHsbyRoleProber.MapValueToRole(raw, "WallClockTime.SyncStatus").ShouldBe(expected);
}
[Theory]
[InlineData(0L, HsbyRole.Standby)] // bit 0 = 0 → Standby
[InlineData(1L, HsbyRole.Active)] // bit 0 = 1 → Active
[InlineData(2L, HsbyRole.Standby)] // bit 0 = 0 → Standby (2 = 0b10)
[InlineData(3L, HsbyRole.Active)] // bit 0 = 1 → Active (3 = 0b11)
public void MapValueToRole_handles_S34_bitmask_fallback(long raw, HsbyRole expected)
{
AbCipHsbyRoleProber.MapValueToRole(raw, "S:34").ShouldBe(expected);
}
[Fact]
public void MapValueToRole_returns_Unknown_for_null_raw()
{
AbCipHsbyRoleProber.MapValueToRole(null, "WallClockTime.SyncStatus").ShouldBe(HsbyRole.Unknown);
}
// ---- ProbeAsync against fake runtime ----
[Fact]
public async Task ProbeAsync_returns_Active_when_runtime_decodes_one()
{
var rt = new FakeAbCipTag(MakeParams("WallClockTime.SyncStatus")) { Value = 1 };
var role = await AbCipHsbyRoleProber.ProbeAsync(rt, "WallClockTime.SyncStatus", CancellationToken.None);
role.ShouldBe(HsbyRole.Active);
}
[Fact]
public async Task ProbeAsync_returns_Unknown_when_read_throws()
{
var rt = new FakeAbCipTag(MakeParams("WallClockTime.SyncStatus")) { ThrowOnRead = true };
var role = await AbCipHsbyRoleProber.ProbeAsync(rt, "WallClockTime.SyncStatus", CancellationToken.None);
role.ShouldBe(HsbyRole.Unknown);
}
[Fact]
public async Task ProbeAsync_returns_Unknown_on_non_zero_status()
{
var rt = new FakeAbCipTag(MakeParams("WallClockTime.SyncStatus")) { Value = 1, Status = -1 };
var role = await AbCipHsbyRoleProber.ProbeAsync(rt, "WallClockTime.SyncStatus", CancellationToken.None);
role.ShouldBe(HsbyRole.Unknown);
}
// ---- End-to-end driver loop ----
[Fact]
public async Task Primary_active_partner_standby_resolves_ActiveAddress_to_primary()
{
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 0);
try
{
await WaitForRoleAsync(drv, "ab://10.0.0.5/1,0");
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
state.ActiveAddress.ShouldBe("ab://10.0.0.5/1,0");
state.PrimaryRole.ShouldBe(HsbyRole.Active);
state.PartnerRole.ShouldBe(HsbyRole.Standby);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task Both_active_primary_wins_and_warning_is_emitted()
{
var warnings = new ConcurrentQueue<string>();
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 1,
warningSink: warnings.Enqueue);
try
{
await WaitForRoleAsync(drv, "ab://10.0.0.5/1,0");
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
state.ActiveAddress.ShouldBe("ab://10.0.0.5/1,0",
"split-brain ties must resolve to primary deterministically");
state.PrimaryRole.ShouldBe(HsbyRole.Active);
state.PartnerRole.ShouldBe(HsbyRole.Active);
warnings.ShouldContain(w => w.Contains("split-brain", StringComparison.OrdinalIgnoreCase));
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task Both_standby_clears_ActiveAddress()
{
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 0, partnerRoleValue: 0);
try
{
// Let the loop tick at least once + sample the role state.
await WaitForAsync(() => drv.GetDeviceState("ab://10.0.0.5/1,0")?.PrimaryRole != HsbyRole.Unknown);
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
state.ActiveAddress.ShouldBeNull(
"neither chassis Active means no routing target — PR abcip-5.2 will fault writes here");
state.PrimaryRole.ShouldBe(HsbyRole.Standby);
state.PartnerRole.ShouldBe(HsbyRole.Standby);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task Primary_read_fails_and_partner_active_routes_to_partner()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => p.Gateway == "10.0.0.5"
? new FakeAbCipTag(p) { ThrowOnRead = true }
: new FakeAbCipTag(p) { Value = 1 },
};
var drv = BuildDriver(factory);
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
await WaitForRoleAsync(drv, "ab://10.0.0.6/1,0");
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
state.ActiveAddress.ShouldBe("ab://10.0.0.6/1,0");
state.PrimaryRole.ShouldBe(HsbyRole.Unknown);
state.PartnerRole.ShouldBe(HsbyRole.Active);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task Hsby_disabled_skips_role_probing_entirely()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
"ab://10.0.0.5/1,0",
PartnerHostAddress: "ab://10.0.0.6/1,0",
Hsby: new AbCipHsbyOptions { Enabled = false }),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-hsby-off", factory);
try
{
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(150);
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
state.PrimaryRole.ShouldBe(HsbyRole.Unknown);
state.PartnerRole.ShouldBe(HsbyRole.Unknown);
state.ActiveAddress.ShouldBeNull();
// Factory must not have been used since Hsby.Enabled = false + probe disabled.
factory.Tags.ShouldBeEmpty();
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
[Fact]
public async Task Diagnostics_surface_HsbyActive_and_role_codes()
{
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 0);
try
{
await WaitForRoleAsync(drv, "ab://10.0.0.5/1,0");
var diag = drv.GetHealth().Diagnostics.ShouldNotBeNull();
diag.ShouldContainKey("AbCip.HsbyActive");
diag["AbCip.HsbyActive"].ShouldBe(1); // primary is the active chassis
diag["AbCip.HsbyPrimaryRole"].ShouldBe((int)HsbyRole.Active);
diag["AbCip.HsbyPartnerRole"].ShouldBe((int)HsbyRole.Standby);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
// ---- DTO round-trip ----
[Fact]
public async Task DTO_json_round_trip_preserves_PartnerHostAddress_and_Hsby()
{
const string json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PartnerHostAddress": "ab://10.0.0.6/1,0",
"Hsby": {
"Enabled": true,
"RoleTagAddress": "S:34",
"ProbeIntervalMs": 5000
}
}
]
}
""";
var driver = AbCipDriverFactoryExtensions.CreateInstance("drv-roundtrip", json);
try
{
// Initialise so the device map is populated, then read back via GetDeviceState.
await driver.InitializeAsync(json, CancellationToken.None);
var state = driver.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
state.Options.PartnerHostAddress.ShouldBe("ab://10.0.0.6/1,0");
state.Options.Hsby.ShouldNotBeNull();
state.Options.Hsby!.Enabled.ShouldBeTrue();
state.Options.Hsby.RoleTagAddress.ShouldBe("S:34");
state.Options.Hsby.ProbeInterval.ShouldBe(TimeSpan.FromMilliseconds(5000));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
// ---- Helpers ----
private static AbCipDriver BuildDriver(FakeAbCipTagFactory factory, Action<string>? warningSink = null) =>
new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
"ab://10.0.0.5/1,0",
PartnerHostAddress: "ab://10.0.0.6/1,0",
Hsby: new AbCipHsbyOptions
{
Enabled = true,
RoleTagAddress = "WallClockTime.SyncStatus",
ProbeInterval = TimeSpan.FromMilliseconds(50),
}),
],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warningSink,
}, "drv-hsby", factory);
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)>
BuildHsbyDriverAsync(int primaryRoleValue, int partnerRoleValue, Action<string>? warningSink = null)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => p.Gateway == "10.0.0.5"
? new FakeAbCipTag(p) { Value = primaryRoleValue }
: new FakeAbCipTag(p) { Value = partnerRoleValue },
};
var drv = BuildDriver(factory, warningSink);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
private static AbCipTagCreateParams MakeParams(string tagName) => new(
Gateway: "10.0.0.5",
Port: 44818,
CipPath: "1,0",
LibplctagPlcAttribute: "ControlLogix",
TagName: tagName,
Timeout: TimeSpan.FromSeconds(2));
private static Task WaitForRoleAsync(AbCipDriver drv, string expectedActive) =>
WaitForAsync(() => drv.GetDeviceState("ab://10.0.0.5/1,0")?.ActiveAddress == expectedActive);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(2));
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}