Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
T
Joseph Doherty 098adf43d0 fix(ablegacy): dispose per-parent RMW locks on teardown (review symmetry)
DisposeRuntimes() now disposes and clears _rmwLocks, _creationLocks, and
_runtimeLocks so ReinitializeAsync/ShutdownAsync cycles don't orphan their
SemaphoreSlim instances. Mirrors the TwinCAT _bitRmwLocks fix already shipped.
2026-06-17 12:10:42 -04:00

943 lines
46 KiB
C#

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