feat(twincat): resolve equipment-tag refs (read + write) via EquipmentTagRefResolver
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
|
||||
/// <c>TwinCATTagConfigModel</c>) into a transient <see cref="TwinCATTagDefinition"/> whose
|
||||
/// <see cref="TwinCATTagDefinition.Name"/> equals the reference string itself, so a value the
|
||||
/// driver publishes back keys the runtime's forward router correctly.</summary>
|
||||
public static class TwinCATEquipmentTagParser
|
||||
{
|
||||
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
|
||||
/// <param name="def">The transient definition when parsing succeeds.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is a TwinCAT symbol object.</returns>
|
||||
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<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
|
||||
&& Enum.TryParse<TEnum>(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() ?? "" : "";
|
||||
}
|
||||
@@ -27,6 +27,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, TwinCATTagDefinition> _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<TwinCATTagDefinition> _resolver;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Occurs when a subscribed tag value changes.</summary>
|
||||
@@ -50,6 +56,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
|
||||
_logger = logger ?? NullLogger<TwinCATDriver>.Instance;
|
||||
_resolver = new EquipmentTagRefResolver<TwinCATTagDefinition>(
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user