namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// /// Parsed Logix-symbolic tag path. Handles controller-scope (Motor1_Speed), /// program-scope (Program:MainProgram.StepIndex), structured member access /// (Motor1.Speed.Setpoint), array subscripts (Array[0], Matrix[1,2]), /// and bit-within-DINT access (Flags.3). Reassembles the canonical Logix syntax via /// , which is the exact string libplctag's name=... /// attribute consumes. /// /// /// Scope + members + subscripts are captured structurally so PR 6 (UDT support) can walk /// the path against a cached template without re-parsing. is /// non-null only when the trailing segment is a decimal integer between 0 and 31 that /// parses as a bit-selector — this is the .N syntax documented in the Logix 5000 /// General Instructions Reference §Tags, and it applies only to DINT-typed parents. The /// parser does not validate the parent type (requires live template data) — it accepts the /// shape and defers type-correctness to the runtime. /// public sealed record AbCipTagPath( string? ProgramScope, IReadOnlyList Segments, int? BitIndex) { /// Rebuild the canonical Logix tag string. public string ToLibplctagName() { var buf = new System.Text.StringBuilder(); if (ProgramScope is not null) buf.Append("Program:").Append(ProgramScope).Append('.'); for (var i = 0; i < Segments.Count; i++) { if (i > 0) buf.Append('.'); var seg = Segments[i]; buf.Append(seg.Name); if (seg.Subscripts.Count > 0) buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']'); } if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value); return buf.ToString(); } /// /// Parse a Logix-symbolic tag reference. Returns null on a shape the parser /// doesn't support — the driver surfaces that as a config-validation error rather than /// attempting a best-effort translation. /// public static AbCipTagPath? TryParse(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var src = value.Trim(); string? programScope = null; const string programPrefix = "Program:"; if (src.StartsWith(programPrefix, StringComparison.OrdinalIgnoreCase)) { var afterPrefix = src[programPrefix.Length..]; var dotIdx = afterPrefix.IndexOf('.'); if (dotIdx <= 0) return null; programScope = afterPrefix[..dotIdx]; src = afterPrefix[(dotIdx + 1)..]; if (string.IsNullOrEmpty(src)) return null; } // Split on dots, but preserve any [i,j] subscript runs that contain only digits + commas. var parts = new List(); var depth = 0; var start = 0; for (var i = 0; i < src.Length; i++) { var c = src[i]; if (c == '[') depth++; else if (c == ']') depth--; else if (c == '.' && depth == 0) { parts.Add(src[start..i]); start = i + 1; } } parts.Add(src[start..]); if (depth != 0 || parts.Any(string.IsNullOrEmpty)) return null; int? bitIndex = null; if (parts.Count >= 2 && int.TryParse(parts[^1], out var maybeBit) && maybeBit is >= 0 and <= 31 && !parts[^1].Contains('[')) { bitIndex = maybeBit; parts.RemoveAt(parts.Count - 1); } var segments = new List(parts.Count); foreach (var part in parts) { var bracketIdx = part.IndexOf('['); if (bracketIdx < 0) { if (!IsValidIdent(part)) return null; segments.Add(new AbCipTagPathSegment(part, [])); continue; } if (!part.EndsWith(']')) return null; var name = part[..bracketIdx]; if (!IsValidIdent(name)) return null; var inner = part[(bracketIdx + 1)..^1]; var subs = new List(); foreach (var tok in inner.Split(',')) { if (!int.TryParse(tok, out var n) || n < 0) return null; subs.Add(n); } if (subs.Count == 0) return null; segments.Add(new AbCipTagPathSegment(name, subs)); } if (segments.Count == 0) return null; return new AbCipTagPath(programScope, segments, bitIndex); } private static bool IsValidIdent(string s) { if (string.IsNullOrEmpty(s)) return false; if (!char.IsLetter(s[0]) && s[0] != '_') return false; for (var i = 1; i < s.Length; i++) if (!char.IsLetterOrDigit(s[i]) && s[i] != '_') return false; return true; } } /// One path segment: a member name plus any numeric subscripts. public sealed record AbCipTagPathSegment(string Name, IReadOnlyList Subscripts);