using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; 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, ISkippedTagReporter, IDisposable, IAsyncDisposable { /// Authored tags this driver could not build into its poll table (Gitea #541). private readonly SkippedTagLedger _skippedTags = new(); /// public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot(); /// Mutable so can adopt a re-parsed config on reinitialize /// (#516). Only ever written on the init path, before any reader/session uses it. private 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); // v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper. private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal); // Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the // authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss). 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; // Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed // Transport in a reinit config is inert; a deploy recreates the instance instead. _transport = AbLegacyTransportResolver.Resolve(options); _transportDescription = AbLegacyTransportResolver.Describe(options.Transport); _tagFactory = tagFactory ?? (_transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory()); _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), onError: HandlePollError, backoffCap: PollBackoffCap, // The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this // closure IS the production consumer of its overrun signal. onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1, new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.AbLegacy), new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId))); } /// The tag factory chosen at construction; exposed so tests can pin transport selection. internal IAbLegacyTagFactory TagFactory => _tagFactory; /// The effective transport resolved at construction (see ). /// Describes the OPTION only: an injected tagFactory (tests) is used regardless. internal AbLegacyTransport EffectiveTransport => _transport; // The transport chosen at construction (see the ctor); logged at every init so an operator can tell // from the node log which stack serves this instance. private readonly AbLegacyTransport _transport; // Configured-vs-effective rendering of _transport for the init log, e.g. "Libplctag (default)". private readonly string _transportDescription; // Set once the explicit-Native-on-a-limited-family Warning has been logged for this instance. private bool _limitedFamilyWarned; /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); /// /// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and /// degrades to preserving LastSuccessfulRead. /// Never downgrades a state (a stronger, config-level signal). /// /// The exception caught by the poll engine. internal void HandlePollError(Exception ex) { _logger.LogWarning(ex, "AbLegacy poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); var current = _health; if (current.State != DriverState.Faulted) _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message); } /// public string DriverInstanceId => _driverInstanceId; /// public string DriverType => "AbLegacy"; /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always /// passes a populated document; some unit tests pass "{}" or an empty string to exercise /// lifecycle shape without a config — those keep the constructor-supplied options. private static bool HasConfigBody(string? driverConfigJson) { if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; var trimmed = driverConfigJson.Trim(); return trimmed is not "{}" and not "[]"; } /// public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); _logger.LogInformation( "AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}", _driverInstanceId, _transportDescription, _tagFactory.GetType().Name); try { // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED // with, so an operator's edit is discarded while the deployment still seals green. if (HasConfigBody(driverConfigJson)) _options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); // Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly // even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance — // not on every init, which includes every comms-loss reconnect retry (Task 22 review r1 M4). if (!_limitedFamilyWarned && _transport == AbLegacyTransport.Native && AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited) { _limitedFamilyWarned = true; _logger.LogWarning( "AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " + "support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " + "\"transport\": \"Libplctag\" to use the fallback stack.", _driverInstanceId, string.Join(", ", limited)); } 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); } // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig // blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the // def (it lives on the RawTagEntry, not inside the blob), and its DeviceName is resolved to // the owning device's host so the existing DeviceHostAddress-keyed routing continues to work. // A mapper miss is skipped (logged), never thrown — a bad tag must not fail the whole init. _skippedTags.BeginRebuild(_options.RawTags.Count); foreach (var entry in _options.RawTags) { if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) { _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition"); _logger.LogWarning( "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", _driverInstanceId, entry.RawPath); continue; } _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent, DeviceHostAddress = ResolveDeviceHost(entry.DeviceName), }; } _skippedTags.Commit(); // 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 _tagsByRawPath.Values) { 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; state.ProbeTask = 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); _logger.LogError(ex, "AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId); // Release cached state created before the failure so a caller who catches and abandons // (rather than retrying via ReinitializeAsync) doesn't leave CancellationTokenSources and // libplctag handles alive. No probe-loop join here: the loops start as the LAST statement of // the try and nothing after that can throw, so a failure never has a running loop to join. foreach (var state in _devices.Values) { state.ProbeCts?.Dispose(); state.ProbeCts = null; state.ProbeTask = null; state.DisposeRuntimes(); } _devices.Clear(); _tagsByRawPath.Clear(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses throw; } return Task.CompletedTask; } /// public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } /// Upper bound on how long shutdown waits for the probe loops to observe cancellation. internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5); /// /// Synchronous bounded join of the (already-cancelled) probe loops for the sync teardown paths. /// The loop body uses ConfigureAwait(false) throughout, so blocking here cannot deadlock. /// private void JoinProbeLoopsSync() { var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToArray(); if (loops.Length == 0) return; try { Task.WaitAll(loops, LoopJoinTimeout); } catch (AggregateException) { /* loops observe cancellation; a fault is not actionable at teardown */ } } /// public async Task ShutdownAsync(CancellationToken cancellationToken) { await _poll.DisposeAsync().ConfigureAwait(false); // STAB-6: cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes. // The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could // outlive shutdown and keep a libplctag handle alive past DisposeRuntimes. foreach (var state in _devices.Values) try { state.ProbeCts?.Cancel(); } catch { } var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList(); if (loops.Count > 0) { var all = Task.WhenAll(loops); var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); if (finished != all) _logger.LogWarning( "AbLegacy shutdown: {Count} probe loop(s) did not stop within {Timeout}; proceeding with teardown. Driver={DriverInstanceId}", loops.Count, LoopJoinTimeout, _driverInstanceId); } foreach (var state in _devices.Values) { state.ProbeCts?.Dispose(); state.ProbeCts = null; state.ProbeTask = null; state.DisposeRuntimes(); } _devices.Clear(); _tagsByRawPath.Clear(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// public DriverHealth GetHealth() => _health; /// /// 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 ---- /// 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 { // Indirect addresses (N7:[N7:0]) parse but are not resolved at runtime (#247) — answer the // refusal before a runtime is created for a name neither transport can encode. if (AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily) is { IsIndirect: true }) { results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); continue; } 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, device.Options.PlcFamily); 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) { // Evict the stale handle so the next call re-creates it (mirrors AbCip). EvictRuntime(device, def.Name); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(status), null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, $"transport status {status} reading {reference}"); if (!device.FirstNonZeroStatusLogged) { device.FirstNonZeroStatusLogged = true; _logger.LogWarning( "AbLegacy non-zero transport 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 (AbTransportException ex) when (IsLocalRefusal(ex)) { // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read, #571). // Not a comms fault: BadNotSupported, keep the handle, leave health alone. results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); } catch (Exception ex) { // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip). EvictRuntime(device, def.Name); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } return results; } // ---- IWritable ---- /// 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, device.Options.PlcFamily); // Indirect addressing is parse-only (#247): refuse before any runtime or RMW read is created. if (parsed is { IsIndirect: true }) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); continue; } // A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN, // or any PLC-5 write) is answered BEFORE a runtime is created. Creating one first costs an init // READ, and against a device lacking that file the read failure surfaced as a comms fault — // BadCommunicationError, evicted handle, Degraded health — instead of the refusal (#593 follow-up). // Ahead of the bit-RMW branch too, which would otherwise read the parent word first. if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is not null) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); continue; } // 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 // (T4:0.DN, even when authored Bit-typed) never reach this path: AbLegacyAddress.TryParse rejects a // sub-element combined with a /N bit index, so their BitIndex is always null. 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(); } if (status != 0) { // Evict the stale handle so the next call re-creates it (mirrors AbCip). EvictRuntime(device, def.Name); results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); } else { results[i] = new WriteResult(AbLegacyStatusMapper.Good); } } 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 (AbTransportException ex) when (IsLocalRefusal(ex)) { // The runtime refused the write before the wire — a native status-bit sub-element write or a // PLC-5 write (#571). Not a comms fault: BadNotSupported, keep the handle, leave health alone. results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); } catch (Exception ex) { // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip). EvictRuntime(device, def.Name); results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } return results; } // ---- ITagDiscovery ---- /// public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; /// 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 = _tagsByRawPath.Values.Where(t => string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); foreach (var tag in tagsForDevice) { 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) ---- /// public Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) => Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval)); /// public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { // Async teardown: the engine drains the in-flight poll without blocking the caller's // thread — this runs inside a DriverInstanceActor mailbox turn. await _poll.UnsubscribeAsync(handle).ConfigureAwait(false); } // ---- IHostConnectivityProbe ---- /// public IReadOnlyList GetHostStatuses() => [.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))]; private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct) { var probeParams = new 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; } 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)); } /// /// Resolve the a raw tag carries to the host-address key of /// the device it should route to. The AB Legacy driver is multi-device and keys its /// _devices table by HostAddress, so this maps the tag's owning device name to that /// key: it matches the name against each configured by its /// first, then by HostAddress as a fallback, /// and returns the matched device's HostAddress. When the name matches nothing but the /// driver has exactly one device, that sole device is used (single-device deployments never need /// to name their tag's device). Otherwise returns empty — the tag is unroutable and its /// read/write surfaces BadNodeIdUnknown. /// /// /// is already the per-device DeviceConfig /// (DriverDeviceConfigMerger rebuilds it at deploy time), so routing matches the delivered /// against _options.Devices and reuses that entry's /// HostAddress as the connection key. The HostAddress fallback is deliberate: the Client CLI /// passes the host as the DeviceName. /// /// The owning device name delivered on the . /// The matched device's HostAddress; empty when unroutable. private string ResolveDeviceHost(string deviceName) { if (!string.IsNullOrEmpty(deviceName)) { foreach (var d in _options.Devices) if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase)) return d.HostAddress; foreach (var d in _options.Devices) if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase)) return d.HostAddress; } // Best-available fallback: a single-device driver routes every tag to its sole device. return _options.Devices.Count == 1 ? _options.Devices[0].HostAddress : ""; } // ---- IPerCallHostResolver ---- /// /// Resolve a reference to the device host that keys its per-host resilience /// (bulkhead / circuit breaker). CONV-4: routes through /// so an equipment-tag reference (raw TagConfig /// JSON) resolves to its OWN authored device rather than the first-device fallback. The /// resolver caches parses, so this adds no per-call cost after first use. /// /// The tag reference (authored name or equipment-tag JSON). /// The device host key. public string ResolveHost(string fullReference) { if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress)) 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.MapStatus(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.MapStatus(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)); await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct) .ConfigureAwait(false); return runtime; } finally { creationLock.Release(); } } /// /// 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; /// /// 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; /// /// True for a deterministic refusal of the operation itself () — the /// native runtime raises it BEFORE anything reaches the wire (status-bit writes, PLC-5 non-N reads and /// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the /// device's reachability, so it must not evict the handle or degrade driver health. /// internal static bool IsLocalRefusal(Exception ex) => ex is AbTransportException { Status: AbStatus.Unsupported }; /// /// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is /// still KEPT: the refusal is deterministic and per-operation, so caching it avoids re-creating — and /// re-leasing the pooled device connection — on every poll. Any other failure disposes it, as before. /// private static async Task InitializeOrKeepRefusedAsync(IAbLegacyTagRuntime runtime, Action keep, CancellationToken ct) { try { await runtime.InitializeAsync(ct).ConfigureAwait(false); } catch (Exception ex) when (IsLocalRefusal(ex) && runtime is NativeAbLegacyTagRuntime) { keep(); throw; } catch { runtime.Dispose(); throw; } keep(); } /// The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so /// the refusal is asked about exactly the runtime a write would otherwise create. private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) => new(Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.EffectiveCipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parsed.ToLibplctagName(), Timeout: _options.Timeout, ElementCount: EffectiveArrayLength(def)); 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, device.Options.PlcFamily) ?? throw new InvalidOperationException( $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}."); var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed)); await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct) .ConfigureAwait(false); return runtime; } finally { creationLock.Release(); } } /// /// Evict the runtime for from the device's cache and dispose it so /// the next read/write call re-creates and re-initializes a fresh libplctag handle. Called /// after a non-zero libplctag status or a transport exception on a read/write — a non-zero /// status can mean the controller dropped the connection or the handle became permanently /// invalid (e.g. after a PLC download), and reusing it would return the same failure forever. /// Mirrors AbCipDriver.EvictRuntime and the probe loop's recreate-on-failure behaviour /// (previously AbLegacy leaned only on the probe loop + libplctag's internal recovery, so a /// data-path fault recovered more slowly than in AbCip). /// /// The device whose runtime cache holds the stale handle. /// The tag name key of the runtime to evict. private static void EvictRuntime(DeviceState device, string tagName) { if (device.Runtimes.TryRemove(tagName, out var stale)) { try { stale.Dispose(); } catch { /* best-effort */ } } } /// /// 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 { } JoinProbeLoopsSync(); foreach (var state in _devices.Values) { state.ProbeCts?.Dispose(); state.ProbeCts = null; state.ProbeTask = null; state.DisposeRuntimes(); } _devices.Clear(); _tagsByRawPath.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. 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 the probe loop task, retained so shutdown can join it (STAB-6). /// public Task? ProbeTask { get; set; } /// /// Gets or sets a value indicating whether the probe has been initialized. /// public bool ProbeInitialized { get; set; } /// /// 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(); } } }