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, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable { private readonly AbCipDriverOptions _options; private readonly string _driverInstanceId; private readonly IAbCipTagFactory _tagFactory; private readonly IAbCipTagEnumeratorFactory _enumeratorFactory; private readonly IAbCipTemplateReaderFactory _templateReaderFactory; private readonly AbCipTemplateCache _templateCache = new(); private readonly PollGroupEngine _poll; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly AbCipAlarmProjection _alarmProjection; private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; public event EventHandler? OnAlarmEvent; /// Internal seam for the alarm projection to raise events through the driver. internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args); public AbCipDriver(AbCipDriverOptions options, string driverInstanceId, IAbCipTagFactory? tagFactory = null, IAbCipTagEnumeratorFactory? enumeratorFactory = null, IAbCipTemplateReaderFactory? templateReaderFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _tagFactory = tagFactory ?? new LibplctagTagFactory(); _enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory(); _templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory(); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval); } /// /// Fetch + cache the shape of a Logix UDT by template instance id. First call reads /// the Template Object off the controller; subsequent calls for the same /// (deviceHostAddress, templateInstanceId) return the cached shape without /// additional network traffic. null on template-not-found / decode failure so /// callers can fall back to declaration-driven UDT fan-out. /// internal async Task FetchUdtShapeAsync( string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken) { var cached = _templateCache.TryGet(deviceHostAddress, templateInstanceId); if (cached is not null) return cached; if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null; var deviceParams = new AbCipTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.ParsedAddress.CipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: $"@udt/{templateInstanceId}", Timeout: _options.Timeout); try { using var reader = _templateReaderFactory.Create(); var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false); var shape = CipTemplateObjectDecoder.Decode(buffer); if (shape is not null) _templateCache.Put(deviceHostAddress, templateInstanceId, shape); return shape; } catch (OperationCanceledException) { throw; } catch { // Template read failure — log via the driver's health surface so operators see it, // but don't propagate since callers should fall back to declaration-driven UDT // semantics rather than failing the whole discovery run. return null; } } /// 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; if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 }) { foreach (var member in tag.Members) { var memberTag = new AbCipTagDefinition( Name: $"{tag.Name}.{member.Name}", DeviceHostAddress: tag.DeviceHostAddress, TagPath: $"{tag.TagPath}.{member.Name}", DataType: member.DataType, Writable: member.Writable, WriteIdempotent: member.WriteIdempotent); _tagsByName[memberTag.Name] = memberTag; } } } // Probe loops — one per device when enabled + a ProbeTagPath is configured. if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath)) { foreach (var state in _devices.Values) { state.ProbeCts = new CancellationTokenSource(); var ct = state.ProbeCts.Token; _ = Task.Run(() => ProbeLoopAsync(state, ct), ct); } } _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 async Task ShutdownAsync(CancellationToken cancellationToken) { await _alarmProjection.DisposeAsync().ConfigureAwait(false); await _poll.DisposeAsync().ConfigureAwait(false); foreach (var state in _devices.Values) { try { state.ProbeCts?.Cancel(); } catch { } state.ProbeCts?.Dispose(); state.ProbeCts = null; state.DisposeHandles(); } _devices.Clear(); _tagsByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } // ---- ISubscribable (polling overlay via shared engine) ---- public Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) => Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval)); public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { _poll.Unsubscribe(handle); return Task.CompletedTask; } // ---- IAlarmSource (ALMD projection, #177) ---- /// /// Subscribe to ALMD alarm transitions on . Each id /// names a declared ALMD UDT tag; the projection polls the tag's InFaulted + /// Severity members at and /// fires on 0→1 (raise) + 1→0 (clear) transitions. /// Feature-gated — when is /// false (the default), returns a handle wrapping a no-op subscription so /// capability negotiation still works; never fires. /// public Task SubscribeAlarmsAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) { if (!_options.EnableAlarmProjection) { var disabled = new AbCipAlarmSubscriptionHandle(0); return Task.FromResult(disabled); } return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken); } public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => _options.EnableAlarmProjection ? _alarmProjection.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask; public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) => _options.EnableAlarmProjection ? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask; // ---- IHostConnectivityProbe ---- public IReadOnlyList GetHostStatuses() => [.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))]; private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct) { var probeParams = new AbCipTagCreateParams( Gateway: state.ParsedAddress.Gateway, Port: state.ParsedAddress.Port, CipPath: state.ParsedAddress.CipPath, LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, TagName: _options.Probe.ProbeTagPath!, Timeout: _options.Probe.Timeout); IAbCipTagRuntime? probeRuntime = null; while (!ct.IsCancellationRequested) { var success = false; try { probeRuntime ??= _tagFactory.Create(probeParams); // Lazy-init on first attempt; re-init after a transport failure has caused the // native handle to be destroyed. if (!state.ProbeInitialized) { await probeRuntime.InitializeAsync(ct).ConfigureAwait(false); state.ProbeInitialized = true; } await probeRuntime.ReadAsync(ct).ConfigureAwait(false); success = probeRuntime.GetStatus() == 0; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch { // Wire / init error — tear down the probe runtime so the next tick re-creates it. try { probeRuntime?.Dispose(); } catch { } probeRuntime = null; state.ProbeInitialized = false; } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } try { probeRuntime?.Dispose(); } catch { } } private void TransitionDeviceState(DeviceState state, HostState newState) { HostState old; lock (state.ProbeLock) { old = state.HostState; if (old == newState) return; state.HostState = newState; state.HostStateChangedUtc = DateTime.UtcNow; } OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState)); } // ---- IPerCallHostResolver ---- /// /// Resolve the device host address for a given tag full-reference. Per plan decision #144 /// the Phase 6.1 resilience pipeline keys its bulkhead + breaker on /// (DriverInstanceId, hostName) so multi-PLC drivers get per-device isolation — /// one dead PLC trips only its own breaker. Unknown references fall back to the /// first configured device's host address rather than throwing — the invoker handles the /// mislookup at the capability level when the actual read returns BadNodeIdUnknown. /// public string ResolveHost(string fullReference) { if (_tagsByName.TryGetValue(fullReference, out var def)) return def.DeviceHostAddress; return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId; } // ---- 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]; // Task #194 — plan the batch: members of the same parent UDT get collapsed into one // whole-UDT read + in-memory member decode; every other reference falls back to the // per-tag path that's been here since PR 3. Planner is a pure function over the // current tag map; BOOL/String/Structure members stay on the fallback path because // declaration-only offsets can't place them under Logix alignment rules. var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName); foreach (var group in plan.Groups) await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false); foreach (var fb in plan.Fallbacks) await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false); return results; } private async Task ReadSingleAsync( AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct) { if (!_tagsByName.TryGetValue(reference, out var def)) { results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now); return; } if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now); return; } try { var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false); await runtime.ReadAsync(ct).ConfigureAwait(false); var status = runtime.GetStatus(); if (status != 0) { results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.MapLibplctagStatus(status), null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, $"libplctag status {status} reading {reference}"); return; } var tagPath = AbCipTagPath.TryParse(def.TagPath); var bitIndex = tagPath?.BitIndex; var value = runtime.DecodeValue(def.DataType, bitIndex); results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now); _health = new DriverHealth(DriverState.Healthy, now, null); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadCommunicationError, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } /// /// Task #194 — perform one whole-UDT read on the parent tag, then decode each /// grouped member from the runtime's buffer at its computed byte offset. A per-group /// failure (parent read raised, non-zero libplctag status, or missing device) stamps /// the mapped fault across every grouped member only — sibling groups + the /// per-tag fallback list are unaffected. /// private async Task ReadGroupAsync( AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct) { var parent = group.ParentDefinition; if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device)) { StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown); return; } try { var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false); await runtime.ReadAsync(ct).ConfigureAwait(false); var status = runtime.GetStatus(); if (status != 0) { var mapped = AbCipStatusMapper.MapLibplctagStatus(status); StampGroupStatus(group, results, now, mapped); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, $"libplctag status {status} reading UDT {group.ParentName}"); return; } foreach (var member in group.Members) { var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null); results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now); } _health = new DriverHealth(DriverState.Healthy, now, null); } catch (OperationCanceledException) { throw; } catch (Exception ex) { StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } private static void StampGroupStatus( AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, uint statusCode) { foreach (var member in group.Members) results[member.OriginalIndex] = new DataValueSnapshot(null, statusCode, null, now); } // ---- 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 || def.SafetyTag) { results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable); continue; } if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); continue; } try { var parsedPath = AbCipTagPath.TryParse(def.TagPath); // BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT // runtime. Dispatching here keeps the normal EncodeValue path clean; the // per-parent lock prevents two concurrent bit writes to the same DINT from // losing one another's update. if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit) { results[i] = new WriteResult( await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken) .ConfigureAwait(false)); if (results[i].StatusCode == AbCipStatusMapper.Good) _health = new DriverHealth(DriverState.Healthy, now, null); continue; } var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); runtime.EncodeValue(def.DataType, parsedPath?.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; } /// /// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel /// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent /// writers against the same parent via a per-parent . /// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern shipped in pass 1 of task #181. /// private async Task WriteBitInDIntAsync( DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct) { var parentPath = bitPath with { BitIndex = null }; var parentName = parentPath.ToLibplctagName(); var rmwLock = device.GetRmwLock(parentName); await rmwLock.WaitAsync(ct).ConfigureAwait(false); try { var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false); await parentRuntime.ReadAsync(ct).ConfigureAwait(false); var readStatus = parentRuntime.GetStatus(); if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus); var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0); var updated = Convert.ToBoolean(value) ? current | (1 << bit) : current & ~(1 << bit); parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated); await parentRuntime.WriteAsync(ct).ConfigureAwait(false); var writeStatus = parentRuntime.GetStatus(); return writeStatus == 0 ? AbCipStatusMapper.Good : AbCipStatusMapper.MapLibplctagStatus(writeStatus); } finally { rmwLock.Release(); } } /// /// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device /// so repeated bit writes against the same DINT share one handle. /// private async Task EnsureParentRuntimeAsync( DeviceState device, string parentTagName, CancellationToken ct) { if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing; var runtime = _tagFactory.Create(new AbCipTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.ParsedAddress.CipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parentTagName, Timeout: _options.Timeout)); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); } catch { runtime.Dispose(); throw; } device.ParentRuntimes[parentTagName] = runtime; return runtime; } /// /// 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. UDT tags with declared // Members fan out into a sub-folder + one Variable per member instead of a single // Structure Variable (Structure has no useful scalar value + member-addressable paths // are what downstream consumers actually want). 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; if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 }) { var udtFolder = deviceFolder.Folder(tag.Name, tag.Name); foreach (var member in tag.Members) { var memberFullName = $"{tag.Name}.{member.Name}"; udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo( FullName: memberFullName, DriverDataType: member.DataType.ToDriverDataType(), IsArray: false, ArrayDim: null, SecurityClass: member.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: member.WriteIdempotent)); } continue; } deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag)); } // Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags // walker (LibplctagTagEnumerator) is the factory default since task #178 shipped, // so leaving the flag off keeps the strict-config path for deployments where only // declared tags should appear. if (_options.EnableControllerBrowse && _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 && !tag.SafetyTag) ? 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 object ProbeLock { get; } = new(); public HostState HostState { get; set; } = HostState.Unknown; public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow; public CancellationTokenSource? ProbeCts { get; set; } public bool ProbeInitialized { get; set; } 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); /// /// Parent-DINT runtimes created on-demand by /// for BOOL-within-DINT RMW writes. Separate from because a /// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT /// parent ("Motor.Flags") used to do the read + write. /// public Dictionary ParentRuntimes { get; } = new(StringComparer.OrdinalIgnoreCase); private readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new(); public SemaphoreSlim GetRmwLock(string parentTagName) => _rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1)); public void DisposeHandles() { foreach (var h in TagHandles.Values) h.Dispose(); TagHandles.Clear(); foreach (var r in Runtimes.Values) r.Dispose(); Runtimes.Clear(); foreach (var r in ParentRuntimes.Values) r.Dispose(); ParentRuntimes.Clear(); } } }