feat(abcip): resolve equipment-tag refs (read + write) via EquipmentTagRefResolver

This commit is contained in:
Joseph Doherty
2026-06-13 11:23:21 -04:00
parent 232c557985
commit 9d49cb7bbe
3 changed files with 142 additions and 2 deletions
@@ -0,0 +1,51 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
/// <c>AbCipTagConfigModel</c>) into a transient <see cref="AbCipTagDefinition"/> whose
/// <see cref="AbCipTagDefinition.Name"/> equals the reference string itself, so a value the
/// driver publishes back keys the runtime's forward router correctly.</summary>
public static class AbCipEquipmentTagParser
{
/// <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 an AbCip TagConfig object.</returns>
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<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() ?? "" : "";
}
@@ -36,6 +36,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _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<AbCipTagDefinition> _resolver;
private readonly ILogger<AbCipDriver> _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<AbCipDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
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;
@@ -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();
/// <summary>
/// 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.
/// </summary>
[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);
}
}