diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs index d97231a2..5084e201 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs @@ -100,7 +100,10 @@ public sealed record AbCipTagPath( var bracketIdx = part.IndexOf('['); if (bracketIdx < 0) { - if (!IsValidIdent(part)) return null; + // The controller-scope base name of a module-defined I/O tag carries colons + // (Local:1:I, ABB_OmniCore:I) — legal in the first segment only. + var isModuleBase = segments.Count == 0 && programScope is null && IsValidModuleBase(part); + if (!isModuleBase && !IsValidIdent(part)) return null; segments.Add(new AbCipTagPathSegment(part, [])); continue; } @@ -122,6 +125,25 @@ public sealed record AbCipTagPath( return new AbCipTagPath(programScope, segments, bitIndex); } + /// + /// true for a module-defined I/O tag base name: an identifier followed by one or more + /// :-separated identifier or slot-number parts (Local:1:I, ABB_OmniCore:I). + /// + /// The candidate base name. + /// true when is a module tag base name. + private static bool IsValidModuleBase(string s) + { + var parts = s.Split(':'); + if (parts.Length < 2 || !IsValidIdent(parts[0])) return false; + for (var i = 1; i < parts.Length; i++) + { + var p = parts[i]; + if (p.Length == 0) return false; + if (!IsValidIdent(p) && !p.All(char.IsAsciiDigit)) return false; + } + return true; + } + private static bool IsValidIdent(string s) { if (string.IsNullOrEmpty(s)) return false; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs index 3ec53e34..03af71ee 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs @@ -128,6 +128,41 @@ public sealed class AbCipTagPathTests AbCipTagPath.TryParse(input).ShouldBeNull(); } + /// + /// Module-defined I/O tags carry colons in their base name (Local:1:I, + /// ABB_OmniCore:I). Seen live: a CompactLogix lists ABB_OmniCore:I in + /// @tags, libplctag reads ABB_OmniCore:I.ConnectionFaulted fine, and the driver + /// refused it as a malformed path. + /// + /// A module tag path. + /// The expected base (first segment) name. + [Theory] + [InlineData("ABB_OmniCore:I.ConnectionFaulted", "ABB_OmniCore:I")] + [InlineData("Local:1:I.Data", "Local:1:I")] + [InlineData("Local:2:O.Data.3", "Local:2:O")] + [InlineData("Rack1:3:C.Ch0Config.RangeType", "Rack1:3:C")] + public void Module_IO_tag_base_names_with_colons_parse_and_round_trip(string input, string baseName) + { + var parsed = AbCipTagPath.TryParse(input); + parsed.ShouldNotBeNull(); + parsed.ProgramScope.ShouldBeNull(); + parsed.Segments[0].Name.ShouldBe(baseName); + parsed.ToLibplctagName().ShouldBe(input); + } + + /// A colon is only legal in the base name, and never as a dangling or doubled separator. + /// A malformed path. + [Theory] + [InlineData("Motor.Sub:I")] // colon past the base segment + [InlineData("Local::I.Data")] // empty colon part + [InlineData("Local:1:.Data")] // trailing colon + [InlineData(":Local.Data")] // leading colon + [InlineData("Program:P.Local:1:I")] // module tags are controller-scope only + public void Colons_outside_a_module_base_name_are_rejected(string input) + { + AbCipTagPath.TryParse(input).ShouldBeNull(); + } + /// Verifies that identifiers with underscores are accepted. [Fact] public void Ident_with_underscore_accepted()