fix(abcip,ablegacy): pass a null libplctag path for an empty CIP route (Micro800 / unrouted MicroLogix)

The libplctag adapters assigned Tag.Path straight from the device's parsed
CipPath. A device address with no route (ab://host:44818/ - the normal
Micro800 form, and a MicroLogix with no route) parses to "", and libplctag
rejects an empty path attribute with ErrorBadData; omitting the attribute
(null) is what it accepts. So Micro800 could not connect on the default
libplctag transport.

This predates the native transport work and exists on master.

Add an internal LibplctagPath.Normalize helper per driver project (blank ->
null) and route all four Tag constructions through it (AbCip tag runtime,
symbol-list enumerator, template reader; AbLegacy tag runtime). The runtime
tag construction is extracted to an internal BuildTag so tests can inspect
Tag.Path without connecting. Tests fail with Normalize reverted to identity.
Update the Micro800 smoke-test comment: both transports are expected to pass.
This commit is contained in:
Joseph Doherty
2026-10-07 19:05:43 -04:00
parent c8b35663e5
commit afe6850f3d
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");
}
}