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