Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipUdtMemberWriteTests.cs
T
Joseph Doherty 5018627b35 feat(abcip): #611 discovered UDT member writes + whole-UDT refusal by decision
Item 2 — controller-discovered UDT members are writable on both transports once
browse-committed and authored ReadWrite (writability is the operator's: the commit
lands AccessLevel Read with no writable key). The write names the member (one CIP
symbolic segment per member), its own type, its own bytes. BIT members are supported:
Z-35162 (read-only probe) exposes a UDT BIT member as a 1-byte 0xC1 BOOL, so the
controller applies the bit and the hidden host's other bits are never in the request.

- AbCipDriver: pre-write width check on BOTH transports (libplctag patched part of
  its read buffer and sent it all): BadTypeMismatch, Bool over a wider value
  BadNotSupported; nothing sent, handle kept, health untouched, warn once.
- A runtime NotSupportedException is a refusal: no longer latches Degraded.

Item 3 — whole-UDT writes refused BadNotSupported before any runtime exists, by
decision (a structure write overwrites hidden hosts + padding), documented.

ab_server patch 0003: serve member paths from dotted flat tags (UdtSeg.*), and
accept libplctag's pad byte on 1-byte writes (stock ab_server refused every
libplctag BOOL/SINT write with 0x03). New AbCipUdtMemberWriteTests (both
transports), AbCipDiscoveredMemberWriteTests, native request-byte theory, and a
browse-commit mapper pin. Docs: AbCip.md Member writes / Whole-UDT writes / Known
gaps, fixture docs, CLAUDE.md AB section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 04:23:59 -04:00

223 lines
11 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// 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 <c>TagConfig</c> carries the member path as <c>tagPath</c> and the browsed atomic type as
/// <c>dataType</c> (<c>RawBrowseCommitMapper</c>), with no <c>writable</c> key. ab_server has no structures,
/// so the <c>UdtSeg.*</c> 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
/// <c>UdtSeg.Points[2]</c>), the member's own CIP type, and a value that reads back through a fresh handle.
/// </summary>
/// <remarks>
/// Writes: skipped unless <c>AB_SERVER_ALLOW_WRITES=1</c> (<see cref="IntegrationWriteGate"/>), so pointing
/// <c>AB_SERVER_ENDPOINT</c> at a real controller never writes to it. BIT-member neighbour preservation is
/// NOT claimed here: ab_server's <c>UdtSeg.Enable</c> / <c>UdtSeg.Ready</c> 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
/// <c>NativeAbCipTagRuntimeTests</c> — see docs/drivers/AbCip.md §"Member writes".
/// </remarks>
[Trait("Category", "Integration")]
public sealed class AbCipUdtMemberWriteTests
{
/// <summary>A discovered member authored writable writes, and the value reads back through a fresh driver.</summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[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);
}
}
/// <summary>
/// An element write to a member array touches that element only: the neighbours read back unchanged.
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[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);
}
}
/// <summary>
/// A member authored narrower than the controller's type (INT over the DINT <c>UdtSeg.ID</c>) is refused
/// <c>BadTypeMismatch</c> 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.
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[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);
}
}
/// <summary>A discovered member the operator authored read-only (<c>writable:false</c>) is never written.</summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[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";
/// <summary>A raw tag exactly as browse-commit authors a discovered member: tagPath + dataType, no writable key.</summary>
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<AbCipDriver> 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;
}
/// <summary>
/// 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.
/// </summary>
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.");
}
}