From cc35c77d647908ec485e83eb7b26b5dffd4481bc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 16:38:54 -0400 Subject: [PATCH] =?UTF-8?q?AB=20CIP=20PR=203=20=E2=80=94=20IReadable=20imp?= =?UTF-8?q?lementation=20against=20libplctag.=20Introduces=20IAbCipTagRunt?= =?UTF-8?q?ime=20+=20IAbCipTagFactory=20abstraction=20matching=20the=20Mod?= =?UTF-8?q?bus=20transport-factory=20pattern=20(ctor=20optional=20arg,=20d?= =?UTF-8?q?efault=20production=20impl=20injected)=20so=20the=20driver's=20?= =?UTF-8?q?read/status-mapping=20logic=20is=20unit-testable=20without=20a?= =?UTF-8?q?=20live=20PLC=20or=20the=20native=20libplctag=20binary.=20Libpl?= =?UTF-8?q?ctagTagRuntime=20is=20the=20default=20wire-backed=20implementat?= =?UTF-8?q?ion=20=E2=80=94=20wraps=20libplctag.Tag=20+=20translates=20our?= =?UTF-8?q?=20AbCipDataType=20enum=20into=20GetInt8/GetUInt8/GetInt16/GetU?= =?UTF-8?q?Int16/GetInt32/GetUInt32/GetInt64/GetUInt64/GetFloat32/GetFloat?= =?UTF-8?q?64/GetString/GetBit=20calls=20covering=20Bool=20(standalone=20+?= =?UTF-8?q?=20BOOL-in-DINT=20via=20.N=20bit=20selector),=20SInt/USInt,=20I?= =?UTF-8?q?nt/UInt,=20DInt/UDInt,=20LInt/ULInt,=20Real,=20LReal,=20String,?= =?UTF-8?q?=20Dt=20(epoch=20DINT),=20with=20Structure=20deferred=20to=20PR?= =?UTF-8?q?=206.=20MapPlcType=20bridges=20our=20libplctag=20attribute=20st?= =?UTF-8?q?rings=20(controllogix,=20compactlogix,=20micro800)=20to=20libpl?= =?UTF-8?q?ctag.PlcType=20enum;=20CompactLogix=20rolls=20under=20ControlLo?= =?UTF-8?q?gix=20per=20libplctag's=20family=20grouping=20which=20matches?= =?UTF-8?q?=20the=20wire=20protocol=20reality.=20AbCipDriver=20now=20imple?= =?UTF-8?q?ments=20IReadable=20=E2=80=94=20ReadAsync=20iterates=20fullRefe?= =?UTF-8?q?rences=20preserving=20order,=20looks=20up=20each=20tag=20defini?= =?UTF-8?q?tion=20+=20its=20device,=20lazily=20materialises=20the=20tag=20?= =?UTF-8?q?runtime=20via=20EnsureTagRuntimeAsync=20on=20first=20touch=20(c?= =?UTF-8?q?ached=20thereafter=20for=20the=20lifetime=20of=20the=20device),?= =?UTF-8?q?=20catches=20OperationCanceledException=20to=20honor=20cancella?= =?UTF-8?q?tion,=20maps=20libplctag=20non-zero=20status=20via=20AbCipStatu?= =?UTF-8?q?sMapper.MapLibplctagStatus,=20catches=20any=20other=20exception?= =?UTF-8?q?=20as=20BadCommunicationError.=20Health=20surface=20moves=20to?= =?UTF-8?q?=20Healthy=20on=20success=20+=20Degraded=20with=20the=20last=20?= =?UTF-8?q?error=20message=20on=20failure.=20Initialize-failure=20path=20d?= =?UTF-8?q?isposes=20the=20half-created=20runtime=20before=20rethrowing=20?= =?UTF-8?q?so=20no=20native=20handles=20leak.=20DeviceState=20gains=20a=20?= =?UTF-8?q?Runtimes=20dict=20alongside=20the=20existing=20TagHandles=20col?= =?UTF-8?q?lection;=20DisposeHandles=20walks=20both=20so=20ShutdownAsync?= =?UTF-8?q?=20+=20ReinitializeAsync=20cleanly=20destroy=20every=20native?= =?UTF-8?q?=20tag.=2012=20new=20unit=20tests=20in=20AbCipDriverReadTests?= =?UTF-8?q?=20using=20FakeAbCipTag=20/=20FakeAbCipTagFactory=20(test=20fak?= =?UTF-8?q?e=20under=20tests/...AbCip.Tests/FakeAbCipTag.cs)=20covering=20?= =?UTF-8?q?unknown=20reference=20=E2=86=92=20BadNodeIdUnknown,=20unknown?= =?UTF-8?q?=20device=20=E2=86=92=20BadNodeIdUnknown,=20successful=20DInt?= =?UTF-8?q?=20read=20with=20correct=20Good=20status=20+=20captured=20value?= =?UTF-8?q?,=20lazy-init=20on=20first=20read=20with=20reuse=20across=20sub?= =?UTF-8?q?sequent=20reads,=20non-zero=20libplctag=20status=20mapping=20vi?= =?UTF-8?q?a=20AbCipStatusMapper,=20exception=20during=20read=20surfacing?= =?UTF-8?q?=20as=20BadCommunicationError=20with=20health=20Degraded,=20bat?= =?UTF-8?q?ched=20reads=20preserving=20order=20+=20per-tag=20status,=20hea?= =?UTF-8?q?lth=20Healthy=20after=20success,=20TagCreateParams=20compositio?= =?UTF-8?q?n=20from=20device=20+=20profile=20(gateway=20/=20port=20/=20CIP?= =?UTF-8?q?=20path=20/=20libplctag=20attribute=20/=20tag=20name=20wiring),?= =?UTF-8?q?=20cancellation=20propagation=20via=20OperationCanceledExceptio?= =?UTF-8?q?n,=20ShutdownAsync=20disposing=20every=20runtime,=20Initialize-?= =?UTF-8?q?failure=20disposing=20the=20aborted=20runtime.=20Total=20AbCip?= =?UTF-8?q?=20unit=20tests=20now=2088/88=20passing.=20Integration=20test?= =?UTF-8?q?=20project=20scaffolding=20=E2=80=94=20tests/ZB.MOM.WW.OtOpcUa.?= =?UTF-8?q?Driver.AbCip.IntegrationTests=20with=20AbServerFixture=20(IAsyn?= =?UTF-8?q?cLifetime=20that=20starts=20ab=5Fserver=20when=20the=20binary?= =?UTF-8?q?=20is=20on=20PATH,=20otherwise=20marks=20IsAvailable=3Dfalse),?= =?UTF-8?q?=20AbServerFact=20attribute=20(Fact-equivalent=20that=20skips?= =?UTF-8?q?=20when=20ab=5Fserver=20is=20missing),=20one=20smoke=20test=20e?= =?UTF-8?q?xercising=20DInt=20read=20end-to-end.=20Project=20runs=20cleanl?= =?UTF-8?q?y=20=E2=80=94=20the=20single=20smoke=20test=20skips=20on=20boxe?= =?UTF-8?q?s=20without=20ab=5Fserver=20(0=20failed,=200=20passed,=201=20sk?= =?UTF-8?q?ipped)=20+=20runs=20on=20boxes=20with=20it.=20Follow-up=20work?= =?UTF-8?q?=20captured=20in=20comments=20=E2=80=94=20ab=5Fserver=20CI=20fi?= =?UTF-8?q?xture=20(download=20prebuilt=20Windows=20x64=20binary=20as=20Gi?= =?UTF-8?q?tHub=20release=20asset)=20+=20per-family=20JSON=20profiles=20+?= =?UTF-8?q?=20hand-rolled=20CIP=20stub=20for=20UDT=20fidelity=20ship=20in?= =?UTF-8?q?=20the=20PR=206/9-12=20window.=20Solution=20file=20updated.=20F?= =?UTF-8?q?ull=20solution=20builds=200=20errors=20across=20all=2028=20proj?= =?UTF-8?q?ects.=20Modbus=20+=20other=20existing=20tests=20untouched.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../AbCipDriver.cs | 119 +++++++++- .../IAbCipTagRuntime.cs | 63 ++++++ .../LibplctagTagRuntime.cs | 89 ++++++++ .../AbCipReadSmokeTests.cs | 44 ++++ .../AbServerFixture.cs | 109 +++++++++ ...OpcUa.Driver.AbCip.IntegrationTests.csproj | 31 +++ .../AbCipDriverReadTests.cs | 214 ++++++++++++++++++ .../FakeAbCipTag.cs | 67 ++++++ 9 files changed, 735 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs 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; + } +} -- 2.49.1