Auto: abcip-4.3 — diagnostic / system tags as browseable variables

Closes #240
This commit is contained in:
Joseph Doherty
2026-04-26 02:55:56 -04:00
parent 9c108cd00a
commit 901a5b9b21
10 changed files with 915 additions and 10 deletions

View File

@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// PR abcip-4.3 — end-to-end coverage that the synthetic <c>_System</c> folder + its five
/// diagnostic variables ride a real ab_server lifecycle. Skipped when the binary isn't
/// on PATH (<see cref="AbServerFactAttribute"/>).
/// </summary>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipSystemTagDiscoveryTests
{
[AbServerFact]
public async Task System_folder_browses_and_each_variable_reads_non_empty()
{
var profile = KnownProfiles.ControlLogix;
var fixture = new AbServerFixture(profile);
await fixture.InitializeAsync();
try
{
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)],
Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
Timeout = TimeSpan.FromSeconds(5),
}, "drv-system-tags");
await drv.InitializeAsync("{}", CancellationToken.None);
// Discovery — five system variables exposed under _System/ for the device.
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "_System");
var systemVars = builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Select(v => v.BrowseName)
.ToList();
systemVars.ShouldContain("_ConnectionStatus");
systemVars.ShouldContain("_ScanRate");
systemVars.ShouldContain("_TagCount");
systemVars.ShouldContain("_DeviceError");
systemVars.ShouldContain("_LastScanTimeMs");
// Read — each system variable returns Good with a non-null value, with no
// libplctag round-trip required.
var refs = systemVars
.Select(name => $"_System/{deviceUri}/{name}")
.ToList();
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
for (var i = 0; i < snaps.Count; i++)
{
snaps[i].StatusCode.ShouldBe(AbCipStatusMapper.Good,
$"system variable {refs[i]} should read Good");
snaps[i].Value.ShouldNotBeNull(
$"system variable {refs[i]} should not be null");
}
await drv.ShutdownAsync(CancellationToken.None);
}
finally
{
await fixture.DisposeAsync();
}
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}

View File

@@ -28,9 +28,11 @@ public sealed class AbCipDriverDiscoveryTests
builder.Folders.ShouldContain(f => f.BrowseName == "AbCip");
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC");
builder.Variables.Count.ShouldBe(2);
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
// PR abcip-4.3 — exclude the synthetic _System/ folder vars from the count.
var userVars = builder.Variables.Where(v => !v.Info.FullName.StartsWith("_System/")).ToList();
userVars.Count.ShouldBe(2);
userVars.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
userVars.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
@@ -67,7 +69,10 @@ public sealed class AbCipDriverDiscoveryTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
builder.Variables
.Where(v => !v.Info.FullName.StartsWith("_System/"))
.Select(v => v.BrowseName)
.ShouldBe(["UserTag"]);
}
[Fact]
@@ -83,7 +88,7 @@ public sealed class AbCipDriverDiscoveryTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldBeEmpty();
builder.Variables.Where(v => !v.Info.FullName.StartsWith("_System/")).ShouldBeEmpty();
}
[Fact]
@@ -126,7 +131,10 @@ public sealed class AbCipDriverDiscoveryTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
builder.Variables
.Where(v => !v.Info.FullName.StartsWith("_System/"))
.Select(v => v.Info.FullName)
.ShouldBe(["KeepMe"]);
}
[Fact]
@@ -145,7 +153,10 @@ public sealed class AbCipDriverDiscoveryTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
builder.Variables
.Where(v => !v.Info.FullName.StartsWith("_System/"))
.Single()
.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]

View File

@@ -0,0 +1,322 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-4.3 unit coverage for the diagnostic / system-tag source. Tests both the
/// standalone <see cref="AbCipSystemTagSource"/> + the <see cref="AbCipDriver"/>
/// integration: discovery emits the five canonical nodes, ReadAsync routes
/// <c>_System/...</c> through the source instead of libplctag, and snapshot updates
/// follow probe transitions.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipSystemTagSourceTests
{
[Fact]
public void Update_then_TryRead_returns_the_snapshot()
{
var src = new AbCipSystemTagSource();
const string host = "ab://10.0.0.5/1,0";
src.Update(host, new SystemTagSnapshot("Running", 500.0, 12, "", 4.7));
src.TryRead("_ConnectionStatus", host, out var status).ShouldBeTrue();
status.ShouldBe("Running");
src.TryRead("_ScanRate", host, out var rate).ShouldBeTrue();
rate.ShouldBe(500.0);
src.TryRead("_TagCount", host, out var count).ShouldBeTrue();
count.ShouldBe(12);
src.TryRead("_DeviceError", host, out var err).ShouldBeTrue();
err.ShouldBe("");
src.TryRead("_LastScanTimeMs", host, out var last).ShouldBeTrue();
last.ShouldBe(4.7);
}
[Fact]
public void TryRead_accepts_either_bare_or_prefixed_form()
{
var src = new AbCipSystemTagSource();
const string host = "ab://10.0.0.5/1,0";
src.Update(host, new SystemTagSnapshot("Running", 500.0, 1, "", 0.0));
src.TryRead("_ConnectionStatus", host, out var bare).ShouldBeTrue();
src.TryRead("_System/_ConnectionStatus", host, out var prefixed).ShouldBeTrue();
bare.ShouldBe(prefixed);
}
[Fact]
public void TryRead_unknown_name_returns_false()
{
var src = new AbCipSystemTagSource();
src.Update("h", new SystemTagSnapshot("Running", 500, 0, "", 0));
src.TryRead("_NotARealName", "h", out var v).ShouldBeFalse();
v.ShouldBeNull();
}
[Fact]
public void TryRead_without_snapshot_returns_typed_default()
{
var src = new AbCipSystemTagSource();
src.TryRead("_ConnectionStatus", "missing", out var status).ShouldBeTrue();
status.ShouldBe("Unknown");
src.TryRead("_ScanRate", "missing", out var rate).ShouldBeTrue();
rate.ShouldBe(0.0);
src.TryRead("_TagCount", "missing", out var count).ShouldBeTrue();
count.ShouldBe(0);
src.TryRead("_DeviceError", "missing", out var err).ShouldBeTrue();
err.ShouldBe(string.Empty);
src.TryRead("_LastScanTimeMs", "missing", out var last).ShouldBeTrue();
last.ShouldBe(0.0);
}
[Fact]
public void Two_devices_keep_independent_snapshots()
{
var src = new AbCipSystemTagSource();
const string a = "ab://10.0.0.5/1,0";
const string b = "ab://10.0.0.6/1,0";
src.Update(a, new SystemTagSnapshot("Running", 500, 10, "", 1.0));
src.Update(b, new SystemTagSnapshot("Stopped", 1000, 3, "boom", 99.9));
src.TryRead("_ConnectionStatus", a, out var sa).ShouldBeTrue();
src.TryRead("_ConnectionStatus", b, out var sb).ShouldBeTrue();
sa.ShouldBe("Running");
sb.ShouldBe("Stopped");
src.TryRead("_DeviceError", a, out var ea).ShouldBeTrue();
src.TryRead("_DeviceError", b, out var eb).ShouldBeTrue();
ea.ShouldBe("");
eb.ShouldBe("boom");
}
[Fact]
public void IsSystemReference_matches_only_the_System_prefix()
{
AbCipSystemTagSource.IsSystemReference("_System/foo/_ConnectionStatus").ShouldBeTrue();
AbCipSystemTagSource.IsSystemReference("_System/").ShouldBeTrue();
AbCipSystemTagSource.IsSystemReference("Motor.Speed").ShouldBeFalse();
AbCipSystemTagSource.IsSystemReference("").ShouldBeFalse();
AbCipSystemTagSource.IsSystemReference("MySystem/foo").ShouldBeFalse();
}
[Fact]
public async Task Discovery_emits_five_system_nodes_per_device()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "PLC-A")],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "_System");
var systemVars = builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Select(v => v.BrowseName)
.OrderBy(s => s)
.ToList();
systemVars.ShouldBe(new[]
{
"_ConnectionStatus", "_DeviceError", "_LastScanTimeMs",
"_ScanRate", "_TagCount",
});
// All five carry the device host inside the FullName.
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.ShouldAllBe(v => v.Info.FullName.StartsWith("_System/ab://10.0.0.5/1,0/"));
// PR 4.4 will flip _RefreshTagDb to writeable; today every system var is ViewOnly.
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
}
[Fact]
public async Task Discovery_emits_System_folder_for_each_device()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-2");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.Count(f => f.BrowseName == "_System").ShouldBe(2);
builder.Variables.Count(v => v.Info.FullName.StartsWith("_System/")).ShouldBe(10);
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Select(v => v.Info.FullName)
.ShouldContain("_System/ab://10.0.0.5/1,0/_ConnectionStatus");
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Select(v => v.Info.FullName)
.ShouldContain("_System/ab://10.0.0.6/1,0/_ConnectionStatus");
}
[Fact]
public async Task ReadAsync_dispatches_System_reference_to_source_not_libplctag()
{
// FakeAbCipTagFactory throws when used; if the driver materialises a libplctag handle
// for a _System/... reference, this test will trip ThrowOnRead and surface a Bad status.
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true },
};
const string host = "ab://10.0.0.5/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Seed the source so we have a known snapshot to read back.
drv.SystemTagSource.Update(host,
new SystemTagSnapshot("Running", 250.0, 5, "", 7.5));
var snaps = await drv.ReadAsync(
[$"_System/{host}/_ConnectionStatus", $"_System/{host}/_TagCount"],
CancellationToken.None);
snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snaps[0].Value.ShouldBe("Running");
snaps[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snaps[1].Value.ShouldBe(5);
}
[Fact]
public async Task ReadAsync_unknown_System_name_returns_BadNodeIdUnknown()
{
const string host = "ab://10.0.0.5/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"_System/{host}/_NotARealName"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task TagCount_reflects_count_excluding_System_folder()
{
var builder = new RecordingBuilder();
const string host = "ab://10.0.0.5/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Tags =
[
new AbCipTagDefinition("Speed", host, "Motor1.Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Temp", host, "T", AbCipDataType.Real),
new AbCipTagDefinition("Pressure", host, "P", AbCipDataType.Real),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
// Read the synthetic _TagCount and assert it matches the three pre-declared tags.
var snaps = await drv.ReadAsync(
[$"_System/{host}/_TagCount"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snaps[0].Value.ShouldBe(3);
}
[Fact]
public async Task ResolveHost_for_System_reference_returns_embedded_device_host()
{
const string a = "ab://10.0.0.5/1,0";
const string b = "ab://10.0.0.6/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(a), new AbCipDeviceOptions(b)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost($"_System/{a}/_ConnectionStatus").ShouldBe(a);
drv.ResolveHost($"_System/{b}/_ScanRate").ShouldBe(b);
}
[Fact]
public async Task Probe_transition_updates_system_tag_snapshot()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 } };
const string host = "ab://10.0.0.5/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait until the snapshot itself flips to Running — the probe transition runs the
// snapshot refresh inline so once the snapshot reflects Running, GetHostStatuses must
// too. Polling the snapshot directly avoids a race against the post-transition
// refresh window.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
object? status = null;
while (DateTime.UtcNow < deadline)
{
drv.SystemTagSource.TryRead("_ConnectionStatus", host, out status);
if (Equals(status, "Running")) break;
await Task.Delay(20);
}
status.ShouldBe("Running");
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- helpers (mirror AbCipDriverDiscoveryTests) ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}

View File

@@ -266,7 +266,11 @@ public sealed class AbCipUdtMemberTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true);
// PR abcip-4.3 — exclude the synthetic _System/ folder vars from the count.
builder.Variables
.Where(v => !v.Info.FullName.StartsWith("_System/"))
.Select(v => v.BrowseName)
.ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true);
}
// ---- helpers ----