Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
2026-04-26 03:50:47 -04:00

902 lines
44 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// AB Legacy / PCCC driver — SLC 500, MicroLogix, PLC-5, LogixPccc. Implements
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
/// host-resolver capabilities ship in PRs 2 and 3.
/// </summary>
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<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// PR 8 — per-tag last published <c>(value, status)</c> cache for the deadband filter.
/// Layered on top of <see cref="PollGroupEngine"/> because the engine's change-detection
/// is binary (publish on any value/status diff). Cleared on <see cref="ShutdownAsync"/>
/// so a reconnect doesn't suppress legitimate post-reconnect updates against stale state.
/// Keyed by full reference (== tag name) — matches the engine's own <c>LastValues</c> key
/// space.
/// </summary>
private readonly Dictionary<string, (object? Value, uint StatusCode)> _lastPublished =
new(StringComparer.OrdinalIgnoreCase);
private readonly object _lastPublishedLock = new();
/// <summary>
/// PR ablegacy-10 / #253 — per-device diagnostic counters surfaced as
/// <c>_Diagnostics/&lt;host&gt;/&lt;name&gt;</c> read-only variables. Updated on
/// every <see cref="ReadAsync"/> call (success, failure, retry) so HMIs can bind
/// directly without a separate diagnostics RPC.
/// </summary>
private readonly AbLegacyDiagnosticTags _diagnosticTags = new();
/// <summary>Test seam — exposes the live diagnostic-tag source so unit tests can poke counters.</summary>
internal AbLegacyDiagnosticTags DiagnosticTags => _diagnosticTags;
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? 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);
}
/// <summary>
/// PR 8 — wraps the <see cref="PollGroupEngine"/> change callback with a per-tag
/// deadband filter. Booleans bypass (publish on every edge); strings + status changes
/// always publish; numerics pass only when <c>|new - prev|</c> meets the configured
/// absolute and / or percent deadband. First-seen always publishes.
/// </summary>
private void DispatchPollChange(ISubscriptionHandle handle, string tagRef, DataValueSnapshot snapshot)
{
if (!ShouldPublish(tagRef, snapshot)) return;
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
}
/// <summary>
/// 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).
/// </summary>
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;
/// <summary>
/// PR 9 — per-device timeout precedence: device-level override wins, otherwise the
/// driver-wide default. Probe loop has its own timeout knob via
/// <see cref="AbLegacyProbeOptions.Timeout"/> but still falls back to the per-device
/// value when the probe override is absent (handled at the call site).
/// </summary>
internal TimeSpan ResolveTimeout(DeviceState device) =>
device.Options.Timeout ?? _options.Timeout;
/// <summary>
/// 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
/// <c>null</c> by default so a vanilla AbLegacy config still issues exactly one read per
/// reference, matching pre-PR-9 behaviour.
/// </summary>
internal int ResolveRetries(DeviceState device) =>
device.Options.Retries ?? _options.Retries ?? 0;
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> 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/<host>/<name> 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<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> 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/<host>/<name> reference so ReadAsync can short-circuit before
// EnsureTagRuntimeAsync. Mirrors AbCip's _System/ pattern from abcip-4.3.
EmitDiagnosticsFolder(deviceFolder, device.HostAddress);
}
return Task.CompletedTask;
}
/// <summary>
/// PR ablegacy-10 / #253 — emit the per-device <c>_Diagnostics</c> folder + its
/// seven read-only diagnostic-counter variables. The <c>FullName</c> on each
/// variable encodes the owning device's host address
/// (<c>_Diagnostics/&lt;host&gt;/&lt;name&gt;</c>) so the read path can route to
/// <see cref="AbLegacyDiagnosticTags.TryRead"/> without a separate registry. Names
/// + types stay in lockstep with <see cref="AbLegacyDiagnosticTags.DiagnosticTagNames"/>.
/// </summary>
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<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> 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<HostConnectivityStatus> 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;
}
/// <summary>
/// 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 <see cref="SemaphoreSlim"/>.
/// </summary>
private async Task<uint> 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<IAbLegacyTagRuntime> 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<IAbLegacyTagRuntime> 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;
}
/// <summary>
/// PR 7 — pull <paramref name="elementCount"/> 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.
/// </summary>
private static object DecodeArrayAs(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int elementCount)
{
return type switch
{
AbLegacyDataType.Bit => BuildArray<bool>(runtime, type, elementCount),
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => BuildArray<int>(runtime, type, elementCount),
AbLegacyDataType.Long => BuildArray<int>(runtime, type, elementCount),
AbLegacyDataType.Float => BuildArray<float>(runtime, type, elementCount),
_ => throw new NotSupportedException(
$"AbLegacyDataType {type} is not supported in array contiguous-block reads."),
};
}
private static T[] BuildArray<T>(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;
}
/// <summary>
/// PR 7 — resolve the effective array element count for a tag. Explicit
/// <see cref="AbLegacyTagDefinition.ArrayLength"/> on the tag definition wins; otherwise
/// the parsed <see cref="AbLegacyAddress.ArrayCount"/> 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.
/// </summary>
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<string, IAbLegacyTagRuntime> Runtimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 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.
/// </summary>
public Dictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _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();
}
}
}