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()