diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 53b75f8..7e73c3e 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -20,7 +20,7 @@ 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, IReadable, IDisposable, IAsyncDisposable
+public sealed class AbCipDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
{
private readonly AbCipDriverOptions _options;
private readonly string _driverInstanceId;
@@ -147,6 +147,88 @@ public sealed class AbCipDriver : IDriver, IReadable, IDisposable, IAsyncDisposa
return results;
}
+ // ---- IWritable ----
+
+ ///
+ /// Write each request in order. Writes are NOT auto-retried by the driver — per plan
+ /// decisions #44, #45, #143 the caller opts in via
+ /// and the resilience pipeline (layered above the driver) decides whether to replay.
+ /// Non-writable configurations surface as BadNotWritable; type-conversion failures
+ /// as BadTypeMismatch; transport errors as BadCommunicationError.
+ ///
+ public async Task> WriteAsync(
+ IReadOnlyList writes, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(writes);
+ var results = new WriteResult[writes.Count];
+ var now = DateTime.UtcNow;
+
+ for (var i = 0; i < writes.Count; i++)
+ {
+ var w = writes[i];
+ if (!_tagsByName.TryGetValue(w.FullReference, out var def))
+ {
+ results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
+ continue;
+ }
+ if (!def.Writable)
+ {
+ results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
+ continue;
+ }
+ if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
+ {
+ results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
+ continue;
+ }
+
+ try
+ {
+ var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
+ var tagPath = AbCipTagPath.TryParse(def.TagPath);
+ runtime.EncodeValue(def.DataType, tagPath?.BitIndex, w.Value);
+ await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
+
+ var status = runtime.GetStatus();
+ results[i] = new WriteResult(status == 0
+ ? AbCipStatusMapper.Good
+ : AbCipStatusMapper.MapLibplctagStatus(status));
+ if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (NotSupportedException nse)
+ {
+ results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
+ }
+ catch (FormatException fe)
+ {
+ results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
+ }
+ catch (InvalidCastException ice)
+ {
+ results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
+ }
+ catch (OverflowException oe)
+ {
+ results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
+ }
+ catch (Exception ex)
+ {
+ results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
+ _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
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
index b12eec6..d791855 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
@@ -40,6 +40,7 @@ public static class AbCipStatusMapper
public const uint BadDeviceFailure = 0x80550000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
+ public const uint BadTypeMismatch = 0x80730000u;
/// Map a CIP general-status byte to an OPC UA StatusCode.
public static uint MapCipGeneralStatus(byte status) => status switch
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 931b58b..414de55 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -55,12 +55,60 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
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.");
+ switch (type)
+ {
+ case AbCipDataType.Bool:
+ if (bitIndex is int bit)
+ {
+ // BOOL-within-DINT writes require read-modify-write on the parent DINT.
+ // Deferred to a follow-up PR — matches the Modbus BitInRegister pattern at
+ // ModbusDriver.cs:640.
+ throw new NotSupportedException(
+ "BOOL-within-DINT writes require read-modify-write; not implemented in PR 4.");
+ }
+ _tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
+ break;
+ case AbCipDataType.SInt:
+ _tag.SetInt8(0, Convert.ToSByte(value));
+ break;
+ case AbCipDataType.USInt:
+ _tag.SetUInt8(0, Convert.ToByte(value));
+ break;
+ case AbCipDataType.Int:
+ _tag.SetInt16(0, Convert.ToInt16(value));
+ break;
+ case AbCipDataType.UInt:
+ _tag.SetUInt16(0, Convert.ToUInt16(value));
+ break;
+ case AbCipDataType.DInt:
+ _tag.SetInt32(0, Convert.ToInt32(value));
+ break;
+ case AbCipDataType.UDInt:
+ _tag.SetUInt32(0, Convert.ToUInt32(value));
+ break;
+ case AbCipDataType.LInt:
+ _tag.SetInt64(0, Convert.ToInt64(value));
+ break;
+ case AbCipDataType.ULInt:
+ _tag.SetUInt64(0, Convert.ToUInt64(value));
+ break;
+ case AbCipDataType.Real:
+ _tag.SetFloat32(0, Convert.ToSingle(value));
+ break;
+ case AbCipDataType.LReal:
+ _tag.SetFloat64(0, Convert.ToDouble(value));
+ break;
+ case AbCipDataType.String:
+ _tag.SetString(0, Convert.ToString(value) ?? string.Empty);
+ break;
+ case AbCipDataType.Dt:
+ _tag.SetInt32(0, Convert.ToInt32(value));
+ break;
+ case AbCipDataType.Structure:
+ throw new NotSupportedException("Whole-UDT writes land in PR 6.");
+ default:
+ throw new NotSupportedException($"AbCipDataType {type} not writable.");
+ }
}
public void Dispose() => _tag.Dispose();
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs
new file mode 100644
index 0000000..d6fc72c
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs
@@ -0,0 +1,230 @@
+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 AbCipDriverWriteTests
+{
+ private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
+ {
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = tags,
+ }, "drv-1", factory);
+ return (drv, factory);
+ }
+
+ [Fact]
+ public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
+ {
+ var (drv, _) = NewDriver();
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync(
+ [new WriteRequest("does-not-exist", 1)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
+ }
+
+ [Fact]
+ public async Task Non_writable_tag_maps_to_BadNotWritable()
+ {
+ var (drv, _) = NewDriver(
+ new AbCipTagDefinition("ReadOnly", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync(
+ [new WriteRequest("ReadOnly", 7)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
+ }
+
+ [Fact]
+ public async Task Successful_DInt_write_encodes_and_flushes()
+ {
+ var (drv, factory) = NewDriver(
+ new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync(
+ [new WriteRequest("Speed", 4200)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ factory.Tags["Motor1.Speed"].Value.ShouldBe(4200);
+ factory.Tags["Motor1.Speed"].WriteCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Bit_in_dint_write_returns_BadNotSupported()
+ {
+ var factory = new FakeAbCipTagFactory { Customise = p => new ThrowingBoolBitFake(p) };
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)],
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync(
+ [new WriteRequest("Flag3", true)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
+ }
+
+ [Fact]
+ public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper()
+ {
+ var (drv, factory) = NewDriver(
+ new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbCipTag(p) { Status = -5 /* timeout */ };
+
+ var results = await drv.WriteAsync(
+ [new WriteRequest("Broken", 1)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
+ }
+
+ [Fact]
+ public async Task Type_mismatch_surfaces_BadTypeMismatch()
+ {
+ var (drv, _) = NewDriver(
+ new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ // Force a FormatException inside Convert.ToInt32 via a runtime that forwards to real Convert.
+ var factory = new FakeAbCipTagFactory
+ {
+ Customise = p => new RealConvertFake(p),
+ };
+ var drv2 = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)],
+ }, "drv-2", factory);
+ await drv2.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv2.WriteAsync(
+ [new WriteRequest("Speed", "not-a-number")], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
+ }
+
+ [Fact]
+ public async Task Overflow_surfaces_BadOutOfRange()
+ {
+ var factory = new FakeAbCipTagFactory { Customise = p => new RealConvertFake(p) };
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)],
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync(
+ [new WriteRequest("Narrow", 1_000_000)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
+ }
+
+ [Fact]
+ public async Task Exception_during_write_surfaces_BadCommunicationError()
+ {
+ var (drv, factory) = NewDriver(
+ new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new ThrowOnWriteFake(p);
+
+ var results = await drv.WriteAsync(
+ [new WriteRequest("Broken", 1)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ }
+
+ [Fact]
+ public async Task Batch_preserves_order_across_success_and_failure()
+ {
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags =
+ [
+ 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, Writable: false),
+ new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
+ ],
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync(
+ [
+ new WriteRequest("A", 1),
+ new WriteRequest("B", 2),
+ new WriteRequest("UnknownTag", 3),
+ new WriteRequest("C", 4),
+ ], CancellationToken.None);
+
+ results.Count.ShouldBe(4);
+ results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
+ results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
+ results[3].StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ }
+
+ [Fact]
+ public async Task Cancellation_propagates_from_write()
+ {
+ 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 CancelOnWriteFake(p);
+
+ await Should.ThrowAsync(
+ () => drv.WriteAsync([new WriteRequest("Slow", 1)], CancellationToken.None));
+ }
+
+ // ---- test-fake variants that exercise the real type / error handling ----
+
+ private sealed class RealConvertFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
+ {
+ public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
+ {
+ switch (type)
+ {
+ case AbCipDataType.Int: _ = Convert.ToInt16(value); break;
+ case AbCipDataType.DInt: _ = Convert.ToInt32(value); break;
+ default: _ = Convert.ToInt32(value); break;
+ }
+ Value = value;
+ }
+ }
+
+ private sealed class ThrowingBoolBitFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
+ {
+ public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
+ {
+ if (type == AbCipDataType.Bool && bitIndex is not null)
+ throw new NotSupportedException("bit-in-DINT deferred");
+ Value = value;
+ }
+ }
+
+ private sealed class ThrowOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
+ {
+ public override Task WriteAsync(CancellationToken ct) =>
+ Task.FromException(new InvalidOperationException("wire dropped"));
+ }
+
+ private sealed class CancelOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
+ {
+ public override Task WriteAsync(CancellationToken ct) =>
+ Task.FromException(new OperationCanceledException());
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
index bd2945b..78dac91 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// to simulate libplctag error codes,
/// / to simulate exceptions.
///
-internal sealed class FakeAbCipTag : IAbCipTagRuntime
+internal class FakeAbCipTag : IAbCipTagRuntime
{
public AbCipTagCreateParams CreationParams { get; }
public object? Value { get; set; }
@@ -23,33 +23,33 @@ internal sealed class FakeAbCipTag : IAbCipTagRuntime
public FakeAbCipTag(AbCipTagCreateParams createParams) => CreationParams = createParams;
- public Task InitializeAsync(CancellationToken cancellationToken)
+ public virtual Task InitializeAsync(CancellationToken cancellationToken)
{
InitializeCount++;
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException("fake initialize failure");
return Task.CompletedTask;
}
- public Task ReadAsync(CancellationToken cancellationToken)
+ public virtual Task ReadAsync(CancellationToken cancellationToken)
{
ReadCount++;
if (ThrowOnRead) throw Exception ?? new InvalidOperationException("fake read failure");
return Task.CompletedTask;
}
- public Task WriteAsync(CancellationToken cancellationToken)
+ public virtual Task WriteAsync(CancellationToken cancellationToken)
{
WriteCount++;
return Task.CompletedTask;
}
- public int GetStatus() => Status;
+ public virtual int GetStatus() => Status;
- public object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
+ public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
- public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
+ public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
- public void Dispose() => Disposed = true;
+ public virtual void Dispose() => Disposed = true;
}
/// Test factory that produces s and indexes them for assertion.