merge: libplctag empty-path fix (null Path for an empty CIP route)

This commit is contained in:
Joseph Doherty
2026-10-07 19:06:36 -04:00
9 changed files with 135 additions and 9 deletions
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Normalizes a parsed CIP route for libplctag's <c>Path</c> attribute. libplctag rejects an
/// empty-string path with <c>ErrorBadData</c>; "no route" (Micro800, unrouted MicroLogix) must be
/// expressed by omitting the attribute, i.e. <see langword="null"/>.
/// </summary>
internal static class LibplctagPath
{
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
/// <param name="path">The parsed CIP path.</param>
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
}
@@ -37,7 +37,7 @@ internal static class LibplctagListingReader
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams, string listingName = "@tags") => new()
{
Gateway = deviceParams.LibplctagGateway,
Path = deviceParams.CipPath,
Path = LibplctagPath.Normalize(deviceParams.CipPath),
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = listingName,
@@ -18,12 +18,18 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
/// <param name="p">The tag creation parameters.</param>
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
_tag = new Tag
_tag = BuildTag(p);
}
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
internal static Tag BuildTag(AbCipTagCreateParams p) =>
new Tag
{
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
Gateway = p.LibplctagGateway,
Path = p.CipPath,
Path = LibplctagPath.Normalize(p.CipPath),
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = p.TagName,
@@ -33,7 +39,6 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
// count so libplctag pulls every element in one CIP read; scalar tags pass 1.
ElementCount = p.ElementCount > 1 ? p.ElementCount : 1,
};
}
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
@@ -39,7 +39,7 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
internal static Tag BuildTemplateTag(AbCipTagCreateParams deviceParams, uint templateInstanceId) => new()
{
Gateway = deviceParams.LibplctagGateway,
Path = deviceParams.CipPath,
Path = LibplctagPath.Normalize(deviceParams.CipPath),
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = $"@udt/{templateInstanceId}",
@@ -17,19 +17,24 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
/// <param name="p">The parameters for tag creation.</param>
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
{
_tag = new Tag
_tag = BuildTag(p);
}
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
internal static Tag BuildTag(AbLegacyTagCreateParams p) =>
new Tag
{
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
Gateway = p.LibplctagGateway,
Path = p.CipPath,
Path = LibplctagPath.Normalize(p.CipPath),
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
Name = p.TagName,
Timeout = p.Timeout,
ElementCount = p.ElementCount > 1 ? p.ElementCount : null,
};
}
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Normalizes a parsed CIP route for libplctag's <c>Path</c> attribute. libplctag rejects an
/// empty-string path with <c>ErrorBadData</c>; "no route" (Micro800, unrouted MicroLogix) must be
/// expressed by omitting the attribute, i.e. <see langword="null"/>.
/// </summary>
internal static class LibplctagPath
{
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
/// <param name="path">The parsed CIP path.</param>
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
}
@@ -32,7 +32,9 @@ public sealed class AbCipReadSmokeTests
// smoke test was missed.
// Micro800 has no backplane: its cip-path is empty (the compose service runs
// ab_server --plc=Micro800 with no --path, which refuses a routed request). Every
// other family reaches slot 0 through `1,0`.
// other family reaches slot 0 through `1,0`. Both transports are expected to pass
// for Micro800: the libplctag adapters map the empty route to a null `path` attribute
// (LibplctagPath.Normalize) because libplctag rejects "" with ErrorBadData.
var cipPath = profile.Family == AbCipPlcFamily.Micro800 ? "" : "1,0";
var deviceUri = $"ab://{fixture.Host}:{fixture.Port}/{cipPath}";
var drv = new AbCipDriver(new AbCipDriverOptions
@@ -0,0 +1,51 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// libplctag rejects an empty-string <c>path</c> with <c>ErrorBadData</c>; "no route" (Micro800, or any
/// unrouted device authored as <c>ab://host:44818/</c>) must reach it as null. Pre-dates the native transport.
/// </summary>
public sealed class LibplctagEmptyPathTests
{
private static AbCipTagCreateParams Params(string cipPath) =>
new(Gateway: "10.100.0.35", Port: 44818, CipPath: cipPath, LibplctagPlcAttribute: "micro800",
TagName: "TestDINT", Timeout: TimeSpan.FromSeconds(5));
[Theory]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData(null, null)]
[InlineData("1,0", "1,0")]
public void Normalize_maps_blank_to_null_and_keeps_a_route(string? input, string? expected) =>
LibplctagPath.Normalize(input).ShouldBe(expected);
[Fact]
public void The_data_tag_omits_the_path_for_an_empty_route()
{
using var tag = LibplctagTagRuntime.BuildTag(Params(""));
tag.Path.ShouldBeNull();
}
[Fact]
public void The_data_tag_keeps_a_real_route()
{
using var tag = LibplctagTagRuntime.BuildTag(Params("1,0"));
tag.Path.ShouldBe("1,0");
}
[Fact]
public void The_symbol_list_tag_omits_the_path_for_an_empty_route()
{
using var tag = LibplctagListingReader.BuildSymbolListTag(Params(""));
tag.Path.ShouldBeNull();
}
[Fact]
public void The_template_tag_omits_the_path_for_an_empty_route()
{
using var tag = LibplctagTemplateReader.BuildTemplateTag(Params(""), templateInstanceId: 1);
tag.Path.ShouldBeNull();
}
}
@@ -0,0 +1,37 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// libplctag rejects an empty-string <c>path</c> with <c>ErrorBadData</c>; an unrouted MicroLogix
/// (<c>ab://host:44818/</c>) must reach it as null.
/// </summary>
public sealed class LibplctagEmptyPathTests
{
private static AbLegacyTagCreateParams Params(string cipPath) =>
new(Gateway: "10.100.0.35", Port: 44818, CipPath: cipPath, LibplctagPlcAttribute: "micrologix",
TagName: "N7:0", Timeout: TimeSpan.FromSeconds(5));
[Theory]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData(null, null)]
[InlineData("1,0", "1,0")]
public void Normalize_maps_blank_to_null_and_keeps_a_route(string? input, string? expected) =>
LibplctagPath.Normalize(input).ShouldBe(expected);
[Fact]
public void The_data_tag_omits_the_path_for_an_empty_route()
{
using var tag = LibplctagLegacyTagRuntime.BuildTag(Params(""));
tag.Path.ShouldBeNull();
}
[Fact]
public void The_data_tag_keeps_a_real_route()
{
using var tag = LibplctagLegacyTagRuntime.BuildTag(Params("1,0"));
tag.Path.ShouldBe("1,0");
}
}