Compare commits
2 Commits
abcip-pr3-
...
abcip-pr4-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
257f4fd3f5 | ||
| be2379107d |
@@ -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
|
||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||
/// </remarks>
|
||||
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 ----
|
||||
|
||||
/// <summary>
|
||||
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
|
||||
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
||||
/// and the resilience pipeline (layered above the driver) decides whether to replay.
|
||||
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
|
||||
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently materialise the runtime handle for a tag definition. First call creates
|
||||
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
public static uint MapCipGeneralStatus(byte status) => status switch
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<OperationCanceledException>(
|
||||
() => 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());
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
/// <see cref="Status"/> to simulate libplctag error codes,
|
||||
/// <see cref="ThrowOnInitialize"/> / <see cref="ThrowOnRead"/> to simulate exceptions.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Test factory that produces <see cref="FakeAbCipTag"/>s and indexes them for assertion.</summary>
|
||||
|
||||
Reference in New Issue
Block a user