From 5ebf541f54f93c8031bba4000a4a30c151792f32 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 13 Jun 2026 11:24:12 -0400 Subject: [PATCH] feat(twincat): resolve equipment-tag refs (read + write) via EquipmentTagRefResolver --- .../TwinCATEquipmentTagParser.cs | 49 +++++++++++ .../TwinCATDriver.cs | 17 +++- .../TwinCATEquipmentTagTests.cs | 81 +++++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagTests.cs 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); + } +}