Compare commits

..

4 Commits

Author SHA1 Message Date
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
Joseph Doherty
3e0452e8a4 AB CIP PR 2 — scaffolding + Core (AbCipDriver skeleton + libplctag binding + host / tag-path / data-type / status-code parsers + per-family profiles + SafeHandle wrapper + test harness). Ships everything needed to stand up the driver project as a compiling assembly with no wire calls yet — PR 3 adds IReadable against ab_server which is the first PR that actually touches the native library. Project reference shape matches Modbus / OpcUaClient / S7 (only Core.Abstractions, no Core / Configuration / Polly) so the driver stays lean and doesn't drag EF Core into every deployment that wants AB support. libplctag 1.5.2 pinned (1.6.x only exists as alpha — stable 1.5 series covers ControlLogix / CompactLogix / Micro800 / SLC500 / PLC-5 / MicroLogix which matches plan decision #11 family coverage). libplctag.NativeImport arrives transitively. AbCipHostAddress parses ab://gateway[:port]/cip-path canonical strings end-to-end: handles hostname or IP gateway, optional explicit port (default 44818 EtherNet-IP reserved), CIP path including bridged routes (1,2,2,10.0.0.10,1,0), empty path for Micro800 / MicroLogix without backplane routing, case-insensitive scheme, default-port stripping in canonical form for round-trip stability. Opaque string survives straight into libplctag's gateway / path attributes so no translation layer at wire time. AbCipTagPath handles the full Logix symbolic tag surface — controller-scope (Motor1_Speed), program-scope (Program:MainProgram.StepIndex), structured member access (Motor1.Speed.Setpoint), multi-dim array subscripts (Matrix[1,2,3]), bit-within-DINT via .N syntax (Flags.3, Motor.Status.12) with valid range 0-31 per Logix 5000 General Instructions Reference. Structural capture so PR 6 UDT work can walk the path against a cached template without reparsing. Rejects malformed shapes (empty scopes, ident starting with digit, spaces, empty/negative/non-numeric subscripts, unbalanced brackets, leading / trailing dots). Round-trips via ToLibplctagName producing the exact string libplctag's name attribute expects. AbCipDataType mirrors ModbusDataType shape — atomic Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / String / Dt plus a Structure marker for UDT-typed tags (resolved via CIP Template Object at discovery time in PR 5/6). ToDriverDataType adapter follows the Modbus widening convention for unsigned + 64-bit until DriverDataType picks those up. AbCipStatusMapper covers the CIP general-status values an AB PLC actually returns during normal operation (0x00/0x04/0x05/0x06/0x08/0x0A/0x0B/0x0E/0x10/0x13/0x16) + libplctag PLCTAG_STATUS_* codes (0, >0 pending, negative error families). Mirrors ModbusDriver.MapModbusExceptionToStatus so Admin UI status displays stay uniform across drivers. PlcTagHandle is a SafeHandle around the int32 native tag ID with plc_tag_destroy slot wired as a no-op for PR 2 (P/Invoke DllImport arrives with PR 3 when the wire calls land). Lifetime guaranteed by the SafeHandle finalizer — every leaked handle gets cleaned up even when the owner is GC'd without explicit Dispose. IsInvalid when native ID <= 0 so destroying a negative (error) handle never happens. Critical because driver-specs.md §3 flags libplctag native heap as invisible to GetMemoryFootprint — leaked handles directly feed the Tier-B recycle trigger. AbCipDriverOptions captures the multi-device shape — one driver instance can talk to N PLCs via Devices[] (each with HostAddress + PlcFamily + optional DeviceName); Tags[] references devices by HostAddress as the cross-key; AbCipProbeOptions + driver-wide Timeout. AbCipDriver implements IDriver only — InitializeAsync parses every device's HostAddress and selects its PlcFamilyProfile (fails fast on malformed strings via InvalidOperationException → Faulted health), per-device state cached in a DeviceState record with parsed address + profile + empty TagHandles dict for later PRs. ReinitializeAsync is the Tier-B escape hatch — shuts down every device, disposes every PlcTagHandle via SafeHandle lifetime, reinitializes from options. ShutdownAsync clears the device dict and flips health to Unknown. PlcFamilies/AbCipPlcFamilyProfile gives four baseline profiles — ControlLogix (4002 ConnectionSize, path 1,0, Large Forward Open + request packing + connected messaging, FW20+ baseline), CompactLogix (narrower 504 default for 5069-L3x safety), Micro800 (488 cap, empty path, unconnected-only, no request packing), GuardLogix (shares ControlLogix wire protocol — safety partition is tag-level, surfaced as ViewOnly in PR 12). Tests — 76 new cases across 4 test classes — AbCipHostAddressTests (10 valid shapes, 10 invalid shapes, ToString canonicalization, round-trip stability), AbCipTagPathTests (18 cases including multi-scope / multi-member / multi-subscript / bit-in-DINT / rejected shapes / underscore idents / round-trip), AbCipStatusMapperTests (12 CIP + 8 libplctag codes), AbCipDriverTests (IDriver lifecycle + multi-device init + malformed-address fault + per-family profile lookup + PlcTagHandle invalid/dispose idempotency + AbCipDataType mapping). Full solution builds 0 errors; 254 warnings are pre-existing xUnit1051 CancellationToken hints outside this PR. Solution file updated to include both new projects. Unblocks PR 3 (IReadable against ab_server) which is the first PR to exercise the native library end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:58:15 -04:00
bff6651b4b Merge pull request (#108) - PollGroupEngine extraction 2026-04-19 15:51:11 -04:00
22 changed files with 1856 additions and 0 deletions

View File

@@ -10,6 +10,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
@@ -29,6 +30,8 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
<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

@@ -0,0 +1,61 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Logix atomic + string data types, plus a <see cref="Structure"/> marker used when a tag
/// references a UDT / predefined structure (Timer, Counter, Control). The concrete UDT
/// shape is resolved via the CIP Template Object at discovery time (PR 5 / PR 6).
/// </summary>
/// <remarks>
/// Mirrors the shape of <c>ModbusDataType</c>. Atomic Logix names (BOOL / SINT / INT / DINT /
/// LINT / REAL / LREAL / STRING / DT) map one-to-one; BIT + BOOL-in-DINT collapse into
/// <see cref="Bool"/> with the <c>.N</c> bit-index carried on the <see cref="AbCipTagPath"/>
/// rather than the data type itself.
/// </remarks>
public enum AbCipDataType
{
Bool,
SInt, // signed 8-bit
Int, // signed 16-bit
DInt, // signed 32-bit
LInt, // signed 64-bit
USInt, // unsigned 8-bit (Logix 5000 post-V21)
UInt, // unsigned 16-bit
UDInt, // unsigned 32-bit
ULInt, // unsigned 64-bit
Real, // 32-bit IEEE-754
LReal, // 64-bit IEEE-754
String, // Logix STRING (DINT Length + SINT[82] DATA — flattened to .NET string by libplctag)
Dt, // Date/Time — Logix DT == DINT representing seconds-since-epoch per Rockwell conventions
/// <summary>
/// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is
/// resolved at discovery time; reads + writes fan out to member Variables unless the
/// caller has explicitly opted into whole-UDT decode.
/// </summary>
Structure,
}
/// <summary>Map a Logix atomic type to the driver-surface <see cref="DriverDataType"/>.</summary>
public static class AbCipDataTypeExtensions
{
/// <summary>
/// Map to the driver-agnostic type the server's address-space builder consumes. Unsigned
/// Logix types widen into signed equivalents until <c>DriverDataType</c> picks up unsigned
/// + 64-bit variants (Modbus has the same gap — see <c>ModbusDriver.MapDataType</c>
/// comment re: PR 25).
/// </summary>
public static DriverDataType ToDriverDataType(this AbCipDataType t) => t switch
{
AbCipDataType.Bool => DriverDataType.Boolean,
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
AbCipDataType.LInt or AbCipDataType.ULInt => DriverDataType.Int32, // TODO: Int64 — matches Modbus gap
AbCipDataType.Real => DriverDataType.Float32,
AbCipDataType.LReal => DriverDataType.Float64,
AbCipDataType.String => DriverDataType.String,
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
_ => DriverDataType.Int32,
};
}

View File

@@ -0,0 +1,241 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Allen-Bradley CIP / EtherNet-IP driver for ControlLogix / CompactLogix / Micro800 /
/// GuardLogix families. Implements <see cref="IDriver"/> only for now — read/write/
/// subscribe/discover capabilities ship in subsequent PRs (38) and family-specific quirk
/// profiles ship in PRs 912.
/// </summary>
/// <remarks>
/// <para>Wire layer is libplctag 1.6.x (plan decision #11). Per-device host addresses use
/// the <c>ab://gateway[:port]/cip-path</c> canonical form parsed via
/// <see cref="AbCipHostAddress.TryParse"/>; those strings become the <c>hostName</c> key
/// for Polly bulkhead + circuit-breaker isolation per plan decision #144.</para>
///
/// <para>Tier A per plan decisions #143145 — in-process, shares server lifetime, no
/// sidecar. <see cref="ReinitializeAsync"/> is the Tier-B escape hatch for recovering
/// 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
{
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,
IAbCipTagFactory? tagFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagTagFactory();
}
public string DriverInstanceId => _driverInstanceId;
public string DriverType => "AbCip";
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
foreach (var device in _options.Devices)
{
var addr = AbCipHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
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)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
return Task.CompletedTask;
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public Task ShutdownAsync(CancellationToken cancellationToken)
{
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;
}
/// <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>
/// CLR-visible allocation footprint only — libplctag's native heap is invisible to the
/// GC. driver-specs.md §3 flags this: operators must watch whole-process RSS for the
/// full picture, and <see cref="ReinitializeAsync"/> is the Tier-B remediation.
/// </summary>
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
internal int DeviceCount => _devices.Count;
/// <summary>Looked-up device state for the given host address. Tests + later-PR capabilities hit this.</summary>
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
/// Per-device runtime state. Holds the parsed host address, family profile, and the
/// live <see cref="PlcTagHandle"/> cache keyed by tag path. PRs 38 populate + consume
/// this dict via libplctag.
/// </summary>
internal sealed class DeviceState(
AbCipHostAddress parsedAddress,
AbCipDeviceOptions options,
AbCipPlcFamilyProfile profile)
{
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
public AbCipDeviceOptions Options { get; } = options;
public AbCipPlcFamilyProfile Profile { get; } = profile;
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

@@ -0,0 +1,91 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// AB CIP / EtherNet-IP driver configuration, bound from the driver's <c>DriverConfig</c>
/// JSON at <c>DriverHost.RegisterAsync</c>. One instance supports N devices (PLCs) behind
/// the same driver; per-device routing is keyed on <see cref="AbCipDeviceOptions.HostAddress"/>
/// via <c>IPerCallHostResolver</c>.
/// </summary>
/// <remarks>
/// Per v2 plan decisions #11 (libplctag), #41 (AbCip vs AbLegacy split), #143144 (per-call
/// host resolver + resilience keys), #144 (bulkhead keyed on <c>(DriverInstanceId, HostName)</c>).
/// </remarks>
public sealed class AbCipDriverOptions
{
/// <summary>
/// PLCs this driver instance talks to. Each device contributes its own <see cref="AbCipHostAddress"/>
/// string as the <c>hostName</c> key used by resilience pipelines and the Admin UI.
/// </summary>
public IReadOnlyList<AbCipDeviceOptions> Devices { get; init; } = [];
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
/// <summary>
/// Default libplctag call timeout applied to reads/writes/discovery when the caller does
/// not pass a more specific value. Matches the Modbus driver's 2-second default.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}
/// <summary>
/// One PLC endpoint. <see cref="HostAddress"/> must parse via
/// <see cref="AbCipHostAddress.TryParse"/>; misconfigured devices fail driver
/// initialization rather than silently connecting to nothing.
/// </summary>
/// <param name="HostAddress">Canonical <c>ab://gateway[:port]/cip-path</c> string.</param>
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
/// request-packing support, unconnected-only hint, and other quirks.</param>
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
string? DeviceName = null);
/// <summary>
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
/// </summary>
/// <param name="Name">Tag name; becomes the OPC UA browse name and full reference.</param>
/// <param name="DeviceHostAddress">Which device (<see cref="AbCipDeviceOptions.HostAddress"/>) this tag lives on.</param>
/// <param name="TagPath">Logix symbolic path (controller or program scope).</param>
/// <param name="DataType">Logix atomic type, or <see cref="AbCipDataType.Structure"/> for UDT-typed tags.</param>
/// <param name="Writable">When <c>true</c> and the tag's ExternalAccess permits writes, IWritable routes writes here.</param>
/// <param name="WriteIdempotent">Per plan decisions #44#45, #143 — safe to replay on write timeout. Default <c>false</c>.</param>
public sealed record AbCipTagDefinition(
string Name,
string DeviceHostAddress,
string TagPath,
AbCipDataType DataType,
bool Writable = true,
bool WriteIdempotent = false);
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
public enum AbCipPlcFamily
{
ControlLogix,
CompactLogix,
Micro800,
GuardLogix,
}
/// <summary>
/// Background connectivity-probe settings. Enabled by default; the probe reads a cheap tag
/// on the PLC at the configured interval to drive <see cref="Core.Abstractions.IHostConnectivityProbe"/>
/// state transitions + Admin UI health status.
/// </summary>
public sealed class AbCipProbeOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Tag path used for the probe. If null, the driver attempts to read a default
/// system tag (PR 8 wires this up — the choice is family-dependent, e.g.
/// <c>@raw_cpu_type</c> on ControlLogix or a user-configured probe tag on Micro800).
/// </summary>
public string? ProbeTagPath { get; init; }
}

View File

@@ -0,0 +1,68 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Parsed <c>ab://gateway[:port]/cip-path</c> host-address string used by the AbCip driver
/// as the <c>hostName</c> key across <see cref="Core.Abstractions.IHostConnectivityProbe"/>,
/// <see cref="Core.Abstractions.IPerCallHostResolver"/>, and the Polly bulkhead key
/// <c>(DriverInstanceId, hostName)</c> per v2 plan decision #144.
/// </summary>
/// <remarks>
/// <para>Format matches what libplctag's <c>gateway=...</c> + <c>path=...</c> attributes
/// consume, so no translation is needed at the wire layer — the parsed <see cref="CipPath"/>
/// is handed to the native library verbatim.</para>
/// <list type="bullet">
/// <item><c>ab://10.0.0.5/1,0</c> — single-chassis ControlLogix, CPU in slot 0.</item>
/// <item><c>ab://10.0.0.5/1,4</c> — CPU in slot 4.</item>
/// <item><c>ab://10.0.0.5/1,2,2,192.168.50.20,1,0</c> — bridged ControlLogix.</item>
/// <item><c>ab://10.0.0.5/</c> (empty path) — Micro800 / MicroLogix without backplane routing.</item>
/// <item><c>ab://10.0.0.5:44818/1,0</c> — explicit EIP port (default 44818).</item>
/// </list>
/// <para>Opaque to the rest of the stack: Admin UI, telemetry, and logs display the full
/// string so an incident ticket can be matched to the exact gateway + CIP route.</para>
/// </remarks>
public sealed record AbCipHostAddress(string Gateway, int Port, string CipPath)
{
/// <summary>Default EtherNet/IP TCP port — spec-reserved.</summary>
public const int DefaultEipPort = 44818;
/// <summary>Recompose the canonical <c>ab://...</c> form.</summary>
public override string ToString() => Port == DefaultEipPort
? $"ab://{Gateway}/{CipPath}"
: $"ab://{Gateway}:{Port}/{CipPath}";
/// <summary>
/// Parse <paramref name="value"/>. Returns <c>null</c> on any malformed input — callers
/// should treat a null return as a config-validation failure rather than catching.
/// </summary>
public static AbCipHostAddress? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
const string prefix = "ab://";
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
var remainder = value[prefix.Length..];
var slashIdx = remainder.IndexOf('/');
if (slashIdx < 0) return null;
var authority = remainder[..slashIdx];
var cipPath = remainder[(slashIdx + 1)..];
if (string.IsNullOrEmpty(authority)) return null;
var port = DefaultEipPort;
var colonIdx = authority.LastIndexOf(':');
string gateway;
if (colonIdx >= 0)
{
gateway = authority[..colonIdx];
if (!int.TryParse(authority[(colonIdx + 1)..], out port) || port <= 0 || port > 65535)
return null;
}
else
{
gateway = authority;
}
if (string.IsNullOrEmpty(gateway)) return null;
return new AbCipHostAddress(gateway, port, cipPath);
}
}

View File

@@ -0,0 +1,78 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Maps libplctag / CIP General Status codes to OPC UA StatusCodes. Mirrors the shape of
/// <c>ModbusDriver.MapModbusExceptionToStatus</c> so Admin UI status displays stay
/// uniform across drivers.
/// </summary>
/// <remarks>
/// <para>Coverage: the CIP general-status values an AB PLC actually returns during normal
/// driver operation. Full CIP Volume 1 Appendix B lists 50+ codes; the ones here are the
/// ones that move the driver's status needle:</para>
/// <list type="bullet">
/// <item>0x00 success — OPC UA <c>Good (0)</c>.</item>
/// <item>0x04 path segment error / 0x05 path destination unknown — <c>BadNodeIdUnknown</c>
/// (tag doesn't exist).</item>
/// <item>0x06 partial data transfer — <c>GoodMoreData</c> (fragmented read underway).</item>
/// <item>0x08 service not supported — <c>BadNotSupported</c> (e.g. write on a safety
/// partition tag from a non-safety task).</item>
/// <item>0x0A / 0x13 attribute-list error / insufficient data — <c>BadOutOfRange</c>
/// (type mismatch or truncated buffer).</item>
/// <item>0x0B already in requested mode — benign, treated as <c>Good</c>.</item>
/// <item>0x0E attribute not settable — <c>BadNotWritable</c>.</item>
/// <item>0x10 device state conflict — <c>BadDeviceFailure</c> (program-mode protected
/// writes during download / test-mode transitions).</item>
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
/// <item>any libplctag <c>PLCTAG_STATUS_*</c> below zero — wrapped as
/// <c>BadCommunicationError</c> until fine-grained mapping lands (PR 3).</item>
/// </list>
/// </remarks>
public static class AbCipStatusMapper
{
public const uint Good = 0u;
public const uint GoodMoreData = 0x00A70000u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u;
public const uint BadOutOfRange = 0x803C0000u;
public const uint BadNotSupported = 0x803D0000u;
public const uint BadDeviceFailure = 0x80550000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
public static uint MapCipGeneralStatus(byte status) => status switch
{
0x00 => Good,
0x04 or 0x05 => BadNodeIdUnknown,
0x06 => GoodMoreData,
0x08 => BadNotSupported,
0x0A or 0x13 => BadOutOfRange,
0x0B => Good,
0x0E => BadNotWritable,
0x10 => BadDeviceFailure,
0x16 => BadNodeIdUnknown,
_ => BadInternalError,
};
/// <summary>
/// Map a libplctag return/status code (<c>PLCTAG_STATUS_*</c>) to an OPC UA StatusCode.
/// libplctag uses <c>0 = PLCTAG_STATUS_OK</c>, positive values for pending, negative
/// values for errors.
/// </summary>
public static uint MapLibplctagStatus(int status)
{
if (status == 0) return Good;
if (status > 0) return GoodMoreData; // PLCTAG_STATUS_PENDING
return status switch
{
-5 => BadTimeout, // PLCTAG_ERR_TIMEOUT
-7 => BadCommunicationError, // PLCTAG_ERR_BAD_CONNECTION
-14 => BadNodeIdUnknown, // PLCTAG_ERR_NOT_FOUND
-16 => BadNotWritable, // PLCTAG_ERR_NOT_ALLOWED / read-only tag
-17 => BadOutOfRange, // PLCTAG_ERR_OUT_OF_BOUNDS
_ => BadCommunicationError,
};
}
}

View File

@@ -0,0 +1,132 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Parsed Logix-symbolic tag path. Handles controller-scope (<c>Motor1_Speed</c>),
/// program-scope (<c>Program:MainProgram.StepIndex</c>), structured member access
/// (<c>Motor1.Speed.Setpoint</c>), array subscripts (<c>Array[0]</c>, <c>Matrix[1,2]</c>),
/// and bit-within-DINT access (<c>Flags.3</c>). Reassembles the canonical Logix syntax via
/// <see cref="ToLibplctagName"/>, which is the exact string libplctag's <c>name=...</c>
/// attribute consumes.
/// </summary>
/// <remarks>
/// Scope + members + subscripts are captured structurally so PR 6 (UDT support) can walk
/// the path against a cached template without re-parsing. <see cref="BitIndex"/> is
/// non-null only when the trailing segment is a decimal integer between 0 and 31 that
/// parses as a bit-selector — this is the <c>.N</c> syntax documented in the Logix 5000
/// General Instructions Reference §Tags, and it applies only to DINT-typed parents. The
/// parser does not validate the parent type (requires live template data) — it accepts the
/// shape and defers type-correctness to the runtime.
/// </remarks>
public sealed record AbCipTagPath(
string? ProgramScope,
IReadOnlyList<AbCipTagPathSegment> Segments,
int? BitIndex)
{
/// <summary>Rebuild the canonical Logix tag string.</summary>
public string ToLibplctagName()
{
var buf = new System.Text.StringBuilder();
if (ProgramScope is not null)
buf.Append("Program:").Append(ProgramScope).Append('.');
for (var i = 0; i < Segments.Count; i++)
{
if (i > 0) buf.Append('.');
var seg = Segments[i];
buf.Append(seg.Name);
if (seg.Subscripts.Count > 0)
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
}
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
return buf.ToString();
}
/// <summary>
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
/// doesn't support — the driver surfaces that as a config-validation error rather than
/// attempting a best-effort translation.
/// </summary>
public static AbCipTagPath? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim();
string? programScope = null;
const string programPrefix = "Program:";
if (src.StartsWith(programPrefix, StringComparison.OrdinalIgnoreCase))
{
var afterPrefix = src[programPrefix.Length..];
var dotIdx = afterPrefix.IndexOf('.');
if (dotIdx <= 0) return null;
programScope = afterPrefix[..dotIdx];
src = afterPrefix[(dotIdx + 1)..];
if (string.IsNullOrEmpty(src)) return null;
}
// Split on dots, but preserve any [i,j] subscript runs that contain only digits + commas.
var parts = new List<string>();
var depth = 0;
var start = 0;
for (var i = 0; i < src.Length; i++)
{
var c = src[i];
if (c == '[') depth++;
else if (c == ']') depth--;
else if (c == '.' && depth == 0)
{
parts.Add(src[start..i]);
start = i + 1;
}
}
parts.Add(src[start..]);
if (depth != 0 || parts.Any(string.IsNullOrEmpty)) return null;
int? bitIndex = null;
if (parts.Count >= 2 && int.TryParse(parts[^1], out var maybeBit)
&& maybeBit is >= 0 and <= 31
&& !parts[^1].Contains('['))
{
bitIndex = maybeBit;
parts.RemoveAt(parts.Count - 1);
}
var segments = new List<AbCipTagPathSegment>(parts.Count);
foreach (var part in parts)
{
var bracketIdx = part.IndexOf('[');
if (bracketIdx < 0)
{
if (!IsValidIdent(part)) return null;
segments.Add(new AbCipTagPathSegment(part, []));
continue;
}
if (!part.EndsWith(']')) return null;
var name = part[..bracketIdx];
if (!IsValidIdent(name)) return null;
var inner = part[(bracketIdx + 1)..^1];
var subs = new List<int>();
foreach (var tok in inner.Split(','))
{
if (!int.TryParse(tok, out var n) || n < 0) return null;
subs.Add(n);
}
if (subs.Count == 0) return null;
segments.Add(new AbCipTagPathSegment(name, subs));
}
if (segments.Count == 0) return null;
return new AbCipTagPath(programScope, segments, bitIndex);
}
private static bool IsValidIdent(string s)
{
if (string.IsNullOrEmpty(s)) return false;
if (!char.IsLetter(s[0]) && s[0] != '_') return false;
for (var i = 1; i < s.Length; i++)
if (!char.IsLetterOrDigit(s[i]) && s[i] != '_') return false;
return true;
}
}
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);

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,89 @@
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)
{
// 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.");
}
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,62 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
/// <summary>
/// Per-family libplctag defaults. Picked up at device-initialization time so each PLC
/// family gets the correct ConnectionSize, path semantics, and quirks applied without
/// the caller having to know the protocol-level differences.
/// </summary>
/// <remarks>
/// Mirrors the shape of the Modbus driver's per-family profiles (DL205, Siemens S7,
/// Mitsubishi MELSEC). ControlLogix is the baseline; each subsequent family is a delta.
/// Family-specific wire tests ship in PRs 912.
/// </remarks>
public sealed record AbCipPlcFamilyProfile(
string LibplctagPlcAttribute,
int DefaultConnectionSize,
string DefaultCipPath,
bool SupportsRequestPacking,
bool SupportsConnectedMessaging,
int MaxFragmentBytes)
{
/// <summary>Look up the profile for a configured family.</summary>
public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch
{
AbCipPlcFamily.ControlLogix => ControlLogix,
AbCipPlcFamily.CompactLogix => CompactLogix,
AbCipPlcFamily.Micro800 => Micro800,
AbCipPlcFamily.GuardLogix => GuardLogix,
_ => ControlLogix,
};
public static readonly AbCipPlcFamilyProfile ControlLogix = new(
LibplctagPlcAttribute: "controllogix",
DefaultConnectionSize: 4002, // Large Forward Open; FW20+
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
MaxFragmentBytes: 4000);
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
LibplctagPlcAttribute: "compactlogix",
DefaultConnectionSize: 504, // 5069-L3x narrower buffer; safe baseline that never over-shoots
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
MaxFragmentBytes: 500);
public static readonly AbCipPlcFamilyProfile Micro800 = new(
LibplctagPlcAttribute: "micro800",
DefaultConnectionSize: 488, // Micro800 hard cap
DefaultCipPath: "", // no backplane routing
SupportsRequestPacking: false,
SupportsConnectedMessaging: false, // unconnected-only on most models
MaxFragmentBytes: 484);
public static readonly AbCipPlcFamilyProfile GuardLogix = new(
LibplctagPlcAttribute: "controllogix", // wire protocol identical; safety partition is tag-level
DefaultConnectionSize: 4002,
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
MaxFragmentBytes: 4000);
}

View File

@@ -0,0 +1,59 @@
using System.Runtime.InteropServices;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// <see cref="SafeHandle"/> wrapper around a libplctag native tag handle (an <c>int32</c>
/// returned from <c>plc_tag_create_ex</c>). Owns lifetime of the native allocation so a
/// leaked / GC-collected <see cref="PlcTagHandle"/> still calls <c>plc_tag_destroy</c>
/// during finalization — necessary because native libplctag allocations are opaque to
/// the driver's <see cref="Core.Abstractions.IDriver.GetMemoryFootprint"/>.
/// </summary>
/// <remarks>
/// <para>Risk documented in driver-specs.md §3 ("Operational Stability Notes"): the CLR
/// allocation tracker doesn't see libplctag's native heap, only whole-process RSS can.
/// Every handle leaked past its useful life is a direct contributor to the Tier-B recycle
/// trigger, so owning lifetime via SafeHandle is non-negotiable.</para>
///
/// <para><see cref="IsInvalid"/> is <c>true</c> when the native ID is &lt;= 0 — libplctag
/// returns negative <c>PLCTAG_ERR_*</c> codes on <c>plc_tag_create_ex</c> failure, which
/// we surface as an invalid handle rather than a disposable one (destroying a negative
/// handle would be undefined behavior in the native library).</para>
///
/// <para>The actual <c>DllImport</c> for <c>plc_tag_destroy</c> is deferred to PR 3 when
/// the driver first makes wire calls — PR 2 ships the lifetime scaffold + tests only.
/// Until the P/Invoke lands, <see cref="ReleaseHandle"/> is a no-op; the finalizer still
/// runs so the integration is correct as soon as the import is added.</para>
/// </remarks>
public sealed class PlcTagHandle : SafeHandle
{
/// <summary>Construct an invalid handle placeholder (use <see cref="FromNative"/> once created).</summary>
public PlcTagHandle() : base(invalidHandleValue: IntPtr.Zero, ownsHandle: true) { }
private PlcTagHandle(int nativeId) : base(invalidHandleValue: IntPtr.Zero, ownsHandle: true)
{
SetHandle(new IntPtr(nativeId));
}
/// <summary>Handle is invalid when the native ID is zero or negative (libplctag error).</summary>
public override bool IsInvalid => handle.ToInt32() <= 0;
/// <summary>Integer ID libplctag issued on <c>plc_tag_create_ex</c>.</summary>
public int NativeId => handle.ToInt32();
/// <summary>Wrap a native tag ID returned from libplctag.</summary>
public static PlcTagHandle FromNative(int nativeId) => new(nativeId);
/// <summary>
/// Destroy the native tag. No-op for PR 2 (the wire P/Invoke lands in PR 3). The base
/// <see cref="SafeHandle"/> machinery still guarantees this runs exactly once per
/// handle — either during <see cref="SafeHandle.Dispose()"/> or during finalization
/// if the owner was GC'd without explicit Dispose.
/// </summary>
protected override bool ReleaseHandle()
{
if (IsInvalid) return true;
// PR 3: wire up plc_tag_destroy(handle.ToInt32()) once the DllImport lands.
return true;
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.AbCip</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<!-- libplctag managed wrapper (pulls in libplctag.NativeImport transitively).
Decision #11 — EtherNet/IP + CIP + Logix symbolic against ControlLogix / CompactLogix /
Micro800 / SLC500 / PLC-5. -->
<PackageReference Include="libplctag" Version="1.5.2"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
</ItemGroup>
</Project>

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,131 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverTests
{
[Fact]
public void DriverType_is_AbCip()
{
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
drv.DriverType.ShouldBe("AbCip");
drv.DriverInstanceId.ShouldBe("drv-1");
}
[Fact]
public async Task InitializeAsync_with_empty_devices_succeeds_and_marks_healthy()
{
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task InitializeAsync_registers_each_device_with_its_family_profile()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix),
new AbCipDeviceOptions("ab://10.0.0.6/", AbCipPlcFamily.Micro800),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(2);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.ShouldBe(AbCipPlcFamilyProfile.ControlLogix);
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbCipPlcFamilyProfile.Micro800);
}
[Fact]
public async Task InitializeAsync_with_malformed_host_address_faults()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("not-a-valid-address")],
}, "drv-1");
await Should.ThrowAsync<InvalidOperationException>(
() => drv.InitializeAsync("{}", CancellationToken.None));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
[Fact]
public async Task ShutdownAsync_clears_devices_and_marks_unknown()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
[Fact]
public async Task ReinitializeAsync_cycles_devices()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReinitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public void Family_profiles_expose_expected_defaults()
{
AbCipPlcFamilyProfile.ControlLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize.ShouldBe(4002);
AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath.ShouldBe("1,0");
AbCipPlcFamilyProfile.Micro800.DefaultCipPath.ShouldBe(""); // no backplane routing
AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
AbCipPlcFamilyProfile.Micro800.SupportsConnectedMessaging.ShouldBeFalse();
AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(504);
AbCipPlcFamilyProfile.GuardLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
}
[Fact]
public void PlcTagHandle_IsInvalid_for_zero_or_negative_native_id()
{
PlcTagHandle.FromNative(-5).IsInvalid.ShouldBeTrue();
PlcTagHandle.FromNative(0).IsInvalid.ShouldBeTrue();
PlcTagHandle.FromNative(42).IsInvalid.ShouldBeFalse();
}
[Fact]
public void PlcTagHandle_Dispose_is_idempotent()
{
var h = PlcTagHandle.FromNative(42);
h.Dispose();
h.Dispose(); // must not throw
}
[Fact]
public void AbCipDataType_maps_atomics_to_driver_types()
{
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
}
}

View File

@@ -0,0 +1,65 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipHostAddressTests
{
[Theory]
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
[InlineData("ab://10.0.0.5/1,4", "10.0.0.5", 44818, "1,4")]
[InlineData("ab://10.0.0.5/1,2,2,192.168.50.20,1,0", "10.0.0.5", 44818, "1,2,2,192.168.50.20,1,0")]
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
[InlineData("ab://plc-01.factory.internal/1,0", "plc-01.factory.internal", 44818, "1,0")]
[InlineData("ab://10.0.0.5:44818/1,0", "10.0.0.5", 44818, "1,0")]
[InlineData("ab://10.0.0.5:2222/1,0", "10.0.0.5", 2222, "1,0")]
[InlineData("AB://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")] // case-insensitive scheme
public void TryParse_accepts_valid_forms(string input, string gateway, int port, string cipPath)
{
var parsed = AbCipHostAddress.TryParse(input);
parsed.ShouldNotBeNull();
parsed.Gateway.ShouldBe(gateway);
parsed.Port.ShouldBe(port);
parsed.CipPath.ShouldBe(cipPath);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("http://10.0.0.5/1,0")] // wrong scheme
[InlineData("ab:10.0.0.5/1,0")] // missing //
[InlineData("ab://10.0.0.5")] // no path slash
[InlineData("ab:///1,0")] // no gateway
[InlineData("ab://10.0.0.5:0/1,0")] // invalid port
[InlineData("ab://10.0.0.5:65536/1,0")] // port out of range
[InlineData("ab://10.0.0.5:abc/1,0")] // non-numeric port
public void TryParse_rejects_invalid_forms(string? input)
{
AbCipHostAddress.TryParse(input).ShouldBeNull();
}
[Theory]
[InlineData("10.0.0.5", 44818, "1,0", "ab://10.0.0.5/1,0")]
[InlineData("10.0.0.5", 2222, "1,0", "ab://10.0.0.5:2222/1,0")]
[InlineData("10.0.0.5", 44818, "", "ab://10.0.0.5/")]
public void ToString_canonicalises(string gateway, int port, string path, string expected)
{
var addr = new AbCipHostAddress(gateway, port, path);
addr.ToString().ShouldBe(expected);
}
[Fact]
public void RoundTrip_is_stable()
{
const string input = "ab://plc-01:44818/1,2,2,10.0.0.10,1,0";
var parsed = AbCipHostAddress.TryParse(input)!;
// Default port is stripped in canonical form; explicit 44818 → becomes default form.
parsed.ToString().ShouldBe("ab://plc-01/1,2,2,10.0.0.10,1,0");
var parsedAgain = AbCipHostAddress.TryParse(parsed.ToString())!;
parsedAgain.ShouldBe(parsed);
}
}

View File

@@ -0,0 +1,41 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipStatusMapperTests
{
[Theory]
[InlineData((byte)0x00, AbCipStatusMapper.Good)]
[InlineData((byte)0x04, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData((byte)0x05, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData((byte)0x06, AbCipStatusMapper.GoodMoreData)]
[InlineData((byte)0x08, AbCipStatusMapper.BadNotSupported)]
[InlineData((byte)0x0A, AbCipStatusMapper.BadOutOfRange)]
[InlineData((byte)0x13, AbCipStatusMapper.BadOutOfRange)]
[InlineData((byte)0x0B, AbCipStatusMapper.Good)]
[InlineData((byte)0x0E, AbCipStatusMapper.BadNotWritable)]
[InlineData((byte)0x10, AbCipStatusMapper.BadDeviceFailure)]
[InlineData((byte)0x16, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData((byte)0xFF, AbCipStatusMapper.BadInternalError)]
public void MapCipGeneralStatus_maps_known_codes(byte status, uint expected)
{
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
}
[Theory]
[InlineData(0, AbCipStatusMapper.Good)]
[InlineData(1, AbCipStatusMapper.GoodMoreData)] // PLCTAG_STATUS_PENDING
[InlineData(-5, AbCipStatusMapper.BadTimeout)]
[InlineData(-7, AbCipStatusMapper.BadCommunicationError)]
[InlineData(-14, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData(-16, AbCipStatusMapper.BadNotWritable)]
[InlineData(-17, AbCipStatusMapper.BadOutOfRange)]
[InlineData(-99, AbCipStatusMapper.BadCommunicationError)] // unknown negative → generic comms failure
public void MapLibplctagStatus_maps_known_codes(int status, uint expected)
{
AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
}
}

View File

@@ -0,0 +1,146 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipTagPathTests
{
[Fact]
public void Controller_scope_single_segment()
{
var p = AbCipTagPath.TryParse("Motor1_Speed");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBeNull();
p.Segments.Count.ShouldBe(1);
p.Segments[0].Name.ShouldBe("Motor1_Speed");
p.Segments[0].Subscripts.ShouldBeEmpty();
p.BitIndex.ShouldBeNull();
p.ToLibplctagName().ShouldBe("Motor1_Speed");
}
[Fact]
public void Program_scope_parses()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.StepIndex");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Single().Name.ShouldBe("StepIndex");
p.ToLibplctagName().ShouldBe("Program:MainProgram.StepIndex");
}
[Fact]
public void Structured_member_access_splits_segments()
{
var p = AbCipTagPath.TryParse("Motor1.Speed.Setpoint");
p.ShouldNotBeNull();
p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Speed", "Setpoint"]);
p.ToLibplctagName().ShouldBe("Motor1.Speed.Setpoint");
}
[Fact]
public void Single_dim_array_subscript()
{
var p = AbCipTagPath.TryParse("Data[7]");
p.ShouldNotBeNull();
p.Segments.Single().Name.ShouldBe("Data");
p.Segments.Single().Subscripts.ShouldBe([7]);
p.ToLibplctagName().ShouldBe("Data[7]");
}
[Fact]
public void Multi_dim_array_subscript()
{
var p = AbCipTagPath.TryParse("Matrix[1,2,3]");
p.ShouldNotBeNull();
p.Segments.Single().Subscripts.ShouldBe([1, 2, 3]);
p.ToLibplctagName().ShouldBe("Matrix[1,2,3]");
}
[Fact]
public void Bit_in_dint_captured_as_bit_index()
{
var p = AbCipTagPath.TryParse("Flags.3");
p.ShouldNotBeNull();
p.Segments.Single().Name.ShouldBe("Flags");
p.BitIndex.ShouldBe(3);
p.ToLibplctagName().ShouldBe("Flags.3");
}
[Fact]
public void Bit_in_dint_after_member()
{
var p = AbCipTagPath.TryParse("Motor.Status.12");
p.ShouldNotBeNull();
p.Segments.Select(s => s.Name).ShouldBe(["Motor", "Status"]);
p.BitIndex.ShouldBe(12);
p.ToLibplctagName().ShouldBe("Motor.Status.12");
}
[Fact]
public void Bit_index_32_rejected_out_of_range()
{
// 32 exceeds the DINT bit width — treated as a member name rather than bit selector,
// which fails ident validation and returns null.
AbCipTagPath.TryParse("Flags.32").ShouldBeNull();
}
[Fact]
public void Program_scope_with_members_and_subscript_and_bit()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors[0].Status.5");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Status"]);
p.Segments[0].Subscripts.ShouldBe([0]);
p.BitIndex.ShouldBe(5);
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors[0].Status.5");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("Program:")] // empty scope
[InlineData("Program:MP")] // no body after scope
[InlineData("1InvalidStart")] // ident starts with digit
[InlineData("Bad Name")] // space in ident
[InlineData("Motor[]")] // empty subscript
[InlineData("Motor[-1]")] // negative subscript
[InlineData("Motor[a]")] // non-numeric subscript
[InlineData("Motor[")] // unbalanced bracket
[InlineData("Motor.")] // trailing dot
[InlineData(".Motor")] // leading dot
public void Invalid_shapes_return_null(string? input)
{
AbCipTagPath.TryParse(input).ShouldBeNull();
}
[Fact]
public void Ident_with_underscore_accepted()
{
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
}
[Fact]
public void ToLibplctagName_recomposes_round_trip()
{
var cases = new[]
{
"Motor1_Speed",
"Program:Main.Counter",
"Array[5]",
"Matrix[1,2]",
"Obj.Member.Sub",
"Flags.0",
"Program:P.Obj[2].Flags.15",
};
foreach (var c in cases)
{
var parsed = AbCipTagPath.TryParse(c);
parsed.ShouldNotBeNull(c);
parsed.ToLibplctagName().ShouldBe(c);
}
}
}

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 sealed 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 Task InitializeAsync(CancellationToken cancellationToken)
{
InitializeCount++;
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException("fake initialize failure");
return Task.CompletedTask;
}
public Task ReadAsync(CancellationToken cancellationToken)
{
ReadCount++;
if (ThrowOnRead) throw Exception ?? new InvalidOperationException("fake read failure");
return Task.CompletedTask;
}
public Task WriteAsync(CancellationToken cancellationToken)
{
WriteCount++;
return Task.CompletedTask;
}
public int GetStatus() => Status;
public object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
public 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;
}
}

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.Tests</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>