fix(abcip): accept module-defined I/O tag paths (Local:1:I.Data, ABB_OmniCore:I.X)

Live read-only check: a production CompactLogix lists the module tag ABB_OmniCore:I in
@tags and libplctag reads ABB_OmniCore:I.ConnectionFaulted without complaint, but the
driver refused the path as malformed (identifier rule had no colon) and reported the
tag BadCommunicationError. Colon-separated identifier/slot parts are now legal in the
controller-scope base segment only (never past it, never under Program: scope).
This commit is contained in:
Joseph Doherty
2026-10-07 14:07:49 -04:00
parent 28bbb5e5e2
commit a0a024c80b
2 changed files with 58 additions and 1 deletions
@@ -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);
}
/// <summary>
/// <c>true</c> for a module-defined I/O tag base name: an identifier followed by one or more
/// <c>:</c>-separated identifier or slot-number parts (<c>Local:1:I</c>, <c>ABB_OmniCore:I</c>).
/// </summary>
/// <param name="s">The candidate base name.</param>
/// <returns><c>true</c> when <paramref name="s"/> is a module tag base name.</returns>
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;
@@ -128,6 +128,41 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse(input).ShouldBeNull();
}
/// <summary>
/// Module-defined I/O tags carry colons in their base name (<c>Local:1:I</c>,
/// <c>ABB_OmniCore:I</c>). Seen live: a CompactLogix lists <c>ABB_OmniCore:I</c> in
/// <c>@tags</c>, libplctag reads <c>ABB_OmniCore:I.ConnectionFaulted</c> fine, and the driver
/// refused it as a malformed path.
/// </summary>
/// <param name="input">A module tag path.</param>
/// <param name="baseName">The expected base (first segment) name.</param>
[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);
}
/// <summary>A colon is only legal in the base name, and never as a dangling or doubled separator.</summary>
/// <param name="input">A malformed path.</param>
[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();
}
/// <summary>Verifies that identifiers with underscores are accepted.</summary>
[Fact]
public void Ident_with_underscore_accepted()