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 PollGroupEngine _poll; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); /// /// PR 8 — per-tag last published (value, status) cache for the deadband filter. /// Layered on top of because the engine's change-detection /// is binary (publish on any value/status diff). Cleared on /// so a reconnect doesn't suppress legitimate post-reconnect updates against stale state. /// Keyed by full reference (== tag name) — matches the engine's own LastValues key /// space. /// private readonly Dictionary _lastPublished = new(StringComparer.OrdinalIgnoreCase); private readonly object _lastPublishedLock = new(); /// /// PR ablegacy-10 / #253 — per-device diagnostic counters surfaced as /// _Diagnostics/<host>/<name> read-only variables. Updated on /// every call (success, failure, retry) so HMIs can bind /// directly without a separate diagnostics RPC. /// private readonly AbLegacyDiagnosticTags _diagnosticTags = new(); /// Test seam — exposes the live diagnostic-tag source so unit tests can poke counters. internal AbLegacyDiagnosticTags DiagnosticTags => _diagnosticTags; private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId, IAbLegacyTagFactory? tagFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory(); _poll = new PollGroupEngine( reader: ReadAsync, onChange: DispatchPollChange); } /// /// PR 8 — wraps the change callback with a per-tag /// deadband filter. Booleans bypass (publish on every edge); strings + status changes /// always publish; numerics pass only when |new - prev| meets the configured /// absolute and / or percent deadband. First-seen always publishes. /// private void DispatchPollChange(ISubscriptionHandle handle, string tagRef, DataValueSnapshot snapshot) { if (!ShouldPublish(tagRef, snapshot)) return; OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)); } /// /// PR 8 — deadband decision for one new sample. Updates the per-tag last-published /// cache when the publish goes through so the next sample compares against the actual /// emitted value (not every polled value). /// internal bool ShouldPublish(string tagRef, DataValueSnapshot snapshot) { // Tags absent from config (impossible via the engine path, defensive against callers // that exercise the dispatch logic in isolation) bypass the filter. var hasTag = _tagsByName.TryGetValue(tagRef, out var def); lock (_lastPublishedLock) { var firstSeen = !_lastPublished.TryGetValue(tagRef, out var prev); // First-seen, status change, or no tag config: always publish. if (firstSeen || prev.StatusCode != snapshot.StatusCode || !hasTag) { _lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode); return true; } // No deadband configured -> defer to PollGroupEngine's value-equality decision // (the engine already filtered to "different from last engine snapshot" before we // got here, so any sample reaching this point is a legitimate change). if (def!.AbsoluteDeadband is null && def.PercentDeadband is null) { _lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode); return true; } // Booleans + strings + non-numerics: deadband is meaningless; publish whenever the // value differs from the last published one. if (!TryAsDouble(snapshot.Value, out var newD) || !TryAsDouble(prev.Value, out var prevD)) { if (Equals(prev.Value, snapshot.Value)) return false; _lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode); return true; } var delta = Math.Abs(newD - prevD); var absPass = def.AbsoluteDeadband is double abs && delta >= abs; // Percent: |prev| == 0 short-circuits to "always publish on any change" — avoids // div-by-zero and matches Kepware's documented behaviour. bool percentPass; if (def.PercentDeadband is double pct) { if (prevD == 0) percentPass = delta > 0; else percentPass = delta >= Math.Abs(prevD * pct / 100.0); } else percentPass = false; // Logical OR — either filter triggering is enough. Matches the spec note in the // PR plan ("Both deadbands set -> either triggers, Kepware semantics"). var pass = (def.AbsoluteDeadband is not null && absPass) || (def.PercentDeadband is not null && percentPass); if (!pass) return false; _lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode); return true; } } private static bool TryAsDouble(object? value, out double result) { switch (value) { case null: result = 0; return false; case bool: result = 0; return false; // booleans use the equality fast path case string: result = 0; return false; case Array: result = 0; return false; case IConvertible conv: try { result = conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture); return true; } catch { result = 0; return false; } default: result = 0; return false; } } public string DriverInstanceId => _driverInstanceId; public string DriverType => "AbLegacy"; 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); // PR ablegacy-10 / #253 — pre-allocate the diagnostic-counter slot so the // first read against this device sees zero-initialised counters instead of // having to lazy-add on the request path. _diagnosticTags.EnsureDevice(device.HostAddress); } foreach (var tag in _options.Tags) { // PR ablegacy-10 / #253 — collision rejection. User-config tags must not // shadow the seven driver-emitted diagnostic names, and they must not live // under the synthetic _Diagnostics/ folder. Both shapes would silently // never resolve at read time (the diagnostics short-circuit wins) so we // reject up front with a clear error rather than letting the operator wonder // why their tag returns BadNodeIdUnknown. if (AbLegacyDiagnosticTags.IsDiagnosticAddress(tag.Address)) { throw new InvalidOperationException( $"AbLegacy tag '{tag.Name}' has Address '{tag.Address}' under the reserved " + $"'_Diagnostics/' namespace; that prefix is owned by the auto-emitted " + $"diagnostic counters. Choose a different address."); } if (AbLegacyDiagnosticTags.IsReservedName(tag.Name)) { throw new InvalidOperationException( $"AbLegacy tag name '{tag.Name}' collides with a reserved diagnostic " + $"counter ({string.Join(", ", AbLegacyDiagnosticTags.DiagnosticTagNames)}). " + $"Rename the tag."); } _tagsByName[tag.Name] = tag; } // 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); throw; } return Task.CompletedTask; } public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); // PR ablegacy-10 / #253 — counters were dropped along with the device map when // ShutdownAsync called ResetAll; the InitializeAsync below re-EnsureDevice's each // host so the freshly registered counters start at zero. Belt-and-braces clear // here in case a downstream override of either method skips the cycle. _diagnosticTags.ResetAll(); await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } 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(); // PR 8 — clear the deadband last-published cache so a ReinitializeAsync (or a // reconnect-driven shutdown) doesn't suppress the very first post-reconnect sample // by comparing it against pre-disconnect state. lock (_lastPublishedLock) { _lastPublished.Clear(); } // PR ablegacy-10 / #253 — drop every per-device counter so a reinit / redeploy // starts with a clean diagnostic surface. Reset (per-host) is also exposed so a // future "clear counters" admin RPC can reach in without a full shutdown. _diagnosticTags.ResetAll(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } public DriverHealth GetHealth() => _health; public long GetMemoryFootprint() => 0; public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; internal int DeviceCount => _devices.Count; internal DeviceState? GetDeviceState(string hostAddress) => _devices.TryGetValue(hostAddress, out var s) ? s : null; /// /// PR 9 — per-device timeout precedence: device-level override wins, otherwise the /// driver-wide default. Probe loop has its own timeout knob via /// but still falls back to the per-device /// value when the probe override is absent (handled at the call site). /// internal TimeSpan ResolveTimeout(DeviceState device) => device.Options.Timeout ?? _options.Timeout; /// /// PR 9 — per-device retry count: device-level override wins, otherwise the driver-wide /// default, otherwise zero (single attempt). The driver-wide default itself is /// null by default so a vanilla AbLegacy config still issues exactly one read per /// reference, matching pre-PR-9 behaviour. /// internal int ResolveRetries(DeviceState device) => device.Options.Retries ?? _options.Retries ?? 0; // ---- 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]; // PR ablegacy-10 / #253 — synthetic _Diagnostics// reference; // serve from the in-process counter store and skip the libplctag dispatch // entirely. Diagnostic reads do NOT bump RequestCount — they're driver-local // observability, not field traffic, and counting them would make the // counter chase its own tail when a subscription polls at 1 Hz. if (AbLegacyDiagnosticTags.IsDiagnosticAddress(reference)) { if (_diagnosticTags.TryRead(reference, out var diagValue)) { results[i] = new DataValueSnapshot(diagValue, AbLegacyStatusMapper.Good, now, now); } else { results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); } continue; } if (!_tagsByName.TryGetValue(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; } // PR ablegacy-10 / #253 — bump RequestCount once per non-diagnostic reference, // success or fail. The retry loop below counts retries through RecordRetry so // operators can spot a flapping link via the RetryCount counter without us // double-counting the original attempt as a retry. _diagnosticTags.RecordRequest(def.DeviceHostAddress); // PR 9 — per-device retry loop: on transient BadCommunicationError (libplctag throw // OR a non-zero status that maps to BadCommunicationError) retry up to N times. A // terminal mapped status (e.g. BadNodeIdUnknown for a missing PLC tag, BadTypeMismatch // for a decoder mismatch) is surfaced as-is — retrying won't fix it. Cancellation // always rethrows. var retries = ResolveRetries(device); DataValueSnapshot? snapshot = null; for (var attempt = 0; attempt <= retries; attempt++) { // PR ablegacy-10 / #253 — second + later attempts count as retries for the // diagnostic counter. Increment BEFORE the work so a thrown exception still // shows up in the retry tally. if (attempt > 0) _diagnosticTags.RecordRetry(def.DeviceHostAddress); try { var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); await runtime.ReadAsync(cancellationToken).ConfigureAwait(false); var status = runtime.GetStatus(); if (status != 0) { var mappedStatus = AbLegacyStatusMapper.MapLibplctagStatus(status); // Transient: BadCommunicationError → eligible for retry. if (mappedStatus == AbLegacyStatusMapper.BadCommunicationError && attempt < retries) { continue; } // PR ablegacy-10 / #253 — terminal failure: bump the error counter // + record the libplctag status. CommFailure tally rolls only when // the mapped status is BadCommunicationError so operators see a // single "wire fell off" counter independent of other error codes. _diagnosticTags.RecordError( def.DeviceHostAddress, status, $"libplctag status {status} reading {reference}", commFailure: mappedStatus == AbLegacyStatusMapper.BadCommunicationError); snapshot = new DataValueSnapshot(null, mappedStatus, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, $"libplctag status {status} reading {reference}"); break; } var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); // PR 7 — array contiguous block. Decode N consecutive elements via the runtime's // per-index accessor and box the result as a typed .NET array. The parser has // already rejected array+bit and array+sub-element combinations, so the array // path can ignore the bit/sub-element decoders entirely. int arrayCount; if (parsed is not null && (def.ArrayLength is not null || (parsed.ArrayCount ?? 1) > 1)) { arrayCount = ResolveElementCount(def, parsed); } else arrayCount = 1; if (arrayCount > 1) { var arr = DecodeArrayAs(runtime, def.DataType, arrayCount); snapshot = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now); _health = new DriverHealth(DriverState.Healthy, now, null); // PR ablegacy-10 / #253 — successful array read. _diagnosticTags.RecordResponse(def.DeviceHostAddress); break; } // Timer/Counter/Control status bits route through GetBit at the parent-word // address — translate the .DN/.EN/etc. sub-element to its standard bit position // and pass it down to the runtime as a synthetic bitIndex. var decodeBit = parsed?.BitIndex ?? AbLegacyDataTypeExtensions.StatusBitIndex(def.DataType, parsed?.SubElement); var value = runtime.DecodeValue(def.DataType, decodeBit); snapshot = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); _health = new DriverHealth(DriverState.Healthy, now, null); // PR ablegacy-10 / #253 — successful scalar / sub-element / bit read. _diagnosticTags.RecordResponse(def.DeviceHostAddress); break; } catch (OperationCanceledException) { throw; } catch (Exception ex) { // Transient — exhaust retries before reporting BadCommunicationError. if (attempt < retries) continue; // PR ablegacy-10 / #253 — exhausted retries surface as a comm // failure. Pass libplctag status 0 because the throw means we never // got a status code back, but record the exception message so the // LastErrorMessage diagnostic still has actionable text. _diagnosticTags.RecordError( def.DeviceHostAddress, libplctagStatus: 0, errorMessage: ex.Message, commFailure: true); snapshot = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } results[i] = snapshot ?? new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); } 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 (!_tagsByName.TryGetValue(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); // Timer/Counter/Control PLC-set status bits (DN, TT, OV, UN, FD, ER, EM, UL, // IN) are read-only — the PLC sets them; any client write would be silently // overwritten on the next scan. Reject up front with BadNotWritable. if (AbLegacyDataTypeExtensions.IsPlcSetStatusBit(def.DataType, parsed?.SubElement)) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable); continue; } // PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel // parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises // concurrent bit writers. Applies to N-file bit-in-word (N7:0/3) + B-file bits // (B3:0/0). T/C/R sub-elements don't hit this path because they're not Bit typed. if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit && parsed.FileLetter is not "B" and not "I" and not "O") { results[i] = new WriteResult( await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false)); continue; } var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); var status = runtime.GetStatus(); 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 (ArgumentOutOfRangeException) { // ST-file string writes exceeding the 82-byte fixed element. Surfaces from // LibplctagLegacyTagRuntime.EncodeValue's length guard; mapped to BadOutOfRange so // the OPC UA client sees a clean rejection rather than a silent truncation. 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 ---- 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) { var parsed = AbLegacyAddress.TryParse(tag.Address, device.PlcFamily); // Timer/Counter/Control sub-elements (.DN/.EN/.TT/.PRE/.ACC/etc.) refine the // base element's Int32 to Boolean for status bits and Int32 for word members. var effectiveType = AbLegacyDataTypeExtensions.EffectiveDriverDataType( tag.DataType, parsed?.SubElement); var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit( tag.DataType, parsed?.SubElement); // PR 7 — array contiguous-block tags advertise IsArray + ArrayDim so the OPC UA // generic node-manager builds a 1-D array variable. ArrayLength on the tag // definition wins over the parsed `,N` / `[N]` suffix; both null = scalar. var arrayLen = tag.ArrayLength ?? (parsed?.ArrayCount is int n && n > 1 ? n : (int?)null); deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( FullName: tag.Name, DriverDataType: effectiveType, IsArray: arrayLen is int al && al > 1, ArrayDim: arrayLen is int al2 && al2 > 1 ? (uint)al2 : null, SecurityClass: tag.Writable && !plcSetBit ? SecurityClassification.Operate : SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: tag.WriteIdempotent)); } // PR ablegacy-10 / #253 — auto-emit the per-device _Diagnostics folder + its // seven read-only counter variables. FullName carries the synthetic // _Diagnostics// reference so ReadAsync can short-circuit before // EnsureTagRuntimeAsync. Mirrors AbCip's _System/ pattern from abcip-4.3. EmitDiagnosticsFolder(deviceFolder, device.HostAddress); } return Task.CompletedTask; } /// /// PR ablegacy-10 / #253 — emit the per-device _Diagnostics folder + its /// seven read-only diagnostic-counter variables. The FullName on each /// variable encodes the owning device's host address /// (_Diagnostics/<host>/<name>) so the read path can route to /// without a separate registry. Names /// + types stay in lockstep with . /// private static void EmitDiagnosticsFolder(IAddressSpaceBuilder deviceFolder, string deviceHostAddress) { var diag = deviceFolder.Folder("_Diagnostics", "_Diagnostics"); EmitDiagnosticVariable(diag, deviceHostAddress, "RequestCount", DriverDataType.Int64, "Total ReadAsync requests issued against this device (one per non-diagnostic reference per call, success or fail)."); EmitDiagnosticVariable(diag, deviceHostAddress, "ResponseCount", DriverDataType.Int64, "Successful read responses for this device."); EmitDiagnosticVariable(diag, deviceHostAddress, "ErrorCount", DriverDataType.Int64, "Failed read responses for this device (any non-Good status)."); EmitDiagnosticVariable(diag, deviceHostAddress, "RetryCount", DriverDataType.Int64, "Retry attempts beyond the first per the AbLegacy retry loop. Bumps once per extra attempt — a single read with two retries adds two."); EmitDiagnosticVariable(diag, deviceHostAddress, "LastErrorCode", DriverDataType.Int32, "Most recent libplctag status code on a failed read; 0 when no error has been seen since the last reset."); EmitDiagnosticVariable(diag, deviceHostAddress, "LastErrorMessage", DriverDataType.String, "Most recent libplctag error message on a failed read; empty when no error has been seen since the last reset."); EmitDiagnosticVariable(diag, deviceHostAddress, "CommFailures", DriverDataType.Int64, "Count of read failures mapped to BadCommunicationError. Spans transient libplctag throws + retried-out chains so operators see a single 'wire fell off' counter."); } private static void EmitDiagnosticVariable( IAddressSpaceBuilder folder, string deviceHostAddress, string name, DriverDataType type, string description) { var fullName = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{deviceHostAddress}/{name}"; folder.Variable(name, name, new DriverAttributeInfo( FullName: fullName, DriverDataType: type, IsArray: false, ArrayDim: null, // Read-only — operators can't write the diagnostic surface from a SCADA template. SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: false, Description: description)); } // ---- 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; } // ---- 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) { // PR 9 — per-device timeout wins over the probe's own timeout. Slow chassis (SLC 5/01 // RS-232 ~5 s round-trip) need their per-device override to flow into the probe too, // otherwise the probe times out before the device ever has a chance to respond. var probeTimeout = state.Options.Timeout ?? _options.Probe.Timeout; var probeParams = new AbLegacyTagCreateParams( Gateway: state.ParsedAddress.Gateway, Port: state.ParsedAddress.Port, CipPath: state.ParsedAddress.CipPath, LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, TagName: _options.Probe.ProbeAddress!, Timeout: probeTimeout); 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; } OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState)); } // ---- IPerCallHostResolver ---- 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 N-file word. Strips the /N bit suffix to /// form the parent-word address (N7:0/3 → N7:0), creates / reuses a parent-word runtime /// typed as Int16, serialises concurrent bit writers against the same parent via a /// per-parent . /// private async Task WriteBitInWordAsync( AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct) { 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); var current = Convert.ToInt32(parentRuntime.DecodeValue(AbLegacyDataType.Int, bitIndex: null) ?? 0); var updated = Convert.ToBoolean(value) ? current | (1 << bit) : current & ~(1 << bit); 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) { if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing; var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.ParsedAddress.CipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parentName, Timeout: ResolveTimeout(device))); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); } catch { runtime.Dispose(); throw; } device.ParentRuntimes[parentName] = runtime; return runtime; } private async Task EnsureTagRuntimeAsync( DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) { if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing; var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily) ?? throw new InvalidOperationException( $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); // TODO(#247): libplctag's PCCC text decoder does not natively accept the bracket-form // indirect address. Resolving N7:[N7:0] requires reading the inner address first, then // rewriting the tag name with the resolved word number, then issuing the actual read. // For now we surface a clear runtime error rather than letting libplctag fail with an // opaque parser error. if (parsed.IsIndirect) throw new NotSupportedException( $"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented."); // PR 7 — resolve the effective array length: explicit ArrayLength override on the tag // definition wins over the parsed `,N` / `[N]` suffix. ElementCount of 1 means // single-element scalar (libplctag's default); >1 triggers the contiguous-block path. var elementCount = ResolveElementCount(def, parsed); // Drop the parsed array suffix from the libplctag tag name when ArrayLength overrides // it — libplctag would otherwise read the parsed length, not the override. var tagName = (def.ArrayLength is int && parsed.ArrayCount is not null) ? (parsed with { ArrayCount = null }).ToLibplctagName() : parsed.ToLibplctagName(); var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.ParsedAddress.CipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: tagName, Timeout: ResolveTimeout(device), ElementCount: elementCount)); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); } catch { runtime.Dispose(); throw; } device.Runtimes[def.Name] = runtime; return runtime; } /// /// PR 7 — pull consecutive elements from a runtime that /// just completed a single contiguous-block read. Element type drives both the .NET /// array shape (Int32[] / Single[] / Boolean[]) and the per-index decoder routing. /// private static object DecodeArrayAs(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int elementCount) { return type switch { AbLegacyDataType.Bit => BuildArray(runtime, type, elementCount), AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => BuildArray(runtime, type, elementCount), AbLegacyDataType.Long => BuildArray(runtime, type, elementCount), AbLegacyDataType.Float => BuildArray(runtime, type, elementCount), _ => throw new NotSupportedException( $"AbLegacyDataType {type} is not supported in array contiguous-block reads."), }; } private static T[] BuildArray(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int n) { var arr = new T[n]; for (var i = 0; i < n; i++) { var element = runtime.DecodeArrayElement(type, i); arr[i] = (T)Convert.ChangeType(element!, typeof(T))!; } return arr; } /// /// PR 7 — resolve the effective array element count for a tag. Explicit /// on the tag definition wins; otherwise /// the parsed from the address suffix is used; /// otherwise 1 (scalar). Validates the override against the same PCCC frame ceiling /// enforced by the parser so config-overrides can't bypass the limit. /// internal static int ResolveElementCount(AbLegacyTagDefinition def, AbLegacyAddress parsed) { if (def.ArrayLength is int n) { if (n < 1 || n > AbLegacyAddress.MaxArrayCount) throw new InvalidOperationException( $"AbLegacy tag '{def.Name}' has ArrayLength {n}; expected 1..{AbLegacyAddress.MaxArrayCount}."); return n; } return parsed.ArrayCount ?? 1; } public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); internal sealed class DeviceState( AbLegacyHostAddress parsedAddress, AbLegacyDeviceOptions options, AbLegacyPlcFamilyProfile profile) { public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress; public AbLegacyDeviceOptions Options { get; } = options; public AbLegacyPlcFamilyProfile Profile { get; } = profile; public Dictionary 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 Dictionary ParentRuntimes { get; } = new(StringComparer.OrdinalIgnoreCase); private readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new(); public SemaphoreSlim GetRmwLock(string parentName) => _rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(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 void DisposeRuntimes() { foreach (var r in Runtimes.Values) r.Dispose(); Runtimes.Clear(); foreach (var r in ParentRuntimes.Values) r.Dispose(); ParentRuntimes.Clear(); } } }