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, IReadable, IWritable, ITagDiscovery, IDisposable, IAsyncDisposable { private readonly AbCipDriverOptions _options; private readonly string _driverInstanceId; private readonly IAbCipTagFactory _tagFactory; private readonly IAbCipTagEnumeratorFactory _enumeratorFactory; private readonly AbCipTemplateCache _templateCache = new(); private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); public AbCipDriver(AbCipDriverOptions options, string driverInstanceId, IAbCipTagFactory? tagFactory = null, IAbCipTagEnumeratorFactory? enumeratorFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _tagFactory = tagFactory ?? new LibplctagTagFactory(); _enumeratorFactory = enumeratorFactory ?? new EmptyAbCipTagEnumeratorFactory(); } /// Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics. internal AbCipTemplateCache TemplateCache => _templateCache; public string DriverInstanceId => _driverInstanceId; public string DriverType => "AbCip"; public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); try { foreach (var device in _options.Devices) { var addr = AbCipHostAddress.TryParse(device.HostAddress) ?? throw new InvalidOperationException( $"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'."); var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily); _devices[device.HostAddress] = new DeviceState(addr, device, profile); } foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) { _health = new DriverHealth(DriverState.Faulted, null, ex.Message); throw; } return Task.CompletedTask; } public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } public Task ShutdownAsync(CancellationToken cancellationToken) { foreach (var state in _devices.Values) state.DisposeHandles(); _devices.Clear(); _tagsByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); return Task.CompletedTask; } // ---- IReadable ---- /// /// Read each fullReference in order. Unknown tags surface as /// BadNodeIdUnknown; libplctag-layer failures map through /// ; any other exception becomes /// BadCommunicationError. The driver health surface is updated per-call so the /// Admin UI sees a tight feedback loop between read failures + the driver's state. /// public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fullReferences); var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; for (var i = 0; i < fullReferences.Count; i++) { var reference = fullReferences[i]; if (!_tagsByName.TryGetValue(reference, out var def)) { results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now); continue; } if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now); continue; } try { var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); await runtime.ReadAsync(cancellationToken).ConfigureAwait(false); var status = runtime.GetStatus(); if (status != 0) { results[i] = new DataValueSnapshot(null, AbCipStatusMapper.MapLibplctagStatus(status), null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, $"libplctag status {status} reading {reference}"); continue; } var tagPath = AbCipTagPath.TryParse(def.TagPath); var bitIndex = tagPath?.BitIndex; var value = runtime.DecodeValue(def.DataType, bitIndex); results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now); _health = new DriverHealth(DriverState.Healthy, now, null); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadCommunicationError, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } return results; } // ---- IWritable ---- /// /// Write each request in order. Writes are NOT auto-retried by the driver — per plan /// decisions #44, #45, #143 the caller opts in via /// and the resilience pipeline (layered above the driver) decides whether to replay. /// Non-writable configurations surface as BadNotWritable; type-conversion failures /// as BadTypeMismatch; transport errors as BadCommunicationError. /// public async Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(writes); var results = new WriteResult[writes.Count]; var now = DateTime.UtcNow; for (var i = 0; i < writes.Count; i++) { var w = writes[i]; if (!_tagsByName.TryGetValue(w.FullReference, out var def)) { results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); continue; } if (!def.Writable) { results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable); continue; } if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); continue; } try { var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); var tagPath = AbCipTagPath.TryParse(def.TagPath); runtime.EncodeValue(def.DataType, tagPath?.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); var status = runtime.GetStatus(); results[i] = new WriteResult(status == 0 ? AbCipStatusMapper.Good : AbCipStatusMapper.MapLibplctagStatus(status)); if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null); } catch (OperationCanceledException) { throw; } catch (NotSupportedException nse) { results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); } catch (FormatException fe) { results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message); } catch (InvalidCastException ice) { results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message); } catch (OverflowException oe) { results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message); } catch (Exception ex) { results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } return results; } /// /// Idempotently materialise the runtime handle for a tag definition. First call creates /// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the /// lifetime of the device. /// private async Task EnsureTagRuntimeAsync( DeviceState device, AbCipTagDefinition def, CancellationToken ct) { if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing; var parsed = AbCipTagPath.TryParse(def.TagPath) ?? throw new InvalidOperationException( $"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'."); var runtime = _tagFactory.Create(new AbCipTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.ParsedAddress.CipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parsed.ToLibplctagName(), Timeout: _options.Timeout)); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); } catch { runtime.Dispose(); throw; } device.Runtimes[def.Name] = runtime; return runtime; } public DriverHealth GetHealth() => _health; /// /// 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) { _templateCache.Clear(); return Task.CompletedTask; } // ---- ITagDiscovery ---- /// /// Stream the driver's tag set into the builder. Pre-declared tags from /// emit first; optionally, the /// walks each device's symbol table and adds /// controller-discovered tags under a Discovered/ sub-folder. System / module / /// routine / task tags are hidden via . /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); var root = builder.Folder("AbCip", "AbCip"); foreach (var device in _options.Devices) { var deviceLabel = device.DeviceName ?? device.HostAddress; var deviceFolder = root.Folder(device.HostAddress, deviceLabel); // Pre-declared tags — always emitted; the primary config path. var preDeclared = _options.Tags.Where(t => string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); foreach (var tag in preDeclared) { if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue; deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag)); } // Controller-discovered tags — optional. Default enumerator returns an empty sequence; // tests + the follow-up real @tags walker plug in via the ctor parameter. if (_devices.TryGetValue(device.HostAddress, out var state)) { using var enumerator = _enumeratorFactory.Create(); var deviceParams = new AbCipTagCreateParams( Gateway: state.ParsedAddress.Gateway, Port: state.ParsedAddress.Port, CipPath: state.ParsedAddress.CipPath, LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, TagName: "@tags", Timeout: _options.Timeout); IAddressSpaceBuilder? discoveredFolder = null; await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken) .ConfigureAwait(false)) { if (discovered.IsSystemTag) continue; if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue; discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered"); var fullName = discovered.ProgramScope is null ? discovered.Name : $"Program:{discovered.ProgramScope}.{discovered.Name}"; discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo( FullName: fullName, DriverDataType: discovered.DataType.ToDriverDataType(), IsArray: false, ArrayDim: null, SecurityClass: discovered.ReadOnly ? SecurityClassification.ViewOnly : SecurityClassification.Operate, IsHistorized: false, IsAlarm: false, WriteIdempotent: false)); } } } } private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new( FullName: tag.Name, DriverDataType: tag.DataType.ToDriverDataType(), IsArray: false, ArrayDim: null, SecurityClass: tag.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: tag.WriteIdempotent); /// 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); /// /// Per-tag runtime handles owned by this device. One entry per configured tag is /// created lazily on first read (see ). /// public Dictionary Runtimes { get; } = new(StringComparer.OrdinalIgnoreCase); public void DisposeHandles() { foreach (var h in TagHandles.Values) h.Dispose(); TagHandles.Clear(); foreach (var r in Runtimes.Values) r.Dispose(); Runtimes.Clear(); } } }