diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs new file mode 100644 index 00000000..3f4f96e6 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI +/// AbLegacyTagConfigModel) 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 AbLegacyEquipmentTagParser +{ + /// 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 AbLegacy address object. + public static bool TryParse(string reference, out AbLegacyTagDefinition 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("address", out var addr) + || addr.ValueKind != JsonValueKind.String) + return false; + var address = addr.GetString(); + if (string.IsNullOrWhiteSpace(address)) return false; + var dataType = ReadEnum(root, "dataType", AbLegacyDataType.Int); + var deviceHostAddress = ReadString(root, "deviceHostAddress"); + def = new AbLegacyTagDefinition( + Name: reference, DeviceHostAddress: deviceHostAddress, Address: address, + 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.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 2d02283b..5b6a311c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -21,6 +21,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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 AbLegacyEquipmentTagParser, cached). + private readonly EquipmentTagRefResolver _resolver; + // volatile: _health is read by GetHealth() on any thread while ReadAsync / WriteAsync / // InitializeAsync write it from worker / poll threads. The record-reference assignment is // atomic on all .NET platforms, but without a memory barrier a reader can see a stale @@ -54,6 +59,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover _driverInstanceId = driverInstanceId; _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory(); _logger = logger ?? NullLogger.Instance; + _resolver = new EquipmentTagRefResolver( + r => _tagsByName.TryGetValue(r, out var t) ? t : null, + r => AbLegacyEquipmentTagParser.TryParse(r, out var d) ? d : null); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => @@ -143,6 +151,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover } _devices.Clear(); _tagsByName.Clear(); + _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses throw; } return Task.CompletedTask; @@ -177,6 +186,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover } _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); } @@ -230,7 +240,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); continue; @@ -320,7 +330,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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(AbLegacyStatusMapper.BadNodeIdUnknown); continue; @@ -734,6 +744,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover } _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.AbLegacy.Tests/AbLegacyEquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEquipmentTagTests.cs new file mode 100644 index 00000000..b485229c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEquipmentTagTests.cs @@ -0,0 +1,63 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +[Trait("Category", "Unit")] +public sealed class AbLegacyEquipmentTagTests +{ + [Fact] + public void Parses_equipment_tagconfig_into_a_transient_definition() + { + var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int"}"""; + AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); + def!.Name.ShouldBe(json); + def.Address.ShouldBe("N7:0"); + def.DataType.ShouldBe(AbLegacyDataType.Int); + def.DeviceHostAddress.ShouldBe("ab://10.0.0.5/1,0"); + } + + [Fact] + public void Rejects_a_non_address_blob() + => AbLegacyEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse(); + + [Fact] + public void Rejects_garbage() + => AbLegacyEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse(); + + [Fact] + public void Rejects_address_as_a_json_number() + => AbLegacyEquipmentTagParser.TryParse( + """{"address":7,"dataType":"Int"}""", out _).ShouldBeFalse(); + + [Fact] + public void Rejects_empty_address() + => AbLegacyEquipmentTagParser.TryParse( + """{"address":"","dataType":"Int"}""", out _).ShouldBeFalse(); + + /// + /// End-to-end driver-level proof: an AbLegacy driver with NO authored tags can still read an + /// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient + /// definition and the read goes to the fake runtime 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","address":"N7:0","dataType":"Int"}"""; + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 4242 } }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [], + }, "ablegacy-eq", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync([json], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + r[0].StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadNodeIdUnknown); + r[0].Value.ShouldBe(4242); + } +}