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

1367 lines
74 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 Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
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 (plan decision #11). 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 bulkhead + circuit-breaker isolation per plan decision #144.</para>
///
/// <para>Tier A per plan decisions #143145 — 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, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
private AbCipDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IAbCipTagFactory _tagFactory;
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
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;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _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 AbCipEquipmentTagParser, cached).
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 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; uses libplctag default if null.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; uses libplctag default if null.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; uses libplctag default if null.</param>
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
IAbCipTemplateReaderFactory? templateReaderFactory = null,
ILogger<AbCipDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagTagFactory();
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => AbCipEquipmentTagParser.TryParse(r, out var d) ? d : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
}
/// <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
{
using var reader = _templateReaderFactory.Create();
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">Top-level template instance id when known; <c>null</c> for a
/// nested-struct member (which has no fetchable id in the decoded parent shape).</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;
/// <summary>Gets the unique identifier for this driver instance.</summary>
public string DriverInstanceId => _driverInstanceId;
/// <summary>Gets the driver type identifier.</summary>
public string DriverType => "AbCip";
/// <summary>
/// Initialize the driver from its <c>DriverConfig</c> JSON. When
/// <paramref name="driverConfigJson"/> carries a real configuration (any device or tag),
/// it is parsed via <see cref="AbCipDriverFactoryExtensions.ParseOptions"/> and the
/// parsed options REPLACE the construction-time options — this is what makes
/// <see cref="ReinitializeAsync"/> pick up a changed config (new device, new tag,
/// changed timeout). A blank or empty-object JSON (<c>"{}"</c>) is treated as "no
/// override" so callers that constructed the driver with explicit options — chiefly
/// unit tests — keep those options. The driver's address-space + runtime state is then
/// built from the effective <see cref="_options"/>.
/// </summary>
/// <param name="driverConfigJson">The driver configuration as JSON; empty or "{}" means no override.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task representing the asynchronous initialization.</returns>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
if (parsed.Devices.Count > 0 || parsed.Tags.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);
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
}
foreach (var tag in _options.Tags)
{
// Duplicate-key check: a collision means two configured tags have the same name.
// Fail fast at init time with a diagnostic rather than silently clobbering.
// (Driver.AbCip-005)
if (_tagsByName.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.");
_tagsByName[tag.Name] = 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);
// 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 (_tagsByName.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.");
_tagsByName[memberTag.Name] = memberTag;
}
}
}
// 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 (Driver.AbCip-008).
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
else if (_options.Probe.Enabled && _devices.Count > 0)
{
// Driver.AbCip-011: 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;
}
return Task.CompletedTask;
}
/// <summary>Reinitialize the driver by shutting down and reinitializing with new configuration.</summary>
/// <param name="driverConfigJson">The new driver configuration as JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task representing the asynchronous reinitialization.</returns>
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Tear the driver down: stop the alarm projection + poll engine, then for each device
/// cancel its probe loop, <em>await the loop's clean exit</em>, and only then dispose
/// the probe CTS + runtime handles. Awaiting the probe Task before disposing closes the
/// race where a still-running loop touches a disposed CTS or a cleared runtime
/// dictionary (Driver.AbCip-008). Idempotent — safe to call twice (e.g. ShutdownAsync
/// from ReinitializeAsync followed by DisposeAsync).
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task representing the asynchronous shutdown.</returns>
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();
_tagsByName.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
// ---- ISubscribable (polling overlay via shared engine) ----
/// <summary>Subscribe to value changes for the specified tag references.</summary>
/// <param name="fullReferences">The tag references to subscribe to.</param>
/// <param name="publishingInterval">The interval at which to publish changes.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A handle representing the subscription.</returns>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
/// <summary>Unsubscribe from value changes using a subscription handle.</summary>
/// <param name="handle">The subscription handle to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- IAlarmSource (ALMD projection, #177) ----
/// <summary>
/// Subscribe to ALMD alarm transitions on <paramref name="sourceNodeIds"/>. Each id
/// names a declared ALMD UDT tag; the projection polls the tag's <c>InFaulted</c> +
/// <c>Severity</c> members at <see cref="AbCipDriverOptions.AlarmPollInterval"/> and
/// fires <see cref="OnAlarmEvent"/> on 0→1 (raise) + 1→0 (clear) transitions.
/// Feature-gated — when <see cref="AbCipDriverOptions.EnableAlarmProjection"/> is
/// <c>false</c> (the default), returns a handle wrapping a no-op subscription so
/// capability negotiation still works; <see cref="OnAlarmEvent"/> never fires.
/// </summary>
/// <param name="sourceNodeIds">The node IDs of alarm sources to subscribe to.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A handle representing the alarm subscription.</returns>
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);
}
/// <summary>Unsubscribe from alarm events.</summary>
/// <param name="handle">The alarm subscription handle.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
: Task.CompletedTask;
/// <summary>Acknowledge alarms.</summary>
/// <param name="acknowledgements">The alarm acknowledgements to process.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken)
: Task.CompletedTask;
// ---- IHostConnectivityProbe ----
/// <summary>Gets the connectivity status of all configured devices.</summary>
/// <returns>A read-only 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 = state.BuildCreateParams(_options.Probe.ProbeTagPath!, _options.Probe.Timeout);
IAbCipTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
{
var success = false;
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;
}
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;
}
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 the device host address for a given tag full-reference. Per plan decision #144
/// the Phase 6.1 resilience pipeline keys its bulkhead + breaker on
/// <c>(DriverInstanceId, hostName)</c> so multi-PLC drivers get per-device isolation —
/// one dead PLC trips only its own breaker. Unknown references fall back to the
/// first configured device's host address rather than throwing — the invoker handles the
/// mislookup at the capability level when the actual read returns BadNodeIdUnknown.
/// </summary>
/// <param name="fullReference">The full tag reference to resolve.</param>
/// <returns>The device host address for the tag.</returns>
public string ResolveHost(string fullReference)
{
if (_tagsByName.TryGetValue(fullReference, out var def))
return def.DeviceHostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
// ---- IReadable ----
/// <summary>
/// Read each <c>fullReference</c> in order. Unknown tags surface as
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>; any other exception becomes
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
/// </summary>
/// <param name="fullReferences">The tag references to read.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A read-only 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];
// Task #194 — 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 (see
// Driver.AbCip-003 / AbCipUdtMemberLayout remarks).
var plan = AbCipUdtReadPlanner.Build(
fullReferences, _tagsByName, _options.EnableDeclarationOnlyUdtGrouping);
foreach (var group in plan.Groups)
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
foreach (var fb in plan.Fallbacks)
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);
return results;
}
private async Task 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;
}
// Driver.AbCip-005: 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;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
// Evict the stale handle so the next call re-creates it (Driver.AbCip-010).
// 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.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
_logger.LogWarning(
"AbCip read returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, reference, def.DeviceHostAddress);
return;
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
// 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);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
_logger.LogWarning(ex,
"AbCip read transport exception for tag {Tag} on device {Device}",
reference, def.DeviceHostAddress);
}
}
/// <summary>
/// Task #194 — 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 libplctag 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 ReadGroupAsync(
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var parent = group.ParentDefinition;
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
return;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
EvictRuntime(device, parent.Name); // Driver.AbCip-010
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
StampGroupStatus(group, results, now, mapped);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading UDT {group.ParentName}");
_logger.LogWarning(
"AbCip whole-UDT read returned non-zero libplctag status {LibplctagStatus} for parent {Parent} " +
"on device {Device}; {MemberCount} member values stamped with mapped status",
status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
return;
}
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);
}
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
EvictRuntime(device, parent.Name); // Driver.AbCip-010
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
_logger.LogWarning(ex,
"AbCip whole-UDT read transport exception for parent {Parent} on device {Device}",
group.ParentName, parent.DeviceHostAddress);
}
}
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 ----
/// <summary>
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
/// and the resilience pipeline (layered above the driver) decides whether to replay.
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
/// </summary>
/// <param name="writes">The write requests to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A read-only 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];
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 (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
try
{
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
// BOOL-within-DINT writes — per task #181, 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);
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
EvictRuntime(device, def.Name); // Driver.AbCip-010
results[i] = new WriteResult(AbCipStatusMapper.MapLibplctagStatus(status));
_logger.LogWarning(
"AbCip write returned non-zero libplctag status {LibplctagStatus} 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 (Exception ex)
{
// Transport / wire error — evict so the next write creates a fresh handle.
EvictRuntime(device, def.Name); // Driver.AbCip-010
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 shipped in pass 1 of task #181.
/// </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.MapLibplctagStatus(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.MapLibplctagStatus(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>
/// 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>
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 InvalidOperationException(
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
// 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)));
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.Runtimes.TryAdd(def.Name, runtime))
return runtime;
runtime.Dispose();
return device.Runtimes[def.Name];
}
/// <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 libplctag status or transport exception —
/// mirroring the probe loop's recreate-on-failure behaviour (Driver.AbCip-010).
/// </summary>
private static void EvictRuntime(DeviceState device, string tagName)
{
if (device.Runtimes.TryRemove(tagName, out var stale))
{
try { stale.Dispose(); } catch { }
}
}
/// <summary>Gets the current health status of the driver.</summary>
/// <returns>The driver health information.</returns>
public DriverHealth GetHealth() => _health;
/// <summary>
/// CLR-visible allocation footprint only — libplctag's native heap is invisible to the
/// GC. driver-specs.md §3 flags this: operators must watch whole-process RSS for the
/// full picture, and <see cref="ReinitializeAsync"/> is the Tier-B remediation.
/// </summary>
/// <returns>The memory footprint in bytes.</returns>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches to free memory.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
{
_templateCache.Clear();
return Task.CompletedTask;
}
// ---- ITagDiscovery ----
/// <summary>
/// Stream the driver's tag set into the builder. Pre-declared tags from
/// <see cref="AbCipDriverOptions.Tags"/> emit first; optionally, the
/// <see cref="IAbCipTagEnumerator"/> walks each device's symbol table and adds
/// controller-discovered tags under a <c>Discovered/</c> sub-folder. System / module /
/// routine / task tags are hidden via <see cref="AbCipSystemTagFilter"/>.
/// </summary>
/// <param name="builder">The address space builder to populate with discovered tags.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task representing the asynchronous discovery.</returns>
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).
var preDeclared = _options.Tags.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
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));
}
continue;
}
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
}
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
// walker (LibplctagTagEnumerator) is the factory default since task #178 shipped,
// 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);
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.
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 not null)
{
// 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;
// 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;
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: discovered.DataType.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));
}
}
}
}
/// <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)
{
var memberFullName = $"{parentFullName}.{member.Name}";
if (member.DataType == AbCipDataType.Structure)
{
// Nested struct: recurse into its sub-shape if it resolves, 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 (depth + 1 > MaxUdtDepth) continue;
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
// The decoded parent shape carries the nested UDT's template instance id (low 12 bits of the
// member info, captured by CipTemplateObjectDecoder) — thread it so the nested shape is fetched
// off the controller via FetchUdtShapeAsync, making nested-struct members addressable in
// production (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 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: member.DataType.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));
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;
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);
/// <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 (Driver.AbCip-006).
/// </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>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 (Driver.AbCip-008).
/// </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));
/// <summary>
/// Driver.AbCip-013 — 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>
/// <returns>The computed tag creation parameters.</returns>
public AbCipTagCreateParams BuildCreateParams(
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false) => 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);
/// <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();
}
}
}