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

1695 lines
84 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.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
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 <see cref="IDriver"/> only for now — read/write/
/// subscribe/discover capabilities ship in subsequent PRs (38) and family-specific quirk
/// profiles ship in PRs 912.
/// </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 every
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
/// </remarks>
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDriverControl, IDisposable, IAsyncDisposable
{
private readonly AbCipDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IAbCipTagFactory _tagFactory;
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
private readonly AbCipTemplateCache _templateCache = new();
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly AbCipAlarmProjection _alarmProjection;
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <summary>Internal seam for the alarm projection to raise events through the driver.</summary>
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
IAbCipTemplateReaderFactory? templateReaderFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagTagFactory();
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
}
/// <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>
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;
// UDT template reads target the @udt/{id} pseudo-tag, which the controller already
// serves via a logical-segment path of its own. Force Symbolic addressing so we don't
// overlay the driver's Logical mode on top — libplctag knows how to dereference the
// pseudo-tag directly.
var deviceParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: $"@udt/{templateInstanceId}",
Timeout: _options.Timeout,
ConnectionSize: device.ConnectionSize,
AddressingMode: AddressingMode.Symbolic);
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
{
// Template read failure — log via the driver's health surface so operators see it,
// but don't propagate since callers should fall back to declaration-driven UDT
// semantics rather than failing the whole discovery run.
return null;
}
}
/// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
internal AbCipTemplateCache TemplateCache => _templateCache;
public string DriverInstanceId => _driverInstanceId;
public string DriverType => "AbCip";
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
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);
// PR abcip-3.1 — validate the optional ConnectionSize override before stamping
// the device. The Kepware-supported range [500..4002] is what the libplctag
// ControlLogix driver supports; anything outside that fails the Forward Open at
// runtime, so we reject it loudly at config time instead.
if (device.ConnectionSize is int explicitSize)
{
if (explicitSize < AbCipConnectionSize.Min || explicitSize > AbCipConnectionSize.Max)
throw new InvalidOperationException(
$"AbCip device '{device.HostAddress}' has ConnectionSize {explicitSize} outside the supported range " +
$"[{AbCipConnectionSize.Min}..{AbCipConnectionSize.Max}].");
// Legacy-firmware warning: families whose profile default is 504 (CompactLogix
// narrow cap, also where v19-and-earlier ControlLogix lives) can't actually
// raise their CIP buffer above 511 bytes — the controller rejects the Forward
// Open. Ship the override anyway so newer firmware can use it, but flag the
// mismatch so operators see it in the warning sink.
if (explicitSize > AbCipConnectionSize.LegacyFirmwareCap
&& profile.DefaultConnectionSize <= AbCipConnectionSize.LegacyFirmwareCap)
{
_options.OnWarning?.Invoke(
$"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' uses a narrow-buffer profile " +
$"(default ConnectionSize {profile.DefaultConnectionSize}); the configured ConnectionSize {explicitSize} " +
$"exceeds the {AbCipConnectionSize.LegacyFirmwareCap}-byte legacy-firmware cap and will fail the " +
"Forward Open on v19-and-earlier ControlLogix or 5069-L1/L2/L3 CompactLogix firmware.");
}
}
// PR abcip-3.2 — resolve AddressingMode at the device level. Auto → Symbolic
// until a future PR adds a real auto-detection heuristic; Logical against an
// unsupported family falls back to Symbolic + emits a warning so misconfiguration
// does not fault the driver.
var resolvedAddressing = ResolveAddressingMode(device, profile);
// PR abcip-3.3 — resolve ReadStrategy at the device level. User-forced MultiPacket
// against a non-packing family (Micro800 et al) falls back to WholeUdt with a
// warning. Auto stays as-is — the planner re-evaluates per-batch using the
// device's MultiPacketSparsityThreshold.
var resolvedReadStrategy = ResolveReadStrategy(device, profile);
_devices[device.HostAddress] = new DeviceState(
addr, device, profile, resolvedAddressing, resolvedReadStrategy);
}
// Pre-declared tags first; L5K imports fill in only the names not already covered
// (operators can override an imported entry by re-declaring it under Tags).
var declaredNames = new HashSet<string>(
_options.Tags.Select(t => t.Name),
StringComparer.OrdinalIgnoreCase);
var allTags = new List<AbCipTagDefinition>(_options.Tags);
foreach (var import in _options.L5kImports)
{
MergeImport(
deviceHost: import.DeviceHostAddress,
filePath: import.FilePath,
inlineText: import.InlineText,
namePrefix: import.NamePrefix,
parse: L5kParser.Parse,
formatLabel: "L5K",
declaredNames: declaredNames,
allTags: allTags);
}
foreach (var import in _options.L5xImports)
{
MergeImport(
deviceHost: import.DeviceHostAddress,
filePath: import.FilePath,
inlineText: import.InlineText,
namePrefix: import.NamePrefix,
parse: L5xParser.Parse,
formatLabel: "L5X",
declaredNames: declaredNames,
allTags: allTags);
}
foreach (var import in _options.CsvImports)
{
MergeCsvImport(import, declaredNames, allTags);
}
foreach (var tag in allTags)
{
_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,
StringLength: member.StringLength);
_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;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
return Task.CompletedTask;
}
/// <summary>
/// PR abcip-3.2 — resolve <see cref="AbCipDeviceOptions.AddressingMode"/> against the
/// family profile. <see cref="AddressingMode.Auto"/> resolves to <see cref="AddressingMode.Symbolic"/>
/// today (the same behaviour every previous build had); a future PR will plumb a real
/// auto-detection heuristic and document it in <c>docs/drivers/AbCip-Performance.md</c>.
/// <see cref="AddressingMode.Logical"/> against a family whose profile sets
/// <see cref="AbCipPlcFamilyProfile.SupportsLogicalAddressing"/> = <c>false</c> (Micro800,
/// SLC500, PLC5) falls back to <see cref="AddressingMode.Symbolic"/> with a warning so
/// the operator sees the misconfiguration in the log without the driver faulting.
/// </summary>
private AddressingMode ResolveAddressingMode(AbCipDeviceOptions device, AbCipPlcFamilyProfile profile)
{
switch (device.AddressingMode)
{
case AddressingMode.Logical:
if (!profile.SupportsLogicalAddressing)
{
_options.OnWarning?.Invoke(
$"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' does not support " +
"Logical (instance-ID) addressing — its CIP firmware lacks Symbol Object class 0x6B. " +
"Falling back to Symbolic addressing for this device.");
return AddressingMode.Symbolic;
}
return AddressingMode.Logical;
case AddressingMode.Symbolic:
return AddressingMode.Symbolic;
case AddressingMode.Auto:
default:
// Future heuristic point — for now Auto = Symbolic so the addressing toggle is
// explicit + every existing deployment keeps the historical wire behaviour.
return AddressingMode.Symbolic;
}
}
/// <summary>
/// PR abcip-3.3 — resolve <see cref="AbCipDeviceOptions.ReadStrategy"/> against the
/// family profile. <see cref="ReadStrategy.MultiPacket"/> against a family whose profile
/// sets <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> = <c>false</c>
/// (Micro800 today; SLC500 / PLC5 when those profiles ship) falls back to
/// <see cref="ReadStrategy.WholeUdt"/> with a warning so the operator sees the
/// misconfiguration in the log without the driver faulting. <see cref="ReadStrategy.Auto"/>
/// stays as-is — the planner re-evaluates the choice per-batch from the device's
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>; we still need the
/// resolution step here so a future PR can cap Auto at WholeUdt on non-packing families
/// in one place rather than scattering the check across the read path.
/// </summary>
private ReadStrategy ResolveReadStrategy(AbCipDeviceOptions device, AbCipPlcFamilyProfile profile)
{
switch (device.ReadStrategy)
{
case ReadStrategy.MultiPacket:
if (!profile.SupportsRequestPacking)
{
_options.OnWarning?.Invoke(
$"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' does not support " +
"Multi-Service Packet request packing — its CIP firmware lacks the 0x0A service. " +
"Falling back to WholeUdt read strategy for this device.");
return ReadStrategy.WholeUdt;
}
return ReadStrategy.MultiPacket;
case ReadStrategy.WholeUdt:
return ReadStrategy.WholeUdt;
case ReadStrategy.Auto:
default:
// Auto on a non-packing family stays Auto here so the planner's per-batch
// heuristic still runs; the heuristic itself never picks MultiPacket against a
// device whose AddressingMode-style guard tripped — but for ReadStrategy the guard
// lives in the device-init resolution above (user-forced MultiPacket → WholeUdt).
// For Auto, the planner consults device.Profile.SupportsRequestPacking before
// emitting MultiPacket so non-packing families always read WholeUdt under Auto.
return ReadStrategy.Auto;
}
}
/// <summary>
/// Shared L5K / L5X import path — keeps source-format selection (parser delegate) the
/// only behavioural axis between the two formats. Adds the parser's tags to
/// <paramref name="allTags"/> while skipping any name already covered by an earlier
/// declaration or import (declared > L5K > L5X precedence falls out from call order).
/// </summary>
private static void MergeImport(
string deviceHost,
string? filePath,
string? inlineText,
string namePrefix,
Func<IL5kSource, L5kDocument> parse,
string formatLabel,
HashSet<string> declaredNames,
List<AbCipTagDefinition> allTags)
{
if (string.IsNullOrWhiteSpace(deviceHost))
throw new InvalidOperationException(
$"AbCip {formatLabel} import is missing DeviceHostAddress — every imported tag needs a target device.");
IL5kSource? src = null;
if (!string.IsNullOrEmpty(filePath))
src = new FileL5kSource(filePath);
else if (!string.IsNullOrEmpty(inlineText))
src = new StringL5kSource(inlineText);
if (src is null) return;
var doc = parse(src);
var ingest = new L5kIngest
{
DefaultDeviceHostAddress = deviceHost,
NamePrefix = namePrefix,
};
var result = ingest.Ingest(doc);
foreach (var importedTag in result.Tags)
{
if (declaredNames.Contains(importedTag.Name)) continue;
allTags.Add(importedTag);
declaredNames.Add(importedTag.Name);
}
}
/// <summary>
/// CSV-import variant of <see cref="MergeImport"/>. The CSV path produces
/// <see cref="AbCipTagDefinition"/> records directly (no intermediate document) so we
/// can't share the L5K/L5X parser-delegate signature. Merge semantics are identical:
/// a name already covered by a declaration or an earlier import is left untouched so
/// the precedence chain (declared > L5K > L5X > CSV) holds.
/// </summary>
private static void MergeCsvImport(
AbCipCsvImportOptions import,
HashSet<string> declaredNames,
List<AbCipTagDefinition> allTags)
{
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress))
throw new InvalidOperationException(
"AbCip CSV import is missing DeviceHostAddress — every imported tag needs a target device.");
string? csvText = null;
if (!string.IsNullOrEmpty(import.FilePath))
csvText = System.IO.File.ReadAllText(import.FilePath);
else if (!string.IsNullOrEmpty(import.InlineText))
csvText = import.InlineText;
if (csvText is null) return;
var importer = new CsvTagImporter
{
DefaultDeviceHostAddress = import.DeviceHostAddress,
NamePrefix = import.NamePrefix,
};
var result = importer.Import(csvText);
foreach (var tag in result.Tags)
{
if (declaredNames.Contains(tag.Name)) continue;
allTags.Add(tag);
declaredNames.Add(tag.Name);
}
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
await _poll.DisposeAsync().ConfigureAwait(false);
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.DisposeHandles();
}
_devices.Clear();
_tagsByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- 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>
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);
}
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
: Task.CompletedTask;
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken)
: Task.CompletedTask;
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
// Probe handles always run in Symbolic mode regardless of the device's resolved
// AddressingMode — the probe tag (e.g. @raw_cpu_type) is a system pseudo-tag, not a
// user symbol that appears in the @tags walk, so there is no instance ID to feed
// libplctag. Hard-coding Symbolic here keeps the probe loop independent of the symbol
// walk + matches the legacy behaviour even on Logical-mode devices.
var probeParams = new AbCipTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
CipPath: state.ParsedAddress.CipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: _options.Probe.ProbeTagPath!,
Timeout: _options.Probe.Timeout,
ConnectionSize: state.ConnectionSize,
AddressingMode: AddressingMode.Symbolic);
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
{
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
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>
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"/>; 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>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
// PR abcip-3.2 — first-read symbol-walk for Logical-mode devices. Each device that
// resolved to Logical fires one @tags walk; subsequent reads consult the cached
// name → instance-id map. Devices in Symbolic mode skip the walk entirely.
await EnsureLogicalMappingsAsync(fullReferences, cancellationToken).ConfigureAwait(false);
// 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 path that's been here since PR 3. 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.
//
// PR abcip-3.3 — dispatch by device's resolved ReadStrategy:
// WholeUdt — every group goes through the whole-UDT planner (task #194 default).
// MultiPacket — every group goes through the multi-packet planner; one read per
// subscribed member, bundled per parent.
// Auto — per-group heuristic on subscribedMembers / totalMembers.
await ExecuteReadPlanAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false);
return results;
}
/// <summary>
/// PR abcip-3.3 — strategy-aware dispatch wrapper around the WholeUdt + MultiPacket
/// planners. Both planners produce the same shape of "groups + per-tag fallbacks" so
/// fallbacks always run through <see cref="ReadSingleAsync"/>; only the group shape
/// differs. Auto resolves per-group: members of the same parent UDT either flow through
/// <see cref="ReadGroupAsync"/> (one whole-UDT read) or
/// <see cref="ReadMultiPacketBatchAsync"/> (per-member reads bundled per parent).
/// </summary>
private async Task ExecuteReadPlanAsync(
IReadOnlyList<string> fullReferences, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
// First pass — segregate references by parent UDT vs everything-else, identical to the
// shape both planners produce; we can then route each parent group through the chosen
// planner. Reuse AbCipUdtReadPlanner.Build for the WholeUdt+Auto path because it already
// demotes single-member groups to fallback, and the MultiPacket planner does NOT demote
// (sparse reads of one member are still a win on the wire).
var wholeUdtPlan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
var multiPacketPlan = AbCipMultiPacketReadPlanner.Build(fullReferences, _tagsByName);
// Determine per-parent strategy. WholeUdt planner emits a Group only when ≥2 members of
// the same parent are subscribed; MultiPacket planner emits a Batch for every parent
// touched. Index multiPacket batches by parent name so we can co-route them with the
// WholeUdt grouping decisions.
var multiPacketByParent = new Dictionary<string, AbCipMultiPacketReadBatch>(
multiPacketPlan.Batches.Count, StringComparer.OrdinalIgnoreCase);
foreach (var b in multiPacketPlan.Batches)
multiPacketByParent[b.ParentName] = b;
// Treat each parent that survived the WholeUdt planner as the candidate for whole-UDT
// dispatch; per-strategy routing decides whether it goes WholeUdt or MultiPacket.
var routedParents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in wholeUdtPlan.Groups)
{
_devices.TryGetValue(group.ParentDefinition.DeviceHostAddress, out var device);
var strategy = ChooseEffectiveStrategy(device, group);
if (strategy == ReadStrategy.MultiPacket
&& multiPacketByParent.TryGetValue(group.ParentName, out var mpBatch))
{
if (device is not null) Interlocked.Increment(ref device.MultiPacketGroupsExecuted);
await ReadMultiPacketBatchAsync(mpBatch, results, now, ct).ConfigureAwait(false);
}
else
{
if (device is not null) Interlocked.Increment(ref device.WholeUdtGroupsExecuted);
await ReadGroupAsync(group, results, now, ct).ConfigureAwait(false);
}
routedParents.Add(group.ParentName);
}
// Per-tag fallbacks from the WholeUdt planner are the union of (a) tags with no UDT
// parent, (b) UDT members where the parent had only one subscribed member, and (c)
// unknown references. We re-route singletons through MultiPacket too when the device
// strategy is MultiPacket, because a 1-of-50 read is exactly the sparse case the planner
// is designed for.
foreach (var fb in wholeUdtPlan.Fallbacks)
{
// Is this a UDT-member fallback that the MultiPacket planner can route?
if (multiPacketPlan.Batches.Count > 0
&& _tagsByName.TryGetValue(fb.Reference, out var def))
{
var dot = fb.Reference.IndexOf('.');
if (dot > 0 && dot < fb.Reference.Length - 1)
{
var parentName = fb.Reference[..dot];
if (!routedParents.Contains(parentName)
&& multiPacketByParent.TryGetValue(parentName, out var mpBatch)
&& _devices.TryGetValue(def.DeviceHostAddress, out var device))
{
// Singleton parent — Auto picks based on the same heuristic; MultiPacket
// wins by default because the WholeUdt planner already demoted single
// members under the assumption that one whole-UDT read is no cheaper
// than one member read. With explicit MultiPacket the answer flips.
var strategy = ChooseEffectiveStrategyForSingleton(device, mpBatch);
if (strategy == ReadStrategy.MultiPacket)
{
Interlocked.Increment(ref device.MultiPacketGroupsExecuted);
await ReadMultiPacketBatchAsync(mpBatch, results, now, ct).ConfigureAwait(false);
routedParents.Add(parentName);
continue;
}
}
}
}
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, ct).ConfigureAwait(false);
}
}
/// <summary>
/// PR abcip-3.3 — pick the effective <see cref="ReadStrategy"/> for one parent UDT group.
/// <see cref="ReadStrategy.WholeUdt"/> + <see cref="ReadStrategy.MultiPacket"/> are
/// forced explicitly (already family-compat-checked at device init). <see cref="ReadStrategy.Auto"/>
/// consults the planner heuristic on subscribed-member fraction, but only when the
/// family supports request packing — non-packing families always read WholeUdt regardless
/// of sparsity because they have no Multi-Service-Packet path on the wire.
/// </summary>
private static ReadStrategy ChooseEffectiveStrategy(DeviceState? device, AbCipUdtReadGroup group)
{
if (device is null) return ReadStrategy.WholeUdt;
switch (device.ReadStrategy)
{
case ReadStrategy.MultiPacket:
return ReadStrategy.MultiPacket;
case ReadStrategy.WholeUdt:
return ReadStrategy.WholeUdt;
case ReadStrategy.Auto:
default:
if (!device.Profile.SupportsRequestPacking) return ReadStrategy.WholeUdt;
var totalMembers = group.ParentDefinition.Members?.Count ?? 0;
return AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(
subscribedMembers: group.Members.Count,
totalMembers: totalMembers,
threshold: device.Options.MultiPacketSparsityThreshold);
}
}
/// <summary>
/// PR abcip-3.3 — strategy pick for a singleton (one-member) UDT batch. Only relevant
/// when the device strategy is explicit <see cref="ReadStrategy.MultiPacket"/> or Auto
/// produces a MultiPacket result; otherwise the per-tag fallback path runs as before.
/// </summary>
private static ReadStrategy ChooseEffectiveStrategyForSingleton(
DeviceState device, AbCipMultiPacketReadBatch batch)
{
switch (device.ReadStrategy)
{
case ReadStrategy.MultiPacket:
return ReadStrategy.MultiPacket;
case ReadStrategy.WholeUdt:
return ReadStrategy.WholeUdt;
case ReadStrategy.Auto:
default:
if (!device.Profile.SupportsRequestPacking) return ReadStrategy.WholeUdt;
var totalMembers = batch.ParentDefinition.Members?.Count ?? 0;
return AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(
subscribedMembers: batch.Members.Count,
totalMembers: totalMembers,
threshold: device.Options.MultiPacketSparsityThreshold);
}
}
/// <summary>
/// PR abcip-3.3 — execute a Multi-Service-Packet batch. Today this issues one libplctag
/// read per member (the same N reads the per-tag fallback path does), keyed on the
/// batch's parent so the diagnostic counters track which strategy ran. Wire-level
/// Multi-Service-Packet bundling depends on the libplctag .NET wrapper exposing the
/// 0x0A service explicitly — same wrapper limitation as PR abcip-3.1's connection_size
/// and PR abcip-3.2's instance-ID addressing. The planner's grouping is still
/// load-bearing because it gives the runtime the correct plan when an upstream wrapper
/// release exposes the bundling primitive.
/// </summary>
private async Task ReadMultiPacketBatchAsync(
AbCipMultiPacketReadBatch batch, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var parent = batch.ParentDefinition;
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
{
foreach (var m in batch.Members)
results[m.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
}
foreach (var member in batch.Members)
{
var memberFullName = member.Definition.Name;
var fb = new AbCipUdtReadFallback(member.OriginalIndex, memberFullName);
await ReadSingleAsync(fb, memberFullName, results, now, ct).ConfigureAwait(false);
}
}
/// <summary>
/// PR abcip-3.2 — for each Logical-mode device touched by this read batch, fire the
/// one-time <c>@tags</c> symbol-table walk + populate <see cref="DeviceState.LogicalInstanceMap"/>.
/// Subsequent reads short-circuit on <see cref="DeviceState.LogicalWalkComplete"/>;
/// concurrent first reads on the same device serialise on
/// <see cref="DeviceState.LogicalWalkLock"/> so the walk is dispatched once even under
/// parallel load.
/// </summary>
/// <remarks>
/// <para>The walk uses the same <see cref="LibplctagTagEnumerator"/> as discovery —
/// reading <c>@tags</c> + decoding the Symbol Object response. Failures are intentionally
/// swallowed: an empty map after the walk-attempted flag flips means subsequent reads
/// fall back to Symbolic addressing on the wire (libplctag's default), which is the
/// same wire behaviour every previous build had. Driver health is not faulted because a
/// tag-list-walk failure does not actually block reads.</para>
/// </remarks>
private async Task EnsureLogicalMappingsAsync(
IReadOnlyList<string> fullReferences, CancellationToken ct)
{
// Find the unique set of Logical-mode devices the batch touches. Most batches touch
// one device, so the HashSet is a small allocation.
HashSet<DeviceState>? pending = null;
foreach (var reference in fullReferences)
{
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
if (device.AddressingMode != AddressingMode.Logical) continue;
if (device.LogicalWalkComplete) continue;
(pending ??= []).Add(device);
}
if (pending is null) return;
foreach (var device in pending)
{
await device.LogicalWalkLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (device.LogicalWalkComplete) continue;
try
{
using var enumerator = _enumeratorFactory.Create();
var deviceParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: "@tags",
Timeout: _options.Timeout,
ConnectionSize: device.ConnectionSize,
AddressingMode: AddressingMode.Symbolic);
// Symbol Object instance IDs aren't surfaced on AbCipDiscoveredTag yet — the
// record carries Name / ProgramScope / DataType / ReadOnly. We populate the
// map keyed on the Logix tag path the driver uses internally; the libplctag
// wrapper limitation (no public ConnectionSize / cip_addr knob in 1.5.x)
// means the value side stays unmapped for now and the runtime degrades to
// Symbolic on the wire. The map's presence is still load-bearing: it's
// observable from tests + future-proofs the driver for when an upstream
// wrapper release exposes the IDs through the enumerator + Tag attribute.
await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, ct)
.ConfigureAwait(false))
{
if (discovered.IsSystemTag) continue;
if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue;
var fullName = discovered.ProgramScope is null
? discovered.Name
: $"Program:{discovered.ProgramScope}.{discovered.Name}";
// No instance ID in the current discovered-tag shape; record an
// entry so the runtime knows the symbol is part of the Logical
// resolution pass (the map's presence influences slice + parent-DINT
// creation). 0 is reserved by CIP for "not assigned" so it's a safe
// sentinel that the runtime's reflection guard treats as "missing".
device.LogicalInstanceMap[fullName] = 0u;
}
}
catch (OperationCanceledException) { throw; }
catch
{
// Walk failure is non-fatal — the driver keeps Logical mode set but
// every per-tag handle ends up using Symbolic addressing on the wire.
}
finally
{
device.LogicalWalkComplete = true;
}
}
finally
{
device.LogicalWalkLock.Release();
}
}
}
private async Task ReadSingleAsync(
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
}
// PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to
// AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one
// Rockwell array read; the contiguous buffer is decoded at element stride into a
// single snapshot whose Value is an object[] of the N elements.
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
if (parsedPath?.Slice is not null)
{
await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false);
return;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
return;
}
var bitIndex = parsedPath?.BitIndex;
var value = 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)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
/// <summary>
/// PR abcip-1.3 — slice read path. Builds an <see cref="AbCipArrayReadPlan"/> from the
/// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so
/// repeat reads reuse the same libplctag handle), issues one PLC array read, and
/// decodes the contiguous buffer into <c>object?[]</c> at element stride. Unsupported
/// element types fall back to <see cref="AbCipStatusMapper.BadNotSupported"/>.
/// </summary>
private async Task ReadSliceAsync(
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
// PR abcip-3.2 — slice reads piggyback on the device's resolved addressing mode. Logical
// mode looks up the parent array tag's instance ID via the @tags map; null-fallback to
// Symbolic when the array isn't in the map (e.g. @tags walk hasn't populated the entry).
uint? sliceLogicalId = null;
if (device.AddressingMode == AddressingMode.Logical
&& device.LogicalInstanceMap.TryGetValue(def.TagPath, out var sliceId))
{
sliceLogicalId = sliceId;
}
var baseParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsedPath.ToLibplctagName(),
Timeout: _options.Timeout,
ConnectionSize: device.ConnectionSize,
AddressingMode: device.AddressingMode,
LogicalInstanceId: sliceLogicalId);
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
if (plan is null)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadNotSupported, null, now);
return;
}
try
{
var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, ct)
.ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading slice {def.Name}");
return;
}
var values = AbCipArrayReadPlanner.Decode(plan, runtime);
results[fb.OriginalIndex] = new DataValueSnapshot(values, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
/// <summary>
/// Idempotently materialise a slice-read runtime. Slice runtimes share the device's
/// <see cref="DeviceState.Runtimes"/> dict keyed by the tag's full name so repeated
/// reads reuse the same libplctag handle without re-creating the native tag every poll.
/// </summary>
private async Task<IAbCipTagRuntime> EnsureSliceRuntimeAsync(
DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct)
{
if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing;
var runtime = _tagFactory.Create(createParams);
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.Runtimes[tagName] = runtime;
return runtime;
}
/// <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)
{
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
StampGroupStatus(group, results, now, mapped);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading UDT {group.ParentName}");
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)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, 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 ----
/// <summary>
/// Write each request in the batch. 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>
/// <remarks>
/// PR abcip-1.4 — multi-tag write packing. Writes are grouped by device via
/// <see cref="AbCipMultiWritePlanner"/>. Devices whose family
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> is <c>true</c> dispatch
/// their packable writes concurrently so libplctag's native scheduler can coalesce them
/// onto one CIP Multi-Service Packet (0x0A) per round-trip; Micro800 (no packing) still
/// issues writes one-at-a-time. BOOL-within-DINT writes always go through the RMW path
/// under a per-parent semaphore, regardless of the family flag, because two concurrent
/// RMWs on the same DINT could lose one another's update. Per-tag StatusCodes are
/// preserved in the caller's input order on partial failures.
/// </remarks>
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
var plans = AbCipMultiWritePlanner.Build(
writes, _tagsByName, _devices,
reportPreflight: (idx, code) => results[idx] = new WriteResult(code));
foreach (var plan in plans)
{
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
{
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
// Bit-RMW writes always serialise per-parent — never packed.
foreach (var entry in plan.BitRmw)
results[entry.OriginalIndex] = new WriteResult(
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
if (plan.Packable.Count == 0) continue;
if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1)
{
// Concurrent dispatch — libplctag's native scheduler packs same-connection writes
// into one Multi-Service Packet when the family supports it.
var tasks = new Task<(int idx, uint code)>[plan.Packable.Count];
for (var i = 0; i < plan.Packable.Count; i++)
{
var entry = plan.Packable[i];
tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken);
}
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
foreach (var (idx, code) in outcomes)
results[idx] = new WriteResult(code);
}
else
{
// Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential.
foreach (var entry in plan.Packable)
{
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
.ConfigureAwait(false);
results[entry.OriginalIndex] = new WriteResult(code.code);
}
}
}
return results;
}
/// <summary>
/// Execute one packable write — encode the value into the per-tag runtime, flush, and
/// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the
/// pre-1.4 per-tag loop so callers see no behaviour change for individual writes.
/// </summary>
private async Task<(int idx, uint code)> ExecutePackableWriteAsync(
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
{
var def = entry.Definition;
var w = entry.Request;
var now = DateTime.UtcNow;
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
runtime.EncodeValue(def.DataType, entry.ParsedPath?.BitIndex, w.Value);
await runtime.WriteAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status == 0)
{
_health = new DriverHealth(DriverState.Healthy, now, null);
return (entry.OriginalIndex, AbCipStatusMapper.Good);
}
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
}
catch (OperationCanceledException)
{
throw;
}
catch (NotSupportedException nse)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadNotSupported);
}
catch (FormatException fe)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
}
catch (InvalidCastException ice)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
}
catch (OverflowException oe)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadOutOfRange);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return (entry.OriginalIndex, AbCipStatusMapper.BadCommunicationError);
}
}
/// <summary>
/// Execute one BOOL-within-DINT write through <see cref="WriteBitInDIntAsync"/>, with
/// the same exception-mapping fan-out as the pre-1.4 per-tag loop. Bit RMWs cannot be
/// packed because two concurrent writes against the same parent DINT would race their
/// read-modify-write windows.
/// </summary>
private async Task<uint> ExecuteBitRmwWriteAsync(
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
{
try
{
var bit = entry.ParsedPath!.BitIndex!.Value;
var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct)
.ConfigureAwait(false);
if (code == AbCipStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
return code;
}
catch (OperationCanceledException)
{
throw;
}
catch (NotSupportedException nse)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
return AbCipStatusMapper.BadNotSupported;
}
catch (FormatException fe)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
return AbCipStatusMapper.BadTypeMismatch;
}
catch (InvalidCastException ice)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
return AbCipStatusMapper.BadTypeMismatch;
}
catch (OverflowException oe)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
return AbCipStatusMapper.BadOutOfRange;
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return AbCipStatusMapper.BadCommunicationError;
}
}
/// <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;
// PR abcip-3.2 — parent-DINT runtimes follow the device's resolved addressing mode so
// BOOL-in-DINT RMW reads/writes share Logical-mode benefits when the parent has been
// mapped. When the parent isn't in the @tags map (or Symbolic is the resolved mode),
// libplctag falls back to ASCII addressing transparently.
uint? parentLogicalId = null;
if (device.AddressingMode == AddressingMode.Logical
&& device.LogicalInstanceMap.TryGetValue(parentTagName, out var pid))
{
parentLogicalId = pid;
}
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentTagName,
Timeout: _options.Timeout,
ConnectionSize: device.ConnectionSize,
AddressingMode: device.AddressingMode,
LogicalInstanceId: parentLogicalId));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.ParentRuntimes[parentTagName] = runtime;
return runtime;
}
/// <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}'.");
// PR abcip-3.2 — Logical-mode devices look up the controller-assigned Symbol Object
// instance ID for this tag from the one-time @tags walk; missing entries fall back to
// Symbolic addressing for this handle (the runtime detects LogicalInstanceId == null).
uint? logicalId = null;
if (device.AddressingMode == AddressingMode.Logical
&& device.LogicalInstanceMap.TryGetValue(def.TagPath, out var resolvedId))
{
logicalId = resolvedId;
}
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout,
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null,
ConnectionSize: device.ConnectionSize,
AddressingMode: device.AddressingMode,
LogicalInstanceId: logicalId));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.Runtimes[def.Name] = runtime;
return runtime;
}
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>
public long GetMemoryFootprint() => 0;
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>
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false);
}
finally
{
_discoverySemaphore.Release();
}
}
/// <summary>
/// PR abcip-2.5 — operator-triggered rebrowse. Drops the cached UDT template shapes so
/// the next read re-fetches them from the controller, then runs the same enumerator
/// walk + builder fan-out that <see cref="DiscoverAsync"/> drives. Serialised against
/// other rebrowse / discovery passes via <see cref="_discoverySemaphore"/> so two
/// concurrent triggers don't double-issue the @tags read.
/// </summary>
public async Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Stale template shapes can outlive a controller program-download, so a rebrowse
// is the natural moment to drop them; subsequent UDT reads re-populate on demand.
_templateCache.Clear();
await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false);
}
finally
{
_discoverySemaphore.Release();
}
}
private async Task DiscoverCoreAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
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);
// PR abcip-2.6 — AOI-aware fan-out. When any member carries a non-Local
// AoiQualifier the tag is treated as an AOI instance: Input / Output / InOut
// members get grouped under sub-folders (Inputs/, Outputs/, InOut/) so the
// browse tree visually matches Studio 5000's AOI parameter tabs. Plain UDT
// tags (every member Local) retain the pre-2.6 flat layout under the parent
// folder so existing browse paths stay stable.
var hasDirectional = tag.Members.Any(m => m.AoiQualifier != AoiQualifier.Local);
IAddressSpaceBuilder? inputsFolder = null;
IAddressSpaceBuilder? outputsFolder = null;
IAddressSpaceBuilder? inOutFolder = null;
foreach (var member in tag.Members)
{
var parentFolder = udtFolder;
if (hasDirectional)
{
parentFolder = member.AoiQualifier switch
{
AoiQualifier.Input => inputsFolder ??= udtFolder.Folder("Inputs", "Inputs"),
AoiQualifier.Output => outputsFolder ??= udtFolder.Folder("Outputs", "Outputs"),
AoiQualifier.InOut => inOutFolder ??= udtFolder.Folder("InOut", "InOut"),
_ => udtFolder, // Local stays at the AOI root
};
}
var memberFullName = $"{tag.Name}.{member.Name}";
parentFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
FullName: memberFullName,
DriverDataType: member.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
SecurityClass: member.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: member.WriteIdempotent,
Description: member.Description));
}
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();
// The @tags walker reads the controller's Symbol Object class 0x6B directly +
// does not need the driver's per-tag addressing-mode plumbing — it already
// operates on instance-ID semantics by definition. Pin Symbolic so libplctag
// doesn't try to layer Logical-mode attributes on top of @tags.
var deviceParams = new AbCipTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
CipPath: state.ParsedAddress.CipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: "@tags",
Timeout: _options.Timeout,
ConnectionSize: state.ConnectionSize,
AddressingMode: AddressingMode.Symbolic);
IAddressSpaceBuilder? discoveredFolder = null;
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}";
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: discovered.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
SecurityClass: discovered.ReadOnly
? SecurityClassification.ViewOnly
: SecurityClassification.Operate,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
}
}
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
SecurityClass: (tag.Writable && !tag.SafetyTag)
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent,
Description: tag.Description);
/// <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>
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
_discoverySemaphore.Dispose();
}
/// <summary>
/// Per-device runtime state. Holds the parsed host address, family profile, and the
/// live <see cref="PlcTagHandle"/> cache keyed by tag path. PRs 38 populate + consume
/// this dict via libplctag.
/// </summary>
internal sealed class DeviceState(
AbCipHostAddress parsedAddress,
AbCipDeviceOptions options,
AbCipPlcFamilyProfile profile,
AddressingMode resolvedAddressingMode,
ReadStrategy resolvedReadStrategy)
{
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
public AbCipDeviceOptions Options { get; } = options;
public AbCipPlcFamilyProfile Profile { get; } = profile;
/// <summary>
/// PR abcip-3.1 — effective CIP connection size for this device. Per-device
/// <see cref="AbCipDeviceOptions.ConnectionSize"/> override wins; otherwise the
/// family profile's <see cref="AbCipPlcFamilyProfile.DefaultConnectionSize"/>
/// (4002 / 504 / 488 depending on family). Threaded through every
/// <see cref="AbCipTagCreateParams"/> the driver builds so libplctag receives a
/// consistent buffer-size hint across read / write / probe / discovery handles.
/// </summary>
public int ConnectionSize { get; } = options.ConnectionSize ?? profile.DefaultConnectionSize;
/// <summary>
/// PR abcip-3.2 — concrete addressing mode in effect for this device. Always
/// <see cref="AbCip.AddressingMode.Symbolic"/> or <see cref="AbCip.AddressingMode.Logical"/>
/// after <see cref="AbCipDriver.ResolveAddressingMode"/> has run; <c>Auto</c> +
/// unsupported-family fall-back collapse to Symbolic at config time so the
/// read/write hot paths can branch on a single value.
/// </summary>
public AddressingMode AddressingMode { get; } = resolvedAddressingMode;
/// <summary>
/// PR abcip-3.3 — resolved read strategy for this device. <see cref="AbCip.ReadStrategy.WholeUdt"/>
/// or <see cref="AbCip.ReadStrategy.MultiPacket"/> mean "always pick this for every UDT
/// batch on this device." <see cref="AbCip.ReadStrategy.Auto"/> means "let the planner
/// pick per-batch using <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>."
/// User-forced MultiPacket against a non-packing family (Micro800 et al) was already
/// collapsed to WholeUdt at <see cref="AbCipDriver.ResolveReadStrategy"/> time, so the
/// read hot path can branch on this single value without re-checking family compat.
/// </summary>
public ReadStrategy ReadStrategy { get; } = resolvedReadStrategy;
/// <summary>PR abcip-3.3 — count of UDT groups dispatched through the WholeUdt path on
/// this device. Surfaced for tests + a future driver-diagnostics RPC.</summary>
public int WholeUdtGroupsExecuted;
/// <summary>PR abcip-3.3 — count of UDT groups dispatched through the MultiPacket path
/// on this device. Surfaced for tests + a future driver-diagnostics RPC.</summary>
public int MultiPacketGroupsExecuted;
/// <summary>
/// PR abcip-3.2 — name → Symbol Object instance ID map populated by the one-time
/// <c>@tags</c> walk that fires on the first read on a Logical-mode device. Empty
/// for Symbolic-mode devices + before the walk completes; consulted by
/// <see cref="AbCipDriver.EnsureTagRuntimeAsync"/> when materialising the per-tag
/// runtime so libplctag receives the resolved instance ID directly. Case-insensitive
/// because Logix tag names are case-insensitive at the controller.
/// </summary>
public Dictionary<string, uint> LogicalInstanceMap { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// PR abcip-3.2 — guarded inside <see cref="AbCipDriver.EnsureLogicalMappingsAsync"/>
/// so the symbol-table walk fires exactly once per device. Setting this to
/// <c>true</c> means "walk attempted" — the walk's success / failure is captured by
/// the contents of <see cref="LogicalInstanceMap"/>; an empty map after the flag
/// flips means the walk yielded nothing and subsequent reads keep falling back to
/// Symbolic addressing on the wire.
/// </summary>
public bool LogicalWalkComplete { get; set; }
/// <summary>Serialises concurrent first-read symbol-walks against this device.</summary>
public SemaphoreSlim LogicalWalkLock { get; } = new(1, 1);
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
public CancellationTokenSource? ProbeCts { get; set; }
public bool ProbeInitialized { get; set; }
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <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"/>).
/// </summary>
public Dictionary<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 Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
public SemaphoreSlim GetRmwLock(string parentTagName) =>
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
public void DisposeHandles()
{
foreach (var h in TagHandles.Values) h.Dispose();
TagHandles.Clear();
foreach (var r in Runtimes.Values) r.Dispose();
Runtimes.Clear();
foreach (var r in ParentRuntimes.Values) r.Dispose();
ParentRuntimes.Clear();
}
}
}