From 9d49cb7bbecd6f1f26dd8c182d8c5b3292db2997 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 13 Jun 2026 11:23:21 -0400 Subject: [PATCH] feat(abcip): resolve equipment-tag refs (read + write) via EquipmentTagRefResolver --- .../AbCipEquipmentTagParser.cs | 51 ++++++++++++ .../AbCipDriver.cs | 14 +++- .../AbCipEquipmentTagTests.cs | 79 +++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagTests.cs 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); + } +}