5018627b35
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
223 lines
11 KiB
C#
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.");
|
|
}
|
|
}
|