diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs
new file mode 100644
index 00000000..de2bef82
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs
@@ -0,0 +1,51 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI
+/// AbCipTagConfigModel) 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 AbCipEquipmentTagParser
+{
+ /// 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 an AbCip TagConfig object.
+ public static bool TryParse(string reference, out AbCipTagDefinition 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;
+ // AbCip is a symbolic driver: the mandatory addressing field is the Logix tag path.
+ if (root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("tagPath", out var tagPathEl)
+ || tagPathEl.ValueKind != JsonValueKind.String)
+ return false;
+ var tagPath = tagPathEl.GetString();
+ if (string.IsNullOrWhiteSpace(tagPath)) return false;
+
+ var deviceHostAddress = ReadString(root, "deviceHostAddress");
+ var dataType = ReadEnum(root, "dataType", AbCipDataType.DInt);
+ def = new AbCipTagDefinition(
+ Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
+ 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.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index bb53fc21..ff04db65 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -36,6 +36,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _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 AbCipEquipmentTagParser, cached).
+ private readonly EquipmentTagRefResolver _resolver;
+
private readonly ILogger _logger;
private AbCipAlarmProjection _alarmProjection;
private DriverHealth _health = new(DriverState.Unknown, null, null);
@@ -73,6 +79,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
_logger = logger ?? NullLogger.Instance;
+ _resolver = new EquipmentTagRefResolver(
+ r => _tagsByName.TryGetValue(r, out var t) ? t : null,
+ r => AbCipEquipmentTagParser.TryParse(r, out var d) ? d : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -300,6 +309,7 @@ public sealed class AbCipDriver : 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);
}
@@ -497,7 +507,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private async Task ReadSingleAsync(
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
- if (!_tagsByName.TryGetValue(reference, out var def))
+ if (!_resolver.TryResolve(reference, out var def))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
@@ -653,7 +663,7 @@ public sealed class AbCipDriver : 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(AbCipStatusMapper.BadNodeIdUnknown);
continue;
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagTests.cs
new file mode 100644
index 00000000..b1086ff3
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagTests.cs
@@ -0,0 +1,79 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+[Trait("Category", "Unit")]
+public class AbCipEquipmentTagTests
+{
+ [Fact]
+ public void Parses_equipment_tagconfig_into_a_transient_definition()
+ {
+ var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"Real"}""";
+ AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.Name.ShouldBe(json);
+ def.TagPath.ShouldBe("Motor1.Speed");
+ def.DeviceHostAddress.ShouldBe("ab://10.0.0.5/1,0");
+ def.DataType.ShouldBe(AbCipDataType.Real);
+ def.Writable.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Defaults_optional_fields_when_only_the_mandatory_tagPath_is_present()
+ {
+ var json = """{"tagPath":"PT_101"}""";
+ AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.Name.ShouldBe(json);
+ def.TagPath.ShouldBe("PT_101");
+ def.DeviceHostAddress.ShouldBe(""); // absent → empty, matching AbCipTagConfigModel default
+ def.DataType.ShouldBe(AbCipDataType.DInt); // AbCipTagConfigModel's default atomic type
+ }
+
+ [Fact]
+ public void Rejects_a_non_abcip_blob()
+ => AbCipEquipmentTagParser.TryParse("""{"region":"x"}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_a_blob_with_an_empty_tagPath()
+ => AbCipEquipmentTagParser.TryParse("""{"tagPath":" ","dataType":"DInt"}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_tagPath_given_as_a_non_string()
+ => AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_garbage()
+ => AbCipEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_a_plain_authored_name_without_a_leading_brace()
+ => AbCipEquipmentTagParser.TryParse("Motor1.Speed", out _).ShouldBeFalse();
+
+ ///
+ /// End-to-end driver-level proof: an AbCip driver with NO authored tags can still read an
+ /// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
+ /// definition (whose DeviceHostAddress names a configured device) and the read goes to the
+ /// wire instead of returning BadNodeIdUnknown.
+ ///
+ [Fact]
+ public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown()
+ {
+ var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"DInt"}""";
+ var factory = new FakeAbCipTagFactory();
+ var opts = new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [],
+ };
+ var drv = new AbCipDriver(opts, "abcip-eq", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbCipTag(p) { Value = 4242 };
+
+ var snapshots = await drv.ReadAsync([json], CancellationToken.None);
+
+ snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ snapshots.Single().StatusCode.ShouldNotBe(AbCipStatusMapper.BadNodeIdUnknown);
+ snapshots.Single().Value.ShouldBe(4242);
+ }
+}