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");
+ }
}