diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 403e60f..d5efdda 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -10,6 +10,7 @@
+
@@ -29,6 +30,7 @@
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs
new file mode 100644
index 0000000..cc0b822
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs
@@ -0,0 +1,61 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Logix atomic + string data types, plus a 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).
+///
+///
+/// Mirrors the shape of ModbusDataType. Atomic Logix names (BOOL / SINT / INT / DINT /
+/// LINT / REAL / LREAL / STRING / DT) map one-to-one; BIT + BOOL-in-DINT collapse into
+/// with the .N bit-index carried on the
+/// rather than the data type itself.
+///
+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
+ ///
+ /// 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.
+ ///
+ Structure,
+}
+
+/// Map a Logix atomic type to the driver-surface .
+public static class AbCipDataTypeExtensions
+{
+ ///
+ /// Map to the driver-agnostic type the server's address-space builder consumes. Unsigned
+ /// Logix types widen into signed equivalents until DriverDataType picks up unsigned
+ /// + 64-bit variants (Modbus has the same gap — see ModbusDriver.MapDataType
+ /// comment re: PR 25).
+ ///
+ 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,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
new file mode 100644
index 0000000..642e6a2
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -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;
+
+///
+/// Allen-Bradley CIP / EtherNet-IP driver for ControlLogix / CompactLogix / Micro800 /
+/// GuardLogix families. Implements only for now — read/write/
+/// subscribe/discover capabilities ship in subsequent PRs (3–8) and family-specific quirk
+/// profiles ship in PRs 9–12.
+///
+///
+/// Wire layer is libplctag 1.6.x (plan decision #11). Per-device host addresses use
+/// the ab://gateway[:port]/cip-path canonical form parsed via
+/// ; those strings become the hostName key
+/// for Polly bulkhead + circuit-breaker isolation per plan decision #144.
+///
+/// Tier A per plan decisions #143–145 — in-process, shares server lifetime, no
+/// sidecar. is the Tier-B escape hatch for recovering
+/// from native-heap growth that the CLR allocator can't see; it tears down every
+/// and reconnects each device.
+///
+public sealed class AbCipDriver : IDriver, IDisposable, IAsyncDisposable
+{
+ private readonly AbCipDriverOptions _options;
+ private readonly string _driverInstanceId;
+ private readonly Dictionary _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;
+
+ ///
+ /// 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 is the Tier-B remediation.
+ ///
+ public long GetMemoryFootprint() => 0;
+
+ public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ /// Count of registered devices — exposed for diagnostics + tests.
+ internal int DeviceCount => _devices.Count;
+
+ /// Looked-up device state for the given host address. Tests + later-PR capabilities hit this.
+ 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);
+ }
+
+ ///
+ /// Per-device runtime state. Holds the parsed host address, family profile, and the
+ /// live cache keyed by tag path. PRs 3–8 populate + consume
+ /// this dict via libplctag.
+ ///
+ 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 TagHandles { get; } =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public void DisposeHandles()
+ {
+ foreach (var h in TagHandles.Values) h.Dispose();
+ TagHandles.Clear();
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
new file mode 100644
index 0000000..2d99471
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
@@ -0,0 +1,91 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// AB CIP / EtherNet-IP driver configuration, bound from the driver's DriverConfig
+/// JSON at DriverHost.RegisterAsync. One instance supports N devices (PLCs) behind
+/// the same driver; per-device routing is keyed on
+/// via IPerCallHostResolver.
+///
+///
+/// Per v2 plan decisions #11 (libplctag), #41 (AbCip vs AbLegacy split), #143–144 (per-call
+/// host resolver + resilience keys), #144 (bulkhead keyed on (DriverInstanceId, HostName)).
+///
+public sealed class AbCipDriverOptions
+{
+ ///
+ /// PLCs this driver instance talks to. Each device contributes its own
+ /// string as the hostName key used by resilience pipelines and the Admin UI.
+ ///
+ public IReadOnlyList Devices { get; init; } = [];
+
+ /// Pre-declared tag map across all devices — AB discovery lands in PR 5.
+ public IReadOnlyList Tags { get; init; } = [];
+
+ /// Per-device probe settings. Falls back to defaults when omitted.
+ public AbCipProbeOptions Probe { get; init; } = new();
+
+ ///
+ /// 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.
+ ///
+ public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
+}
+
+///
+/// One PLC endpoint. must parse via
+/// ; misconfigured devices fail driver
+/// initialization rather than silently connecting to nothing.
+///
+/// Canonical ab://gateway[:port]/cip-path string.
+/// Which per-family profile to apply. Determines ConnectionSize,
+/// request-packing support, unconnected-only hint, and other quirks.
+/// Optional display label for Admin UI. Falls back to .
+public sealed record AbCipDeviceOptions(
+ string HostAddress,
+ AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
+ string? DeviceName = null);
+
+///
+/// One AB-backed OPC UA variable. Mirrors the ModbusTagDefinition shape.
+///
+/// Tag name; becomes the OPC UA browse name and full reference.
+/// Which device () this tag lives on.
+/// Logix symbolic path (controller or program scope).
+/// Logix atomic type, or for UDT-typed tags.
+/// When true and the tag's ExternalAccess permits writes, IWritable routes writes here.
+/// Per plan decisions #44–#45, #143 — safe to replay on write timeout. Default false.
+public sealed record AbCipTagDefinition(
+ string Name,
+ string DeviceHostAddress,
+ string TagPath,
+ AbCipDataType DataType,
+ bool Writable = true,
+ bool WriteIdempotent = false);
+
+/// Which AB PLC family the device is — selects the profile applied to connection params.
+public enum AbCipPlcFamily
+{
+ ControlLogix,
+ CompactLogix,
+ Micro800,
+ GuardLogix,
+}
+
+///
+/// Background connectivity-probe settings. Enabled by default; the probe reads a cheap tag
+/// on the PLC at the configured interval to drive
+/// state transitions + Admin UI health status.
+///
+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);
+
+ ///
+ /// 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.
+ /// @raw_cpu_type on ControlLogix or a user-configured probe tag on Micro800).
+ ///
+ public string? ProbeTagPath { get; init; }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs
new file mode 100644
index 0000000..18be5a8
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs
@@ -0,0 +1,68 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Parsed ab://gateway[:port]/cip-path host-address string used by the AbCip driver
+/// as the hostName key across ,
+/// , and the Polly bulkhead key
+/// (DriverInstanceId, hostName) per v2 plan decision #144.
+///
+///
+/// Format matches what libplctag's gateway=... + path=... attributes
+/// consume, so no translation is needed at the wire layer — the parsed
+/// is handed to the native library verbatim.
+///
+/// - ab://10.0.0.5/1,0 — single-chassis ControlLogix, CPU in slot 0.
+/// - ab://10.0.0.5/1,4 — CPU in slot 4.
+/// - ab://10.0.0.5/1,2,2,192.168.50.20,1,0 — bridged ControlLogix.
+/// - ab://10.0.0.5/ (empty path) — Micro800 / MicroLogix without backplane routing.
+/// - ab://10.0.0.5:44818/1,0 — explicit EIP port (default 44818).
+///
+/// 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.
+///
+public sealed record AbCipHostAddress(string Gateway, int Port, string CipPath)
+{
+ /// Default EtherNet/IP TCP port — spec-reserved.
+ public const int DefaultEipPort = 44818;
+
+ /// Recompose the canonical ab://... form.
+ public override string ToString() => Port == DefaultEipPort
+ ? $"ab://{Gateway}/{CipPath}"
+ : $"ab://{Gateway}:{Port}/{CipPath}";
+
+ ///
+ /// Parse . Returns null on any malformed input — callers
+ /// should treat a null return as a config-validation failure rather than catching.
+ ///
+ 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);
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
new file mode 100644
index 0000000..b12eec6
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
@@ -0,0 +1,78 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Maps libplctag / CIP General Status codes to OPC UA StatusCodes. Mirrors the shape of
+/// ModbusDriver.MapModbusExceptionToStatus so Admin UI status displays stay
+/// uniform across drivers.
+///
+///
+/// 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:
+///
+/// - 0x00 success — OPC UA Good (0).
+/// - 0x04 path segment error / 0x05 path destination unknown — BadNodeIdUnknown
+/// (tag doesn't exist).
+/// - 0x06 partial data transfer — GoodMoreData (fragmented read underway).
+/// - 0x08 service not supported — BadNotSupported (e.g. write on a safety
+/// partition tag from a non-safety task).
+/// - 0x0A / 0x13 attribute-list error / insufficient data — BadOutOfRange
+/// (type mismatch or truncated buffer).
+/// - 0x0B already in requested mode — benign, treated as Good.
+/// - 0x0E attribute not settable — BadNotWritable.
+/// - 0x10 device state conflict — BadDeviceFailure (program-mode protected
+/// writes during download / test-mode transitions).
+/// - 0x16 object does not exist — BadNodeIdUnknown.
+/// - 0x1E embedded service error — unwrap to the extended status when possible.
+/// - any libplctag PLCTAG_STATUS_* below zero — wrapped as
+/// BadCommunicationError until fine-grained mapping lands (PR 3).
+///
+///
+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;
+
+ /// Map a CIP general-status byte to an OPC UA StatusCode.
+ 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,
+ };
+
+ ///
+ /// Map a libplctag return/status code (PLCTAG_STATUS_*) to an OPC UA StatusCode.
+ /// libplctag uses 0 = PLCTAG_STATUS_OK, positive values for pending, negative
+ /// values for errors.
+ ///
+ 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,
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs
new file mode 100644
index 0000000..0891664
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs
@@ -0,0 +1,132 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Parsed Logix-symbolic tag path. Handles controller-scope (Motor1_Speed),
+/// program-scope (Program:MainProgram.StepIndex), structured member access
+/// (Motor1.Speed.Setpoint), array subscripts (Array[0], Matrix[1,2]),
+/// and bit-within-DINT access (Flags.3). Reassembles the canonical Logix syntax via
+/// , which is the exact string libplctag's name=...
+/// attribute consumes.
+///
+///
+/// Scope + members + subscripts are captured structurally so PR 6 (UDT support) can walk
+/// the path against a cached template without re-parsing. 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 .N 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.
+///
+public sealed record AbCipTagPath(
+ string? ProgramScope,
+ IReadOnlyList Segments,
+ int? BitIndex)
+{
+ /// Rebuild the canonical Logix tag string.
+ 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();
+ }
+
+ ///
+ /// Parse a Logix-symbolic tag reference. Returns null on a shape the parser
+ /// doesn't support — the driver surfaces that as a config-validation error rather than
+ /// attempting a best-effort translation.
+ ///
+ 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();
+ 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(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();
+ 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;
+ }
+}
+
+/// One path segment: a member name plus any numeric subscripts.
+public sealed record AbCipTagPathSegment(string Name, IReadOnlyList Subscripts);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs
new file mode 100644
index 0000000..56ab5f3
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs
@@ -0,0 +1,62 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
+
+///
+/// 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.
+///
+///
+/// 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.
+///
+public sealed record AbCipPlcFamilyProfile(
+ string LibplctagPlcAttribute,
+ int DefaultConnectionSize,
+ string DefaultCipPath,
+ bool SupportsRequestPacking,
+ bool SupportsConnectedMessaging,
+ int MaxFragmentBytes)
+{
+ /// Look up the profile for a configured family.
+ 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);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcTagHandle.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcTagHandle.cs
new file mode 100644
index 0000000..cc6189c
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcTagHandle.cs
@@ -0,0 +1,59 @@
+using System.Runtime.InteropServices;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// wrapper around a libplctag native tag handle (an int32
+/// returned from plc_tag_create_ex). Owns lifetime of the native allocation so a
+/// leaked / GC-collected still calls plc_tag_destroy
+/// during finalization — necessary because native libplctag allocations are opaque to
+/// the driver's .
+///
+///
+/// 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.
+///
+/// is true when the native ID is <= 0 — libplctag
+/// returns negative PLCTAG_ERR_* codes on plc_tag_create_ex 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).
+///
+/// The actual DllImport for plc_tag_destroy 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, is a no-op; the finalizer still
+/// runs so the integration is correct as soon as the import is added.
+///
+public sealed class PlcTagHandle : SafeHandle
+{
+ /// Construct an invalid handle placeholder (use once created).
+ public PlcTagHandle() : base(invalidHandleValue: IntPtr.Zero, ownsHandle: true) { }
+
+ private PlcTagHandle(int nativeId) : base(invalidHandleValue: IntPtr.Zero, ownsHandle: true)
+ {
+ SetHandle(new IntPtr(nativeId));
+ }
+
+ /// Handle is invalid when the native ID is zero or negative (libplctag error).
+ public override bool IsInvalid => handle.ToInt32() <= 0;
+
+ /// Integer ID libplctag issued on plc_tag_create_ex.
+ public int NativeId => handle.ToInt32();
+
+ /// Wrap a native tag ID returned from libplctag.
+ public static PlcTagHandle FromNative(int nativeId) => new(nativeId);
+
+ ///
+ /// Destroy the native tag. No-op for PR 2 (the wire P/Invoke lands in PR 3). The base
+ /// machinery still guarantees this runs exactly once per
+ /// handle — either during or during finalization
+ /// if the owner was GC'd without explicit Dispose.
+ ///
+ protected override bool ReleaseHandle()
+ {
+ if (IsInvalid) return true;
+ // PR 3: wire up plc_tag_destroy(handle.ToInt32()) once the DllImport lands.
+ return true;
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
new file mode 100644
index 0000000..fa4995b
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.AbCip
+ ZB.MOM.WW.OtOpcUa.Driver.AbCip
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs
new file mode 100644
index 0000000..a489d2d
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs
@@ -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(
+ () => 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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHostAddressTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHostAddressTests.cs
new file mode 100644
index 0000000..5b8e9c3
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHostAddressTests.cs
@@ -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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs
new file mode 100644
index 0000000..e8fd55a
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs
@@ -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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs
new file mode 100644
index 0000000..6896c0f
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs
@@ -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);
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj
new file mode 100644
index 0000000..56c62cc
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+