fix(twincat): accept TwinCAT 2 leading-dot globals in TwinCATSymbolPath (ignitionoee 0.19.1)
A TC2 global is named with ONE leading dot (.OPC_HEARTBEAT) and the PLC requires it (SYM_HNDBYNAME answers 0x710 without it). TryParse rejected the dot, so the driver fell back to the raw string and lost bit-index parsing: .X.WORD.3 went to ADS verbatim instead of reading .X.WORD and extracting bit 3. The dot now stays on the root segment and round-trips through ToAdsSymbolName; .., ., .[1] and .9x stay invalid.
This commit is contained in:
@@ -14,7 +14,8 @@ public sealed class ReadCommand : TwinCATTagCommandBase
|
||||
/// <summary>Gets or sets the TwinCAT symbol path to read.</summary>
|
||||
[CommandOption("symbol", 's', Description =
|
||||
"Symbol path. Program scope: 'MAIN.bStart'. Global: 'GVL.Counter'. " +
|
||||
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'.",
|
||||
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'. " +
|
||||
"TwinCAT 2 global (keep its leading dot): '.OPC_HEARTBEAT'.",
|
||||
IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
/// grammar maps cleanly onto the AbCip Logix path parser, but without Logix's
|
||||
/// <c>Program:</c> scope prefix. The leading segment is the namespace (POU name /
|
||||
/// GVL name) and subsequent segments walk into struct/array members.</para>
|
||||
/// <para><b>TwinCAT 2 globals</b> are named with ONE leading dot (<c>.OPC_HEARTBEAT</c>,
|
||||
/// <c>.OPC_MACHINEDATA.PROCESSSTATUS</c>) and the PLC requires it — without the dot
|
||||
/// <c>SYM_HNDBYNAME</c> answers 0x710 (symbol not found), observed live on a TC 2.11 CX. The dot
|
||||
/// is kept on the ROOT segment's <see cref="TwinCATSymbolSegment.Name"/> so
|
||||
/// <see cref="ToAdsSymbolName"/> round-trips it unchanged; it is not a separator and never yields
|
||||
/// an empty segment. Exactly one dot: <c>..x</c>, <c>.</c> and <c>.[1]</c> stay invalid.</para>
|
||||
/// </remarks>
|
||||
public sealed record TwinCATSymbolPath(
|
||||
IReadOnlyList<TwinCATSymbolSegment> Segments,
|
||||
@@ -41,6 +47,11 @@ public sealed record TwinCATSymbolPath(
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
// A TwinCAT 2 global's single leading dot belongs to the root segment, not before it.
|
||||
// Strip it for the split and put it back on segment 0 below.
|
||||
var tc2Global = src.StartsWith('.');
|
||||
if (tc2Global) src = src[1..];
|
||||
|
||||
var parts = new List<string>();
|
||||
var depth = 0;
|
||||
var start = 0;
|
||||
@@ -91,6 +102,7 @@ public sealed record TwinCATSymbolPath(
|
||||
segments.Add(new TwinCATSymbolSegment(name, subs));
|
||||
}
|
||||
if (segments.Count == 0) return null;
|
||||
if (tc2Global) segments[0] = segments[0] with { Name = "." + segments[0].Name };
|
||||
|
||||
return new TwinCATSymbolPath(segments, bitIndex);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,12 @@ public sealed class TwinCATSymbolPathTests
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(".Motor")] // leading dot
|
||||
[InlineData(".")] // a bare dot names nothing
|
||||
[InlineData("..Motor")] // TwinCAT 2 globals carry ONE leading dot, never two
|
||||
[InlineData(".[1]")] // leading dot then no identifier
|
||||
[InlineData(".9x")] // leading dot then an ident starting with a digit
|
||||
[InlineData(".x.")] // leading dot, trailing dot
|
||||
[InlineData(" . x")] // whitespace between the dot and the identifier
|
||||
[InlineData("Motor.")] // trailing dot
|
||||
[InlineData("Motor.[0]")] // empty segment
|
||||
[InlineData("1bad")] // ident starts with digit
|
||||
@@ -148,4 +153,42 @@ public sealed class TwinCATSymbolPathTests
|
||||
parsed.ToAdsSymbolName().ShouldBe(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TwinCAT 2 names a GLOBAL variable with ONE leading dot (<c>.OPC_HEARTBEAT</c>) and the PLC
|
||||
/// requires it: on a real TC 2.11 CX (Elma cleaner Z33856, 2026-09-24) <c>SYM_HNDBYNAME</c>
|
||||
/// answered a handle for <c>.OPC_HEARTBEAT</c> and 0x710 (symbol not found) for
|
||||
/// <c>OPC_HEARTBEAT</c>. The parser rejected the dot, so a TC2 global fell back to the raw
|
||||
/// string — losing bit-index parsing (<c>.X.WORD.3</c> was sent to ADS verbatim).
|
||||
/// Ported from ignitionoee driver 0.19.1.
|
||||
/// </summary>
|
||||
/// <param name="input">A TwinCAT 2 global path as the PLC names it.</param>
|
||||
[Theory]
|
||||
[InlineData(".x")]
|
||||
[InlineData(".OPC_HEARTBEAT")]
|
||||
[InlineData(".OPC_MACHINEDATA.PROCESSSTATUS")]
|
||||
[InlineData(".ARRCARRIERDATA_V[1].SWORKORDER")]
|
||||
[InlineData(".OPC_LOGDATA.STATIONDATA[5].TEMPERATUREMAXIMUM")]
|
||||
public void TwinCAT2_leading_dot_global_parses_and_keeps_its_dot(string input)
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse(input);
|
||||
p.ShouldNotBeNull(input);
|
||||
p.Segments[0].Name.ShouldStartWith(".");
|
||||
p.ToAdsSymbolName().ShouldBe(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The leading dot belongs to the ROOT segment only, so bit access on a TC2 global still parses:
|
||||
/// the parent word is read as <c>.OPC_STATUS.WORD</c> and bit 3 is extracted locally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TwinCAT2_global_bit_access_parses_with_the_dot_on_the_root_segment()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse(".OPC_STATUS.WORD.3");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe([".OPC_STATUS", "WORD"]);
|
||||
p.BitIndex.ShouldBe(3);
|
||||
(p with { BitIndex = null }).ToAdsSymbolName().ShouldBe(".OPC_STATUS.WORD");
|
||||
p.ToAdsSymbolName().ShouldBe(".OPC_STATUS.WORD.3");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user