using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; 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, IDriverControl, 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 readonly SemaphoreSlim _discoverySemaphore = new(1, 1); 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; // UDT template reads target the @udt/{id} pseudo-tag, which the controller already // serves via a logical-segment path of its own. Force Symbolic addressing so we don't // overlay the driver's Logical mode on top — libplctag knows how to dereference the // pseudo-tag directly. 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, ConnectionSize: device.ConnectionSize, AddressingMode: AddressingMode.Symbolic); 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); // PR abcip-3.1 — validate the optional ConnectionSize override before stamping // the device. The Kepware-supported range [500..4002] is what the libplctag // ControlLogix driver supports; anything outside that fails the Forward Open at // runtime, so we reject it loudly at config time instead. if (device.ConnectionSize is int explicitSize) { if (explicitSize < AbCipConnectionSize.Min || explicitSize > AbCipConnectionSize.Max) throw new InvalidOperationException( $"AbCip device '{device.HostAddress}' has ConnectionSize {explicitSize} outside the supported range " + $"[{AbCipConnectionSize.Min}..{AbCipConnectionSize.Max}]."); // Legacy-firmware warning: families whose profile default is 504 (CompactLogix // narrow cap, also where v19-and-earlier ControlLogix lives) can't actually // raise their CIP buffer above 511 bytes — the controller rejects the Forward // Open. Ship the override anyway so newer firmware can use it, but flag the // mismatch so operators see it in the warning sink. if (explicitSize > AbCipConnectionSize.LegacyFirmwareCap && profile.DefaultConnectionSize <= AbCipConnectionSize.LegacyFirmwareCap) { _options.OnWarning?.Invoke( $"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' uses a narrow-buffer profile " + $"(default ConnectionSize {profile.DefaultConnectionSize}); the configured ConnectionSize {explicitSize} " + $"exceeds the {AbCipConnectionSize.LegacyFirmwareCap}-byte legacy-firmware cap and will fail the " + "Forward Open on v19-and-earlier ControlLogix or 5069-L1/L2/L3 CompactLogix firmware."); } } // PR abcip-3.2 — resolve AddressingMode at the device level. Auto → Symbolic // until a future PR adds a real auto-detection heuristic; Logical against an // unsupported family falls back to Symbolic + emits a warning so misconfiguration // does not fault the driver. var resolvedAddressing = ResolveAddressingMode(device, profile); // PR abcip-3.3 — resolve ReadStrategy at the device level. User-forced MultiPacket // against a non-packing family (Micro800 et al) falls back to WholeUdt with a // warning. Auto stays as-is — the planner re-evaluates per-batch using the // device's MultiPacketSparsityThreshold. var resolvedReadStrategy = ResolveReadStrategy(device, profile); _devices[device.HostAddress] = new DeviceState( addr, device, profile, resolvedAddressing, resolvedReadStrategy); } // Pre-declared tags first; L5K imports fill in only the names not already covered // (operators can override an imported entry by re-declaring it under Tags). var declaredNames = new HashSet( _options.Tags.Select(t => t.Name), StringComparer.OrdinalIgnoreCase); var allTags = new List(_options.Tags); foreach (var import in _options.L5kImports) { MergeImport( deviceHost: import.DeviceHostAddress, filePath: import.FilePath, inlineText: import.InlineText, namePrefix: import.NamePrefix, parse: L5kParser.Parse, formatLabel: "L5K", declaredNames: declaredNames, allTags: allTags); } foreach (var import in _options.L5xImports) { MergeImport( deviceHost: import.DeviceHostAddress, filePath: import.FilePath, inlineText: import.InlineText, namePrefix: import.NamePrefix, parse: L5xParser.Parse, formatLabel: "L5X", declaredNames: declaredNames, allTags: allTags); } foreach (var import in _options.CsvImports) { MergeCsvImport(import, declaredNames, allTags); } foreach (var tag in allTags) { _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, StringLength: member.StringLength); _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; } /// /// PR abcip-3.2 — resolve against the /// family profile. resolves to /// today (the same behaviour every previous build had); a future PR will plumb a real /// auto-detection heuristic and document it in docs/drivers/AbCip-Performance.md. /// against a family whose profile sets /// = false (Micro800, /// SLC500, PLC5) falls back to with a warning so /// the operator sees the misconfiguration in the log without the driver faulting. /// private AddressingMode ResolveAddressingMode(AbCipDeviceOptions device, AbCipPlcFamilyProfile profile) { switch (device.AddressingMode) { case AddressingMode.Logical: if (!profile.SupportsLogicalAddressing) { _options.OnWarning?.Invoke( $"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' does not support " + "Logical (instance-ID) addressing — its CIP firmware lacks Symbol Object class 0x6B. " + "Falling back to Symbolic addressing for this device."); return AddressingMode.Symbolic; } return AddressingMode.Logical; case AddressingMode.Symbolic: return AddressingMode.Symbolic; case AddressingMode.Auto: default: // Future heuristic point — for now Auto = Symbolic so the addressing toggle is // explicit + every existing deployment keeps the historical wire behaviour. return AddressingMode.Symbolic; } } /// /// PR abcip-3.3 — resolve against the /// family profile. against a family whose profile /// sets = false /// (Micro800 today; SLC500 / PLC5 when those profiles ship) falls back to /// with a warning so the operator sees the /// misconfiguration in the log without the driver faulting. /// stays as-is — the planner re-evaluates the choice per-batch from the device's /// ; we still need the /// resolution step here so a future PR can cap Auto at WholeUdt on non-packing families /// in one place rather than scattering the check across the read path. /// private ReadStrategy ResolveReadStrategy(AbCipDeviceOptions device, AbCipPlcFamilyProfile profile) { switch (device.ReadStrategy) { case ReadStrategy.MultiPacket: if (!profile.SupportsRequestPacking) { _options.OnWarning?.Invoke( $"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' does not support " + "Multi-Service Packet request packing — its CIP firmware lacks the 0x0A service. " + "Falling back to WholeUdt read strategy for this device."); return ReadStrategy.WholeUdt; } return ReadStrategy.MultiPacket; case ReadStrategy.WholeUdt: return ReadStrategy.WholeUdt; case ReadStrategy.Auto: default: // Auto on a non-packing family stays Auto here so the planner's per-batch // heuristic still runs; the heuristic itself never picks MultiPacket against a // device whose AddressingMode-style guard tripped — but for ReadStrategy the guard // lives in the device-init resolution above (user-forced MultiPacket → WholeUdt). // For Auto, the planner consults device.Profile.SupportsRequestPacking before // emitting MultiPacket so non-packing families always read WholeUdt under Auto. return ReadStrategy.Auto; } } /// /// Shared L5K / L5X import path — keeps source-format selection (parser delegate) the /// only behavioural axis between the two formats. Adds the parser's tags to /// while skipping any name already covered by an earlier /// declaration or import (declared > L5K > L5X precedence falls out from call order). /// private static void MergeImport( string deviceHost, string? filePath, string? inlineText, string namePrefix, Func parse, string formatLabel, HashSet declaredNames, List allTags) { if (string.IsNullOrWhiteSpace(deviceHost)) throw new InvalidOperationException( $"AbCip {formatLabel} import is missing DeviceHostAddress — every imported tag needs a target device."); IL5kSource? src = null; if (!string.IsNullOrEmpty(filePath)) src = new FileL5kSource(filePath); else if (!string.IsNullOrEmpty(inlineText)) src = new StringL5kSource(inlineText); if (src is null) return; var doc = parse(src); var ingest = new L5kIngest { DefaultDeviceHostAddress = deviceHost, NamePrefix = namePrefix, }; var result = ingest.Ingest(doc); foreach (var importedTag in result.Tags) { if (declaredNames.Contains(importedTag.Name)) continue; allTags.Add(importedTag); declaredNames.Add(importedTag.Name); } } /// /// CSV-import variant of . The CSV path produces /// records directly (no intermediate document) so we /// can't share the L5K/L5X parser-delegate signature. Merge semantics are identical: /// a name already covered by a declaration or an earlier import is left untouched so /// the precedence chain (declared > L5K > L5X > CSV) holds. /// private static void MergeCsvImport( AbCipCsvImportOptions import, HashSet declaredNames, List allTags) { if (string.IsNullOrWhiteSpace(import.DeviceHostAddress)) throw new InvalidOperationException( "AbCip CSV import is missing DeviceHostAddress — every imported tag needs a target device."); string? csvText = null; if (!string.IsNullOrEmpty(import.FilePath)) csvText = System.IO.File.ReadAllText(import.FilePath); else if (!string.IsNullOrEmpty(import.InlineText)) csvText = import.InlineText; if (csvText is null) return; var importer = new CsvTagImporter { DefaultDeviceHostAddress = import.DeviceHostAddress, NamePrefix = import.NamePrefix, }; var result = importer.Import(csvText); foreach (var tag in result.Tags) { if (declaredNames.Contains(tag.Name)) continue; allTags.Add(tag); declaredNames.Add(tag.Name); } } 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) { // Probe handles always run in Symbolic mode regardless of the device's resolved // AddressingMode — the probe tag (e.g. @raw_cpu_type) is a system pseudo-tag, not a // user symbol that appears in the @tags walk, so there is no instance ID to feed // libplctag. Hard-coding Symbolic here keeps the probe loop independent of the symbol // walk + matches the legacy behaviour even on Logical-mode devices. 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, ConnectionSize: state.ConnectionSize, AddressingMode: AddressingMode.Symbolic); 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]; // PR abcip-3.2 — first-read symbol-walk for Logical-mode devices. Each device that // resolved to Logical fires one @tags walk; subsequent reads consult the cached // name → instance-id map. Devices in Symbolic mode skip the walk entirely. await EnsureLogicalMappingsAsync(fullReferences, cancellationToken).ConfigureAwait(false); // 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. // // PR abcip-3.3 — dispatch by device's resolved ReadStrategy: // WholeUdt — every group goes through the whole-UDT planner (task #194 default). // MultiPacket — every group goes through the multi-packet planner; one read per // subscribed member, bundled per parent. // Auto — per-group heuristic on subscribedMembers / totalMembers. await ExecuteReadPlanAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false); return results; } /// /// PR abcip-3.3 — strategy-aware dispatch wrapper around the WholeUdt + MultiPacket /// planners. Both planners produce the same shape of "groups + per-tag fallbacks" so /// fallbacks always run through ; only the group shape /// differs. Auto resolves per-group: members of the same parent UDT either flow through /// (one whole-UDT read) or /// (per-member reads bundled per parent). /// private async Task ExecuteReadPlanAsync( IReadOnlyList fullReferences, DataValueSnapshot[] results, DateTime now, CancellationToken ct) { // First pass — segregate references by parent UDT vs everything-else, identical to the // shape both planners produce; we can then route each parent group through the chosen // planner. Reuse AbCipUdtReadPlanner.Build for the WholeUdt+Auto path because it already // demotes single-member groups to fallback, and the MultiPacket planner does NOT demote // (sparse reads of one member are still a win on the wire). var wholeUdtPlan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName); var multiPacketPlan = AbCipMultiPacketReadPlanner.Build(fullReferences, _tagsByName); // Determine per-parent strategy. WholeUdt planner emits a Group only when ≥2 members of // the same parent are subscribed; MultiPacket planner emits a Batch for every parent // touched. Index multiPacket batches by parent name so we can co-route them with the // WholeUdt grouping decisions. var multiPacketByParent = new Dictionary( multiPacketPlan.Batches.Count, StringComparer.OrdinalIgnoreCase); foreach (var b in multiPacketPlan.Batches) multiPacketByParent[b.ParentName] = b; // Treat each parent that survived the WholeUdt planner as the candidate for whole-UDT // dispatch; per-strategy routing decides whether it goes WholeUdt or MultiPacket. var routedParents = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var group in wholeUdtPlan.Groups) { _devices.TryGetValue(group.ParentDefinition.DeviceHostAddress, out var device); var strategy = ChooseEffectiveStrategy(device, group); if (strategy == ReadStrategy.MultiPacket && multiPacketByParent.TryGetValue(group.ParentName, out var mpBatch)) { if (device is not null) Interlocked.Increment(ref device.MultiPacketGroupsExecuted); await ReadMultiPacketBatchAsync(mpBatch, results, now, ct).ConfigureAwait(false); } else { if (device is not null) Interlocked.Increment(ref device.WholeUdtGroupsExecuted); await ReadGroupAsync(group, results, now, ct).ConfigureAwait(false); } routedParents.Add(group.ParentName); } // Per-tag fallbacks from the WholeUdt planner are the union of (a) tags with no UDT // parent, (b) UDT members where the parent had only one subscribed member, and (c) // unknown references. We re-route singletons through MultiPacket too when the device // strategy is MultiPacket, because a 1-of-50 read is exactly the sparse case the planner // is designed for. foreach (var fb in wholeUdtPlan.Fallbacks) { // Is this a UDT-member fallback that the MultiPacket planner can route? if (multiPacketPlan.Batches.Count > 0 && _tagsByName.TryGetValue(fb.Reference, out var def)) { var dot = fb.Reference.IndexOf('.'); if (dot > 0 && dot < fb.Reference.Length - 1) { var parentName = fb.Reference[..dot]; if (!routedParents.Contains(parentName) && multiPacketByParent.TryGetValue(parentName, out var mpBatch) && _devices.TryGetValue(def.DeviceHostAddress, out var device)) { // Singleton parent — Auto picks based on the same heuristic; MultiPacket // wins by default because the WholeUdt planner already demoted single // members under the assumption that one whole-UDT read is no cheaper // than one member read. With explicit MultiPacket the answer flips. var strategy = ChooseEffectiveStrategyForSingleton(device, mpBatch); if (strategy == ReadStrategy.MultiPacket) { Interlocked.Increment(ref device.MultiPacketGroupsExecuted); await ReadMultiPacketBatchAsync(mpBatch, results, now, ct).ConfigureAwait(false); routedParents.Add(parentName); continue; } } } } await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, ct).ConfigureAwait(false); } } /// /// PR abcip-3.3 — pick the effective for one parent UDT group. /// + are /// forced explicitly (already family-compat-checked at device init). /// consults the planner heuristic on subscribed-member fraction, but only when the /// family supports request packing — non-packing families always read WholeUdt regardless /// of sparsity because they have no Multi-Service-Packet path on the wire. /// private static ReadStrategy ChooseEffectiveStrategy(DeviceState? device, AbCipUdtReadGroup group) { if (device is null) return ReadStrategy.WholeUdt; switch (device.ReadStrategy) { case ReadStrategy.MultiPacket: return ReadStrategy.MultiPacket; case ReadStrategy.WholeUdt: return ReadStrategy.WholeUdt; case ReadStrategy.Auto: default: if (!device.Profile.SupportsRequestPacking) return ReadStrategy.WholeUdt; var totalMembers = group.ParentDefinition.Members?.Count ?? 0; return AbCipMultiPacketReadPlanner.ChooseStrategyForGroup( subscribedMembers: group.Members.Count, totalMembers: totalMembers, threshold: device.Options.MultiPacketSparsityThreshold); } } /// /// PR abcip-3.3 — strategy pick for a singleton (one-member) UDT batch. Only relevant /// when the device strategy is explicit or Auto /// produces a MultiPacket result; otherwise the per-tag fallback path runs as before. /// private static ReadStrategy ChooseEffectiveStrategyForSingleton( DeviceState device, AbCipMultiPacketReadBatch batch) { switch (device.ReadStrategy) { case ReadStrategy.MultiPacket: return ReadStrategy.MultiPacket; case ReadStrategy.WholeUdt: return ReadStrategy.WholeUdt; case ReadStrategy.Auto: default: if (!device.Profile.SupportsRequestPacking) return ReadStrategy.WholeUdt; var totalMembers = batch.ParentDefinition.Members?.Count ?? 0; return AbCipMultiPacketReadPlanner.ChooseStrategyForGroup( subscribedMembers: batch.Members.Count, totalMembers: totalMembers, threshold: device.Options.MultiPacketSparsityThreshold); } } /// /// PR abcip-3.3 — execute a Multi-Service-Packet batch. Today this issues one libplctag /// read per member (the same N reads the per-tag fallback path does), keyed on the /// batch's parent so the diagnostic counters track which strategy ran. Wire-level /// Multi-Service-Packet bundling depends on the libplctag .NET wrapper exposing the /// 0x0A service explicitly — same wrapper limitation as PR abcip-3.1's connection_size /// and PR abcip-3.2's instance-ID addressing. The planner's grouping is still /// load-bearing because it gives the runtime the correct plan when an upstream wrapper /// release exposes the bundling primitive. /// private async Task ReadMultiPacketBatchAsync( AbCipMultiPacketReadBatch batch, DataValueSnapshot[] results, DateTime now, CancellationToken ct) { var parent = batch.ParentDefinition; if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device)) { foreach (var m in batch.Members) results[m.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now); return; } foreach (var member in batch.Members) { var memberFullName = member.Definition.Name; var fb = new AbCipUdtReadFallback(member.OriginalIndex, memberFullName); await ReadSingleAsync(fb, memberFullName, results, now, ct).ConfigureAwait(false); } } /// /// PR abcip-3.2 — for each Logical-mode device touched by this read batch, fire the /// one-time @tags symbol-table walk + populate . /// Subsequent reads short-circuit on ; /// concurrent first reads on the same device serialise on /// so the walk is dispatched once even under /// parallel load. /// /// /// The walk uses the same as discovery — /// reading @tags + decoding the Symbol Object response. Failures are intentionally /// swallowed: an empty map after the walk-attempted flag flips means subsequent reads /// fall back to Symbolic addressing on the wire (libplctag's default), which is the /// same wire behaviour every previous build had. Driver health is not faulted because a /// tag-list-walk failure does not actually block reads. /// private async Task EnsureLogicalMappingsAsync( IReadOnlyList fullReferences, CancellationToken ct) { // Find the unique set of Logical-mode devices the batch touches. Most batches touch // one device, so the HashSet is a small allocation. HashSet? pending = null; foreach (var reference in fullReferences) { if (!_tagsByName.TryGetValue(reference, out var def)) continue; if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue; if (device.AddressingMode != AddressingMode.Logical) continue; if (device.LogicalWalkComplete) continue; (pending ??= []).Add(device); } if (pending is null) return; foreach (var device in pending) { await device.LogicalWalkLock.WaitAsync(ct).ConfigureAwait(false); try { if (device.LogicalWalkComplete) continue; try { using var enumerator = _enumeratorFactory.Create(); var deviceParams = new AbCipTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.ParsedAddress.CipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: "@tags", Timeout: _options.Timeout, ConnectionSize: device.ConnectionSize, AddressingMode: AddressingMode.Symbolic); // Symbol Object instance IDs aren't surfaced on AbCipDiscoveredTag yet — the // record carries Name / ProgramScope / DataType / ReadOnly. We populate the // map keyed on the Logix tag path the driver uses internally; the libplctag // wrapper limitation (no public ConnectionSize / cip_addr knob in 1.5.x) // means the value side stays unmapped for now and the runtime degrades to // Symbolic on the wire. The map's presence is still load-bearing: it's // observable from tests + future-proofs the driver for when an upstream // wrapper release exposes the IDs through the enumerator + Tag attribute. await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, ct) .ConfigureAwait(false)) { if (discovered.IsSystemTag) continue; if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue; var fullName = discovered.ProgramScope is null ? discovered.Name : $"Program:{discovered.ProgramScope}.{discovered.Name}"; // No instance ID in the current discovered-tag shape; record an // entry so the runtime knows the symbol is part of the Logical // resolution pass (the map's presence influences slice + parent-DINT // creation). 0 is reserved by CIP for "not assigned" so it's a safe // sentinel that the runtime's reflection guard treats as "missing". device.LogicalInstanceMap[fullName] = 0u; } } catch (OperationCanceledException) { throw; } catch { // Walk failure is non-fatal — the driver keeps Logical mode set but // every per-tag handle ends up using Symbolic addressing on the wire. } finally { device.LogicalWalkComplete = true; } } finally { device.LogicalWalkLock.Release(); } } } 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; } // PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to // AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one // Rockwell array read; the contiguous buffer is decoded at element stride into a // single snapshot whose Value is an object[] of the N elements. var parsedPath = AbCipTagPath.TryParse(def.TagPath); if (parsedPath?.Slice is not null) { await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false); 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 bitIndex = parsedPath?.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); } } /// /// PR abcip-1.3 — slice read path. Builds an from the /// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so /// repeat reads reuse the same libplctag handle), issues one PLC array read, and /// decodes the contiguous buffer into object?[] at element stride. Unsupported /// element types fall back to . /// private async Task ReadSliceAsync( AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath, DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct) { // PR abcip-3.2 — slice reads piggyback on the device's resolved addressing mode. Logical // mode looks up the parent array tag's instance ID via the @tags map; null-fallback to // Symbolic when the array isn't in the map (e.g. @tags walk hasn't populated the entry). uint? sliceLogicalId = null; if (device.AddressingMode == AddressingMode.Logical && device.LogicalInstanceMap.TryGetValue(def.TagPath, out var sliceId)) { sliceLogicalId = sliceId; } var baseParams = new AbCipTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.ParsedAddress.CipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parsedPath.ToLibplctagName(), Timeout: _options.Timeout, ConnectionSize: device.ConnectionSize, AddressingMode: device.AddressingMode, LogicalInstanceId: sliceLogicalId); var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams); if (plan is null) { results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now); return; } try { var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, 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 slice {def.Name}"); return; } var values = AbCipArrayReadPlanner.Decode(plan, runtime); results[fb.OriginalIndex] = new DataValueSnapshot(values, 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); } } /// /// Idempotently materialise a slice-read runtime. Slice runtimes share the device's /// dict keyed by the tag's full name so repeated /// reads reuse the same libplctag handle without re-creating the native tag every poll. /// private async Task EnsureSliceRuntimeAsync( DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct) { if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing; var runtime = _tagFactory.Create(createParams); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); } catch { runtime.Dispose(); throw; } device.Runtimes[tagName] = runtime; return runtime; } /// /// 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 the batch. 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. /// /// /// PR abcip-1.4 — multi-tag write packing. Writes are grouped by device via /// . Devices whose family /// is true dispatch /// their packable writes concurrently so libplctag's native scheduler can coalesce them /// onto one CIP Multi-Service Packet (0x0A) per round-trip; Micro800 (no packing) still /// issues writes one-at-a-time. BOOL-within-DINT writes always go through the RMW path /// under a per-parent semaphore, regardless of the family flag, because two concurrent /// RMWs on the same DINT could lose one another's update. Per-tag StatusCodes are /// preserved in the caller's input order on partial failures. /// public async Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(writes); var results = new WriteResult[writes.Count]; var plans = AbCipMultiWritePlanner.Build( writes, _tagsByName, _devices, reportPreflight: (idx, code) => results[idx] = new WriteResult(code)); foreach (var plan in plans) { if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device)) { foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); continue; } // Bit-RMW writes always serialise per-parent — never packed. foreach (var entry in plan.BitRmw) results[entry.OriginalIndex] = new WriteResult( await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false)); if (plan.Packable.Count == 0) continue; if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1) { // Concurrent dispatch — libplctag's native scheduler packs same-connection writes // into one Multi-Service Packet when the family supports it. var tasks = new Task<(int idx, uint code)>[plan.Packable.Count]; for (var i = 0; i < plan.Packable.Count; i++) { var entry = plan.Packable[i]; tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken); } var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false); foreach (var (idx, code) in outcomes) results[idx] = new WriteResult(code); } else { // Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential. foreach (var entry in plan.Packable) { var code = await ExecutePackableWriteAsync(device, entry, cancellationToken) .ConfigureAwait(false); results[entry.OriginalIndex] = new WriteResult(code.code); } } } return results; } /// /// Execute one packable write — encode the value into the per-tag runtime, flush, and /// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the /// pre-1.4 per-tag loop so callers see no behaviour change for individual writes. /// private async Task<(int idx, uint code)> ExecutePackableWriteAsync( DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct) { var def = entry.Definition; var w = entry.Request; var now = DateTime.UtcNow; try { var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false); runtime.EncodeValue(def.DataType, entry.ParsedPath?.BitIndex, w.Value); await runtime.WriteAsync(ct).ConfigureAwait(false); var status = runtime.GetStatus(); if (status == 0) { _health = new DriverHealth(DriverState.Healthy, now, null); return (entry.OriginalIndex, AbCipStatusMapper.Good); } return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status)); } catch (OperationCanceledException) { throw; } catch (NotSupportedException nse) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); return (entry.OriginalIndex, AbCipStatusMapper.BadNotSupported); } catch (FormatException fe) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message); return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch); } catch (InvalidCastException ice) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message); return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch); } catch (OverflowException oe) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message); return (entry.OriginalIndex, AbCipStatusMapper.BadOutOfRange); } catch (Exception ex) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); return (entry.OriginalIndex, AbCipStatusMapper.BadCommunicationError); } } /// /// Execute one BOOL-within-DINT write through , with /// the same exception-mapping fan-out as the pre-1.4 per-tag loop. Bit RMWs cannot be /// packed because two concurrent writes against the same parent DINT would race their /// read-modify-write windows. /// private async Task ExecuteBitRmwWriteAsync( DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct) { try { var bit = entry.ParsedPath!.BitIndex!.Value; var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct) .ConfigureAwait(false); if (code == AbCipStatusMapper.Good) _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); return code; } catch (OperationCanceledException) { throw; } catch (NotSupportedException nse) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); return AbCipStatusMapper.BadNotSupported; } catch (FormatException fe) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message); return AbCipStatusMapper.BadTypeMismatch; } catch (InvalidCastException ice) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message); return AbCipStatusMapper.BadTypeMismatch; } catch (OverflowException oe) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message); return AbCipStatusMapper.BadOutOfRange; } catch (Exception ex) { _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); return AbCipStatusMapper.BadCommunicationError; } } /// /// 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; // PR abcip-3.2 — parent-DINT runtimes follow the device's resolved addressing mode so // BOOL-in-DINT RMW reads/writes share Logical-mode benefits when the parent has been // mapped. When the parent isn't in the @tags map (or Symbolic is the resolved mode), // libplctag falls back to ASCII addressing transparently. uint? parentLogicalId = null; if (device.AddressingMode == AddressingMode.Logical && device.LogicalInstanceMap.TryGetValue(parentTagName, out var pid)) { parentLogicalId = pid; } 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, ConnectionSize: device.ConnectionSize, AddressingMode: device.AddressingMode, LogicalInstanceId: parentLogicalId)); 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}'."); // PR abcip-3.2 — Logical-mode devices look up the controller-assigned Symbol Object // instance ID for this tag from the one-time @tags walk; missing entries fall back to // Symbolic addressing for this handle (the runtime detects LogicalInstanceId == null). uint? logicalId = null; if (device.AddressingMode == AddressingMode.Logical && device.LogicalInstanceMap.TryGetValue(def.TagPath, out var resolvedId)) { logicalId = resolvedId; } 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, StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null, ConnectionSize: device.ConnectionSize, AddressingMode: device.AddressingMode, LogicalInstanceId: logicalId)); 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); await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false); } finally { _discoverySemaphore.Release(); } } /// /// PR abcip-2.5 — operator-triggered rebrowse. Drops the cached UDT template shapes so /// the next read re-fetches them from the controller, then runs the same enumerator /// walk + builder fan-out that drives. Serialised against /// other rebrowse / discovery passes via so two /// concurrent triggers don't double-issue the @tags read. /// public async Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Stale template shapes can outlive a controller program-download, so a rebrowse // is the natural moment to drop them; subsequent UDT reads re-populate on demand. _templateCache.Clear(); await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false); } finally { _discoverySemaphore.Release(); } } private async Task DiscoverCoreAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { 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); // PR abcip-2.6 — AOI-aware fan-out. When any member carries a non-Local // AoiQualifier the tag is treated as an AOI instance: Input / Output / InOut // members get grouped under sub-folders (Inputs/, Outputs/, InOut/) so the // browse tree visually matches Studio 5000's AOI parameter tabs. Plain UDT // tags (every member Local) retain the pre-2.6 flat layout under the parent // folder so existing browse paths stay stable. var hasDirectional = tag.Members.Any(m => m.AoiQualifier != AoiQualifier.Local); IAddressSpaceBuilder? inputsFolder = null; IAddressSpaceBuilder? outputsFolder = null; IAddressSpaceBuilder? inOutFolder = null; foreach (var member in tag.Members) { var parentFolder = udtFolder; if (hasDirectional) { parentFolder = member.AoiQualifier switch { AoiQualifier.Input => inputsFolder ??= udtFolder.Folder("Inputs", "Inputs"), AoiQualifier.Output => outputsFolder ??= udtFolder.Folder("Outputs", "Outputs"), AoiQualifier.InOut => inOutFolder ??= udtFolder.Folder("InOut", "InOut"), _ => udtFolder, // Local stays at the AOI root }; } var memberFullName = $"{tag.Name}.{member.Name}"; parentFolder.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, Description: member.Description)); } 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(); // The @tags walker reads the controller's Symbol Object class 0x6B directly + // does not need the driver's per-tag addressing-mode plumbing — it already // operates on instance-ID semantics by definition. Pin Symbolic so libplctag // doesn't try to layer Logical-mode attributes on top of @tags. 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, ConnectionSize: state.ConnectionSize, AddressingMode: AddressingMode.Symbolic); 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, Description: tag.Description); /// 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); _discoverySemaphore.Dispose(); } /// /// 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, AddressingMode resolvedAddressingMode, ReadStrategy resolvedReadStrategy) { public AbCipHostAddress ParsedAddress { get; } = parsedAddress; public AbCipDeviceOptions Options { get; } = options; public AbCipPlcFamilyProfile Profile { get; } = profile; /// /// PR abcip-3.1 — effective CIP connection size for this device. Per-device /// override wins; otherwise the /// family profile's /// (4002 / 504 / 488 depending on family). Threaded through every /// the driver builds so libplctag receives a /// consistent buffer-size hint across read / write / probe / discovery handles. /// public int ConnectionSize { get; } = options.ConnectionSize ?? profile.DefaultConnectionSize; /// /// PR abcip-3.2 — concrete addressing mode in effect for this device. Always /// or /// after has run; Auto + /// unsupported-family fall-back collapse to Symbolic at config time so the /// read/write hot paths can branch on a single value. /// public AddressingMode AddressingMode { get; } = resolvedAddressingMode; /// /// PR abcip-3.3 — resolved read strategy for this device. /// or mean "always pick this for every UDT /// batch on this device." means "let the planner /// pick per-batch using ." /// User-forced MultiPacket against a non-packing family (Micro800 et al) was already /// collapsed to WholeUdt at time, so the /// read hot path can branch on this single value without re-checking family compat. /// public ReadStrategy ReadStrategy { get; } = resolvedReadStrategy; /// PR abcip-3.3 — count of UDT groups dispatched through the WholeUdt path on /// this device. Surfaced for tests + a future driver-diagnostics RPC. public int WholeUdtGroupsExecuted; /// PR abcip-3.3 — count of UDT groups dispatched through the MultiPacket path /// on this device. Surfaced for tests + a future driver-diagnostics RPC. public int MultiPacketGroupsExecuted; /// /// PR abcip-3.2 — name → Symbol Object instance ID map populated by the one-time /// @tags walk that fires on the first read on a Logical-mode device. Empty /// for Symbolic-mode devices + before the walk completes; consulted by /// when materialising the per-tag /// runtime so libplctag receives the resolved instance ID directly. Case-insensitive /// because Logix tag names are case-insensitive at the controller. /// public Dictionary LogicalInstanceMap { get; } = new(StringComparer.OrdinalIgnoreCase); /// /// PR abcip-3.2 — guarded inside /// so the symbol-table walk fires exactly once per device. Setting this to /// true means "walk attempted" — the walk's success / failure is captured by /// the contents of ; an empty map after the flag /// flips means the walk yielded nothing and subsequent reads keep falling back to /// Symbolic addressing on the wire. /// public bool LogicalWalkComplete { get; set; } /// Serialises concurrent first-read symbol-walks against this device. public SemaphoreSlim LogicalWalkLock { get; } = new(1, 1); 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(); } } }