using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// /// AB Legacy / PCCC driver — SLC 500, MicroLogix, PLC-5, LogixPccc. Implements /// only at PR 1 time; read / write / discovery / subscribe / probe / /// host-resolver capabilities ship in PRs 2 and 3. /// public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable { private readonly AbLegacyDriverOptions _options; private readonly string _driverInstanceId; private readonly IAbLegacyTagFactory _tagFactory; private readonly ILogger _logger; private readonly PollGroupEngine _poll; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); // Resolves a read/write/subscribe fullReference to a tag definition, bridging the two // authoring models: an authored tag-table entry (by name) OR an equipment tag whose // reference is its raw TagConfig JSON (parsed once via AbLegacyEquipmentTagParser, cached). private readonly EquipmentTagRefResolver _resolver; // volatile: _health is read by GetHealth() on any thread while ReadAsync / WriteAsync / // InitializeAsync write it from worker / poll threads. The record-reference assignment is // atomic on all .NET platforms, but without a memory barrier a reader can see a stale // snapshot indefinitely. volatile enforces acquire/release ordering so GetHealth() always // observes the most recently written value. private volatile DriverHealth _health = new(DriverState.Unknown, null, null); /// /// Occurs when data values change. /// public event EventHandler? OnDataChange; /// /// Occurs when host status changes. /// public event EventHandler? OnHostStatusChanged; /// /// Initializes a new instance of the class. /// /// The driver options. /// The driver instance identifier. /// The tag factory, or null to use the default. /// The logger, or null to use the null logger. public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId, IAbLegacyTagFactory? tagFactory = null, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory(); _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( r => _tagsByName.TryGetValue(r, out var t) ? t : null, r => AbLegacyEquipmentTagParser.TryParse(r, out var d) ? d : null); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); } /// /// Gets the driver instance identifier. /// public string DriverInstanceId => _driverInstanceId; /// /// Gets the driver type. /// public string DriverType => "AbLegacy"; /// /// Initializes the driver asynchronously. /// /// The driver configuration JSON. /// The cancellation token. /// A task representing the asynchronous operation. public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); try { foreach (var device in _options.Devices) { var addr = AbLegacyHostAddress.TryParse(device.HostAddress) ?? throw new InvalidOperationException( $"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'."); var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily); _devices[device.HostAddress] = new DeviceState(addr, device, profile); } foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; // Validate tag types against their device's family profile. Long (32-bit integer) // and String (ST-file) are not supported by all PCCC families; reject them early // so a misconfigured tag fails at init time with a clear message rather than // surfacing an opaque comms error at runtime. foreach (var tag in _options.Tags) { if (!_devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue; var profile = deviceForTag.Profile; if (tag.DataType == AbLegacyDataType.Long && !profile.SupportsLongFile) throw new InvalidOperationException( $"Tag '{tag.Name}' is typed as Long but device '{tag.DeviceHostAddress}' " + $"(family {deviceForTag.Options.PlcFamily}) does not support L-files."); if (tag.DataType == AbLegacyDataType.String && !profile.SupportsStringFile) throw new InvalidOperationException( $"Tag '{tag.Name}' is typed as String but device '{tag.DeviceHostAddress}' " + $"(family {deviceForTag.Options.PlcFamily}) does not support ST-files."); } // Probe loops — one per device when enabled + probe address configured. if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress)) { 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); // Driver.AbLegacy-005 — structured log of the init failure so a field operator sees // the exception in the rolling Serilog file rather than only as a transient Detail // string on DriverHealth. _logger.LogError(ex, "AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId); // Tear down any probe loops and cached state that were created before the failure so // that a caller who catches and abandons (rather than retrying via ReinitializeAsync) // doesn't leave orphaned background tasks, CancellationTokenSources, and libplctag // handles alive. Mirrors the body of ShutdownAsync without awaiting the poll engine // (nothing has been subscribed yet at init time). foreach (var state in _devices.Values) { try { state.ProbeCts?.Cancel(); } catch { } state.ProbeCts?.Dispose(); state.ProbeCts = null; state.DisposeRuntimes(); } _devices.Clear(); _tagsByName.Clear(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses throw; } return Task.CompletedTask; } /// /// Reinitializes the driver asynchronously. /// /// The driver configuration JSON. /// The cancellation token. /// A task representing the asynchronous operation. public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } /// /// Shuts down the driver asynchronously. /// /// The cancellation token. /// A task representing the asynchronous operation. public async Task ShutdownAsync(CancellationToken cancellationToken) { await _poll.DisposeAsync().ConfigureAwait(false); foreach (var state in _devices.Values) { try { state.ProbeCts?.Cancel(); } catch { } state.ProbeCts?.Dispose(); state.ProbeCts = null; state.DisposeRuntimes(); } _devices.Clear(); _tagsByName.Clear(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// /// Gets the driver health status. /// /// The driver health status. public DriverHealth GetHealth() => _health; /// /// Gets the memory footprint of the driver. /// /// The memory footprint in bytes. public long GetMemoryFootprint() => 0; /// /// Flushes optional caches asynchronously. /// /// The cancellation token. /// A task representing the asynchronous operation. public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// /// Gets the device count. /// internal int DeviceCount => _devices.Count; /// /// Gets the device state for the specified host address. /// /// The host address. /// The device state, or null if not found. internal DeviceState? GetDeviceState(string hostAddress) => _devices.TryGetValue(hostAddress, out var s) ? s : null; // ---- IReadable ---- /// /// Reads data values asynchronously. /// /// The full references to read. /// The cancellation token. /// A list of data value snapshots. public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fullReferences); var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; for (var i = 0; i < fullReferences.Count; i++) { var reference = fullReferences[i]; if (!_resolver.TryResolve(reference, out var def)) { results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); continue; } if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); continue; } try { var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); int status; object? value; // Serialise the Read → GetStatus → DecodeValue sequence against the shared // runtime — the server read path and the poll loop both call ReadAsync against // the same cached libplctag Tag handle, which is not concurrency-safe. var opLock = device.GetRuntimeLock(def.Name); await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { await runtime.ReadAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); var parsed = AbLegacyAddress.TryParse(def.Address); // Phase 4c #137 — an ARRAY tag (non-null ArrayLength, ≥1) decodes the whole // contiguous read into a typed CLR array of that count, INCLUDING a 1-element // array (review I-3); a SCALAR tag (null ArrayLength) decodes a single value. // The runtime was created with a matching ElementCount in EnsureTagRuntimeAsync // so its buffer holds all the elements. if (status != 0) value = null; else if (IsArrayTag(def)) value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def)); else value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); } finally { opLock.Release(); } if (status != 0) { results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapLibplctagStatus(status), null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, $"libplctag status {status} reading {reference}"); // Driver.AbLegacy-005 — log the FIRST non-zero libplctag status per device so // a field operator can correlate a comms problem with a structured log // entry. Detail on DriverHealth is overwritten by the very next read; the // log entry persists. Subsequent occurrences on the same device stay quiet so // a permanently-bad PLC doesn't flood the rolling file. if (!device.FirstNonZeroStatusLogged) { device.FirstNonZeroStatusLogged = true; _logger.LogWarning( "AbLegacy non-zero libplctag status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}", _driverInstanceId, def.DeviceHostAddress, reference, status); } continue; } // Healthy read — re-arm the per-device first-failure log so a future non-zero // status logs again rather than being suppressed by an old flag from a prior outage. device.FirstNonZeroStatusLogged = false; results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); _health = new DriverHealth(DriverState.Healthy, now, null); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } return results; } // ---- IWritable ---- /// /// Writes data values asynchronously. /// /// The write requests. /// The cancellation token. /// A list of write results. public async Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(writes); var results = new WriteResult[writes.Count]; for (var i = 0; i < writes.Count; i++) { var w = writes[i]; if (!_resolver.TryResolve(w.FullReference, out var def)) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); continue; } if (!def.Writable) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable); continue; } if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); continue; } try { var parsed = AbLegacyAddress.TryParse(def.Address); // PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N // bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every // bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files // (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements // don't reach this path because they're not Bit typed. NOTE: an Input-image (I) write is sent // to the PLC like any other write — the device drives the input image from physical inputs and // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver. if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit) { results[i] = new WriteResult( await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false)); continue; } var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); int status; // Serialise Encode → Write → GetStatus against the shared runtime — the same // cached Tag handle may be in use by a concurrent ReadAsync or poll loop. var opLock = device.GetRuntimeLock(def.Name); await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); } finally { opLock.Release(); } results[i] = new WriteResult(status == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapLibplctagStatus(status)); } catch (OperationCanceledException) { throw; } catch (NotSupportedException nse) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); } catch (Exception ex) when (ex is FormatException or InvalidCastException) { results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch); } catch (OverflowException) { results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange); } catch (Exception ex) { results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } return results; } // ---- ITagDiscovery ---- /// /// Discovers tags and populates the address space asynchronously. /// /// The address space builder. /// The cancellation token. /// A task representing the asynchronous operation. public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); var root = builder.Folder("AbLegacy", "AbLegacy"); foreach (var device in _options.Devices) { var label = device.DeviceName ?? device.HostAddress; var deviceFolder = root.Folder(device.HostAddress, label); var tagsForDevice = _options.Tags.Where(t => string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); foreach (var tag in tagsForDevice) { // Phase 4c #137 — PCCC data files are inherently arrays of elements (a single N7 // file is up to 256 words). The canonical contract: a tag is an ARRAY ⟺ its // ArrayLength is non-null (≥1, set by the parser only when isArray:true). A tag // with a non-null ArrayLength materialises a 1-D array OPC UA node, INCLUDING a // 1-element array (ArrayLength:1 → a [1] node — review I-3). ArrayDim is clamped // to the PCCC file maximum (AbLegacyArray.MaxElements = 256) so the declared // dimension can never exceed what a single data file holds; ArrayLength == null // (scalar) stays scalar. var isArray = tag.ArrayLength is int len && len >= 1; var arrayDim = isArray ? (uint)Math.Min(tag.ArrayLength!.Value, AbLegacyArray.MaxElements) : (uint?)null; deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( FullName: tag.Name, DriverDataType: tag.DataType.ToDriverDataType(), IsArray: isArray, ArrayDim: arrayDim, SecurityClass: tag.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: tag.WriteIdempotent)); } } return Task.CompletedTask; } // ---- ISubscribable (polling overlay via shared engine) ---- /// /// Subscribes to data changes asynchronously. /// /// The full references to subscribe to. /// The publishing interval. /// The cancellation token. /// A subscription handle. public Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) => Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval)); /// /// Unsubscribes from data changes asynchronously. /// /// The subscription handle. /// The cancellation token. /// A task representing the asynchronous operation. public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { _poll.Unsubscribe(handle); return Task.CompletedTask; } // ---- IHostConnectivityProbe ---- /// /// Gets the host connectivity statuses. /// /// A list of host connectivity statuses. 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 AbLegacyTagCreateParams( Gateway: state.ParsedAddress.Gateway, Port: state.ParsedAddress.Port, CipPath: state.EffectiveCipPath, LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, TagName: _options.Probe.ProbeAddress!, Timeout: _options.Probe.Timeout); IAbLegacyTagRuntime? probeRuntime = null; while (!ct.IsCancellationRequested) { var success = false; try { probeRuntime ??= _tagFactory.Create(probeParams); 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 { 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; } // Driver.AbLegacy-005 — structured log of every probe-driven transition. Operators can // grep the rolling Serilog file for the device address to see when a PLC was last // reachable. Downgrades to Stopped log as Warning; recoveries log as Information. if (newState == HostState.Stopped) _logger.LogWarning( "AbLegacy probe transition. Driver={DriverInstanceId} Device={DeviceHostAddress} From={Old} To={New}", _driverInstanceId, state.Options.HostAddress, old, newState); else _logger.LogInformation( "AbLegacy probe transition. Driver={DriverInstanceId} Device={DeviceHostAddress} From={Old} To={New}", _driverInstanceId, state.Options.HostAddress, old, newState); OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState)); } // ---- IPerCallHostResolver ---- /// /// Map a full reference to the host string used as the resilience-pipeline breaker key. /// Driver.AbLegacy-013 — the contract on requires that /// implementations never throw on an unknown reference. The fallback chain is therefore: /// /// Known tag → its DeviceHostAddress. /// Unknown reference but devices configured → the first device's host address /// (multi-device drivers degrade to single-host behaviour rather than failing). /// Unknown reference and no devices configured → the driver instance id, which /// the dispatch layer treats as the single-host key per the interface /// documentation. Reaching this branch indicates a misconfigured driver (no /// devices) so callers that want to surface that should validate /// before relying on per-tag routing. /// /// /// The full reference to resolve. /// The host address for the reference. public string ResolveHost(string fullReference) { if (_tagsByName.TryGetValue(fullReference, out var def)) return def.DeviceHostAddress; return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId; } /// /// Read-modify-write one bit within a PCCC word. Strips the /N bit suffix to form the /// parent-word address (N7:0/3 → N7:0), creates / reuses a parent-word runtime typed at /// the parent's native width (Int16 for N/I/O/S/A files, Int32 for the 32-bit L file), /// and serialises concurrent bit writers against the same parent via a per-parent /// . The parent word is masked to its native width before the /// bit operation so a sign-extended decode never corrupts the high bits. /// private async Task WriteBitInWordAsync( AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct) { // The parent word width follows the file letter: an L-file element is 32-bit, every // other bit-addressable PCCC file (N/I/O/S/A) is a 16-bit word. bit is already // range-checked by AbLegacyAddress.TryParse (0..15 for 16-bit files, 0..31 for L), so // 1 << bit can never overflow the chosen width here. var isLong = bitAddress.FileLetter == "L"; var parentType = isLong ? AbLegacyDataType.Long : AbLegacyDataType.Int; var widthMask = isLong ? unchecked((int)0xFFFFFFFF) : 0xFFFF; var parentAddress = bitAddress with { BitIndex = null }; var parentName = parentAddress.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 AbLegacyStatusMapper.MapLibplctagStatus(readStatus); // Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a // sign-extended int, so a word with bit 15 set decodes negative. Masking pins the // RMW arithmetic to exactly the parent's bits. var current = Convert.ToInt32(parentRuntime.DecodeValue(parentType, bitIndex: null) ?? 0) & widthMask; var updated = Convert.ToBoolean(value) ? current | (1 << bit) : current & ~(1 << bit); updated &= widthMask; if (isLong) parentRuntime.EncodeValue(AbLegacyDataType.Long, bitIndex: null, updated); else parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated); await parentRuntime.WriteAsync(ct).ConfigureAwait(false); var writeStatus = parentRuntime.GetStatus(); return writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapLibplctagStatus(writeStatus); } finally { rmwLock.Release(); } } private async Task EnsureParentRuntimeAsync( AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct) { // Fast path: runtime already cached. if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing; // Slow path: serialise creation per key so concurrent callers don't each create a // runtime and one of them gets overwritten + leaked. Only one caller initialises; the // others find the entry on the second TryGetValue inside the lock. var creationLock = device.GetCreationLock($"parent:{parentName}"); await creationLock.WaitAsync(ct).ConfigureAwait(false); try { if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing; var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.EffectiveCipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parentName, Timeout: _options.Timeout)); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); } catch { runtime.Dispose(); throw; } device.ParentRuntimes[parentName] = runtime; return runtime; } finally { creationLock.Release(); } } /// /// Phase 4c #137 — whether a tag definition is an ARRAY. The canonical contract: a tag is /// an array ⟺ its is non-null (the parser /// sets it ≥1 only when isArray:true), so a 1-element array (ArrayLength:1) IS an array /// (review I-3). null ArrayLength ⇒ scalar. /// private static bool IsArrayTag(AbLegacyTagDefinition def) => def.ArrayLength is int len && len >= 1; /// /// Phase 4c #137 — the effective libplctag element count for a tag definition: the tag's /// clamped to the PCCC file maximum /// ( = 256) when it is an array (≥1, INCLUDING 1), /// or 1 when the tag is scalar (null ArrayLength). Used both to size the runtime at /// create time and as the element count the read path decodes into an array. /// private static int EffectiveArrayLength(AbLegacyTagDefinition def) => def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1; private async Task EnsureTagRuntimeAsync( DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) { // Fast path: runtime already cached. if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing; // Slow path: serialise creation per tag name so concurrent callers for the same tag // (server read path + poll loop) don't both create a runtime and one gets leaked. var creationLock = device.GetCreationLock($"tag:{def.Name}"); await creationLock.WaitAsync(ct).ConfigureAwait(false); try { if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing; var parsed = AbLegacyAddress.TryParse(def.Address) ?? throw new InvalidOperationException( $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.EffectiveCipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parsed.ToLibplctagName(), Timeout: _options.Timeout, // Phase 4c #137 — multi-element PCCC file read. A multi-element span (ArrayLength // > 1) creates the libplctag tag with that element count so a single read fetches // the whole array from the base address; scalar tags pass 1 and read unchanged. ElementCount: EffectiveArrayLength(def))); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); } catch { runtime.Dispose(); throw; } device.Runtimes[def.Name] = runtime; return runtime; } finally { creationLock.Release(); } } /// /// Driver.AbLegacy-011 — synchronous teardown. Mirrors the body of /// but never wraps the async path in /// .AsTask().GetAwaiter().GetResult(). The poll engine's DisposeAsync is /// drained with a ConfigureAwait(false) awaiter so a captured single-threaded /// can never be the resumption target — the /// classic sync-over-async deadlock cannot occur. Any other awaitable cleanup is /// translated to direct synchronous calls (cancel probe CTSs, dispose runtimes). /// public void Dispose() { // ValueTask.ConfigureAwait(false).GetAwaiter().GetResult() — drains the poll engine // shutdown on the current thread without capturing the SynchronizationContext. The // engine cancels every loop CTS up-front then either completes immediately (no // subscriptions, common case for the test fixture) or awaits a bounded WhenAll on the // already-shutting-down loop tasks. We swallow exceptions so a buggy poll-loop teardown // can't poison the rest of the disposal chain. try { _poll.DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } catch { } foreach (var state in _devices.Values) { try { state.ProbeCts?.Cancel(); } catch { } state.ProbeCts?.Dispose(); state.ProbeCts = null; state.DisposeRuntimes(); } _devices.Clear(); _tagsByName.Clear(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// /// Disposes the driver asynchronously. /// /// A task representing the asynchronous disposal. public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); internal sealed class DeviceState( AbLegacyHostAddress parsedAddress, AbLegacyDeviceOptions options, AbLegacyPlcFamilyProfile profile) { /// /// Gets the parsed host address. /// public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress; /// /// Gets the device options. /// public AbLegacyDeviceOptions Options { get; } = options; /// /// Gets the PLC family profile. /// public AbLegacyPlcFamilyProfile Profile { get; } = profile; /// /// The CIP path to pass to libplctag. When the parsed host address has an empty CIP /// path (e.g. ab://10.0.0.5/), the profile-supplied default is used instead so /// that a SLC 500 misconfigured without an explicit path still gets the required /// 1,0 backplane route. MicroLogix has an empty default by design (direct EIP). /// public string EffectiveCipPath => ParsedAddress.CipPath.Length > 0 ? ParsedAddress.CipPath : Profile.DefaultCipPath; /// /// Per-tag cached runtimes. /// avoids the check-then-act race present on a plain Dictionary: two concurrent /// EnsureTagRuntimeAsync callers for the same key both miss the lookup on a /// plain dict and both create + store a runtime, leaking the loser. Access is guarded /// by a per-key creation semaphore () so exactly one /// runtime is created per tag name. /// public System.Collections.Concurrent.ConcurrentDictionary Runtimes { get; } = new(StringComparer.OrdinalIgnoreCase); /// /// Parent-word runtimes for bit-within-word RMW writes (task #181). Keyed by the /// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a /// single parent runtime for N7:0. /// public System.Collections.Concurrent.ConcurrentDictionary ParentRuntimes { get; } = new(StringComparer.OrdinalIgnoreCase); /// /// Per-key creation locks for and . /// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd /// sequence so that a concurrent caller waits rather than creating a duplicate runtime /// that would be leaked on . /// private readonly System.Collections.Concurrent.ConcurrentDictionary _creationLocks = new(StringComparer.OrdinalIgnoreCase); /// /// Gets or creates the creation lock for the specified key. /// /// The lock key. /// The semaphore slim for the key. public SemaphoreSlim GetCreationLock(string key) => _creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); private readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new(); /// /// Gets or creates the read-modify-write lock for the specified parent name. /// /// The parent name. /// The semaphore slim for the parent. public SemaphoreSlim GetRmwLock(string parentName) => _rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(1, 1)); private readonly System.Collections.Concurrent.ConcurrentDictionary _runtimeLocks = new(); /// /// Per-runtime operation lock. A libplctag Tag handle is not safe for /// concurrent Read/GetStatus/DecodeValue from multiple threads — the server read /// path and the poll loop both call against /// the same cached runtime. Callers hold this lock around the whole /// Read → GetStatus → Decode (or Encode → Write → GetStatus) sequence so a status /// or value is never observed mid-update by another thread. Keyed by tag name, which /// is also the dictionary key. /// /// The tag name. /// The semaphore slim for the tag. public SemaphoreSlim GetRuntimeLock(string tagName) => _runtimeLocks.GetOrAdd(tagName, _ => new SemaphoreSlim(1, 1)); /// /// Gets the probe synchronization lock. /// public object ProbeLock { get; } = new(); /// /// Gets or sets the host state. /// public HostState HostState { get; set; } = HostState.Unknown; /// /// Gets or sets the UTC time when the host state last changed. /// public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow; /// /// Gets or sets the cancellation token source for the probe loop. /// public CancellationTokenSource? ProbeCts { get; set; } /// /// Gets or sets a value indicating whether the probe has been initialized. /// public bool ProbeInitialized { get; set; } /// /// Driver.AbLegacy-005 — per-device latch for the structured "first non-zero /// libplctag status" log. Reset to on a successful read so a /// future outage re-fires the warning rather than being suppressed by a stale flag. /// Concurrent readers on the same device may race the unlatched check + set, but the /// worst case is a small finite number of duplicate warnings at outage onset (one per /// racing reader) — which is preferable to either silently losing the first warning /// or paying lock contention on the hot read path. /// public bool FirstNonZeroStatusLogged { get; set; } /// /// Disposes all cached tag runtimes. /// public void DisposeRuntimes() { foreach (var r in Runtimes.Values) r.Dispose(); Runtimes.Clear(); foreach (var r in ParentRuntimes.Values) r.Dispose(); ParentRuntimes.Clear(); // Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so // ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a // wait handle once contended). foreach (var sem in _rmwLocks.Values) sem.Dispose(); _rmwLocks.Clear(); foreach (var sem in _creationLocks.Values) sem.Dispose(); _creationLocks.Clear(); foreach (var sem in _runtimeLocks.Values) sem.Dispose(); _runtimeLocks.Clear(); } } }