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; + } +}