AB CIP PR 3 — IReadable against libplctag (swappable factory + fake + skip-gated smoke) #110
@@ -31,6 +31,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
|
||||
@@ -20,18 +20,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// from native-heap growth that the CLR allocator can't see; it tears down every
|
||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriver : IDriver, IDisposable, IAsyncDisposable
|
||||
public sealed class AbCipDriver : IDriver, IReadable, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbCipDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbCipTagFactory _tagFactory;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId)
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||
IAbCipTagFactory? tagFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
@@ -50,6 +54,7 @@ public sealed class AbCipDriver : IDriver, IDisposable, IAsyncDisposable
|
||||
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||
}
|
||||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -71,10 +76,111 @@ public sealed class AbCipDriver : IDriver, IDisposable, IAsyncDisposable
|
||||
foreach (var state in _devices.Values)
|
||||
state.DisposeHandles();
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <summary>
|
||||
/// Read each <c>fullReference</c> in order. Unknown tags surface as
|
||||
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
|
||||
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
|
||||
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently materialise the runtime handle for a tag definition. First call creates
|
||||
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
|
||||
/// lifetime of the device.
|
||||
/// </summary>
|
||||
private async Task<IAbCipTagRuntime> EnsureTagRuntimeAsync(
|
||||
DeviceState device, AbCipTagDefinition def, CancellationToken ct)
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
|
||||
|
||||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[def.Name] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
/// <summary>
|
||||
@@ -117,10 +223,19 @@ public sealed class AbCipDriver : IDriver, IDisposable, IAsyncDisposable
|
||||
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Per-tag runtime handles owned by this device. One entry per configured tag is
|
||||
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void DisposeHandles()
|
||||
{
|
||||
foreach (var h in TagHandles.Values) h.Dispose();
|
||||
TagHandles.Clear();
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
Runtimes.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wire-layer abstraction over a single CIP tag. The driver holds one instance per
|
||||
/// <c>(device, tag path)</c> pair; the default implementation delegates to
|
||||
/// <see cref="LibplctagTagRuntime"/>. Tests swap in a fake via
|
||||
/// <see cref="IAbCipTagFactory"/> so the driver's read / write / status-mapping logic can
|
||||
/// be exercised without a running PLC or the native libplctag binary.
|
||||
/// </summary>
|
||||
public interface IAbCipTagRuntime : IDisposable
|
||||
{
|
||||
/// <summary>Create the underlying native tag (equivalent to libplctag's <c>plc_tag_create</c>).</summary>
|
||||
Task InitializeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Issue a read; on completion the local buffer holds the current PLC value.</summary>
|
||||
Task ReadAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Flush the local buffer to the PLC.</summary>
|
||||
Task WriteAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>. Zero on success, negative on error.
|
||||
/// </summary>
|
||||
int GetStatus();
|
||||
|
||||
/// <summary>
|
||||
/// Decode the local buffer into a boxed .NET value per the tag's configured type.
|
||||
/// <paramref name="bitIndex"/> is non-null only for BOOL-within-DINT tags captured in
|
||||
/// the <c>.N</c> syntax at parse time.
|
||||
/// </summary>
|
||||
object? DecodeValue(AbCipDataType type, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
|
||||
/// pair this with <see cref="WriteAsync"/>.
|
||||
/// </summary>
|
||||
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for per-tag runtime handles. Instantiated once per driver, consumed per
|
||||
/// <c>(device, tag path)</c> pair at the first read/write.
|
||||
/// </summary>
|
||||
public interface IAbCipTagFactory
|
||||
{
|
||||
IAbCipTagRuntime Create(AbCipTagCreateParams createParams);
|
||||
}
|
||||
|
||||
/// <summary>Everything libplctag needs to materialise a tag handle.</summary>
|
||||
/// <param name="Gateway">Gateway IP / hostname parsed from <see cref="AbCipHostAddress.Gateway"/>.</param>
|
||||
/// <param name="Port">EtherNet/IP TCP port — default 44818.</param>
|
||||
/// <param name="CipPath">CIP route path, e.g. <c>1,0</c>. Empty for Micro800.</param>
|
||||
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
|
||||
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
|
||||
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
|
||||
public sealed record AbCipTagCreateParams(
|
||||
string Gateway,
|
||||
int Port,
|
||||
string CipPath,
|
||||
string LibplctagPlcAttribute,
|
||||
string TagName,
|
||||
TimeSpan Timeout);
|
||||
89
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
Normal file
89
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Default libplctag-backed <see cref="IAbCipTagRuntime"/>. Wraps a <see cref="Tag"/>
|
||||
/// instance + translates our <see cref="AbCipDataType"/> enum into the
|
||||
/// <c>GetInt32</c> / <c>GetFloat32</c> / <c>GetString</c> / <c>GetBit</c> calls libplctag
|
||||
/// exposes. One runtime instance per <c>(device, tag path)</c>; lifetime is owned by the
|
||||
/// driver's per-device state dict.
|
||||
/// </summary>
|
||||
internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
public LibplctagTagRuntime(AbCipTagCreateParams p)
|
||||
{
|
||||
_tag = new Tag
|
||||
{
|
||||
Gateway = p.Gateway,
|
||||
Path = p.CipPath,
|
||||
PlcType = MapPlcType(p.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
};
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit
|
||||
? _tag.GetBit(bit)
|
||||
: _tag.GetInt8(0) != 0,
|
||||
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(0),
|
||||
AbCipDataType.USInt => (int)_tag.GetUInt8(0),
|
||||
AbCipDataType.Int => (int)_tag.GetInt16(0),
|
||||
AbCipDataType.UInt => (int)_tag.GetUInt16(0),
|
||||
AbCipDataType.DInt => _tag.GetInt32(0),
|
||||
AbCipDataType.UDInt => (int)_tag.GetUInt32(0),
|
||||
AbCipDataType.LInt => _tag.GetInt64(0),
|
||||
AbCipDataType.ULInt => (long)_tag.GetUInt64(0),
|
||||
AbCipDataType.Real => _tag.GetFloat32(0),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(0),
|
||||
AbCipDataType.String => _tag.GetString(0),
|
||||
AbCipDataType.Dt => _tag.GetInt32(0), // seconds-since-epoch DINT; consumer widens as needed
|
||||
AbCipDataType.Structure => null, // UDT whole-tag decode lands in PR 6
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
// Writes land in PR 4 — Encode is declared here so the interface surface is stable;
|
||||
// PR 4 fills in the switch.
|
||||
_ = type;
|
||||
_ = bitIndex;
|
||||
_ = value;
|
||||
throw new NotSupportedException("AbCip writes land in PR 4.");
|
||||
}
|
||||
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"controllogix" => PlcType.ControlLogix,
|
||||
"compactlogix" => PlcType.ControlLogix, // libplctag treats CompactLogix under ControlLogix family
|
||||
"micro800" => PlcType.Micro800,
|
||||
"micrologix" => PlcType.MicroLogix,
|
||||
"slc500" => PlcType.Slc500,
|
||||
"plc5" => PlcType.Plc5,
|
||||
"omron-njnx" => PlcType.Omron,
|
||||
_ => PlcType.ControlLogix,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAbCipTagFactory"/> — creates a fresh <see cref="LibplctagTagRuntime"/>
|
||||
/// per call. Stateless; safe to share across devices.
|
||||
/// </summary>
|
||||
internal sealed class LibplctagTagFactory : IAbCipTagFactory
|
||||
{
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
|
||||
new LibplctagTagRuntime(createParams);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
/// End-to-end smoke tests that exercise the real libplctag stack against a running
|
||||
/// <c>ab_server</c>. Skipped when the binary isn't on PATH (<see cref="AbServerFactAttribute"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intentionally minimal — per-family + per-capability coverage ships in PRs 9–12 once the
|
||||
/// integration harness is CI-ready. This file exists at PR 3 time to prove the wire path
|
||||
/// works end-to-end on developer boxes that have <c>ab_server</c>.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Requires", "AbServer")]
|
||||
public sealed class AbCipReadSmokeTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AbServerFixture _fixture = new();
|
||||
|
||||
public async ValueTask InitializeAsync() => await _fixture.InitializeAsync();
|
||||
public async ValueTask DisposeAsync() => await _fixture.DisposeAsync();
|
||||
|
||||
[AbServerFact]
|
||||
public async Task Driver_reads_DInt_from_ab_server()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions($"ab://127.0.0.1:{_fixture.Port}/1,0", AbCipPlcFamily.ControlLogix)],
|
||||
Tags = [new AbCipTagDefinition("Counter", $"ab://127.0.0.1:{_fixture.Port}/1,0", "TestDINT", AbCipDataType.DInt)],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, "drv-smoke");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Diagnostics;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fixture that starts libplctag's <c>ab_server</c> simulator in the background for
|
||||
/// the duration of an integration test collection. Binary is expected on PATH; the per-test
|
||||
/// JSON profile is passed via <c>--config</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><c>ab_server</c> is a C binary shipped in the same repo as libplctag (see
|
||||
/// <c>test-data-sources.md</c> §2 and plan decision #99). On a developer workstation it's
|
||||
/// built once from source and placed on PATH; in CI we intend to publish a prebuilt Windows
|
||||
/// x64 binary as a GitHub release asset in a follow-up PR so the fixture can download +
|
||||
/// extract it at setup time. Until then every test in this project is skipped when
|
||||
/// <c>ab_server</c> is not locatable.</para>
|
||||
///
|
||||
/// <para>Per-family JSON profiles (ControlLogix / CompactLogix / Micro800 / GuardLogix)
|
||||
/// ship under <c>Profiles/</c> and drive the simulator's tag shape — this is where the
|
||||
/// UDT + Program-scope coverage gap will be filled by the hand-rolled stub in PR 6.</para>
|
||||
/// </remarks>
|
||||
public sealed class AbServerFixture : IAsyncLifetime
|
||||
{
|
||||
private Process? _proc;
|
||||
public int Port { get; } = 44818;
|
||||
|
||||
public bool IsAvailable { get; private set; }
|
||||
|
||||
public ValueTask InitializeAsync() => InitializeAsync(default);
|
||||
public ValueTask DisposeAsync() => DisposeAsync(default);
|
||||
|
||||
public async ValueTask InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (LocateBinary() is not string binary)
|
||||
{
|
||||
IsAvailable = false;
|
||||
return;
|
||||
}
|
||||
IsAvailable = true;
|
||||
|
||||
_proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binary,
|
||||
Arguments = $"--port {Port} --plc controllogix",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
},
|
||||
};
|
||||
_proc.Start();
|
||||
|
||||
// Give the server a moment to accept its listen socket before tests try to connect.
|
||||
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_proc is { HasExited: false })
|
||||
{
|
||||
_proc.Kill(entireProcessTree: true);
|
||||
_proc.WaitForExit(5_000);
|
||||
}
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
_proc?.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate <c>ab_server</c> on PATH. Returns <c>null</c> when missing — tests that
|
||||
/// depend on it should use <see cref="AbServerFact"/> so CI runs without the binary
|
||||
/// simply skip rather than fail.
|
||||
/// </summary>
|
||||
public static string? LocateBinary()
|
||||
{
|
||||
var names = new[] { "ab_server.exe", "ab_server" };
|
||||
var path = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in path.Split(Path.PathSeparator))
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
var candidate = Path.Combine(dir, name);
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Fact]</c>-equivalent that skips when <c>ab_server</c> is not available on PATH.
|
||||
/// Integration tests use this instead of <c>[Fact]</c> so a developer box without
|
||||
/// <c>ab_server</c> installed still gets a green run.
|
||||
/// </summary>
|
||||
public sealed class AbServerFactAttribute : FactAttribute
|
||||
{
|
||||
public AbServerFactAttribute()
|
||||
{
|
||||
if (AbServerFixture.LocateBinary() is null)
|
||||
Skip = "ab_server not on PATH; install libplctag test binaries to run.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,214 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverReadTests
|
||||
{
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["does-not-exist"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tag_on_unknown_device_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)],
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Orphan"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_DInt_read_returns_Good_with_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Customise the fake before the first read so the tag returns 4200.
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(4200);
|
||||
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_runtime_without_reinitialise()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1); // lazy init happens once
|
||||
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = -14 /* PLCTAG_ERR_NOT_FOUND */ };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exception_during_read_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.Real));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Broken"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order_and_per_tag_status()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName switch
|
||||
{
|
||||
"A" => new FakeAbCipTag(p) { Value = 42 },
|
||||
"B" => new FakeAbCipTag(p) { Value = 3.14f },
|
||||
_ => new FakeAbCipTag(p) { Value = "hello" },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
snapshots[0].Value.ShouldBe(42);
|
||||
snapshots[1].Value.ShouldBe(3.14f);
|
||||
snapshots[2].Value.ShouldBe("hello");
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_read_marks_health_Healthy()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Pressure", "ab://10.0.0.5/1,0", "PT_101", AbCipDataType.Real));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 14.7f };
|
||||
|
||||
await drv.ReadAsync(["Pressure"], CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TagCreateParams_are_built_from_device_and_profile()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Program:P.Counter", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
var p = factory.Tags["Program:P.Counter"].CreationParams;
|
||||
p.Gateway.ShouldBe("10.0.0.5");
|
||||
p.Port.ShouldBe(44818);
|
||||
p.CipPath.ShouldBe("1,0");
|
||||
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
p.TagName.ShouldBe("Program:P.Counter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_from_read()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p)
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["Slow"], cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_each_tag_runtime()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Tags["A"].Disposed.ShouldBeTrue();
|
||||
factory.Tags["B"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_failure_disposes_tag_and_surfaces_communication_error()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("DoomedTag", "ab://10.0.0.5/1,0", "Nope", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnInitialize = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["DoomedTag"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
67
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
Normal file
67
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test fake for <see cref="IAbCipTagRuntime"/>. Stores the mock PLC value in
|
||||
/// <see cref="Value"/> + returns it from <see cref="DecodeValue"/>. Use
|
||||
/// <see cref="Status"/> to simulate libplctag error codes,
|
||||
/// <see cref="ThrowOnInitialize"/> / <see cref="ThrowOnRead"/> to simulate exceptions.
|
||||
/// </summary>
|
||||
internal sealed class FakeAbCipTag : IAbCipTagRuntime
|
||||
{
|
||||
public AbCipTagCreateParams CreationParams { get; }
|
||||
public object? Value { get; set; }
|
||||
public int Status { get; set; }
|
||||
public bool ThrowOnInitialize { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public Exception? Exception { get; set; }
|
||||
public int InitializeCount { get; private set; }
|
||||
public int ReadCount { get; private set; }
|
||||
public int WriteCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public FakeAbCipTag(AbCipTagCreateParams createParams) => CreationParams = createParams;
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
InitializeCount++;
|
||||
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException("fake initialize failure");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReadCount++;
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException("fake read failure");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
WriteCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public int GetStatus() => Status;
|
||||
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
|
||||
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
/// <summary>Test factory that produces <see cref="FakeAbCipTag"/>s and indexes them for assertion.</summary>
|
||||
internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
|
||||
{
|
||||
public Dictionary<string, FakeAbCipTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Func<AbCipTagCreateParams, FakeAbCipTag>? Customise { get; set; }
|
||||
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams)
|
||||
{
|
||||
var fake = Customise?.Invoke(createParams) ?? new FakeAbCipTag(createParams);
|
||||
Tags[createParams.TagName] = fake;
|
||||
return fake;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user