Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
T

1966 lines
108 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Allen-Bradley CIP / EtherNet-IP driver for ControlLogix / CompactLogix / Micro800 /
/// GuardLogix families. Implements all read/write/subscribe/discover/probe/alarm
/// capabilities via the libplctag.NET wrapper.
/// </summary>
/// <remarks>
/// <para>Wire layer is libplctag 1.6.x. Per-device host addresses use
/// the <c>ab://gateway[:port]/cip-path</c> canonical form parsed via
/// <see cref="AbCipHostAddress.TryParse"/>; those strings become the <c>hostName</c> key
/// for Polly per-host circuit-breaker isolation.</para>
///
/// <para>Tier A — in-process, shares server lifetime, no
/// sidecar. <see cref="ReinitializeAsync"/> is the Tier-B escape hatch for recovering
/// from native-heap growth that the CLR allocator can't see; it tears down the
/// libplctag.NET <c>Tag</c> instances held in <c>DeviceState.Runtimes</c> and reconnects
/// each device. Native tag lifetime is owned by the libplctag.NET <c>Tag.Dispose()</c>
/// (called in <see cref="DeviceState.DisposeHandles"/>); the library's own finalizer
/// handles GC-collected tags.</para>
/// </remarks>
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
private AbCipDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IAbCipTagFactory _tagFactory;
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
/// <summary>
/// The batch template reader of the discovery run in progress on this async flow, if any. While set,
/// <see cref="FetchUdtShapeAsync"/> reads through it so a browse that fetches many templates holds ONE
/// device lease across them instead of opening a connection per template (Gitea #572).
/// </summary>
private readonly AsyncLocal<IAbCipTemplateReader?> _batchTemplateReader = new();
private readonly AbCipTemplateCache _templateCache = new();
/// <summary>
/// Maximum nesting depth the controller-discovered UDT fan-out recurses through. A member
/// whose path would sit deeper than this (a struct-of-struct chain longer than the cap) is
/// dropped rather than emitted, bounding both browse-tree breadth and the recursion. Depth
/// is counted in dotted segments below the discovered parent (the parent's direct atomic
/// members sit at depth 1).
/// </summary>
public const int MaxUdtDepth = 8;
// Name-keyed UDT shapes consulted by the controller-discovered fan-out, keyed on
// (deviceHostAddress, structTypeOrMemberName). PRODUCTION REALITY of the two population paths:
// * TOP-LEVEL discovered UDT — FUNCTIONAL in production. The Symbol Object decoder surfaces the
// discovered tag's CIP template instance id (AbCipDiscoveredTag.TemplateInstanceId), which
// DiscoverAsync threads into ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the
// Template Object off the controller and cache it in the id-keyed _templateCache. No seed
// needed.
// * NESTED struct members — FUNCTIONAL in production. A decoded Template Object member block carries
// the nested UDT's template instance id (low 12 bits of the member info, captured by
// CipTemplateObjectDecoder onto AbCipUdtMember.NestedTemplateId), which the fan-out threads into
// ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the nested Template Object off the
// controller. The name-keyed seed (SeedDiscoveredUdtShapeForTest) still wins first for tests; a
// nested member whose id can't be resolved is dropped (the top-level UDT still expands).
private readonly Dictionary<string, AbCipUdtShape> _discoveredUdtShapes =
new(StringComparer.OrdinalIgnoreCase);
// Composite key for _discoveredUdtShapes: device + struct/member name joined by a NUL ('\0').
// A NUL appears in neither a canonical ab:// host address nor a Logix symbol name, so the
// separator is unambiguous regardless of what either part contains.
private static string UdtShapeKey(string deviceHostAddress, string structName) =>
$"{deviceHostAddress}\0{structName}";
/// <summary>
/// Test seam — seed a discovered-UDT shape under <paramref name="structName"/> for
/// <paramref name="deviceHostAddress"/>, consulted by name before any id-keyed fetch. Lets a
/// fan-out test link a nested sub-shape by member name without a live Template Object read.
/// Neither the TOP-LEVEL discovered UDT nor a NESTED-struct member needs a seed in production:
/// both carry a template instance id (the top level on
/// <see cref="AbCipDiscoveredTag.TemplateInstanceId"/>, a nested member on
/// <see cref="AbCipUdtMember.NestedTemplateId"/>) that the fan-out fetches via
/// <see cref="FetchUdtShapeAsync"/>.
/// </summary>
/// <param name="deviceHostAddress">The device the struct lives on.</param>
/// <param name="structName">The discovered struct type or nested-member name.</param>
/// <param name="shape">The shape to associate with that name.</param>
internal void SeedDiscoveredUdtShapeForTest(string deviceHostAddress, string structName, AbCipUdtShape shape) =>
_discoveredUdtShapes[UdtShapeKey(deviceHostAddress, structName)] = shape;
private readonly PollGroupEngine _poll;
// Devices keyed by host address — the primary registry (GetDeviceState, discovery, probe, diagnostics).
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
// Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias)
// → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on
// the RawPath's device segment. The HostAddress alias is deliberate (Client CLI + tests route by host);
// _options.Devices is already the merged per-device DeviceConfig.
private readonly Dictionary<string, DeviceState> _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
// v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive
// ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
private volatile Dictionary<string, AbCipTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Top-level authored definitions (parents + flat tags, NOT synthesised members), in author order —
// the source DiscoverAsync groups by device. Members are synthesised inside the discovery/read paths.
private volatile List<AbCipTagDefinition> _declaredTags = new();
// Resolves a read/write/subscribe fullReference (always a RawPath in v3) to a tag definition by a
// single hit on the authored _tagsByRawPath table; a miss is a miss (the pre-v3 blob-parse fallback
// is retired — see EquipmentTagRefResolver).
private readonly EquipmentTagRefResolver<AbCipTagDefinition> _resolver;
private readonly ILogger<AbCipDriver> _logger;
private AbCipAlarmProjection _alarmProjection;
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Occurs when a subscribed tag's value changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>
/// Occurs once per poll cycle that produced at least one change, carrying that cycle's
/// changed tags. Both data-change events fire by design — this one once per cycle,
/// <see cref="OnDataChange"/> once per changed tag — so a consumer subscribes to EXACTLY
/// ONE of the two; subscribing both double-delivers.
/// </summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Occurs when a device's host connectivity status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>Occurs when an alarm event is raised.</summary>
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <summary>Internal seam for the alarm projection to raise events through the driver.</summary>
/// <param name="args">The alarm event arguments.</param>
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
/// <summary>Initializes a new instance of the <see cref="AbCipDriver"/> class.</summary>
/// <param name="options">The driver configuration options.</param>
/// <param name="driverInstanceId">A unique identifier for this driver instance.</param>
/// <param name="tagFactory">Optional factory for creating tag runtimes; when null the resolved transport's (see <see cref="AbCipTransportResolver"/>) is used.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; when null the resolved transport's is used.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; when null the resolved transport's is used.</param>
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key
/// (<c>Drivers:AbCip:DefaultTransport</c>); null means the compiled <see cref="AbCipTransportResolver.Default"/>.</param>
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
IAbCipTemplateReaderFactory? templateReaderFactory = null,
ILogger<AbCipDriver>? logger = null,
AbCipTransport? defaultTransport = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
var nodeDefault = defaultTransport ?? AbCipTransportResolver.Default;
_transport = AbCipTransportResolver.Resolve(options, nodeDefault);
_transportDescription = AbCipTransportResolver.Describe(options.Transport, nodeDefault);
var defaults = AbCipTransportFactories.For(_transport, options.BrowsePageDelay);
_tagFactory = tagFactory ?? defaults.Tags;
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap,
onBatch: (handle, items) =>
OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.AbCip),
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
}
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
internal IAbCipTagFactory TagFactory => _tagFactory;
/// <summary>The effective transport resolved at construction (see <see cref="AbCipTransportResolver"/>).
/// Describes the OPTION only: injected factories (tests) are used regardless.</summary>
public AbCipTransport EffectiveTransport => _transport;
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
// from the node log which stack serves this instance.
private readonly AbCipTransport _transport;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Native (default)".
private readonly string _transportDescription;
// Tags whose type-mismatch Warning has already been logged (final review I4): a mis-authored tag is
// polled forever, so the Warning is once per tag per driver instance, not once per poll.
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _typeMismatchWarned =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Logs a type-mismatch Warning the first time <paramref name="tagName"/> reports one on this instance.</summary>
private void WarnTypeMismatchOnce(string tagName, string message, params object?[] args)
{
if (_typeMismatchWarned.TryAdd(tagName, 0))
_logger.LogWarning(message, args);
}
// Tags whose malformed-name Warning has already been logged (Gitea #570) — polled forever, warned once.
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _configErrorWarned =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Logs the Warning for a tag whose own configuration cannot become a request
/// (<see cref="AbCipTagConfigurationException"/>), the first time <paramref name="tagName"/> reports
/// one on this instance.
/// </summary>
private void WarnConfigurationErrorOnce(string tagName, string reference, string device, AbCipTagConfigurationException ex)
{
if (_configErrorWarned.TryAdd(tagName, 0))
_logger.LogWarning(
"AbCip tag {Tag} on device {Device} is misconfigured: {Reason}; reporting BadConfigurationError " +
"until it is corrected — the device and its other tags are unaffected (logged once per tag)",
reference, device, ex.Message);
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
/// <summary>
/// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
/// Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
/// </summary>
/// <param name="ex">The exception caught by the poll engine.</param>
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "AbCip poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
var current = _health;
if (current.State != DriverState.Faulted)
_health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
}
/// <summary>
/// Fetch + cache the shape of a Logix UDT by template instance id. First call reads
/// the Template Object off the controller; subsequent calls for the same
/// <c>(deviceHostAddress, templateInstanceId)</c> return the cached shape without
/// additional network traffic. <c>null</c> on template-not-found / decode failure so
/// callers can fall back to declaration-driven UDT fan-out.
/// </summary>
/// <param name="deviceHostAddress">The host address of the device to read the template from.</param>
/// <param name="templateInstanceId">The instance ID of the UDT template to fetch.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The UDT shape if found and decoded successfully; null otherwise.</returns>
internal async Task<AbCipUdtShape?> FetchUdtShapeAsync(
string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken)
{
var cached = _templateCache.TryGet(deviceHostAddress, templateInstanceId);
if (cached is not null) return cached;
if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null;
var deviceParams = device.BuildCreateParams($"@udt/{templateInstanceId}", _options.Timeout);
try
{
var batch = _batchTemplateReader.Value;
using var own = batch is null ? _templateReaderFactory.Create() : null;
var reader = batch ?? own!;
var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
var shape = CipTemplateObjectDecoder.Decode(buffer);
if (shape is not null)
_templateCache.Put(deviceHostAddress, templateInstanceId, shape);
return shape;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
// Template read failure — surface via the driver's health surface AND a warning
// log so operators see it; don't propagate since callers should fall back to
// declaration-driven UDT semantics rather than failing the whole discovery run.
_logger.LogWarning(ex,
"AbCip driver {DriverInstanceId} failed to read UDT template {TemplateInstanceId} from device {Device}; " +
"falling back to declaration-driven UDT semantics",
_driverInstanceId, templateInstanceId, deviceHostAddress);
return null;
}
}
/// <summary>
/// Resolve the shape of a controller-discovered struct/UDT (or a nested-struct member) by
/// name. Consults the name-keyed <see cref="_discoveredUdtShapes"/> first (the source for
/// nested-struct sub-shapes + the test seam), then — for the top level only — the id-keyed
/// template cache via <see cref="FetchUdtShapeAsync"/> when a template instance id is
/// available. Returns <c>null</c> when no shape can be resolved so the caller degrades to a
/// single Variable rather than emitting a broken fan-out.
/// </summary>
/// <param name="deviceHostAddress">The device the struct lives on.</param>
/// <param name="structName">The discovered struct type name or a nested-member name.</param>
/// <param name="templateInstanceId">Template instance id when known — the top-level UDT's id, or a
/// nested-struct member's <see cref="AbCipUdtMember.NestedTemplateId"/> decoded from the parent
/// shape; <c>null</c> only when neither a seeded name-keyed shape nor an id can resolve the struct
/// (the caller then degrades to a single Variable rather than a broken fan-out).</param>
/// <param name="cancellationToken">Cancellation token for any live template read.</param>
private async Task<AbCipUdtShape?> ResolveDiscoveredUdtShapeAsync(
string deviceHostAddress, string structName, uint? templateInstanceId, CancellationToken cancellationToken)
{
if (_discoveredUdtShapes.TryGetValue(UdtShapeKey(deviceHostAddress, structName), out var seeded))
return seeded;
if (templateInstanceId is { } id)
return await FetchUdtShapeAsync(deviceHostAddress, id, cancellationToken).ConfigureAwait(false);
return null;
}
/// <summary>
/// Shared UDT template cache populated by <see cref="FetchUdtShapeAsync"/>. Exposed
/// internally so tests + diagnostics can inspect cached shapes.
/// </summary>
internal AbCipTemplateCache TemplateCache => _templateCache;
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => "AbCip";
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
// runs it with no teardown first). Release the previous device states' probe loops + handles before
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
{
_options = parsed;
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
}
}
foreach (var device in _options.Devices)
{
var addr = AbCipHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
var state = new DeviceState(addr, device, profile);
_devices[device.HostAddress] = state;
// Route index: a tag reaches this device by matching its DeviceName routing key OR its
// host address. Devices with no explicit DeviceName are identified purely by host today.
_devicesByRoutingKey[device.HostAddress] = state;
if (!string.IsNullOrEmpty(device.DeviceName))
_devicesByRoutingKey[device.DeviceName] = state;
}
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig blob
// is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are
// threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
_skippedTags.BeginRebuild(_options.RawTags.Count);
var builtTagsByRawPath = new Dictionary<string, AbCipTagDefinition>(StringComparer.Ordinal);
var builtDeclaredTags = new List<AbCipTagDefinition>();
foreach (var entry in _options.RawTags)
{
if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
{
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
_logger.LogWarning(
"AbCip tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
continue;
}
var tag = mapped with { DeviceHostAddress = entry.DeviceName, WriteIdempotent = entry.WriteIdempotent };
// Duplicate-key check: a collision means two authored tags share the same RawPath.
// Fail fast at init time with a diagnostic rather than silently clobbering.
if (builtTagsByRawPath.TryGetValue(tag.Name, out var existingTag))
throw new InvalidOperationException(
$"AbCip tag name collision: '{tag.Name}' is declared more than once. " +
$"Existing entry DeviceHostAddress='{existingTag.DeviceHostAddress}', " +
$"TagPath='{existingTag.TagPath}'. Rename or remove the duplicate.");
builtTagsByRawPath[tag.Name] = tag;
builtDeclaredTags.Add(tag);
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
{
foreach (var member in tag.Members)
{
var memberTag = new AbCipTagDefinition(
Name: $"{tag.Name}.{member.Name}",
DeviceHostAddress: tag.DeviceHostAddress,
TagPath: $"{tag.TagPath}.{member.Name}",
DataType: member.DataType,
Writable: member.Writable,
WriteIdempotent: member.WriteIdempotent,
// Carry the member's array shape into the fanned-out
// runtime definition. Discovery already emits an array node for an array
// member (member.IsArray || member.ElementCount > 1); without these the
// runtime def defaulted to scalar and the read returned a single element
// instead of the typed array, a declared-type-vs-runtime-value mismatch.
ElementCount: member.ElementCount,
IsArray: member.IsArray);
// Member fan-out duplicate check: a member-path collision means two
// configured structure tags produce the same member path, or a member
// name collides with an independently-declared tag.
if (builtTagsByRawPath.TryGetValue(memberTag.Name, out var existingMember))
throw new InvalidOperationException(
$"AbCip tag name collision: '{memberTag.Name}' is produced by both " +
$"'{tag.Name}.{member.Name}' (member fan-out) and an existing tag " +
$"'{existingMember.Name}'. Rename one of the configured tags to resolve.");
builtTagsByRawPath[memberTag.Name] = memberTag;
}
}
}
// Publish the finished tables by reference swap — readers see the old or the new, never a half-built one.
_tagsByRawPath = builtTagsByRawPath;
_declaredTags = builtDeclaredTags;
_skippedTags.Commit();
// Probe loops — one per device when enabled + a ProbeTagPath is configured.
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
{
foreach (var state in _devices.Values)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
// Keep the loop Task so ShutdownAsync can await its clean exit before
// disposing the CTS / handles the loop is still using.
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
else if (_options.Probe.Enabled && _devices.Count > 0)
{
// Probe is Enabled but no ProbeTagPath is configured. Without a
// tag path the loop has nothing to read, so HostState would stay Unknown forever
// and GetHostStatuses() would report every device as Unknown with no warning.
// Log a warning so the misconfiguration is visible in the rolling Serilog file.
_logger.LogWarning(
"AbCip probe is enabled but no ProbeTagPath is configured for driver {DriverInstanceId} — " +
"host connectivity probe loops were NOT started; GetHostStatuses() will report every device " +
"as Unknown until a ProbeTagPath is set or Probe.Enabled is set to false.",
_driverInstanceId);
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
_logger.LogError(ex, "AbCip driver {DriverInstanceId} failed to initialize", _driverInstanceId);
throw;
}
}
/// <summary>
/// Cancels, joins (bounded) and disposes every existing device state's probe loop + handles, then
/// clears the device/route tables. Device part of <see cref="ShutdownAsync"/> only.
/// </summary>
private async Task ReleaseDeviceStatesAsync(CancellationToken cancellationToken)
{
// The projection is replaced (not reused) when a config body is adopted; dispose the old one so its
// poll loop does not outlive it and raise duplicate alarm events through this driver.
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { }
}
foreach (var state in _devices.Values)
{
var probeTask = state.ProbeTask;
if (probeTask is null) continue;
// Own bounded timeout, independent of the caller token: cancelling init must not abort the join
// and dispose handles under a probe loop that is still running.
try { await probeTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
catch (TimeoutException) { }
catch (OperationCanceledException) { }
}
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeHandles();
}
_devices.Clear();
_devicesByRoutingKey.Clear();
// Rebuilt wholesale by Initialize; stale entries would trip its member fan-out collision check.
_tagsByRawPath = new(StringComparer.Ordinal); // swap, never Clear(): the retained poll engine / discovery may be enumerating
_declaredTags = new();
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
await _poll.DisposeAsync().ConfigureAwait(false);
// Phase 1: signal every probe loop to stop.
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { }
}
// Phase 2: wait for each probe loop to observe cancellation and exit. The loop never
// throws on cancellation (it catches OperationCanceledException internally), but guard
// anyway so one slow device can't wedge the whole shutdown.
foreach (var state in _devices.Values)
{
var probeTask = state.ProbeTask;
if (probeTask is null) continue;
try
{
await probeTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException) { }
catch (OperationCanceledException) { }
}
// Phase 3: now the loops are gone, dispose the CTS + native handles with no live reader.
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeHandles();
}
_devices.Clear();
_devicesByRoutingKey.Clear();
_tagsByRawPath = new(StringComparer.Ordinal);
_declaredTags = new();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
// ---- ISubscribable (polling overlay via shared engine) ----
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
/// <inheritdoc />
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
// Async teardown: the engine drains the in-flight poll without blocking the caller's
// thread — this runs inside a DriverInstanceActor mailbox turn.
await _poll.UnsubscribeAsync(handle).ConfigureAwait(false);
}
// ---- IAlarmSource (ALMD projection) ----
/// <inheritdoc />
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
if (!_options.EnableAlarmProjection)
{
var disabled = new AbCipAlarmSubscriptionHandle(0);
return Task.FromResult<IAlarmSubscriptionHandle>(disabled);
}
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
: Task.CompletedTask;
/// <inheritdoc />
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken)
: Task.CompletedTask;
// ---- IHostConnectivityProbe ----
/// <inheritdoc />
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 = state.BuildCreateParams(_options.Probe.ProbeTagPath!, _options.Probe.Timeout);
IAbCipTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
{
var success = false;
// The probe's backoff verdict for this tick: true = record success, false = record
// failure, null = no signal (a read that returned a non-zero status without throwing
// never recorded either, and still doesn't). Recorded AFTER the try/catch so the
// per-device connect gate is taken outside a catch block — see below.
bool? backoffVerdict = null;
try
{
probeRuntime ??= _tagFactory.Create(probeParams);
// Lazy-init on first attempt; re-init after a transport failure has caused the
// native handle to be destroyed.
if (!state.ProbeInitialized)
{
await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
state.ProbeInitialized = true;
}
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
success = probeRuntime.GetStatus() == 0;
if (success)
backoffVerdict = true; // 05/STAB-8: device reachable — reset the data-path window
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
// Log at debug because a wedged device produces one per tick; the
// OnHostStatusChanged event is the persistent record once the state transitions.
_logger.LogDebug(ex,
"AbCip probe tick failed for driver {DriverInstanceId} device {Device}",
_driverInstanceId, state.Options.HostAddress);
try { probeRuntime?.Dispose(); } catch { }
probeRuntime = null;
state.ProbeInitialized = false;
// 05/STAB-8: keep the data-path window open — unless the probe tag's own name is malformed
// (Gitea #570): that never reached the device, so it is no evidence either way.
backoffVerdict = ex is AbCipTagConfigurationException ? null : false;
}
// ConnectionBackoff is caller-serialized, and the data path now records under the
// per-device connect gate — so the probe records under it too, or its reset/increment
// could interleave with a concurrent cold-tag connect record and lose one. The gate is
// held by the data path only across ONE connect attempt, so this wait is bounded by a
// Forward-Open timeout; the data path never waits on anything the probe holds, so no
// deadlock is possible. Cancellation exits the loop as it does anywhere else.
if (backoffVerdict is { } verdict)
{
try
{
await state.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
try
{
if (verdict) state.Backoff.RecordSuccess();
else state.Backoff.RecordFailure(DateTime.UtcNow);
}
finally
{
state.ConnectGate.Release();
}
}
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 ----
/// <summary>
/// Resolve a read/write/subscribe reference (a RawPath in v3) to the device host that keys its
/// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through the authored table so a
/// tag resolves to its OWN device's host rather than the first-device fallback — else a broken
/// device B would trip device A's breaker. The tag's device routing key is resolved to the device's
/// actual host address (the resilience key), matching by DeviceName-or-host.
/// </summary>
/// <param name="fullReference">The tag RawPath reference.</param>
/// <returns>The device host key.</returns>
public string ResolveHost(string fullReference)
{
if (_resolver.TryResolve(fullReference, out var def)
&& _devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var state))
return state.Options.HostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
// ---- IReadable ----
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
// Plan the batch: members of the same parent UDT get collapsed into one
// whole-UDT read + in-memory member decode; every other reference falls back to the
// per-tag read path. Planner is a pure function over the
// current tag map; BOOL/String/Structure members stay on the fallback path because
// declaration-only offsets can't place them under Logix alignment rules. Whole-UDT
// grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
// reorder UDT members vs declaration order, so the fast path is opt-in only.
var plan = AbCipUdtReadPlanner.Build(
fullReferences, _tagsByRawPath, _options.EnableDeclarationOnlyUdtGrouping);
var groups = plan.Groups;
var fallbacks = plan.Fallbacks;
var itemCount = groups.Count + fallbacks.Count;
if (itemCount == 0) return results;
// Bounded-parallel window ACROSS work items (groups ∪ fallbacks). Each item still takes its
// OWN per-tag runtime lock inside ReadGroupAsync / ReadSingleAsync, so one libplctag handle
// is never touched concurrently (05/STAB-4) — the window only lets DIFFERENT tags' device
// round trips overlap, which a strictly sequential await chain made impossible. Results are
// index-addressed, so completion order is irrelevant. Per-item health verdicts are COLLECTED
// and written once after the batch: an inline write to the shared _health field would race.
var maxConcurrent = Math.Max(1, _options.MaxConcurrentReads);
var outcomes = new ReadOutcome[itemCount];
using var window = new SemaphoreSlim(maxConcurrent, maxConcurrent);
var tasks = new Task[itemCount];
for (var i = 0; i < itemCount; i++)
tasks[i] = RunItemAsync(i);
// Task.WhenAll completes only once EVERY item has finished (it does not short-circuit on the
// first fault), so disposing the window on the way out can never pull the semaphore out from
// under an item still waiting on it.
await Task.WhenAll(tasks).ConfigureAwait(false);
ApplyBatchHealth(outcomes, now);
return results;
async Task RunItemAsync(int slot)
{
// Acquire OUTSIDE the try: a wait that throws never acquired, so it must not release.
await window.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (slot < groups.Count)
{
outcomes[slot] = await ReadGroupAsync(groups[slot], results, now, cancellationToken)
.ConfigureAwait(false);
}
else
{
var fb = fallbacks[slot - groups.Count];
outcomes[slot] = await ReadSingleAsync(
fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken)
.ConfigureAwait(false);
}
}
finally
{
window.Release();
}
}
}
/// <summary>
/// One read work item's health verdict. Returned by <see cref="ReadSingleAsync"/> /
/// <see cref="ReadGroupAsync"/> instead of written to the shared health field inline, because
/// under the bounded-parallel read window those writes would race (last-writer-wins across
/// threads could report Healthy for a batch that mostly failed, or vice versa).
/// A <c>null</c> <see cref="State"/> means "no health signal" — a config-level miss such as an
/// unknown reference or unknown device, which never touched the wire and never touched health.
/// </summary>
private readonly record struct ReadOutcome(DriverState? State, string? Error)
{
/// <summary>The item produced no health signal at all.</summary>
public static ReadOutcome None => new(null, null);
/// <summary>The item read successfully.</summary>
public static ReadOutcome Success => new(DriverState.Healthy, null);
/// <summary>The item failed on the wire.</summary>
/// <param name="message">The failure detail surfaced as <c>LastError</c>.</param>
public static ReadOutcome Degraded(string message) => new(DriverState.Degraded, message);
}
private static int HealthRank(DriverState state) => state switch
{
// No ReadOutcome factory produces Faulted today (a read item is Healthy, Degraded, or no
// signal at all); the tier is future-proofing for an outcome source that reports it, so the
// worst-wins order stays correct without a second edit here.
DriverState.Faulted => 2,
DriverState.Degraded => 1,
_ => 0,
};
/// <summary>
/// Write the batch's health ONCE, after every work item has finished: worst outcome wins
/// (Faulted &gt; Degraded &gt; Healthy) and <c>LastSuccessfulRead</c> advances if ANY item
/// succeeded — the contract the comms-loss watchdog reads (a success advances it; a batch
/// where everything failed freezes it, which is what trips the watchdog).
/// </summary>
private void ApplyBatchHealth(ReadOnlySpan<ReadOutcome> outcomes, DateTime now)
{
var anySuccess = false;
DriverState? worst = null;
string? worstError = null;
foreach (var outcome in outcomes)
{
if (outcome.State is not { } state) continue;
if (state == DriverState.Healthy) anySuccess = true;
if (worst is null || HealthRank(state) > HealthRank(worst.Value))
{
worst = state;
worstError = outcome.Error;
}
}
// Nothing reached the wire (every reference was unknown / unroutable) — leave health alone,
// exactly as the per-item writes did before.
if (worst is not { } worstState) return;
_health = new DriverHealth(
worstState,
anySuccess ? now : _health.LastSuccessfulRead,
worstState == DriverState.Healthy ? null : worstError);
}
private async Task<ReadOutcome> ReadSingleAsync(
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
if (!_resolver.TryResolve(reference, out var def))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return ReadOutcome.None;
}
// A Structure tag whose Members are declared is a container —
// its bare name is readable via the whole-UDT grouping path (ReadGroupAsync), not the
// per-tag path. Reading it here returns BadNotSupported rather than Good/null so the
// caller knows to address individual member paths (e.g. "Motor.Speed").
if (def.DataType == AbCipDataType.Structure && def.Members is { Count: > 0 })
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
return ReadOutcome.None;
}
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return ReadOutcome.None;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
// Serialise Read → GetStatus → Decode against the shared runtime — the cached libplctag
// Tag handle is not concurrency-safe and GetStatus returns the LAST op's status (05/STAB-4).
var opLock = device.GetRuntimeLock(def.Name);
await opLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: the controller ANSWERED with a type that disagrees with the authored
// one — a deterministic config error, not a comms fault. Keep the handle (evicting would
// re-create it — and, for a sole lease holder, re-Forward-Open — on every poll), leave
// health alone, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
WarnTypeMismatchOnce(def.Name,
"AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " +
"{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)",
reference, def.DeviceHostAddress, def.DataType);
return ReadOutcome.None;
}
if (status != 0)
{
// Evict the stale handle so the next call re-creates it.
// A non-zero status can mean the controller dropped the connection or the tag
// handle became permanently invalid (e.g. after a PLC download). Evicting
// mirrors the probe loop's recreate-on-failure behaviour.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapStatus(status), null, now);
_logger.LogWarning(
"AbCip read returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, reference, def.DeviceHostAddress);
return ReadOutcome.Degraded($"transport status {status} reading {reference}");
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
// libplctag never checks the CIP type the controller returned; it decodes the bytes as
// the authored type. A scalar whose buffer is not that type's width is mis-typed config
// (seen live: a REAL authored as INT read Good -17346), so refuse it rather than publish
// garbage as Good. Same-width mismatches (DINT vs REAL) cannot be seen this way.
if (!IsArrayTag(def) && bitIndex is null
&& ExpectedScalarWidth(def.DataType) is { } width
&& runtime.GetSize() is var size && size >= 0 && size != width)
{
// Same class as the native TypeMismatch above (final review I4): deterministic, device
// answered — no Degraded, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
WarnTypeMismatchOnce(def.Name,
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)",
reference, def.DeviceHostAddress, size, def.DataType, width);
return ReadOutcome.None;
}
// Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
// element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
// (ElementCount 1). Scalar tags keep the single-value path.
var value = IsArrayTag(def)
? runtime.DecodeArray(def.DataType, Math.Max(1, def.ElementCount))
: runtime.DecodeValue(def.DataType, bitIndex);
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
return ReadOutcome.Success;
}
finally
{
opLock.Release();
}
}
catch (OperationCanceledException)
{
throw;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
// health signal (Task 22 review r1).
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
return ReadOutcome.None;
}
catch (AbCipTagConfigurationException ace)
{
// Gitea #570: this tag's name/path cannot become a request — nothing reached the device, so
// no evict (nothing was cached), no health signal, no backoff; siblings keep reading.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadConfigurationError, null, now);
WarnConfigurationErrorOnce(def.Name, reference, def.DeviceHostAddress, ace);
return ReadOutcome.None;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Final review I4: deterministic, keep the handle, no health signal, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} refused: {Reason} (logged once per tag)",
reference, def.DeviceHostAddress, ate.Message);
return ReadOutcome.None;
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle. An AbTransportException
// carries its AbStatus, so a tag the controller does not have reads
// BadNodeIdUnknown and a decode past the buffer BadOutOfRange, not a comms error.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapException(ex), null, now);
_logger.LogWarning(ex,
"AbCip read transport exception for tag {Tag} on device {Device}",
reference, def.DeviceHostAddress);
return ReadOutcome.Degraded(ex.Message);
}
}
/// <summary>
/// Perform one whole-UDT read on the parent tag, then decode each
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
/// failure (parent read raised, non-zero transport status, or missing device) stamps
/// the mapped fault across every grouped member only — sibling groups + the
/// per-tag fallback list are unaffected.
/// </summary>
private async Task<ReadOutcome> ReadGroupAsync(
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var parent = group.ParentDefinition;
if (!_devicesByRoutingKey.TryGetValue(parent.DeviceHostAddress, out var device))
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
return ReadOutcome.None;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);
// Serialise the whole-UDT Read → GetStatus → per-member DecodeValueAt against the shared
// parent runtime — the members decode from that one handle's buffer (05/STAB-4).
var opLock = device.GetRuntimeLock(parent.Name);
await opLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: deterministic — keep the handle, no health signal, warn once.
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(parent.Name,
"AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " +
"the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)",
group.ParentName, parent.DeviceHostAddress, group.Members.Count);
return ReadOutcome.None;
}
if (status != 0)
{
EvictRuntime(device, parent.Name);
var mapped = AbCipStatusMapper.MapStatus(status);
StampGroupStatus(group, results, now, mapped);
_logger.LogWarning(
"AbCip whole-UDT read returned non-zero transport status {Status} for parent {Parent} " +
"on device {Device}; {MemberCount} member values stamped with mapped status",
status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
return ReadOutcome.Degraded($"transport status {status} reading UDT {group.ParentName}");
}
foreach (var member in group.Members)
{
var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
}
return ReadOutcome.Success;
}
finally
{
opLock.Release();
}
}
catch (OperationCanceledException)
{
throw;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
return ReadOutcome.None;
}
catch (AbCipTagConfigurationException ace)
{
// Gitea #570: the parent's name cannot become a request — per-group config error, device untouched.
StampGroupStatus(group, results, now, AbCipStatusMapper.BadConfigurationError);
WarnConfigurationErrorOnce(parent.Name, group.ParentName, parent.DeviceHostAddress, ace);
return ReadOutcome.None;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(parent.Name,
"AbCip whole-UDT read of {Parent} on device {Device} refused: {Reason} (logged once per tag)",
group.ParentName, parent.DeviceHostAddress, ate.Message);
return ReadOutcome.None;
}
catch (Exception ex)
{
EvictRuntime(device, parent.Name);
StampGroupStatus(group, results, now, AbCipStatusMapper.MapException(ex));
_logger.LogWarning(ex,
"AbCip whole-UDT read transport exception for parent {Parent} on device {Device}",
group.ParentName, parent.DeviceHostAddress);
return ReadOutcome.Degraded(ex.Message);
}
}
private static void StampGroupStatus(
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, uint statusCode)
{
foreach (var member in group.Members)
results[member.OriginalIndex] = new DataValueSnapshot(null, statusCode, null, now);
}
// ---- IWritable ----
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
var now = DateTime.UtcNow;
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!_resolver.TryResolve(w.FullReference, out var def))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
if (!def.Writable || def.SafetyTag)
{
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
continue;
}
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
try
{
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
// BOOL-within-DINT writes — RMW against a parallel parent-DINT
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
// per-parent lock prevents two concurrent bit writes to the same DINT from
// losing one another's update.
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
{
results[i] = new WriteResult(
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
.ConfigureAwait(false));
if (results[i].StatusCode == AbCipStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
continue;
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
// Serialise Encode → Write → GetStatus against the shared runtime — the same cached
// Tag handle may be in use by a concurrent ReadAsync or poll loop (05/STAB-4).
var opLock = device.GetRuntimeLock(def.Name);
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
int status;
try
{
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
}
finally
{
opLock.Release();
}
if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, def.DataType);
}
else if (status != 0)
{
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
_logger.LogWarning(
"AbCip write returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, w.FullReference, def.DeviceHostAddress);
}
else
{
results[i] = new WriteResult(AbCipStatusMapper.Good);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (NotSupportedException nse)
{
// Type/protocol error — not a transport fault; don't evict the handle.
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
_logger.LogWarning(nse,
"AbCip write not supported for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (FormatException fe)
{
// Value conversion error — not a transport fault; don't evict.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
_logger.LogWarning(fe,
"AbCip write value-conversion error for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (InvalidCastException ice)
{
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
_logger.LogWarning(ice,
"AbCip write type-cast error for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (OverflowException oe)
{
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
_logger.LogWarning(oe,
"AbCip write value out of range for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
// A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms
// fault: BadNotSupported, keep the handle, leave health alone (Task 22 review r1).
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
_logger.LogWarning(ate,
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (AbCipTagConfigurationException ace)
{
// Gitea #570: a config error on this tag — no evict, no health signal, no backoff.
results[i] = new WriteResult(AbCipStatusMapper.BadConfigurationError);
WarnConfigurationErrorOnce(def.Name, w.FullReference, def.DeviceHostAddress, ace);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
// CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave
// health alone, warn once per tag. Retrying cannot succeed until the config is corrected.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, ate.Message);
}
catch (Exception ex)
{
// Transport / wire error — evict so the next write creates a fresh handle.
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
_logger.LogWarning(ex,
"AbCip write transport exception for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
}
return results;
}
/// <summary>
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
/// writers against the same parent via a per-parent <see cref="SemaphoreSlim"/>.
/// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern.
/// </summary>
private async Task<uint> WriteBitInDIntAsync(
DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
{
var parentPath = bitPath with { BitIndex = null };
var parentName = parentPath.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 AbCipStatusMapper.MapStatus(readStatus);
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
var updated = Convert.ToBoolean(value)
? current | (1 << bit)
: current & ~(1 << bit);
parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbCipStatusMapper.Good
: AbCipStatusMapper.MapStatus(writeStatus);
}
finally
{
rmwLock.Release();
}
}
/// <summary>
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
/// so repeated bit writes against the same DINT share one handle.
/// </summary>
private async Task<IAbCipTagRuntime> EnsureParentRuntimeAsync(
DeviceState device, string parentTagName, CancellationToken ct)
{
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
var runtime = _tagFactory.Create(device.BuildCreateParams(parentTagName, _options.Timeout));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
// first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
if (device.ParentRuntimes.TryAdd(parentTagName, runtime))
return runtime;
runtime.Dispose();
return device.ParentRuntimes[parentTagName];
}
/// <summary>
/// Thrown when a per-device <see cref="ConnectionBackoff"/> window is still open (05/STAB-8)
/// — a fail-fast that costs no wire traffic. Read/write map it to <c>BadCommunicationError</c>
/// via their generic catch, exactly as a real Forward-Open failure.
/// </summary>
private sealed class ConnectionThrottledException(string message) : Exception(message);
/// <summary>
/// Idempotently materialise the runtime handle for a tag definition. First call creates
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
/// lifetime of the device.
/// </summary>
/// <remarks>
/// The COLD path (no cached handle) runs single-flight per device under
/// <see cref="DeviceState.ConnectGate"/> so the backoff check, the Forward-Open, and the
/// failure/success record form one atomic sequence — see that property for why. The HOT path
/// (cached handle) never touches the gate, so the bounded-parallel read window is undiminished
/// in steady state.
/// </remarks>
private async Task<IAbCipTagRuntime> EnsureTagRuntimeAsync(
DeviceState device, AbCipTagDefinition def, CancellationToken ct)
{
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
var parsed = AbCipTagPath.TryParse(def.TagPath)
?? throw new AbCipTagConfigurationException(
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Re-check under the gate: a sibling cold tag may have connected (or a concurrent write
// may have materialised this very handle) while this caller waited.
if (device.Runtimes.TryGetValue(def.Name, out var raced)) return raced;
// 05/STAB-8: inside an open backoff window a data-path caller fails fast — no runtime
// create, no Forward-Open. The malformed-path config error above still surfaces (a permanent
// fault must never be masked by the window); only the expensive create+init is throttled. The
// probe loop uses its own runtime, and records under this same gate.
if (!device.Backoff.ShouldAttempt(DateTime.UtcNow))
throw new ConnectionThrottledException(
$"AbCip runtime create for '{def.Name}' on {device.Options.HostAddress} throttled — backoff window open after a recent Forward-Open failure.");
// Review I-1 — an array tag (the EXPLICIT IsArray flag, incl. a 1-element array) sets
// libplctag's elem_count so the read pulls every element in one CIP transaction; the read
// path then boxes them into a typed CLR array. Scalar tags pass count 1 + IsArray false.
var runtime = _tagFactory.Create(
device.BuildCreateParams(
parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def),
authoredType: def.DataType));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Gitea #570: the controller ANSWERED with a type refusal — the tag exists and the session
// works. Keep the handle (disposing it re-creates it, and for a sole lease holder
// re-Forward-Opens, on every poll) and let the caller report BadTypeMismatch. The native
// runtime completes its initialization instead of throwing this; the cache keeps any runtime
// that does throw it from churning.
device.Backoff.RecordSuccess();
if (!device.Runtimes.TryAdd(def.Name, runtime)) runtime.Dispose();
throw;
}
catch (AbCipTagConfigurationException)
{
// Gitea #570: the tag's own name cannot become a request — no I/O happened, so it says nothing
// about the device: leave the backoff window exactly as it was.
runtime.Dispose();
throw;
}
catch (Exception ex)
{
runtime.Dispose();
// 05/STAB-8: a failed create opens the backoff window — but only when the device did
// not answer. "Tag not found" means the controller replied over a working session; it
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
// every sibling tag on the device (seen live: one typo blacked out the whole device).
if (ex is AbTransportException ate
&& ate.Status != AbStatus.Ok
&& AbCipDriverProbe.IsReachableStatus(ate.Status))
device.Backoff.RecordSuccess();
else
device.Backoff.RecordFailure(DateTime.UtcNow);
throw;
}
device.Backoff.RecordSuccess(); // 05/STAB-8: Forward-Open succeeded — reset the window
// The gate makes a cold create single-flight, but a concurrent EvictRuntime (a failed read
// on this handle) can still land between the re-check and the add; only the first TryAdd
// wins. Dispose the loser so it doesn't leak a native tag handle.
if (device.Runtimes.TryAdd(def.Name, runtime))
return runtime;
runtime.Dispose();
return device.Runtimes[def.Name];
}
finally
{
device.ConnectGate.Release();
}
}
/// <summary>
/// Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose
/// it so the next read/write call re-creates and re-initializes a fresh handle.
/// Called from <see cref="ReadSingleAsync"/>, <see cref="ReadGroupAsync"/>, and
/// <see cref="WriteAsync"/> after a non-zero transport status or transport exception —
/// mirroring the probe loop's recreate-on-failure behaviour.
/// </summary>
private static void EvictRuntime(DeviceState device, string tagName)
{
if (device.Runtimes.TryRemove(tagName, out var stale))
{
try { stale.Dispose(); } catch { }
}
}
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
// ---- ITagDiscovery ----
/// <inheritdoc />
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <summary>Controller enumeration is real (CIP Symbol Object walk) — config-gated on
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var root = builder.Folder("AbCip", "AbCip");
foreach (var device in _options.Devices)
{
var deviceLabel = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, deviceLabel);
// Pre-declared tags — always emitted; the primary config path. UDT tags with declared
// Members fan out into a sub-folder + one Variable per member instead of a single
// Structure Variable (Structure has no useful scalar value + member-addressable paths
// are what downstream consumers actually want). A tag reaches this device when its
// device routing key matches the device's DeviceName or its host address.
var preDeclared = _declaredTags.Where(t => RoutesToDevice(t, device));
foreach (var tag in preDeclared)
{
if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
{
var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
foreach (var member in tag.Members)
{
var memberFullName = $"{tag.Name}.{member.Name}";
// Review I-1 — array-ness is the EXPLICIT IsArray flag (a 1-element array is
// still an array); a legacy member with ElementCount > 1 but the flag unset
// remains an array for back-compat.
var memberIsArray = member.IsArray || member.ElementCount > 1;
udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
FullName: memberFullName,
DriverDataType: member.DataType.ToDriverDataType(),
IsArray: memberIsArray,
ArrayDim: memberIsArray ? (uint)Math.Max(1, member.ElementCount) : null,
SecurityClass: member.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: member.WriteIdempotent,
NativeDataType: member.DataType.ToString()));
}
continue;
}
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
}
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
// walker (SymbolListingEnumerator) is the factory default,
// so leaving the flag off keeps the strict-config path for deployments where only
// declared tags should appear.
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
{
using var enumerator = _enumeratorFactory.Create();
var deviceParams = state.BuildCreateParams("@tags", _options.Timeout);
// One template-reader lease across every UDT shape this device's browse fetches.
using var batchReader = _templateReaderFactory.CreateBatch();
_batchTemplateReader.Value = batchReader;
using var clearBatch = new ClearOnDispose(_batchTemplateReader);
IAddressSpaceBuilder? discoveredFolder = null;
// Dedup discovered member full-names within this device's Discovered folder.
var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken)
.ConfigureAwait(false))
{
if (discovered.IsSystemTag) continue;
if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue;
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
var fullName = discovered.ProgramScope is null
? discovered.Name
: $"Program:{discovered.ProgramScope}.{discovered.Name}";
// A discovered UDT/struct is expanded into a member sub-folder with one Variable
// per ATOMIC leaf (mirroring the pre-declared fan-out above), rather than the
// bogus single Structure → String placeholder Variable. When the shape can't be
// resolved (or yields no emittable leaf) we fall through to the legacy single
// Variable so nothing regresses.
// Review I-1 — a discovered array of length 1 is still an array; honour the
// explicit IsArray flag (legacy ElementCount > 1 still surfaces as an array).
var discoveredIsArray = discovered.IsArray || discovered.ElementCount > 1;
var discoveredType = discovered.DataType;
if (discovered.DataType == AbCipDataType.Structure)
{
// Pass the discovered tag's CIP template instance id (surfaced by the Symbol
// Object decoder) so ResolveDiscoveredUdtShapeAsync can fetch the Template Object
// off the controller via FetchUdtShapeAsync — this is what makes the production
// top-level UDT fan-out functional (rather than degrading to a placeholder).
var shape = await ResolveDiscoveredUdtShapeAsync(
device.HostAddress, discovered.Name, discovered.TemplateInstanceId, cancellationToken)
.ConfigureAwait(false);
if (shape is { IsStringFamily: true })
{
// A controller reports every STRING (built-in or a user string type such as
// STR0016) as a LEN + DATA struct. It is one String value — the read path reads
// it as such — so it becomes one String Variable, never a LEN/DATA folder.
discoveredType = AbCipDataType.String;
}
else if (shape is not null && !discoveredIsArray)
{
// Materialise the member sub-folder LAZILY — only once a leaf is actually
// about to be emitted into it. A shape that yields no usable atomic leaf
// then leaves no empty sub-folder behind, and we fall through to the legacy
// single Variable below.
var capturedDiscoveredFolder = discoveredFolder;
var udtFolder = new Lazy<IAddressSpaceBuilder>(
() => capturedDiscoveredFolder.Folder(fullName, discovered.Name));
var anyEmitted = await FanOutDiscoveredUdtMembersAsync(
udtFolder, device.HostAddress, fullName, shape, discovered.ReadOnly,
depth: 1, emitted, visited: [discovered.Name], cancellationToken)
.ConfigureAwait(false);
if (anyEmitted) continue; // expanded — don't emit the placeholder Variable
// Empty shape (no usable atomic leaf): degrade to a single Variable below.
}
}
if (!emitted.Add(fullName)) continue;
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: discoveredType.ToDriverDataType(),
IsArray: discoveredIsArray,
ArrayDim: discoveredIsArray ? (uint)Math.Max(1, discovered.ElementCount) : null,
SecurityClass: discovered.ReadOnly
? SecurityClassification.ViewOnly
: SecurityClassification.Operate,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false,
NativeDataType: discoveredType.ToString()));
}
}
}
}
/// <summary>Clears an <see cref="AsyncLocal{T}"/> when the scope that set it ends.</summary>
private sealed class ClearOnDispose(AsyncLocal<IAbCipTemplateReader?> slot) : IDisposable
{
public void Dispose() => slot.Value = null;
}
/// <summary>
/// <c>true</c> for a UDT member the controller generates rather than the user declares: the
/// hidden SINT/DINT hosts Logix packs BOOL members into (<c>ZZZZZZZZZZAlarms0</c>) and AOI
/// backing members (<c>__BitHost00</c>). A user-declared Logix name can never begin with two
/// underscores, so neither rule can hide a real member.
/// </summary>
/// <param name="memberName">The member name from the template.</param>
/// <returns><c>true</c> when the member is controller-generated.</returns>
internal static bool IsHiddenUdtMember(string memberName) =>
memberName.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)
|| memberName.StartsWith("__", StringComparison.Ordinal);
/// <summary>
/// Wire width of a scalar atomic Logix type, or <c>null</c> where the read buffer's size is not
/// a reliable type check (BOOL, STRING, DT, structures).
/// </summary>
/// <param name="type">The authored type.</param>
/// <returns>The width in bytes, or <c>null</c>.</returns>
internal static int? ExpectedScalarWidth(AbCipDataType type) => type switch
{
AbCipDataType.SInt or AbCipDataType.USInt => 1,
AbCipDataType.Int or AbCipDataType.UInt => 2,
AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real => 4,
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
_ => null,
};
/// <summary>
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
/// ATOMIC leaf — under the folder produced by <paramref name="folder"/>. Mirrors the
/// pre-declared UDT fan-out (member full-name = <c>Parent.Member</c>, dot-joined for nested
/// structs; atomic <c>DriverDataType</c>; SecurityClass from writability;
/// <c>IsHistorized:false</c>, <c>IsAlarm:false</c>). Atomic members emit directly; a
/// nested-struct member recurses into ITS members. Bounded by <see cref="MaxUdtDepth"/>
/// (deeper members are dropped) and a per-path <paramref name="visited"/> set (cyclic struct
/// references can't loop). A nested struct whose sub-shape can't be resolved is dropped —
/// never mis-emitted as a placeholder.
/// </summary>
/// <remarks>
/// The <paramref name="folder"/> is a <see cref="Lazy{T}"/> so the sub-folder node is
/// materialised only once a leaf is actually about to be emitted into it. A shape (or nested
/// sub-shape) that yields no emittable leaf therefore never leaves an empty sub-folder in the
/// browse tree.
/// </remarks>
/// <param name="folder">Lazy producer of the address-space sub-folder the member Variables are
/// emitted under; only forced when the first leaf is emitted.</param>
/// <param name="deviceHostAddress">The device the UDT lives on (for nested sub-shape lookup).</param>
/// <param name="parentFullName">Dotted full-name prefix of the parent (e.g. <c>Motor1</c> or
/// <c>Motor1.Status</c>).</param>
/// <param name="shape">The resolved shape whose members are fanned out.</param>
/// <param name="parentReadOnly">Whether the discovered parent tag was read-only (propagated to
/// members — the Symbol Object carries no per-member access bits).</param>
/// <param name="depth">Current recursion depth (the parent's direct members are depth 1).</param>
/// <param name="emitted">Dedup set of member full-names already emitted in this Discovered folder.</param>
/// <param name="visited">Struct names on the current recursion path (cycle guard).</param>
/// <param name="cancellationToken">Cancellation token for any nested sub-shape read.</param>
/// <returns><c>true</c> when at least one atomic leaf was emitted.</returns>
private async Task<bool> FanOutDiscoveredUdtMembersAsync(
Lazy<IAddressSpaceBuilder> folder,
string deviceHostAddress,
string parentFullName,
AbCipUdtShape shape,
bool parentReadOnly,
int depth,
HashSet<string> emitted,
HashSet<string> visited,
CancellationToken cancellationToken)
{
// Defensive: the only caller already gates each descent on depth + 1 <= MaxUdtDepth, so a
// depth past the cap can't be reached here — kept as a belt-and-suspenders guard.
if (depth > MaxUdtDepth) return false;
var anyEmitted = false;
foreach (var member in shape.Members)
{
if (IsHiddenUdtMember(member.Name)) continue;
var memberFullName = $"{parentFullName}.{member.Name}";
var leafType = member.DataType;
if (member.DataType == AbCipDataType.Structure)
{
// Nested struct: resolve its sub-shape first. The decoded parent shape carries the nested
// UDT's template instance id (low 12 bits of the member type word, captured by
// CipTemplateObjectDecoder) — thread it so the nested shape is fetched off the controller
// via FetchUdtShapeAsync (name-keyed seeded shapes still win first, for the test seam).
var nested = await ResolveDiscoveredUdtShapeAsync(
deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
.ConfigureAwait(false);
if (nested is { IsStringFamily: true })
{
// A nested STRING (or user string type) member is one String leaf, not LEN + DATA.
leafType = AbCipDataType.String;
}
else
{
// An array of a UDT has no element-free member path (Parent.Arr.X names no element),
// so it is not recursed into. Otherwise recurse if the depth cap allows it and we're
// not re-entering a struct already on this path (cycle guard). The nested members
// live a level deeper, so the cap is checked against depth + 1.
if (member.ArrayLength > 1) continue;
if (depth + 1 > MaxUdtDepth) continue;
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
if (nested is not null)
{
// Materialise the nested sub-folder lazily under the parent folder (itself lazy),
// so a nested struct that yields no leaf leaves no empty sub-folder behind.
var parentFolder = folder;
var nestedFolder = new Lazy<IAddressSpaceBuilder>(
() => parentFolder.Value.Folder(memberFullName, member.Name));
var nestedEmitted = await FanOutDiscoveredUdtMembersAsync(
nestedFolder, deviceHostAddress, memberFullName, nested, parentReadOnly,
depth + 1, emitted, visited, cancellationToken)
.ConfigureAwait(false);
anyEmitted |= nestedEmitted;
}
visited.Remove(member.Name);
continue;
}
}
// Atomic leaf — emit one Variable. Dedup by full-name within the Discovered folder.
// Forcing folder.Value here is what materialises the sub-folder on the first real leaf.
if (!emitted.Add(memberFullName)) continue;
var memberIsArray = member.ArrayLength > 1;
folder.Value.Variable(member.Name, member.Name, new DriverAttributeInfo(
FullName: memberFullName,
DriverDataType: leafType.ToDriverDataType(),
IsArray: memberIsArray,
ArrayDim: memberIsArray ? (uint)Math.Max(1, member.ArrayLength) : null,
// The Symbol Object carries no per-member ExternalAccess bits, so members inherit the
// parent tag's read-only state (a safety/read-only UDT keeps all its members ViewOnly).
SecurityClass: parentReadOnly
? SecurityClassification.ViewOnly
: SecurityClassification.Operate,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false,
NativeDataType: leafType.ToString()));
anyEmitted = true;
}
return anyEmitted;
}
// Review I-1 — array-ness is the EXPLICIT IsArray flag (a 1-element array is still an array);
// a legacy definition carrying only ElementCount > 1 stays an array for back-compat.
private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1;
// A tag routes to a device when its device routing key (DeviceHostAddress, threaded from the
// RawTagEntry.DeviceName) matches the device's DeviceName or its host address (the host alias is
// deliberate: the Client CLI routes by host).
private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) =>
string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(device.DeviceName)
&& string.Equals(tag.DeviceHostAddress, device.DeviceName, StringComparison.OrdinalIgnoreCase));
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: IsArrayTag(tag),
ArrayDim: IsArrayTag(tag) ? (uint)Math.Max(1, tag.ElementCount) : null,
SecurityClass: (tag.Writable && !tag.SafetyTag)
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent,
NativeDataType: tag.DataType.ToString());
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
internal int DeviceCount => _devices.Count;
/// <summary>Looked-up device state for the given host address. Tests + later-PR capabilities hit this.</summary>
/// <param name="hostAddress">The host address of the device to look up.</param>
/// <returns>The device state if found; null otherwise.</returns>
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
/// <summary>Releases all resources used by the driver.</summary>
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
/// <summary>Asynchronously releases all resources used by the driver.</summary>
/// <returns>A value task representing the asynchronous disposal.</returns>
public async ValueTask DisposeAsync()
{
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
/// Per-device runtime state. Holds the parsed host address, family profile, and the
/// live libplctag.NET <see cref="IAbCipTagRuntime"/> instances keyed by tag name.
/// Native tag lifetime is owned by the <c>Tag.Dispose()</c> inside each
/// <see cref="LibplctagTagRuntime"/>; libplctag.NET's own finalizer covers GC-collected
/// instances so no separate SafeHandle wrapper is needed here.
/// </summary>
internal sealed class DeviceState(
AbCipHostAddress parsedAddress,
AbCipDeviceOptions options,
AbCipPlcFamilyProfile profile)
{
/// <summary>Gets the parsed host address for this device.</summary>
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
/// <summary>Gets the configuration options for this device.</summary>
public AbCipDeviceOptions Options { get; } = options;
/// <summary>Gets the PLC family profile for this device.</summary>
public AbCipPlcFamilyProfile Profile { get; } = profile;
/// <summary>Gets the lock object used for probe synchronization.</summary>
public object ProbeLock { get; } = new();
/// <summary>
/// Per-device connect-attempt throttle (05/STAB-8). After a failed runtime create
/// (Forward-Open), data-path callers fail fast inside the backoff window instead of
/// re-creating + re-initializing a runtime per tag per tick; the probe loop bypasses it
/// (its own runtime) and drives failure/success so a recovered device resets the window.
/// </summary>
public ConnectionBackoff Backoff { get; } = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
/// <summary>
/// Per-device connect gate. <see cref="ConnectionBackoff"/> is documented as
/// caller-serialized — it mutates plain fields — so EVERY touch of <see cref="Backoff"/>
/// plus the connect attempt it guards runs under this semaphore. Task 14's bounded-parallel
/// read window otherwise let up to <c>MaxConcurrentReads</c> work items run the
/// ShouldAttempt → create+Initialize → RecordFailure/RecordSuccess sequence at once for
/// DIFFERENT cold tags on the SAME device: every one passed <c>ShouldAttempt</c> before any
/// failure was recorded (N concurrent Forward-Opens at a down device) and the unsynchronized
/// <c>_consecutiveFailures++</c> lost increments, weakening the 05/STAB-8 window into a
/// reconnect storm. Cold-tag creation is therefore single-flight per device; an
/// already-created runtime bypasses the gate entirely, so the steady-state read path keeps
/// the full window.
/// <para>
/// Deliberately NOT disposed in <see cref="DisposeHandles"/>, unlike the per-tag gates:
/// the probe loop also waits on it, and <see cref="AbCipDriver.ShutdownAsync"/>'s wait
/// for that loop is bounded (10 s), so disposal could race a still-unwinding probe into
/// an <see cref="ObjectDisposedException"/>. A <see cref="SemaphoreSlim"/> whose
/// <c>AvailableWaitHandle</c> was never touched holds no unmanaged resource, and the
/// whole <see cref="DeviceState"/> is dropped on reinitialize.
/// </para>
/// </summary>
public SemaphoreSlim ConnectGate { get; } = new(1, 1);
/// <summary>Gets or sets the current host state of this device.</summary>
public HostState HostState { get; set; } = HostState.Unknown;
/// <summary>Gets or sets the UTC timestamp when the host state was 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 whether the probe has been initialized.</summary>
public bool ProbeInitialized { get; set; }
/// <summary>
/// The fire-and-forget probe loop's <see cref="Task"/>. Stored so
/// <see cref="AbCipDriver.ShutdownAsync"/> can await the loop's clean exit after
/// cancelling <see cref="ProbeCts"/> and BEFORE disposing the CTS or the runtime
/// handles — otherwise the still-running loop can touch a disposed CTS or a cleared
/// runtime dictionary.
/// </summary>
public Task? ProbeTask { get; set; }
/// <summary>
/// Per-tag runtime handles owned by this device. One entry per configured tag is
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
/// <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
/// because <c>ReadAsync</c> is invoked concurrently by the server read path, every
/// polled subscription loop, and the alarm projection loop.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> Runtimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Parent-DINT runtimes created on-demand by <see cref="AbCipDriver.EnsureParentRuntimeAsync"/>
/// for BOOL-within-DINT RMW writes. Separate from <see cref="Runtimes"/> because a
/// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
/// parent ("Motor.Flags") used to do the read + write.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
/// <summary>Gets or creates a semaphore for coordinating RMW (read-modify-write) operations on a parent tag.</summary>
/// <param name="parentTagName">The name of the parent tag.</param>
/// <returns>A semaphore for coordinating RMW operations.</returns>
public SemaphoreSlim GetRmwLock(string parentTagName) =>
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _runtimeLocks =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Per-runtime operation lock (05/STAB-4). A libplctag <c>Tag</c> handle is not safe for
/// concurrent Read/GetStatus/DecodeValue (or Encode/Write/GetStatus) — the server read
/// path, every poll-group loop, and the alarm-projection loop all call
/// <see cref="AbCipDriver.ReadAsync"/> / <see cref="AbCipDriver.WriteAsync"/> against the
/// same cached runtime, and <c>GetStatus</c> returns the <em>last</em> operation's status
/// — so an unserialised overlap decodes a torn / cross-attributed value with Good status.
/// Callers hold this lock around the whole op sequence. Keyed by tag name, which is also
/// the <see cref="Runtimes"/> dictionary key. Mirrors the field-proven AbLegacy pattern.
/// </summary>
/// <param name="tagName">The tag name (the <see cref="Runtimes"/> key).</param>
/// <returns>The per-runtime operation semaphore.</returns>
public SemaphoreSlim GetRuntimeLock(string tagName) =>
_runtimeLocks.GetOrAdd(tagName, _ => new SemaphoreSlim(1, 1));
/// <summary>
/// Compute the effective <see cref="AbCipTagCreateParams"/> for a
/// tag on this device. Combines the per-device options
/// (<see cref="AbCipDeviceOptions.AllowPacking"/>,
/// <see cref="AbCipDeviceOptions.ConnectionSize"/>) with the family profile defaults
/// so the wire layer sees one place that resolves both.
/// </summary>
/// <param name="tagName">The name of the tag to create parameters for.</param>
/// <param name="timeout">The timeout for tag operations.</param>
/// <param name="elementCount">libplctag <c>elem_count</c> — 1 for a scalar tag, the array
/// length for a 1-D array tag (Phase 4c). Coerced to a minimum of 1.</param>
/// <param name="isArray">Review I-1 — the EXPLICIT array signal threaded through so a
/// 1-element array (<paramref name="elementCount"/> 1) is still read as an array.</param>
/// <param name="authoredType">The tag definition's authored type, for the native runtime's
/// returned-type check; null skips it.</param>
/// <returns>The computed tag creation parameters.</returns>
public AbCipTagCreateParams BuildCreateParams(
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false,
AbCipDataType? authoredType = null) => new(
Gateway: ParsedAddress.Gateway,
Port: ParsedAddress.Port,
CipPath: ParsedAddress.CipPath,
LibplctagPlcAttribute: Profile.LibplctagPlcAttribute,
TagName: tagName,
Timeout: timeout,
AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize,
ElementCount: elementCount < 1 ? 1 : elementCount,
IsArray: isArray,
AuthoredType: authoredType,
UseConnectedMessaging: Profile.SupportsConnectedMessaging);
/// <summary>Disposes all runtime tag handles and clears the caches.</summary>
public void DisposeHandles()
{
foreach (var r in Runtimes.Values) r.Dispose();
Runtimes.Clear();
foreach (var r in ParentRuntimes.Values) r.Dispose();
ParentRuntimes.Clear();
// Dispose + clear the per-runtime/RMW gates so ReinitializeAsync cycles don't orphan
// their SemaphoreSlim instances (each leaks a wait handle once contended).
foreach (var sem in _runtimeLocks.Values) sem.Dispose();
_runtimeLocks.Clear();
foreach (var sem in _rmwLocks.Values) sem.Dispose();
_rmwLocks.Clear();
}
}
}