From b7b754fd988b1b2b1e46c7c38fb6db3204690cf2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 13:58:40 -0400 Subject: [PATCH] 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. --- .../Commands/ReadCommand.cs | 3 +- .../TwinCATSymbolPath.cs | 12 +++++ .../TwinCATSymbolPathTests.cs | 45 ++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs index 22e23894..96fe1d87 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs @@ -14,7 +14,8 @@ public sealed class ReadCommand : TwinCATTagCommandBase /// Gets or sets the TwinCAT symbol path to read. [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!; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs index 178773cf..e57284a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs @@ -11,6 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; /// grammar maps cleanly onto the AbCip Logix path parser, but without Logix's /// Program: scope prefix. The leading segment is the namespace (POU name / /// GVL name) and subsequent segments walk into struct/array members. +/// TwinCAT 2 globals are named with ONE leading dot (.OPC_HEARTBEAT, +/// .OPC_MACHINEDATA.PROCESSSTATUS) and the PLC requires it — without the dot +/// SYM_HNDBYNAME answers 0x710 (symbol not found), observed live on a TC 2.11 CX. The dot +/// is kept on the ROOT segment's so +/// round-trips it unchanged; it is not a separator and never yields +/// an empty segment. Exactly one dot: ..x, . and .[1] stay invalid. /// public sealed record TwinCATSymbolPath( IReadOnlyList 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(); 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); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs index f2c9a9a6..2d892b30 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs @@ -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); } } + + /// + /// TwinCAT 2 names a GLOBAL variable with ONE leading dot (.OPC_HEARTBEAT) and the PLC + /// requires it: on a real TC 2.11 CX (Elma cleaner Z33856, 2026-09-24) SYM_HNDBYNAME + /// answered a handle for .OPC_HEARTBEAT and 0x710 (symbol not found) for + /// OPC_HEARTBEAT. The parser rejected the dot, so a TC2 global fell back to the raw + /// string — losing bit-index parsing (.X.WORD.3 was sent to ADS verbatim). + /// Ported from ignitionoee driver 0.19.1. + /// + /// A TwinCAT 2 global path as the PLC names it. + [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); + } + + /// + /// The leading dot belongs to the ROOT segment only, so bit access on a TC2 global still parses: + /// the parent word is read as .OPC_STATUS.WORD and bit 3 is extracted locally. + /// + [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"); + } }