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