From afe6850f3de4a2b7a57cca1ed6960985d8961611 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 19:05:43 -0400 Subject: [PATCH] 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. --- .../LibplctagPath.cs | 13 +++++ .../LibplctagTagEnumerator.cs | 2 +- .../LibplctagTagRuntime.cs | 11 ++-- .../LibplctagTemplateReader.cs | 2 +- .../LibplctagLegacyTagRuntime.cs | 11 ++-- .../LibplctagPath.cs | 13 +++++ .../AbCipReadSmokeTests.cs | 4 +- .../LibplctagEmptyPathTests.cs | 51 +++++++++++++++++++ .../LibplctagEmptyPathTests.cs | 37 ++++++++++++++ 9 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagPath.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagPath.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagEmptyPathTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagEmptyPathTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagPath.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagPath.cs new file mode 100644 index 00000000..69414ab7 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagPath.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Normalizes a parsed CIP route for libplctag's Path attribute. libplctag rejects an +/// empty-string path with ErrorBadData; "no route" (Micro800, unrouted MicroLogix) must be +/// expressed by omitting the attribute, i.e. . +/// +internal static class LibplctagPath +{ + /// Returns for a null, empty or whitespace path; otherwise the path unchanged. + /// The parsed CIP path. + public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs index 304baae7..486dcbfc 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs @@ -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, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index f0655678..cfe3761c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -18,12 +18,18 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime /// The tag creation parameters. public LibplctagTagRuntime(AbCipTagCreateParams p) { - _tag = new Tag + _tag = BuildTag(p); + } + + /// Builds the (uninitialized) libplctag tag for . + /// The tag creation parameters. + 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, }; - } /// public Task InitializeAsync(CancellationToken cancellationToken) => diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs index 2fc4d94f..e04864d6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs @@ -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}", diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs index c15605f6..5cb03a08 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -17,19 +17,24 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime /// The parameters for tag creation. public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p) { - _tag = new Tag + _tag = BuildTag(p); + } + + /// Builds the (uninitialized) libplctag tag for . + /// The tag creation parameters. + 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, }; - } /// public Task InitializeAsync(CancellationToken cancellationToken) => diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagPath.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagPath.cs new file mode 100644 index 00000000..a7104292 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagPath.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Normalizes a parsed CIP route for libplctag's Path attribute. libplctag rejects an +/// empty-string path with ErrorBadData; "no route" (Micro800, unrouted MicroLogix) must be +/// expressed by omitting the attribute, i.e. . +/// +internal static class LibplctagPath +{ + /// Returns for a null, empty or whitespace path; otherwise the path unchanged. + /// The parsed CIP path. + public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs index 44d142e7..a6a5c1cd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs @@ -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 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagEmptyPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagEmptyPathTests.cs new file mode 100644 index 00000000..4cd93ba5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagEmptyPathTests.cs @@ -0,0 +1,51 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// libplctag rejects an empty-string path with ErrorBadData; "no route" (Micro800, or any +/// unrouted device authored as ab://host:44818/) must reach it as null. Pre-dates the native transport. +/// +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(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagEmptyPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagEmptyPathTests.cs new file mode 100644 index 00000000..9871bfa4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagEmptyPathTests.cs @@ -0,0 +1,37 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// libplctag rejects an empty-string path with ErrorBadData; an unrouted MicroLogix +/// (ab://host:44818/) must reach it as null. +/// +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"); + } +}