From b2424a061634a1fe10d0d58ec8d63d80f10bd108 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 17:58:38 -0400 Subject: [PATCH] =?UTF-8?q?AB=20Legacy=20PR=202=20=E2=80=94=20IReadable=20?= =?UTF-8?q?+=20IWritable.=20IAbLegacyTagRuntime=20+=20IAbLegacyTagFactory?= =?UTF-8?q?=20abstraction=20mirrors=20IAbCipTagRuntime=20from=20AbCip=20PR?= =?UTF-8?q?=203.=20LibplctagLegacyTagRuntime=20default=20implementation=20?= =?UTF-8?q?wraps=20libplctag.Tag=20with=20Protocol=3Dab=5Feip=20+=20PlcTyp?= =?UTF-8?q?e=20dispatched=20from=20the=20profile's=20libplctag=20attribute?= =?UTF-8?q?=20(Slc500/MicroLogix/Plc5/LogixPccc)=20=E2=80=94=20libplctag?= =?UTF-8?q?=20routes=20PCCC-over-EIP=20internally=20based=20on=20PlcType,?= =?UTF-8?q?=20so=20our=20layer=20just=20forwards=20the=20atomic=20type=20t?= =?UTF-8?q?o=20Get/Set=20calls.=20DecodeValue=20handles=20Bit=20(GetBit=20?= =?UTF-8?q?when=20bitIndex=20is=20set,=20else=20GetInt8!=3D0),=20Int/Analo?= =?UTF-8?q?gInt=20(GetInt16=20widened=20to=20int),=20Long=20(GetInt32),=20?= =?UTF-8?q?Float=20(GetFloat32),=20String=20(GetString),=20TimerElement/Co?= =?UTF-8?q?unterElement/ControlElement=20(GetInt32=20=E2=80=94=20sub-eleme?= =?UTF-8?q?nt=20selection=20is=20in=20the=20libplctag=20tag=20name=20like?= =?UTF-8?q?=20T4:0.ACC,=20PLC-side=20decode=20picks=20the=20right=20slot).?= =?UTF-8?q?=20EncodeValue=20handles=20the=20same=20types;=20bit-within-wor?= =?UTF-8?q?d=20writes=20throw=20NotSupportedException=20pointing=20at=20fo?= =?UTF-8?q?llow-up=20task=20#181=20(same=20read-modify-write=20gap=20as=20?= =?UTF-8?q?Modbus=20BitInRegister).=20AbLegacyDriver=20implements=20IReada?= =?UTF-8?q?ble=20+=20IWritable=20with=20the=20exact=20same=20shape=20as=20?= =?UTF-8?q?AbCip=20PR=203-4=20=E2=80=94=20per-tag=20lazy=20runtime=20init?= =?UTF-8?q?=20via=20EnsureTagRuntimeAsync=20cached=20in=20DeviceState.Runt?= =?UTF-8?q?imes=20dict,=20ordered-snapshot=20results,=20health=20surface?= =?UTF-8?q?=20updates.=20Exception=20table=20=E2=80=94=20OperationCanceled?= =?UTF-8?q?Exception=20rethrows,=20NotSupportedException=20=E2=86=92=20Bad?= =?UTF-8?q?NotSupported,=20FormatException/InvalidCastException=20?= =?UTF-8?q?=E2=86=92=20BadTypeMismatch=20(guard=20pattern=20C#=2011=20synt?= =?UTF-8?q?ax),=20OverflowException=20=E2=86=92=20BadOutOfRange,=20anythin?= =?UTF-8?q?g=20else=20=E2=86=92=20BadCommunicationError.=20ShutdownAsync?= =?UTF-8?q?=20disposes=20every=20cached=20runtime=20so=20the=20native=20ta?= =?UTF-8?q?g=20handles=20get=20released.=2014=20new=20unit=20tests=20in=20?= =?UTF-8?q?AbLegacyReadWriteTests=20covering=20unknown=20ref=20=E2=86=92?= =?UTF-8?q?=20BadNodeIdUnknown,=20successful=20N-file=20read=20with=20Good?= =?UTF-8?q?=20status=20+=20captured=20value,=20repeat-read=20reuses=20cach?= =?UTF-8?q?ed=20runtime=20(init=20count=201=20across=202=20reads),=20libpl?= =?UTF-8?q?ctag=20non-zero=20status=20mapping=20(-14=20=E2=86=92=20BadNode?= =?UTF-8?q?IdUnknown),=20read=20exception=20=E2=86=92=20BadCommunicationEr?= =?UTF-8?q?ror=20+=20Degraded=20health,=20batched=20reads=20preserve=20ord?= =?UTF-8?q?er=20across=20N/F/ST=20types,=20TagCreateParams=20composition?= =?UTF-8?q?=20(gateway/port/path/slc500=20attribute/tag-name),=20non-writa?= =?UTF-8?q?ble=20tag=20=E2=86=92=20BadNotWritable,=20successful=20write=20?= =?UTF-8?q?encodes=20+=20flushes,=20bit-within-word=20=E2=86=92=20BadNotSu?= =?UTF-8?q?pported=20(RmwThrowingFake=20mirrors=20LibplctagLegacyTagRuntim?= =?UTF-8?q?e's=20runtime=20check),=20write=20exception=20=E2=86=92=20BadCo?= =?UTF-8?q?mmunicationError,=20batch=20preserves=20order=20across=20succes?= =?UTF-8?q?s+fail+unknown,=20cancellation=20propagates,=20ShutdownAsync=20?= =?UTF-8?q?disposes=20runtimes.=20Total=20AbLegacy=20unit=20tests=20now=20?= =?UTF-8?q?82/82=20passing=20(+14=20from=20PR=201's=2068).=20Full=20soluti?= =?UTF-8?q?on=20builds=200=20errors;=20Modbus=20+=20AbCip=20+=20other=20dr?= =?UTF-8?q?ivers=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) --- .../AbLegacyDriver.cs | 166 +++++++++++- .../IAbLegacyTagRuntime.cs | 29 ++ .../LibplctagLegacyTagRuntime.cs | 97 +++++++ .../AbLegacyReadWriteTests.cs | 256 ++++++++++++++++++ .../FakeAbLegacyTag.cs | 59 ++++ 5 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index f0585c9..23957a7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -8,18 +8,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// only at PR 1 time; read / write / discovery / subscribe / probe / /// host-resolver capabilities ship in PRs 2 and 3. /// -public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable +public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable { private readonly AbLegacyDriverOptions _options; private readonly string _driverInstanceId; + private readonly IAbLegacyTagFactory _tagFactory; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); - public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId) + public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId, + IAbLegacyTagFactory? tagFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; + _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory(); } public string DriverInstanceId => _driverInstanceId; @@ -38,6 +42,7 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable var profile = AbLegacyPlcFamilyProfile.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) @@ -56,7 +61,9 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable public Task ShutdownAsync(CancellationToken cancellationToken) { + foreach (var state in _devices.Values) state.DisposeRuntimes(); _devices.Clear(); + _tagsByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); return Task.CompletedTask; } @@ -69,6 +76,153 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable internal DeviceState? GetDeviceState(string hostAddress) => _devices.TryGetValue(hostAddress, out var s) ? s : null; + // ---- IReadable ---- + + 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, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.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, + AbLegacyStatusMapper.MapLibplctagStatus(status), null, now); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"libplctag status {status} reading {reference}"); + continue; + } + + var parsed = AbLegacyAddress.TryParse(def.Address); + var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); + results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); + _health = new DriverHealth(DriverState.Healthy, now, null); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results[i] = new DataValueSnapshot(null, + AbLegacyStatusMapper.BadCommunicationError, null, now); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + + return results; + } + + // ---- IWritable ---- + + public async Task> WriteAsync( + IReadOnlyList writes, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writes); + var results = new WriteResult[writes.Count]; + + for (var i = 0; i < writes.Count; i++) + { + var w = writes[i]; + if (!_tagsByName.TryGetValue(w.FullReference, out var def)) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); + continue; + } + if (!def.Writable) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable); + continue; + } + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); + continue; + } + + try + { + var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); + var parsed = AbLegacyAddress.TryParse(def.Address); + runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value); + await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); + + var status = runtime.GetStatus(); + results[i] = new WriteResult(status == 0 + ? AbLegacyStatusMapper.Good + : AbLegacyStatusMapper.MapLibplctagStatus(status)); + } + catch (OperationCanceledException) { throw; } + catch (NotSupportedException nse) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); + } + catch (Exception ex) when (ex is FormatException or InvalidCastException) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch); + } + catch (OverflowException) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange); + } + catch (Exception ex) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + + return results; + } + + private async Task EnsureTagRuntimeAsync( + DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) + { + if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing; + + var parsed = AbLegacyAddress.TryParse(def.Address) + ?? throw new InvalidOperationException( + $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); + + var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( + 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 void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); @@ -80,5 +234,13 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress; public AbLegacyDeviceOptions Options { get; } = options; public AbLegacyPlcFamilyProfile Profile { get; } = profile; + public Dictionary Runtimes { get; } = + new(StringComparer.OrdinalIgnoreCase); + + public void DisposeRuntimes() + { + foreach (var r in Runtimes.Values) r.Dispose(); + Runtimes.Clear(); + } } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs new file mode 100644 index 0000000..4e0c98b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs @@ -0,0 +1,29 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Wire-layer abstraction over a single PCCC tag. Mirrors IAbCipTagRuntime's shape so +/// the same test-fake pattern applies; the only meaningful difference is the protocol layer +/// underneath (ab_pccc vs ab_eip). +/// +public interface IAbLegacyTagRuntime : IDisposable +{ + Task InitializeAsync(CancellationToken cancellationToken); + Task ReadAsync(CancellationToken cancellationToken); + Task WriteAsync(CancellationToken cancellationToken); + int GetStatus(); + object? DecodeValue(AbLegacyDataType type, int? bitIndex); + void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value); +} + +public interface IAbLegacyTagFactory +{ + IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams); +} + +public sealed record AbLegacyTagCreateParams( + string Gateway, + int Port, + string CipPath, + string LibplctagPlcAttribute, + string TagName, + TimeSpan Timeout); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs new file mode 100644 index 0000000..b05b1fd --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -0,0 +1,97 @@ +using libplctag; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Default libplctag-backed . Uses ab_pccc protocol +/// on top of EtherNet/IP — libplctag's PCCC layer handles the file-letter + word + bit + +/// sub-element decoding internally, so our wrapper just has to forward the atomic type to +/// the right Get/Set call. +/// +internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime +{ + private readonly Tag _tag; + + public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p) + { + _tag = new Tag + { + Gateway = p.Gateway, + Path = p.CipPath, + PlcType = MapPlcType(p.LibplctagPlcAttribute), + Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer + 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(AbLegacyDataType type, int? bitIndex) => type switch + { + AbLegacyDataType.Bit => bitIndex is int bit + ? _tag.GetBit(bit) + : _tag.GetInt8(0) != 0, + AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)_tag.GetInt16(0), + AbLegacyDataType.Long => _tag.GetInt32(0), + AbLegacyDataType.Float => _tag.GetFloat32(0), + AbLegacyDataType.String => _tag.GetString(0), + AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement + or AbLegacyDataType.ControlElement => _tag.GetInt32(0), + _ => null, + }; + + public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) + { + switch (type) + { + case AbLegacyDataType.Bit: + if (bitIndex is int) + throw new NotSupportedException( + "Bit-within-word writes require read-modify-write; tracked in task #181."); + _tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0); + break; + case AbLegacyDataType.Int: + case AbLegacyDataType.AnalogInt: + _tag.SetInt16(0, Convert.ToInt16(value)); + break; + case AbLegacyDataType.Long: + _tag.SetInt32(0, Convert.ToInt32(value)); + break; + case AbLegacyDataType.Float: + _tag.SetFloat32(0, Convert.ToSingle(value)); + break; + case AbLegacyDataType.String: + _tag.SetString(0, Convert.ToString(value) ?? string.Empty); + break; + case AbLegacyDataType.TimerElement: + case AbLegacyDataType.CounterElement: + case AbLegacyDataType.ControlElement: + _tag.SetInt32(0, Convert.ToInt32(value)); + break; + default: + throw new NotSupportedException($"AbLegacyDataType {type} not writable."); + } + } + + public void Dispose() => _tag.Dispose(); + + private static PlcType MapPlcType(string attribute) => attribute switch + { + "slc500" => PlcType.Slc500, + "micrologix" => PlcType.MicroLogix, + "plc5" => PlcType.Plc5, + "logixpccc" => PlcType.LogixPccc, + _ => PlcType.Slc500, + }; +} + +internal sealed class LibplctagLegacyTagFactory : IAbLegacyTagFactory +{ + public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) => + new LibplctagLegacyTagRuntime(createParams); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs new file mode 100644 index 0000000..1997c6f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs @@ -0,0 +1,256 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +[Trait("Category", "Unit")] +public sealed class AbLegacyReadWriteTests +{ + private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags) + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = tags, + }, "drv-1", factory); + return (drv, factory); + } + + // ---- Read ---- + + [Fact] + public async Task Unknown_reference_maps_to_BadNodeIdUnknown() + { + var (drv, _) = NewDriver(); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Successful_N_file_read_returns_Good_value() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 }; + + var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); + + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + snapshots.Single().Value.ShouldBe(42); + factory.Tags["N7:0"].InitializeCount.ShouldBe(1); + factory.Tags["N7:0"].ReadCount.ShouldBe(1); + } + + [Fact] + public async Task Repeat_read_reuses_runtime() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 }; + + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + + factory.Tags["N7:0"].InitializeCount.ShouldBe(1); + factory.Tags["N7:0"].ReadCount.ShouldBe(2); + } + + [Fact] + public async Task NonZero_libplctag_status_maps_via_AbLegacyStatusMapper() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Status = -14 }; + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Read_exception_surfaces_BadCommunicationError() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true }; + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + [Fact] + public async Task Batched_reads_preserve_order() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float), + new AbLegacyTagDefinition("C", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => p.TagName switch + { + "N7:0" => new FakeAbLegacyTag(p) { Value = 1 }, + "F8:0" => new FakeAbLegacyTag(p) { Value = 3.14f }, + _ => new FakeAbLegacyTag(p) { Value = "hello" }, + }; + + var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); + + snapshots.Count.ShouldBe(3); + snapshots[0].Value.ShouldBe(1); + snapshots[1].Value.ShouldBe(3.14f); + snapshots[2].Value.ShouldBe("hello"); + } + + [Fact] + public async Task Read_TagCreateParams_composed_from_device_and_profile() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:5", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.ReadAsync(["X"], CancellationToken.None); + + var p = factory.Tags["N7:5"].CreationParams; + p.Gateway.ShouldBe("10.0.0.5"); + p.Port.ShouldBe(44818); + p.CipPath.ShouldBe("1,0"); + p.LibplctagPlcAttribute.ShouldBe("slc500"); + p.TagName.ShouldBe("N7:5"); + } + + // ---- Write ---- + + [Fact] + public async Task Non_writable_tag_rejects_with_BadNotWritable() + { + var (drv, _) = NewDriver( + new AbLegacyTagDefinition("RO", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, Writable: false)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("RO", 1)], CancellationToken.None); + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable); + } + + [Fact] + public async Task Successful_N_file_write_encodes_and_flushes() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("X", 123)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + factory.Tags["N7:0"].Value.ShouldBe(123); + factory.Tags["N7:0"].WriteCount.ShouldBe(1); + } + + [Fact] + public async Task Bit_within_word_write_rejected_as_BadNotSupported() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new RmwThrowingFake(p) }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition("Bit3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)], + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("Bit3", true)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + } + + [Fact] + public async Task Write_exception_surfaces_BadCommunicationError() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnWrite = true }; + + var results = await drv.WriteAsync( + [new WriteRequest("X", 1)], CancellationToken.None); + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + } + + [Fact] + public async Task Batch_write_preserves_order_across_outcomes() + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = + [ + new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: false), + ], + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [ + new WriteRequest("A", 1), + new WriteRequest("B", 2), + new WriteRequest("Unknown", 3), + ], CancellationToken.None); + + results.Count.ShouldBe(3); + results[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + results[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable); + results[2].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Cancellation_propagates() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) + { + ThrowOnRead = true, + Exception = new OperationCanceledException(), + }; + + await Should.ThrowAsync( + () => drv.ReadAsync(["X"], CancellationToken.None)); + } + + [Fact] + public async Task ShutdownAsync_disposes_runtimes() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 }; + + await drv.ReadAsync(["A"], CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + + factory.Tags["N7:0"].Disposed.ShouldBeTrue(); + } + + private sealed class RmwThrowingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) + { + public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) + { + if (type == AbLegacyDataType.Bit && bitIndex is not null) + throw new NotSupportedException("bit-within-word RMW deferred"); + Value = value; + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs new file mode 100644 index 0000000..914fa8f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs @@ -0,0 +1,59 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +internal class FakeAbLegacyTag : IAbLegacyTagRuntime +{ + public AbLegacyTagCreateParams CreationParams { get; } + public object? Value { get; set; } + public int Status { get; set; } + public bool ThrowOnInitialize { get; set; } + public bool ThrowOnRead { get; set; } + public bool ThrowOnWrite { 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 FakeAbLegacyTag(AbLegacyTagCreateParams p) => CreationParams = p; + + public virtual Task InitializeAsync(CancellationToken ct) + { + InitializeCount++; + if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException(); + return Task.CompletedTask; + } + + public virtual Task ReadAsync(CancellationToken ct) + { + ReadCount++; + if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); + return Task.CompletedTask; + } + + public virtual Task WriteAsync(CancellationToken ct) + { + WriteCount++; + if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); + return Task.CompletedTask; + } + + public virtual int GetStatus() => Status; + public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value; + public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value; + public virtual void Dispose() => Disposed = true; +} + +internal sealed class FakeAbLegacyTagFactory : IAbLegacyTagFactory +{ + public Dictionary Tags { get; } = new(StringComparer.OrdinalIgnoreCase); + public Func? Customise { get; set; } + + public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) + { + var fake = Customise?.Invoke(p) ?? new FakeAbLegacyTag(p); + Tags[p.TagName] = fake; + return fake; + } +} -- 2.49.1