Compare commits

..

4 Commits

Author SHA1 Message Date
Joseph Doherty
257f4fd3f5 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>
2026-04-19 16:57:52 -04:00
be2379107d Merge pull request (#110) - AbCip IReadable 2026-04-19 16:41:02 -04:00
Joseph Doherty
cc35c77d64 AB CIP PR 3 — IReadable implementation against libplctag. Introduces IAbCipTagRuntime + IAbCipTagFactory abstraction matching the Modbus transport-factory pattern (ctor optional arg, default production impl injected) so the driver's read/status-mapping logic is unit-testable without a live PLC or the native libplctag binary. LibplctagTagRuntime is the default wire-backed implementation — wraps libplctag.Tag + translates our AbCipDataType enum into GetInt8/GetUInt8/GetInt16/GetUInt16/GetInt32/GetUInt32/GetInt64/GetUInt64/GetFloat32/GetFloat64/GetString/GetBit calls covering Bool (standalone + BOOL-in-DINT via .N bit selector), SInt/USInt, Int/UInt, DInt/UDInt, LInt/ULInt, Real, LReal, String, Dt (epoch DINT), with Structure deferred to PR 6. MapPlcType bridges our libplctag attribute strings (controllogix, compactlogix, micro800) to libplctag.PlcType enum; CompactLogix rolls under ControlLogix per libplctag's family grouping which matches the wire protocol reality. AbCipDriver now implements IReadable — ReadAsync iterates fullReferences preserving order, looks up each tag definition + its device, lazily materialises the tag runtime via EnsureTagRuntimeAsync on first touch (cached thereafter for the lifetime of the device), catches OperationCanceledException to honor cancellation, maps libplctag non-zero status via AbCipStatusMapper.MapLibplctagStatus, catches any other exception as BadCommunicationError. Health surface moves to Healthy on success + Degraded with the last error message on failure. Initialize-failure path disposes the half-created runtime before rethrowing so no native handles leak. DeviceState gains a Runtimes dict alongside the existing TagHandles collection; DisposeHandles walks both so ShutdownAsync + ReinitializeAsync cleanly destroy every native tag. 12 new unit tests in AbCipDriverReadTests using FakeAbCipTag / FakeAbCipTagFactory (test fake under tests/...AbCip.Tests/FakeAbCipTag.cs) covering unknown reference → BadNodeIdUnknown, unknown device → BadNodeIdUnknown, successful DInt read with correct Good status + captured value, lazy-init on first read with reuse across subsequent reads, non-zero libplctag status mapping via AbCipStatusMapper, exception during read surfacing as BadCommunicationError with health Degraded, batched reads preserving order + per-tag status, health Healthy after success, TagCreateParams composition from device + profile (gateway / port / CIP path / libplctag attribute / tag name wiring), cancellation propagation via OperationCanceledException, ShutdownAsync disposing every runtime, Initialize-failure disposing the aborted runtime. Total AbCip unit tests now 88/88 passing. Integration test project scaffolding — tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests with AbServerFixture (IAsyncLifetime that starts ab_server when the binary is on PATH, otherwise marks IsAvailable=false), AbServerFact attribute (Fact-equivalent that skips when ab_server is missing), one smoke test exercising DInt read end-to-end. Project runs cleanly — the single smoke test skips on boxes without ab_server (0 failed, 0 passed, 1 skipped) + runs on boxes with it. Follow-up work captured in comments — ab_server CI fixture (download prebuilt Windows x64 binary as GitHub release asset) + per-family JSON profiles + hand-rolled CIP stub for UDT fidelity ship in the PR 6/9-12 window. Solution file updated. Full solution builds 0 errors across all 28 projects. Modbus + other existing tests untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:38:54 -04:00
59b59b8ccd Merge pull request (#109) - AbCip scaffolding 2026-04-19 16:00:28 -04:00
11 changed files with 1096 additions and 2 deletions

View File

@@ -31,6 +31,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>

View File

@@ -20,18 +20,22 @@ 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, IDisposable, IAsyncDisposable
public sealed class AbCipDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
{
private readonly AbCipDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IAbCipTagFactory _tagFactory;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId)
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagTagFactory();
}
public string DriverInstanceId => _driverInstanceId;
@@ -50,6 +54,7 @@ public sealed class AbCipDriver : IDriver, IDisposable, IAsyncDisposable
var profile = AbCipPlcFamilyProfile.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)
@@ -71,10 +76,193 @@ public sealed class AbCipDriver : IDriver, IDisposable, IAsyncDisposable
foreach (var state in _devices.Values)
state.DisposeHandles();
_devices.Clear();
_tagsByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
return Task.CompletedTask;
}
// ---- IReadable ----
/// <summary>
/// Read each <c>fullReference</c> in order. Unknown tags surface as
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
/// </summary>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> 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, AbCipStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.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,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
continue;
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
var value = runtime.DecodeValue(def.DataType, bitIndex);
results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
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
/// lifetime of the device.
/// </summary>
private async Task<IAbCipTagRuntime> EnsureTagRuntimeAsync(
DeviceState device, AbCipTagDefinition def, CancellationToken ct)
{
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
var parsed = AbCipTagPath.TryParse(def.TagPath)
?? throw new InvalidOperationException(
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
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 DriverHealth GetHealth() => _health;
/// <summary>
@@ -117,10 +305,19 @@ public sealed class AbCipDriver : IDriver, IDisposable, IAsyncDisposable
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Per-tag runtime handles owned by this device. One entry per configured tag is
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
/// </summary>
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
public void DisposeHandles()
{
foreach (var h in TagHandles.Values) h.Dispose();
TagHandles.Clear();
foreach (var r in Runtimes.Values) r.Dispose();
Runtimes.Clear();
}
}
}

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

@@ -0,0 +1,63 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Thin wire-layer abstraction over a single CIP tag. The driver holds one instance per
/// <c>(device, tag path)</c> pair; the default implementation delegates to
/// <see cref="LibplctagTagRuntime"/>. Tests swap in a fake via
/// <see cref="IAbCipTagFactory"/> so the driver's read / write / status-mapping logic can
/// be exercised without a running PLC or the native libplctag binary.
/// </summary>
public interface IAbCipTagRuntime : IDisposable
{
/// <summary>Create the underlying native tag (equivalent to libplctag's <c>plc_tag_create</c>).</summary>
Task InitializeAsync(CancellationToken cancellationToken);
/// <summary>Issue a read; on completion the local buffer holds the current PLC value.</summary>
Task ReadAsync(CancellationToken cancellationToken);
/// <summary>Flush the local buffer to the PLC.</summary>
Task WriteAsync(CancellationToken cancellationToken);
/// <summary>
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>. Zero on success, negative on error.
/// </summary>
int GetStatus();
/// <summary>
/// Decode the local buffer into a boxed .NET value per the tag's configured type.
/// <paramref name="bitIndex"/> is non-null only for BOOL-within-DINT tags captured in
/// the <c>.N</c> syntax at parse time.
/// </summary>
object? DecodeValue(AbCipDataType type, int? bitIndex);
/// <summary>
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
/// pair this with <see cref="WriteAsync"/>.
/// </summary>
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
}
/// <summary>
/// Factory for per-tag runtime handles. Instantiated once per driver, consumed per
/// <c>(device, tag path)</c> pair at the first read/write.
/// </summary>
public interface IAbCipTagFactory
{
IAbCipTagRuntime Create(AbCipTagCreateParams createParams);
}
/// <summary>Everything libplctag needs to materialise a tag handle.</summary>
/// <param name="Gateway">Gateway IP / hostname parsed from <see cref="AbCipHostAddress.Gateway"/>.</param>
/// <param name="Port">EtherNet/IP TCP port — default 44818.</param>
/// <param name="CipPath">CIP route path, e.g. <c>1,0</c>. Empty for Micro800.</param>
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
string CipPath,
string LibplctagPlcAttribute,
string TagName,
TimeSpan Timeout);

View File

@@ -0,0 +1,137 @@
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Default libplctag-backed <see cref="IAbCipTagRuntime"/>. Wraps a <see cref="Tag"/>
/// instance + translates our <see cref="AbCipDataType"/> enum into the
/// <c>GetInt32</c> / <c>GetFloat32</c> / <c>GetString</c> / <c>GetBit</c> calls libplctag
/// exposes. One runtime instance per <c>(device, tag path)</c>; lifetime is owned by the
/// driver's per-device state dict.
/// </summary>
internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
private readonly Tag _tag;
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
_tag = new Tag
{
Gateway = p.Gateway,
Path = p.CipPath,
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
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(AbCipDataType type, int? bitIndex) => type switch
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
: _tag.GetInt8(0) != 0,
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(0),
AbCipDataType.USInt => (int)_tag.GetUInt8(0),
AbCipDataType.Int => (int)_tag.GetInt16(0),
AbCipDataType.UInt => (int)_tag.GetUInt16(0),
AbCipDataType.DInt => _tag.GetInt32(0),
AbCipDataType.UDInt => (int)_tag.GetUInt32(0),
AbCipDataType.LInt => _tag.GetInt64(0),
AbCipDataType.ULInt => (long)_tag.GetUInt64(0),
AbCipDataType.Real => _tag.GetFloat32(0),
AbCipDataType.LReal => _tag.GetFloat64(0),
AbCipDataType.String => _tag.GetString(0),
AbCipDataType.Dt => _tag.GetInt32(0), // seconds-since-epoch DINT; consumer widens as needed
AbCipDataType.Structure => null, // UDT whole-tag decode lands in PR 6
_ => null,
};
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
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();
private static PlcType MapPlcType(string attribute) => attribute switch
{
"controllogix" => PlcType.ControlLogix,
"compactlogix" => PlcType.ControlLogix, // libplctag treats CompactLogix under ControlLogix family
"micro800" => PlcType.Micro800,
"micrologix" => PlcType.MicroLogix,
"slc500" => PlcType.Slc500,
"plc5" => PlcType.Plc5,
"omron-njnx" => PlcType.Omron,
_ => PlcType.ControlLogix,
};
}
/// <summary>
/// Default <see cref="IAbCipTagFactory"/> — creates a fresh <see cref="LibplctagTagRuntime"/>
/// per call. Stateless; safe to share across devices.
/// </summary>
internal sealed class LibplctagTagFactory : IAbCipTagFactory
{
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
new LibplctagTagRuntime(createParams);
}

View File

@@ -0,0 +1,44 @@
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.IntegrationTests;
/// <summary>
/// End-to-end smoke tests that exercise the real libplctag stack against a running
/// <c>ab_server</c>. Skipped when the binary isn't on PATH (<see cref="AbServerFactAttribute"/>).
/// </summary>
/// <remarks>
/// Intentionally minimal — per-family + per-capability coverage ships in PRs 912 once the
/// integration harness is CI-ready. This file exists at PR 3 time to prove the wire path
/// works end-to-end on developer boxes that have <c>ab_server</c>.
/// </remarks>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipReadSmokeTests : IAsyncLifetime
{
private readonly AbServerFixture _fixture = new();
public async ValueTask InitializeAsync() => await _fixture.InitializeAsync();
public async ValueTask DisposeAsync() => await _fixture.DisposeAsync();
[AbServerFact]
public async Task Driver_reads_DInt_from_ab_server()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions($"ab://127.0.0.1:{_fixture.Port}/1,0", AbCipPlcFamily.ControlLogix)],
Tags = [new AbCipTagDefinition("Counter", $"ab://127.0.0.1:{_fixture.Port}/1,0", "TestDINT", AbCipDataType.DInt)],
Timeout = TimeSpan.FromSeconds(5),
}, "drv-smoke");
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
}

View File

@@ -0,0 +1,109 @@
using System.Diagnostics;
using Xunit;
using Xunit.Sdk;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Shared fixture that starts libplctag's <c>ab_server</c> simulator in the background for
/// the duration of an integration test collection. Binary is expected on PATH; the per-test
/// JSON profile is passed via <c>--config</c>.
/// </summary>
/// <remarks>
/// <para><c>ab_server</c> is a C binary shipped in the same repo as libplctag (see
/// <c>test-data-sources.md</c> §2 and plan decision #99). On a developer workstation it's
/// built once from source and placed on PATH; in CI we intend to publish a prebuilt Windows
/// x64 binary as a GitHub release asset in a follow-up PR so the fixture can download +
/// extract it at setup time. Until then every test in this project is skipped when
/// <c>ab_server</c> is not locatable.</para>
///
/// <para>Per-family JSON profiles (ControlLogix / CompactLogix / Micro800 / GuardLogix)
/// ship under <c>Profiles/</c> and drive the simulator's tag shape — this is where the
/// UDT + Program-scope coverage gap will be filled by the hand-rolled stub in PR 6.</para>
/// </remarks>
public sealed class AbServerFixture : IAsyncLifetime
{
private Process? _proc;
public int Port { get; } = 44818;
public bool IsAvailable { get; private set; }
public ValueTask InitializeAsync() => InitializeAsync(default);
public ValueTask DisposeAsync() => DisposeAsync(default);
public async ValueTask InitializeAsync(CancellationToken cancellationToken)
{
if (LocateBinary() is not string binary)
{
IsAvailable = false;
return;
}
IsAvailable = true;
_proc = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = binary,
Arguments = $"--port {Port} --plc controllogix",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
},
};
_proc.Start();
// Give the server a moment to accept its listen socket before tests try to connect.
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
}
public ValueTask DisposeAsync(CancellationToken cancellationToken)
{
try
{
if (_proc is { HasExited: false })
{
_proc.Kill(entireProcessTree: true);
_proc.WaitForExit(5_000);
}
}
catch { /* best-effort cleanup */ }
_proc?.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>
/// Locate <c>ab_server</c> on PATH. Returns <c>null</c> when missing — tests that
/// depend on it should use <see cref="AbServerFact"/> so CI runs without the binary
/// simply skip rather than fail.
/// </summary>
public static string? LocateBinary()
{
var names = new[] { "ab_server.exe", "ab_server" };
var path = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in path.Split(Path.PathSeparator))
{
foreach (var name in names)
{
var candidate = Path.Combine(dir, name);
if (File.Exists(candidate)) return candidate;
}
}
return null;
}
}
/// <summary>
/// <c>[Fact]</c>-equivalent that skips when <c>ab_server</c> is not available on PATH.
/// Integration tests use this instead of <c>[Fact]</c> so a developer box without
/// <c>ab_server</c> installed still gets a green run.
/// </summary>
public sealed class AbServerFactAttribute : FactAttribute
{
public AbServerFactAttribute()
{
if (AbServerFixture.LocateBinary() is null)
Skip = "ab_server not on PATH; install libplctag test binaries to run.";
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,214 @@
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 AbCipDriverReadTests
{
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
};
var drv = new AbCipDriver(opts, "drv-1", factory);
return (drv, factory);
}
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
var (drv, _) = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["does-not-exist"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
snapshots.Single().Value.ShouldBeNull();
}
[Fact]
public async Task Tag_on_unknown_device_maps_to_BadNodeIdUnknown()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)],
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["Orphan"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Successful_DInt_read_returns_Good_with_value()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
// Customise the fake before the first read so the tag returns 4200.
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
snapshots.Single().Value.ShouldBe(4200);
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1);
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Repeat_read_reuses_runtime_without_reinitialise()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
await drv.ReadAsync(["Speed"], CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1); // lazy init happens once
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
}
[Fact]
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Status = -14 /* PLCTAG_ERR_NOT_FOUND */ };
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
snapshots.Single().Value.ShouldBeNull();
}
[Fact]
public async Task Exception_during_read_surfaces_BadCommunicationError()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.Real));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true };
var snapshots = await drv.ReadAsync(["Broken"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
snapshots.Single().Value.ShouldBeNull();
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
[Fact]
public async Task Batched_reads_preserve_order_and_per_tag_status()
{
var (drv, factory) = NewDriver(
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.Real),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => p.TagName switch
{
"A" => new FakeAbCipTag(p) { Value = 42 },
"B" => new FakeAbCipTag(p) { Value = 3.14f },
_ => new FakeAbCipTag(p) { Value = "hello" },
};
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
snapshots[0].Value.ShouldBe(42);
snapshots[1].Value.ShouldBe(3.14f);
snapshots[2].Value.ShouldBe("hello");
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
}
[Fact]
public async Task Successful_read_marks_health_Healthy()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Pressure", "ab://10.0.0.5/1,0", "PT_101", AbCipDataType.Real));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 14.7f };
await drv.ReadAsync(["Pressure"], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
}
[Fact]
public async Task TagCreateParams_are_built_from_device_and_profile()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Program:P.Counter", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Counter"], CancellationToken.None);
var p = factory.Tags["Program:P.Counter"].CreationParams;
p.Gateway.ShouldBe("10.0.0.5");
p.Port.ShouldBe(44818);
p.CipPath.ShouldBe("1,0");
p.LibplctagPlcAttribute.ShouldBe("controllogix");
p.TagName.ShouldBe("Program:P.Counter");
}
[Fact]
public async Task Cancellation_propagates_from_read()
{
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 FakeAbCipTag(p)
{
ThrowOnRead = true,
Exception = new OperationCanceledException(),
};
using var cts = new CancellationTokenSource();
await Should.ThrowAsync<OperationCanceledException>(
() => drv.ReadAsync(["Slow"], cts.Token));
}
[Fact]
public async Task ShutdownAsync_disposes_each_tag_runtime()
{
var (drv, factory) = NewDriver(
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));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
await drv.ReadAsync(["A", "B"], CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
factory.Tags["A"].Disposed.ShouldBeTrue();
factory.Tags["B"].Disposed.ShouldBeTrue();
}
[Fact]
public async Task Initialize_failure_disposes_tag_and_surfaces_communication_error()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("DoomedTag", "ab://10.0.0.5/1,0", "Nope", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnInitialize = true };
var snapshots = await drv.ReadAsync(["DoomedTag"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags["Nope"].Disposed.ShouldBeTrue();
}
}

View File

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

View File

@@ -0,0 +1,67 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Test fake for <see cref="IAbCipTagRuntime"/>. Stores the mock PLC value in
/// <see cref="Value"/> + returns it from <see cref="DecodeValue"/>. Use
/// <see cref="Status"/> to simulate libplctag error codes,
/// <see cref="ThrowOnInitialize"/> / <see cref="ThrowOnRead"/> to simulate exceptions.
/// </summary>
internal class FakeAbCipTag : IAbCipTagRuntime
{
public AbCipTagCreateParams CreationParams { get; }
public object? Value { get; set; }
public int Status { get; set; }
public bool ThrowOnInitialize { get; set; }
public bool ThrowOnRead { 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 FakeAbCipTag(AbCipTagCreateParams createParams) => CreationParams = createParams;
public virtual Task InitializeAsync(CancellationToken cancellationToken)
{
InitializeCount++;
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException("fake initialize failure");
return Task.CompletedTask;
}
public virtual Task ReadAsync(CancellationToken cancellationToken)
{
ReadCount++;
if (ThrowOnRead) throw Exception ?? new InvalidOperationException("fake read failure");
return Task.CompletedTask;
}
public virtual Task WriteAsync(CancellationToken cancellationToken)
{
WriteCount++;
return Task.CompletedTask;
}
public virtual int GetStatus() => Status;
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
public virtual void Dispose() => Disposed = true;
}
/// <summary>Test factory that produces <see cref="FakeAbCipTag"/>s and indexes them for assertion.</summary>
internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
{
public Dictionary<string, FakeAbCipTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
public Func<AbCipTagCreateParams, FakeAbCipTag>? Customise { get; set; }
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams)
{
var fake = Customise?.Invoke(createParams) ?? new FakeAbCipTag(createParams);
Tags[createParams.TagName] = fake;
return fake;
}
}