AB CIP PR 4 — IWritable implementation. LibplctagTagRuntime.EncodeValue fills in the switch for every atomic Logix type the driver currently surfaces — Bool (standalone BOOL via SetInt8 0/1), SInt/USInt (SetInt8/SetUInt8), Int/UInt (SetInt16/SetUInt16), DInt/UDInt (SetInt32/SetUInt32), LInt/ULInt (SetInt64/SetUInt64), Real (SetFloat32), LReal (SetFloat64), String (SetString 0), Dt (epoch DINT via SetInt32). BOOL-within-DINT writes throw NotSupportedException with a code comment matching the Modbus BitInRegister pattern at ModbusDriver.cs line 640 — the read-modify-write logic + lock-per-DINT discipline is a follow-up PR rather than squeezing it into the initial wire plumbing. Structure writes throw NotSupportedException pointing at PR 6 when UDT support lands. AbCipDriver now implements IWritable. WriteAsync iterates writes preserving order, short-circuits on unknown reference → BadNodeIdUnknown, on non-writable tag definition → BadNotWritable, on unknown device → BadNodeIdUnknown. Happy path materialises the cached runtime via EnsureTagRuntimeAsync (shares PR 3's lazy-init path so read+write on the same tag hits one native handle), EncodeValue into the tag's buffer, WriteAsync flushes, GetStatus confirms the wire status, maps libplctag error codes via AbCipStatusMapper.MapLibplctagStatus, sets health Healthy on success. Per plan decisions #44, #45, #143 the driver does NOT auto-retry writes — that's a resilience-layer concern (Polly pipeline sitting above) keyed on the tag's WriteIdempotent flag. Exception-mapping table — OperationCanceledException rethrows (honors cancellation), NotSupportedException → BadNotSupported (bit-in-DINT, Structure, future unsupported types), FormatException → BadTypeMismatch (Convert.ToInt32 of a non-numeric string), InvalidCastException → BadTypeMismatch (caller passed an object incompatible with the conversion target), OverflowException → BadOutOfRange (value exceeds target type range, e.g. Int16 write of 1_000_000), any other Exception → BadCommunicationError (wire drop, libplctag-internal failure). Health surface updates Degraded on every non-Cancellation exception path, Healthy on success. Introduces AbCipStatusMapper.BadTypeMismatch (0x80730000). 10 new unit tests in AbCipDriverWriteTests covering — unknown ref → BadNodeIdUnknown, non-writable tag → BadNotWritable, successful DInt write encodes + flushes the value + marks WriteCount=1, BOOL-in-DINT rejected as BadNotSupported (separate ThrowingBoolBitFake mirrors LibplctagTagRuntime's runtime check), non-zero libplctag status after write mapped via AbCipStatusMapper (timeout -5 → BadTimeout), FormatException from non-numeric-string write → BadTypeMismatch (RealConvertFake exercises real Convert.ToInt32), OverflowException from Int16 write of 1_000_000 → BadOutOfRange, generic exception during write → BadCommunicationError + health Degraded, batch with mixed success+failure preserves order across four request types, cancellation propagates as OperationCanceledException. FakeAbCipTag's test-fake base class methods made virtual so override hooks work correctly through the IAbCipTagRuntime interface (new-shadow was silently falling through to the base implementation). Total AbCip unit tests now 98/98 passing; Modbus + other existing tests untouched; full solution builds 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-19 16:57:52 -04:00
parent be2379107d
commit 257f4fd3f5
5 changed files with 376 additions and 15 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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();