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