using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// /// Gitea #611 item 2 — writes to controller-discovered UDT members, end to end against ab_server on both /// transports. A discovered member reaches the driver the way browse-commit authors it: an ordinary raw tag /// whose TagConfig carries the member path as tagPath and the browsed atomic type as /// dataType (RawBrowseCommitMapper), with no writable key. ab_server has no structures, /// so the UdtSeg.* members are flat tags served on their member path by Docker patch 0003 — what is /// proved here is the wire: one symbolic segment per member (plus the element index for /// UdtSeg.Points[2]), the member's own CIP type, and a value that reads back through a fresh handle. /// /// /// Writes: skipped unless AB_SERVER_ALLOW_WRITES=1 (), so pointing /// AB_SERVER_ENDPOINT at a real controller never writes to it. BIT-member neighbour preservation is /// NOT claimed here: ab_server's UdtSeg.Enable / UdtSeg.Ready are separate tags, not bits of /// one host SINT, so such a test would pass whatever the driver did. The BIT-member argument rests on the /// driver addressing the member (never its hidden host) — pinned at the request-byte level by /// NativeAbCipTagRuntimeTests — see docs/drivers/AbCip.md §"Member writes". /// [Trait("Category", "Integration")] public sealed class AbCipUdtMemberWriteTests { /// A discovered member authored writable writes, and the value reads back through a fresh driver. /// The transport stack the driver runs on. [AbServerTheory] [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] public async Task A_discovered_member_write_reaches_the_member_and_reads_back(AbCipTransport transport) { IntegrationWriteGate.SkipUnlessWritesAllowed(); var fixture = new AbServerFixture(); var device = Device(fixture); // A per-run value so a stale server value from an earlier run cannot satisfy the read-back. var seed = Random.Shared.Next(1_000, 30_000); var writer = await StartAsync(device, transport, "writer", Member("RampTime", "UdtSeg.RampTime", "Real"), Member("Id", "UdtSeg.ID", "DInt"), Member("Code", "UdtSeg.Sub.Code", "Int"), Member("Point2", "UdtSeg.Points[2]", "DInt"), Member("Enable", "UdtSeg.Enable", "Bool")); await RequireMemberTagsAsync(writer); try { var results = await writer.WriteAsync( [ new WriteRequest("RampTime", seed + 0.5f), new WriteRequest("Id", seed), new WriteRequest("Code", (short)(seed % 30_000)), new WriteRequest("Point2", -seed), new WriteRequest("Enable", seed % 2 == 0), ], CancellationToken.None); string[] names = ["RampTime", "Id", "Code", "Point2", "Enable"]; results.Select((r, i) => $"{names[i]}=0x{r.StatusCode:X8}") .ShouldBe(names.Select(n => $"{n}=0x{AbCipStatusMapper.Good:X8}")); writer.GetHealth().State.ShouldBe(DriverState.Healthy); } finally { await writer.ShutdownAsync(CancellationToken.None); } // A FRESH driver — new handles, nothing cached from the writer — proves the server holds the values. var reader = await StartAsync(device, transport, "reader", Member("RampTime", "UdtSeg.RampTime", "Real"), Member("Id", "UdtSeg.ID", "DInt"), Member("Code", "UdtSeg.Sub.Code", "Int"), Member("Point2", "UdtSeg.Points[2]", "DInt"), Member("Enable", "UdtSeg.Enable", "Bool")); try { var read = await reader.ReadAsync(["RampTime", "Id", "Code", "Point2", "Enable"], CancellationToken.None); read.Select(r => r.StatusCode).ShouldAllBe(s => s == AbCipStatusMapper.Good); Convert.ToSingle(read[0].Value).ShouldBe(seed + 0.5f); Convert.ToInt32(read[1].Value).ShouldBe(seed); Convert.ToInt32(read[2].Value).ShouldBe(seed % 30_000); Convert.ToInt32(read[3].Value).ShouldBe(-seed); Convert.ToBoolean(read[4].Value).ShouldBe(seed % 2 == 0); } finally { await reader.ShutdownAsync(CancellationToken.None); } } /// /// An element write to a member array touches that element only: the neighbours read back unchanged. /// /// The transport stack the driver runs on. [AbServerTheory] [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] public async Task A_member_array_element_write_leaves_the_other_elements_alone(AbCipTransport transport) { IntegrationWriteGate.SkipUnlessWritesAllowed(); var device = Device(new AbServerFixture()); var seed = Random.Shared.Next(1_000, 30_000); var drv = await StartAsync(device, transport, "elements", Member("P0", "UdtSeg.Points[0]", "DInt"), Member("P1", "UdtSeg.Points[1]", "DInt"), Member("P2", "UdtSeg.Points[2]", "DInt"), Member("P3", "UdtSeg.Points[3]", "DInt")); await RequireMemberTagsAsync(drv, "P0"); try { (await drv.WriteAsync( [new WriteRequest("P0", seed), new WriteRequest("P1", seed + 1), new WriteRequest("P2", seed + 2), new WriteRequest("P3", seed + 3)], CancellationToken.None)) .Select(r => r.StatusCode).ShouldAllBe(s => s == AbCipStatusMapper.Good); (await drv.WriteAsync([new WriteRequest("P2", -7)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); var read = await drv.ReadAsync(["P0", "P1", "P2", "P3"], CancellationToken.None); read.Select(r => Convert.ToInt32(r.Value)).ShouldBe([seed, seed + 1, -7, seed + 3]); } finally { await drv.ShutdownAsync(CancellationToken.None); } } /// /// A member authored narrower than the controller's type (INT over the DINT UdtSeg.ID) is refused /// BadTypeMismatch with nothing written, on BOTH transports — libplctag included, which would /// otherwise patch the low two bytes of the 4-byte buffer and send the DINT back. /// /// The transport stack the driver runs on. [AbServerTheory] [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] public async Task A_width_mistyped_member_write_is_refused_and_writes_nothing(AbCipTransport transport) { IntegrationWriteGate.SkipUnlessWritesAllowed(); var device = Device(new AbServerFixture()); var seed = Random.Shared.Next(100_000, 1_000_000); // needs the high word, so a 2-byte patch would show var drv = await StartAsync(device, transport, "mistyped", Member("IdRight", "UdtSeg.ID", "DInt"), Member("IdWrong", "UdtSeg.ID", "Int")); await RequireMemberTagsAsync(drv, "IdRight"); try { (await drv.WriteAsync([new WriteRequest("IdRight", seed)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); (await drv.WriteAsync([new WriteRequest("IdWrong", (short)5)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); drv.GetHealth().State.ShouldBe(DriverState.Healthy); Convert.ToInt32((await drv.ReadAsync(["IdRight"], CancellationToken.None)).Single().Value).ShouldBe(seed); } finally { await drv.ShutdownAsync(CancellationToken.None); } } /// A discovered member the operator authored read-only (writable:false) is never written. /// The transport stack the driver runs on. [AbServerTheory] [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] public async Task A_member_authored_read_only_is_not_written(AbCipTransport transport) { IntegrationWriteGate.SkipUnlessWritesAllowed(); var device = Device(new AbServerFixture()); var seed = Random.Shared.Next(1_000, 30_000); var drv = await StartAsync(device, transport, "readonly", Member("Id", "UdtSeg.ID", "DInt"), Member("IdRo", "UdtSeg.ID", "DInt", writable: false)); await RequireMemberTagsAsync(drv); try { (await drv.WriteAsync([new WriteRequest("Id", seed)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); (await drv.WriteAsync([new WriteRequest("IdRo", seed + 1)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); Convert.ToInt32((await drv.ReadAsync(["Id"], CancellationToken.None)).Single().Value).ShouldBe(seed); } finally { await drv.ShutdownAsync(CancellationToken.None); } } private static string Device(AbServerFixture fixture) => $"ab://{fixture.Host}:{fixture.Port}/1,0"; /// A raw tag exactly as browse-commit authors a discovered member: tagPath + dataType, no writable key. private static RawTagEntry Member(string rawPath, string tagPath, string dataType, bool? writable = null) => new(rawPath, writable is { } w ? $$"""{"tagPath":"{{tagPath}}","dataType":"{{dataType}}","writable":{{(w ? "true" : "false")}}}""" : $$"""{"tagPath":"{{tagPath}}","dataType":"{{dataType}}"}""", WriteIdempotent: false); private static async Task StartAsync( string device, AbCipTransport transport, string id, params RawTagEntry[] tags) { var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)], RawTags = [.. tags.Select(t => t with { DeviceName = device })], Timeout = TimeSpan.FromSeconds(5), Transport = transport, Probe = new AbCipProbeOptions { Enabled = false }, }, $"drv-udtwrite-{id}-{transport}"); await drv.InitializeAsync("{}", CancellationToken.None); return drv; } /// /// Fails (not skips) with a rebuild hint when the running ab_server predates patch 0003: write tests are /// opt-in, so whoever opted in is pointing at a simulator that should carry the member tags. /// private static async Task RequireMemberTagsAsync(AbCipDriver drv, string probe = "Id") { var status = (await drv.ReadAsync([probe], CancellationToken.None)).Single().StatusCode; if (status == AbCipStatusMapper.BadNodeIdUnknown) Assert.Fail("ab_server does not serve the UdtSeg.* member tags — rebuild the image from Docker/ " + "(patch 0003) and run the controllogix profile."); } }