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>
This commit is contained in:
@@ -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.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.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.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.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.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
<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.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.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.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.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.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.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"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||||
|
|||||||
61
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs
Normal file
61
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
126
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
Normal file
126
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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 (3–8) and family-specific quirk
|
||||||
|
/// profiles ship in PRs 9–12.
|
||||||
|
/// </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 #143–145 — 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, IDisposable, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly AbCipDriverOptions _options;
|
||||||
|
private readonly string _driverInstanceId;
|
||||||
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
|
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
_options = options;
|
||||||
|
_driverInstanceId = driverInstanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
_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();
|
||||||
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 3–8 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);
|
||||||
|
|
||||||
|
public void DisposeHandles()
|
||||||
|
{
|
||||||
|
foreach (var h in TagHandles.Values) h.Dispose();
|
||||||
|
TagHandles.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
Normal file
91
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
Normal 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), #143–144 (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; }
|
||||||
|
}
|
||||||
68
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs
Normal file
68
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
Normal file
78
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs
Normal file
132
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs
Normal 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);
|
||||||
@@ -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 9–12.
|
||||||
|
/// </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);
|
||||||
|
}
|
||||||
59
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcTagHandle.cs
Normal file
59
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcTagHandle.cs
Normal 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 <= 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
131
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs
Normal file
131
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs
Normal file
146
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user