diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index d5efdda..8aaaf41 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -31,6 +31,7 @@
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 642e6a2..53b75f8 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -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
/// and reconnects each device.
///
-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 _devices = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _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 ----
+
+ ///
+ /// Read each fullReference in order. Unknown tags surface as
+ /// BadNodeIdUnknown; libplctag-layer failures map through
+ /// ; any other exception becomes
+ /// BadCommunicationError. The driver health surface is updated per-call so the
+ /// Admin UI sees a tight feedback loop between read failures + the driver's state.
+ ///
+ public async Task> ReadAsync(
+ IReadOnlyList 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private async Task 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;
///
@@ -117,10 +223,19 @@ public sealed class AbCipDriver : IDriver, IDisposable, IAsyncDisposable
public Dictionary TagHandles { get; } =
new(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// Per-tag runtime handles owned by this device. One entry per configured tag is
+ /// created lazily on first read (see ).
+ ///
+ public Dictionary 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();
}
}
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
new file mode 100644
index 0000000..e01e011
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
@@ -0,0 +1,63 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Thin wire-layer abstraction over a single CIP tag. The driver holds one instance per
+/// (device, tag path) pair; the default implementation delegates to
+/// . Tests swap in a fake via
+/// so the driver's read / write / status-mapping logic can
+/// be exercised without a running PLC or the native libplctag binary.
+///
+public interface IAbCipTagRuntime : IDisposable
+{
+ /// Create the underlying native tag (equivalent to libplctag's plc_tag_create).
+ Task InitializeAsync(CancellationToken cancellationToken);
+
+ /// Issue a read; on completion the local buffer holds the current PLC value.
+ Task ReadAsync(CancellationToken cancellationToken);
+
+ /// Flush the local buffer to the PLC.
+ Task WriteAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Raw libplctag status code — mapped to an OPC UA StatusCode via
+ /// . Zero on success, negative on error.
+ ///
+ int GetStatus();
+
+ ///
+ /// Decode the local buffer into a boxed .NET value per the tag's configured type.
+ /// is non-null only for BOOL-within-DINT tags captured in
+ /// the .N syntax at parse time.
+ ///
+ object? DecodeValue(AbCipDataType type, int? bitIndex);
+
+ ///
+ /// Encode into the local buffer per the tag's type. Callers
+ /// pair this with .
+ ///
+ void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
+}
+
+///
+/// Factory for per-tag runtime handles. Instantiated once per driver, consumed per
+/// (device, tag path) pair at the first read/write.
+///
+public interface IAbCipTagFactory
+{
+ IAbCipTagRuntime Create(AbCipTagCreateParams createParams);
+}
+
+/// Everything libplctag needs to materialise a tag handle.
+/// Gateway IP / hostname parsed from .
+/// EtherNet/IP TCP port — default 44818.
+/// CIP route path, e.g. 1,0. Empty for Micro800.
+/// libplctag plc=... attribute, per family profile.
+/// Logix symbolic tag name as emitted by .
+/// libplctag operation timeout (applies to Initialize / Read / Write).
+public sealed record AbCipTagCreateParams(
+ string Gateway,
+ int Port,
+ string CipPath,
+ string LibplctagPlcAttribute,
+ string TagName,
+ TimeSpan Timeout);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
new file mode 100644
index 0000000..931b58b
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -0,0 +1,89 @@
+using libplctag;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Default libplctag-backed . Wraps a
+/// instance + translates our enum into the
+/// GetInt32 / GetFloat32 / GetString / GetBit calls libplctag
+/// exposes. One runtime instance per (device, tag path); lifetime is owned by the
+/// driver's per-device state dict.
+///
+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,
+ };
+}
+
+///
+/// Default — creates a fresh
+/// per call. Stateless; safe to share across devices.
+///
+internal sealed class LibplctagTagFactory : IAbCipTagFactory
+{
+ public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
+ new LibplctagTagRuntime(createParams);
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs
new file mode 100644
index 0000000..ebad86e
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs
@@ -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;
+
+///
+/// End-to-end smoke tests that exercise the real libplctag stack against a running
+/// ab_server. Skipped when the binary isn't on PATH ().
+///
+///
+/// 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 ab_server.
+///
+[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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs
new file mode 100644
index 0000000..400b660
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs
@@ -0,0 +1,109 @@
+using System.Diagnostics;
+using Xunit;
+using Xunit.Sdk;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
+
+///
+/// Shared fixture that starts libplctag's ab_server 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 --config.
+///
+///
+/// ab_server is a C binary shipped in the same repo as libplctag (see
+/// test-data-sources.md §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
+/// ab_server is not locatable.
+///
+/// Per-family JSON profiles (ControlLogix / CompactLogix / Micro800 / GuardLogix)
+/// ship under Profiles/ 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.
+///
+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;
+ }
+
+ ///
+ /// Locate ab_server on PATH. Returns null when missing — tests that
+ /// depend on it should use so CI runs without the binary
+ /// simply skip rather than fail.
+ ///
+ 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;
+ }
+}
+
+///
+/// [Fact]-equivalent that skips when ab_server is not available on PATH.
+/// Integration tests use this instead of [Fact] so a developer box without
+/// ab_server installed still gets a green run.
+///
+public sealed class AbServerFactAttribute : FactAttribute
+{
+ public AbServerFactAttribute()
+ {
+ if (AbServerFixture.LocateBinary() is null)
+ Skip = "ab_server not on PATH; install libplctag test binaries to run.";
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj
new file mode 100644
index 0000000..26e818e
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs
new file mode 100644
index 0000000..dc63052
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs
@@ -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(
+ () => 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();
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
new file mode 100644
index 0000000..bd2945b
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
@@ -0,0 +1,67 @@
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// Test fake for . Stores the mock PLC value in
+/// + returns it from . Use
+/// to simulate libplctag error codes,
+/// / to simulate exceptions.
+///
+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;
+}
+
+/// Test factory that produces s and indexes them for assertion.
+internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
+{
+ public Dictionary Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public Func? Customise { get; set; }
+
+ public IAbCipTagRuntime Create(AbCipTagCreateParams createParams)
+ {
+ var fake = Customise?.Invoke(createParams) ?? new FakeAbCipTag(createParams);
+ Tags[createParams.TagName] = fake;
+ return fake;
+ }
+}