Auto: abcip-3.2 — symbolic vs logical addressing toggle

Closes #236
This commit is contained in:
Joseph Doherty
2026-04-25 22:58:33 -04:00
parent 73ff10b595
commit 0c6a0d6e50
13 changed files with 1033 additions and 17 deletions

View File

@@ -0,0 +1,76 @@
using System.Diagnostics;
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-3.2 — wall-clock comparison of Symbolic vs Logical reads on a running
/// <c>ab_server</c> (or a real ControlLogix). Skipped when <c>ab_server</c> isn't
/// reachable, same gating rule as <see cref="AbCipReadSmokeTests"/>.
/// </summary>
/// <remarks>
/// <para>This is a <em>scaffold</em>: it builds + runs against the existing test fixture,
/// but the libplctag .NET 1.5.x wrapper does not yet expose a public knob for instance-ID
/// addressing (see <c>docs/drivers/AbCip-Performance.md</c> §"Addressing mode"). On a live
/// fixture the two paths therefore measure the same wire behaviour today; the assertion
/// just sanity-checks that both modes complete + produce well-formed snapshots, with timing
/// emitted to the test output for inspection. When the wrapper exposes the attribute
/// publicly (or libplctag native gains hot-update of cip_addr) the assertion can be
/// tightened to require Logical &lt; Symbolic on N-tag scans.</para>
///
/// <para>Marked <c>[Trait("Category", "Bench")]</c> so a future <c>--filter</c> rule can
/// opt out of bench tests in CI runs that only want the smoke set.</para>
/// </remarks>
[Trait("Category", "Bench")]
[Trait("Requires", "AbServer")]
public sealed class AbCipAddressingModeBenchTests
{
[AbServerFact]
public async Task Symbolic_and_Logical_modes_both_read_seeded_DInt_and_emit_timing()
{
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 symElapsed = await ReadOnceAsync(deviceUri, profile.Family, AddressingMode.Symbolic);
var logElapsed = await ReadOnceAsync(deviceUri, profile.Family, AddressingMode.Logical);
// Wall-clock timing is captured for human inspection; the assertion just confirms
// both completed. The actual symbolic-vs-logical comparison is qualitative until
// the libplctag wrapper exposes logical-segment addressing publicly — see class doc.
Console.WriteLine($"Symbolic read elapsed: {symElapsed.TotalMilliseconds:F2} ms");
Console.WriteLine($"Logical read elapsed: {logElapsed.TotalMilliseconds:F2} ms");
symElapsed.ShouldBeGreaterThan(TimeSpan.Zero);
logElapsed.ShouldBeGreaterThan(TimeSpan.Zero);
}
finally
{
await fixture.DisposeAsync();
}
}
private static async Task<TimeSpan> ReadOnceAsync(string deviceUri, AbCipPlcFamily family, AddressingMode mode)
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(deviceUri, family, AddressingMode: mode)],
Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
Timeout = TimeSpan.FromSeconds(5),
}, $"drv-bench-{mode}");
await drv.InitializeAsync("{}", CancellationToken.None);
var sw = Stopwatch.StartNew();
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
sw.Stop();
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
return sw.Elapsed;
}
}

View File

@@ -0,0 +1,375 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-3.2 — coverage for the per-device <c>AddressingMode</c> toggle.
/// Asserts (a) <see cref="AddressingMode.Auto"/> resolves to
/// <see cref="AddressingMode.Symbolic"/> at the device level, (b) explicit
/// <see cref="AddressingMode.Logical"/> threads through every
/// <see cref="AbCipTagCreateParams"/> the driver builds, (c) Logical against an unsupported
/// family (Micro800) emits a warning + falls back to Symbolic, (d) the Driver-config DTO
/// round-trips the mode, and (e) family compatibility is captured by
/// <see cref="AbCipPlcFamilyProfile.SupportsLogicalAddressing"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipAddressingModeTests
{
// ---- Auto resolves to Symbolic ----
[Fact]
public async Task Default_AddressingMode_resolves_to_Symbolic_on_DeviceState()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
[Fact]
public async Task Auto_AddressingMode_resolves_to_Symbolic_on_DeviceState()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Auto),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
// ---- Logical threads through to AbCipTagCreateParams ----
[Fact]
public async Task Logical_AddressingMode_threads_through_into_create_params()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory,
enumeratorFactory: new EmptyEnumeratorFactoryStub());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Logical);
}
[Fact]
public async Task Symbolic_AddressingMode_explicitly_set_threads_through()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Symbolic),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
// ---- Logical against unsupported family falls back with warning ----
[Fact]
public async Task Logical_on_Micro800_falls_back_to_Symbolic_with_warning()
{
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.6/",
PlcFamily: AbCipPlcFamily.Micro800,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.6/")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Micro800");
warnings[0].ShouldContain("Logical");
}
[Fact]
public async Task Logical_on_Micro800_carries_Symbolic_into_create_params()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.6/",
PlcFamily: AbCipPlcFamily.Micro800,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.6/", "Speed", AbCipDataType.DInt),
],
OnWarning = _ => { },
}, "drv-1", tagFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
[Fact]
public async Task Logical_on_ControlLogix_does_not_warn()
{
var warnings = new List<string>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
OnWarning = warnings.Add,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
warnings.ShouldBeEmpty();
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Logical);
}
// ---- Family-profile compatibility flags ----
[Fact]
public void Family_profiles_advertise_logical_support_correctly()
{
AbCipPlcFamilyProfile.ControlLogix.SupportsLogicalAddressing.ShouldBeTrue();
AbCipPlcFamilyProfile.CompactLogix.SupportsLogicalAddressing.ShouldBeTrue();
AbCipPlcFamilyProfile.GuardLogix.SupportsLogicalAddressing.ShouldBeTrue();
AbCipPlcFamilyProfile.Micro800.SupportsLogicalAddressing.ShouldBeFalse();
}
// ---- DTO round-trip ----
[Fact]
public async Task DTO_round_trips_AddressingMode_Logical_through_config_json()
{
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"AddressingMode": "Logical"
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
await drv.InitializeAsync(json, CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Logical);
}
[Fact]
public async Task DTO_round_trips_AddressingMode_Symbolic_through_config_json()
{
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix",
"AddressingMode": "Symbolic"
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
await drv.InitializeAsync(json, CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
[Fact]
public async Task DTO_omitted_AddressingMode_falls_back_to_Auto_then_Symbolic()
{
// No AddressingMode in JSON → DTO field is null → factory parses fallback Auto →
// device-level resolution lands on Symbolic.
var json = """
{
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "ControlLogix"
}
],
"Probe": { "Enabled": false }
}
""";
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
await drv.InitializeAsync(json, CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
}
// ---- Logical-mode triggers a one-time symbol walk ----
[Fact]
public async Task Logical_mode_first_read_triggers_symbol_walk_once()
{
var enumStub = new RecordingEnumeratorFactory(
new AbCipDiscoveredTag("Speed", null, AbCipDataType.DInt, false),
new AbCipDiscoveredTag("Counter", null, AbCipDataType.DInt, false));
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Logical),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Counter", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory, enumeratorFactory: enumStub);
await drv.InitializeAsync("{}", CancellationToken.None);
// First read fires the walk
await drv.ReadAsync(["Speed"], CancellationToken.None);
// Second read must NOT walk again
await drv.ReadAsync(["Counter"], CancellationToken.None);
enumStub.CreateCount.ShouldBe(1);
var device = drv.GetDeviceState("ab://10.0.0.5/1,0")!;
device.LogicalWalkComplete.ShouldBeTrue();
device.LogicalInstanceMap.ShouldContainKey("Speed");
device.LogicalInstanceMap.ShouldContainKey("Counter");
}
[Fact]
public async Task Symbolic_mode_does_not_trigger_symbol_walk()
{
var enumStub = new RecordingEnumeratorFactory();
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions(
HostAddress: "ab://10.0.0.5/1,0",
PlcFamily: AbCipPlcFamily.ControlLogix,
AddressingMode: AddressingMode.Symbolic),
],
Probe = new AbCipProbeOptions { Enabled = false },
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
],
}, "drv-1", tagFactory: factory, enumeratorFactory: enumStub);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
enumStub.CreateCount.ShouldBe(0);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.LogicalWalkComplete.ShouldBeFalse();
}
// ---- Stubs ----
private sealed class EmptyEnumeratorFactoryStub : IAbCipTagEnumeratorFactory
{
public IAbCipTagEnumerator Create() => new EmptyStub();
private sealed class EmptyStub : IAbCipTagEnumerator
{
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
public void Dispose() { }
}
}
private sealed class RecordingEnumeratorFactory : IAbCipTagEnumeratorFactory
{
private readonly AbCipDiscoveredTag[] _seed;
public int CreateCount;
public RecordingEnumeratorFactory(params AbCipDiscoveredTag[] seed) => _seed = seed;
public IAbCipTagEnumerator Create()
{
CreateCount++;
return new SeededStub(_seed);
}
private sealed class SeededStub(AbCipDiscoveredTag[] seed) : IAbCipTagEnumerator
{
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var tag in seed)
yield return tag;
await Task.CompletedTask;
}
public void Dispose() { }
}
}
}