diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
new file mode 100644
index 00000000..09e0e41c
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI
+/// TwinCATTagConfigModel) into a transient whose
+/// equals the reference string itself, so a value the
+/// driver publishes back keys the runtime's forward router correctly.
+public static class TwinCATEquipmentTagParser
+{
+ /// Attempts to parse an equipment-tag reference into a transient definition.
+ /// The equipment tag's TagConfig JSON (also used as the def identity).
+ /// The transient definition when parsing succeeds.
+ /// when is a TwinCAT symbol object.
+ public static bool TryParse(string reference, out TwinCATTagDefinition def)
+ {
+ def = null!;
+ // Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob.
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("symbolPath", out var symbol)
+ || symbol.ValueKind != JsonValueKind.String)
+ return false;
+ var symbolPath = symbol.GetString();
+ if (string.IsNullOrWhiteSpace(symbolPath)) return false;
+ var deviceHostAddress = ReadString(root, "deviceHostAddress");
+ var dataType = ReadEnum(root, "dataType", TwinCATDataType.DInt);
+ def = new TwinCATTagDefinition(
+ Name: reference, DeviceHostAddress: deviceHostAddress, SymbolPath: symbolPath,
+ DataType: dataType, Writable: true);
+ return true;
+ }
+ catch (JsonException) { return false; }
+ catch (FormatException) { return false; }
+ catch (InvalidOperationException) { return false; }
+ }
+
+ private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
+ && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
+
+ private static string ReadString(JsonElement o, string name)
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
+ ? e.GetString() ?? "" : "";
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index 89feddc4..37c61c8d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -27,6 +27,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary _tagsByName =
new(StringComparer.OrdinalIgnoreCase);
+
+ // Resolves a read/write/subscribe fullReference to a tag definition, bridging the two
+ // authoring models: an authored tag-table entry (by name) OR an equipment tag whose
+ // reference is its raw TagConfig JSON (parsed once via TwinCATEquipmentTagParser, cached).
+ private readonly EquipmentTagRefResolver _resolver;
+
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// Occurs when a subscribed tag value changes.
@@ -50,6 +56,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
_logger = logger ?? NullLogger.Instance;
+ _resolver = new EquipmentTagRefResolver(
+ r => _tagsByName.TryGetValue(r, out var t) ? t : null,
+ r => TwinCATEquipmentTagParser.TryParse(r, out var d) ? d : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -151,6 +160,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
}
_devices.Clear();
_tagsByName.Clear();
+ _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -201,7 +211,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
- if (!_tagsByName.TryGetValue(reference, out var def))
+ if (!_resolver.TryResolve(reference, out var def))
{
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
continue;
@@ -258,7 +268,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
- if (!_tagsByName.TryGetValue(w.FullReference, out var def))
+ if (!_resolver.TryResolve(w.FullReference, out var def))
{
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
continue;
@@ -412,7 +422,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
foreach (var reference in fullReferences)
{
- if (!_tagsByName.TryGetValue(reference, out var def)) continue;
+ if (!_resolver.TryResolve(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
@@ -655,6 +665,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
}
_devices.Clear();
_tagsByName.Clear();
+ _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagTests.cs
new file mode 100644
index 00000000..2b2090b5
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagTests.cs
@@ -0,0 +1,81 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+[Trait("Category", "Unit")]
+public class TwinCATEquipmentTagTests
+{
+ [Fact]
+ public void Parses_equipment_tagconfig_into_a_transient_definition()
+ {
+ var json = """{"deviceHostAddress":"ads://5.23.91.23.1.1:851","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
+ TwinCATEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.Name.ShouldBe(json);
+ def.SymbolPath.ShouldBe("MAIN.Speed");
+ def.DeviceHostAddress.ShouldBe("ads://5.23.91.23.1.1:851");
+ def.DataType.ShouldBe(TwinCATDataType.DInt);
+ def.Writable.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Defaults_optional_fields_when_only_symbol_path_is_present()
+ {
+ TwinCATEquipmentTagParser.TryParse("""{"symbolPath":"GVL.X"}""", out var def).ShouldBeTrue();
+ def!.SymbolPath.ShouldBe("GVL.X");
+ def.DeviceHostAddress.ShouldBe(""); // absent host → empty
+ def.DataType.ShouldBe(TwinCATDataType.DInt); // enum default
+ }
+
+ [Fact]
+ public void Rejects_a_non_address_blob()
+ => TwinCATEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_garbage()
+ => TwinCATEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_blank_reference()
+ => TwinCATEquipmentTagParser.TryParse(" ", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_symbol_path_as_a_non_string()
+ => TwinCATEquipmentTagParser.TryParse("""{"symbolPath":42}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Ignores_a_non_string_data_type_and_falls_back_to_default()
+ {
+ // dataType as a number is not a String enum — the guard ignores it and keeps the default.
+ TwinCATEquipmentTagParser.TryParse("""{"symbolPath":"MAIN.X","dataType":3}""", out var def).ShouldBeTrue();
+ def!.DataType.ShouldBe(TwinCATDataType.DInt);
+ }
+
+ ///
+ /// End-to-end driver-level proof: a TwinCAT driver with NO authored tags can still read an
+ /// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
+ /// definition (with a deviceHostAddress matching a configured device) and the read reaches
+ /// the fake client instead of returning BadNodeIdUnknown.
+ ///
+ [Fact]
+ public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown()
+ {
+ var host = "ads://5.23.91.23.1.1:851";
+ var json = $$"""{"deviceHostAddress":"{{host}}","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
+ var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 4242 } } };
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(host)],
+ Tags = [], // no authored tags — resolution must come from the equipment-ref parser
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "twincat-eq", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync([json], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ r[0].StatusCode.ShouldNotBe(TwinCATStatusMapper.BadNodeIdUnknown);
+ r[0].Value.ShouldBe(4242);
+ }
+}