e77c8a3569
The second thing #516/§8.2 deliberately did not build. The original objection stands — Modbus, S7, MQTT, AbLegacy and Sql echo authored config from DiscoverAsync, so diffing discovered against authored is a tautology for them — but it is now ENCODED rather than used as a reason not to build. The discriminator already existed: ITagDiscovery.SupportsOnlineDiscovery is documented as "enumerates the tag set from the live backend rather than replaying pre-declared/authored tags", and it separates the fleet cleanly — FOCAS, TwinCAT, MTConnect and AbCip true; the five config-echo drivers explicitly false. The check runs only for a driver that is SupportsOnlineDiscovery AND reports the new AuthoredDiscoveryRefs. That is nullable rather than empty-by-default on purpose: an empty collection legitimately means "nothing authored, everything discovered is new", so conflating the two would report total drift for a driver that never opted in. Where the value is: AbCip and FOCAS browse a live backend but implement no IRediscoverable, so before this they had no change signal at all. A periodic compare is the only way to notice a PLC re-download. A finding that changed the design: FOCAS, TwinCAT and AbCip all re-emit their authored tags into the same DiscoverAsync stream as device-derived ones, so the browse picker can show an operator their existing tags. Discovered is therefore always a superset of authored, and a VANISHED tag can never appear missing — one whole direction is structurally undetectable for three of the four. Shipping a Vanished list that is permanently empty for them would have been the half-inert seam this whole exercise removes. So the driver declares DiscoveryStreamIncludesAuthoredTags, the detector does not compute the undetectable half, and the operator message says "missing-tag detection unavailable for this driver" rather than letting the absence of a missing-tag clause read as reassurance. MTConnect is the only driver where both directions work — its stream is purely probe-model-derived. Behaviour: a failed browse is not drift (an unreachable device would otherwise report every authored tag as vanished); a truncated capture is skipped for the same reason; an unchanged drift is reported once rather than re-stamping its timestamp; drift that resolves clears the prompt. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an engineering event, not a runtime one. It reuses the Stage-2 surfacing, raising the same /hosts re-browse prompt, and never rebuilds the served address space. Comparison logic is a pure, separately-tested type rather than actor-inline. 14 new tests. The gate was verified load-bearing by deleting the SupportsOnlineDiscovery half: the tautology-driver test goes red, and it asserts discovery was never invoked rather than merely "no prompt raised", which would have passed for the wrong reason if the timer never fired. Full suite green except the same three pre-existing fixture-gated integration suites, identical counts.
1456 lines
78 KiB
C#
1456 lines
78 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|
|
|
/// <summary>
|
|
/// Allen-Bradley CIP / EtherNet-IP driver for ControlLogix / CompactLogix / Micro800 /
|
|
/// GuardLogix families. Implements all read/write/subscribe/discover/probe/alarm
|
|
/// capabilities via the libplctag.NET wrapper.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>Wire layer is libplctag 1.6.x. 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, ISubscribable,
|
|
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
|
{
|
|
private AbCipDriverOptions _options;
|
|
private readonly string _driverInstanceId;
|
|
private readonly IAbCipTagFactory _tagFactory;
|
|
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
|
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
|
|
private readonly AbCipTemplateCache _templateCache = new();
|
|
|
|
/// <summary>
|
|
/// Maximum nesting depth the controller-discovered UDT fan-out recurses through. A member
|
|
/// whose path would sit deeper than this (a struct-of-struct chain longer than the cap) is
|
|
/// dropped rather than emitted, bounding both browse-tree breadth and the recursion. Depth
|
|
/// is counted in dotted segments below the discovered parent (the parent's direct atomic
|
|
/// members sit at depth 1).
|
|
/// </summary>
|
|
public const int MaxUdtDepth = 8;
|
|
|
|
// Name-keyed UDT shapes consulted by the controller-discovered fan-out, keyed on
|
|
// (deviceHostAddress, structTypeOrMemberName). PRODUCTION REALITY of the two population paths:
|
|
// * TOP-LEVEL discovered UDT — FUNCTIONAL in production. The Symbol Object decoder surfaces the
|
|
// discovered tag's CIP template instance id (AbCipDiscoveredTag.TemplateInstanceId), which
|
|
// DiscoverAsync threads into ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the
|
|
// Template Object off the controller and cache it in the id-keyed _templateCache. No seed
|
|
// needed.
|
|
// * NESTED struct members — FUNCTIONAL in production. A decoded Template Object member block carries
|
|
// the nested UDT's template instance id (low 12 bits of the member info, captured by
|
|
// CipTemplateObjectDecoder onto AbCipUdtMember.NestedTemplateId), which the fan-out threads into
|
|
// ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the nested Template Object off the
|
|
// controller. The name-keyed seed (SeedDiscoveredUdtShapeForTest) still wins first for tests; a
|
|
// nested member whose id can't be resolved is dropped (the top-level UDT still expands).
|
|
private readonly Dictionary<string, AbCipUdtShape> _discoveredUdtShapes =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Composite key for _discoveredUdtShapes: device + struct/member name joined by a NUL ('\0').
|
|
// A NUL appears in neither a canonical ab:// host address nor a Logix symbol name, so the
|
|
// separator is unambiguous regardless of what either part contains.
|
|
private static string UdtShapeKey(string deviceHostAddress, string structName) =>
|
|
$"{deviceHostAddress}\0{structName}";
|
|
|
|
/// <summary>
|
|
/// Test seam — seed a discovered-UDT shape under <paramref name="structName"/> for
|
|
/// <paramref name="deviceHostAddress"/>, consulted by name before any id-keyed fetch. Lets a
|
|
/// fan-out test link a nested sub-shape by member name without a live Template Object read.
|
|
/// Neither the TOP-LEVEL discovered UDT nor a NESTED-struct member needs a seed in production:
|
|
/// both carry a template instance id (the top level on
|
|
/// <see cref="AbCipDiscoveredTag.TemplateInstanceId"/>, a nested member on
|
|
/// <see cref="AbCipUdtMember.NestedTemplateId"/>) that the fan-out fetches via
|
|
/// <see cref="FetchUdtShapeAsync"/>.
|
|
/// </summary>
|
|
/// <param name="deviceHostAddress">The device the struct lives on.</param>
|
|
/// <param name="structName">The discovered struct type or nested-member name.</param>
|
|
/// <param name="shape">The shape to associate with that name.</param>
|
|
internal void SeedDiscoveredUdtShapeForTest(string deviceHostAddress, string structName, AbCipUdtShape shape) =>
|
|
_discoveredUdtShapes[UdtShapeKey(deviceHostAddress, structName)] = shape;
|
|
|
|
private readonly PollGroupEngine _poll;
|
|
// 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. TODO(v3 WaveC): collapses to a pure DeviceName index once the connection
|
|
// host moves into the Device row's 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 readonly 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 readonly 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 when a device's host connectivity status changes.</summary>
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
|
|
/// <summary>Occurs when an alarm event is raised.</summary>
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
|
|
|
/// <summary>Internal seam for the alarm projection to raise events through the driver.</summary>
|
|
/// <param name="args">The alarm event arguments.</param>
|
|
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="AbCipDriver"/> class.</summary>
|
|
/// <param name="options">The driver configuration options.</param>
|
|
/// <param name="driverInstanceId">A unique identifier for this driver instance.</param>
|
|
/// <param name="tagFactory">Optional factory for creating tag runtimes; uses libplctag default if null.</param>
|
|
/// <param name="enumeratorFactory">Optional factory for enumerating tags; uses libplctag default if null.</param>
|
|
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; uses libplctag default if null.</param>
|
|
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
|
|
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
|
IAbCipTagFactory? tagFactory = null,
|
|
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
|
|
IAbCipTemplateReaderFactory? templateReaderFactory = null,
|
|
ILogger<AbCipDriver>? logger = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_options = options;
|
|
_driverInstanceId = driverInstanceId;
|
|
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
|
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
|
|
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
|
|
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
|
|
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
|
|
r => _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);
|
|
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
using var reader = _templateReaderFactory.Create();
|
|
var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
|
|
var shape = CipTemplateObjectDecoder.Decode(buffer);
|
|
if (shape is not null)
|
|
_templateCache.Put(deviceHostAddress, templateInstanceId, shape);
|
|
return shape;
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (Exception ex)
|
|
{
|
|
// Template read failure — surface via the driver's health surface AND a warning
|
|
// log so operators see it; don't propagate since callers should fall back to
|
|
// declaration-driven UDT semantics rather than failing the whole discovery run.
|
|
_logger.LogWarning(ex,
|
|
"AbCip driver {DriverInstanceId} failed to read UDT template {TemplateInstanceId} from device {Device}; " +
|
|
"falling back to declaration-driven UDT semantics",
|
|
_driverInstanceId, templateInstanceId, deviceHostAddress);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve the shape of a controller-discovered struct/UDT (or a nested-struct member) by
|
|
/// name. Consults the name-keyed <see cref="_discoveredUdtShapes"/> first (the source for
|
|
/// nested-struct sub-shapes + the test seam), then — for the top level only — the id-keyed
|
|
/// template cache via <see cref="FetchUdtShapeAsync"/> when a template instance id is
|
|
/// available. Returns <c>null</c> when no shape can be resolved so the caller degrades to a
|
|
/// single Variable rather than emitting a broken fan-out.
|
|
/// </summary>
|
|
/// <param name="deviceHostAddress">The device the struct lives on.</param>
|
|
/// <param name="structName">The discovered struct type name or a nested-member name.</param>
|
|
/// <param name="templateInstanceId">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 Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
|
{
|
|
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
|
if (parsed.Devices.Count > 0 || parsed.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.
|
|
foreach (var entry in _options.RawTags)
|
|
{
|
|
if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
|
|
{
|
|
_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 (_tagsByRawPath.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.");
|
|
_tagsByRawPath[tag.Name] = tag;
|
|
_declaredTags.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 (_tagsByRawPath.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.");
|
|
_tagsByRawPath[memberTag.Name] = memberTag;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Probe loops — one per device when enabled + a ProbeTagPath is configured.
|
|
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
|
|
{
|
|
foreach (var state in _devices.Values)
|
|
{
|
|
state.ProbeCts = new CancellationTokenSource();
|
|
var ct = state.ProbeCts.Token;
|
|
// Keep the loop Task so ShutdownAsync can await its clean exit before
|
|
// disposing the CTS / handles the loop is still using.
|
|
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;
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <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.Clear();
|
|
_declaredTags.Clear();
|
|
_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 Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
_poll.Unsubscribe(handle);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---- 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;
|
|
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)
|
|
state.Backoff.RecordSuccess(); // 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;
|
|
state.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: keep the data-path window open
|
|
}
|
|
|
|
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);
|
|
|
|
foreach (var group in plan.Groups)
|
|
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
|
|
|
|
foreach (var fb in plan.Fallbacks)
|
|
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);
|
|
|
|
return results;
|
|
}
|
|
|
|
private async Task ReadSingleAsync(
|
|
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
|
{
|
|
if (!_resolver.TryResolve(reference, out var def))
|
|
{
|
|
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
|
return;
|
|
}
|
|
// A Structure tag whose Members are declared is a container —
|
|
// its bare name is readable via the whole-UDT grouping path (ReadGroupAsync), not the
|
|
// per-tag path. Reading it here returns BadNotSupported rather than Good/null so the
|
|
// caller knows to address individual member paths (e.g. "Motor.Speed").
|
|
if (def.DataType == AbCipDataType.Structure && def.Members is { Count: > 0 })
|
|
{
|
|
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
|
return;
|
|
}
|
|
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
|
|
{
|
|
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
|
|
|
// 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 != 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.MapLibplctagStatus(status), null, now);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
|
$"libplctag status {status} reading {reference}");
|
|
_logger.LogWarning(
|
|
"AbCip read returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
|
|
"evicting cached runtime so next call re-creates it",
|
|
status, reference, def.DeviceHostAddress);
|
|
return;
|
|
}
|
|
|
|
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
|
var bitIndex = tagPath?.BitIndex;
|
|
// Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
|
|
// element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
|
|
// (ElementCount 1). Scalar tags keep the single-value path.
|
|
var value = IsArrayTag(def)
|
|
? runtime.DecodeArray(def.DataType, Math.Max(1, def.ElementCount))
|
|
: runtime.DecodeValue(def.DataType, bitIndex);
|
|
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
|
}
|
|
finally
|
|
{
|
|
opLock.Release();
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Transport exception — evict so the next read creates a fresh handle.
|
|
EvictRuntime(device, def.Name);
|
|
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
|
AbCipStatusMapper.BadCommunicationError, null, now);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
_logger.LogWarning(ex,
|
|
"AbCip read transport exception for tag {Tag} on device {Device}",
|
|
reference, def.DeviceHostAddress);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Perform one whole-UDT read on the parent tag, then decode each
|
|
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
|
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
|
|
/// the mapped fault across every grouped member only — sibling groups + the
|
|
/// per-tag fallback list are unaffected.
|
|
/// </summary>
|
|
private async Task ReadGroupAsync(
|
|
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
|
{
|
|
var parent = group.ParentDefinition;
|
|
|
|
if (!_devicesByRoutingKey.TryGetValue(parent.DeviceHostAddress, out var device))
|
|
{
|
|
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
|
|
return;
|
|
}
|
|
|
|
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 != 0)
|
|
{
|
|
EvictRuntime(device, parent.Name);
|
|
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
|
|
StampGroupStatus(group, results, now, mapped);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
|
$"libplctag status {status} reading UDT {group.ParentName}");
|
|
_logger.LogWarning(
|
|
"AbCip whole-UDT read returned non-zero libplctag status {LibplctagStatus} for parent {Parent} " +
|
|
"on device {Device}; {MemberCount} member values stamped with mapped status",
|
|
status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
|
|
return;
|
|
}
|
|
|
|
foreach (var member in group.Members)
|
|
{
|
|
var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
|
|
results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
|
}
|
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
|
}
|
|
finally
|
|
{
|
|
opLock.Release();
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
EvictRuntime(device, parent.Name);
|
|
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
_logger.LogWarning(ex,
|
|
"AbCip whole-UDT read transport exception for parent {Parent} on device {Device}",
|
|
group.ParentName, parent.DeviceHostAddress);
|
|
}
|
|
}
|
|
|
|
private static void StampGroupStatus(
|
|
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, uint statusCode)
|
|
{
|
|
foreach (var member in group.Members)
|
|
results[member.OriginalIndex] = new DataValueSnapshot(null, statusCode, null, now);
|
|
}
|
|
|
|
// ---- IWritable ----
|
|
|
|
/// <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 != 0)
|
|
{
|
|
EvictRuntime(device, def.Name);
|
|
results[i] = new WriteResult(AbCipStatusMapper.MapLibplctagStatus(status));
|
|
_logger.LogWarning(
|
|
"AbCip write returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
|
|
"evicting cached runtime so next call re-creates it",
|
|
status, w.FullReference, def.DeviceHostAddress);
|
|
}
|
|
else
|
|
{
|
|
results[i] = new WriteResult(AbCipStatusMapper.Good);
|
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (NotSupportedException nse)
|
|
{
|
|
// Type/protocol error — not a transport fault; don't evict the handle.
|
|
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
|
_logger.LogWarning(nse,
|
|
"AbCip write not supported for tag {Tag} on device {Device}",
|
|
w.FullReference, def.DeviceHostAddress);
|
|
}
|
|
catch (FormatException fe)
|
|
{
|
|
// Value conversion error — not a transport fault; don't evict.
|
|
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
|
_logger.LogWarning(fe,
|
|
"AbCip write value-conversion error for tag {Tag} on device {Device}",
|
|
w.FullReference, def.DeviceHostAddress);
|
|
}
|
|
catch (InvalidCastException ice)
|
|
{
|
|
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
|
_logger.LogWarning(ice,
|
|
"AbCip write type-cast error for tag {Tag} on device {Device}",
|
|
w.FullReference, def.DeviceHostAddress);
|
|
}
|
|
catch (OverflowException oe)
|
|
{
|
|
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
|
_logger.LogWarning(oe,
|
|
"AbCip write value out of range for tag {Tag} on device {Device}",
|
|
w.FullReference, def.DeviceHostAddress);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Transport / wire error — evict so the next write creates a fresh handle.
|
|
EvictRuntime(device, def.Name);
|
|
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.MapLibplctagStatus(readStatus);
|
|
|
|
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
|
|
var updated = Convert.ToBoolean(value)
|
|
? current | (1 << bit)
|
|
: current & ~(1 << bit);
|
|
|
|
parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
|
|
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
|
|
var writeStatus = parentRuntime.GetStatus();
|
|
return writeStatus == 0
|
|
? AbCipStatusMapper.Good
|
|
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
|
|
}
|
|
finally
|
|
{
|
|
rmwLock.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
|
|
/// so repeated bit writes against the same DINT share one handle.
|
|
/// </summary>
|
|
private async Task<IAbCipTagRuntime> EnsureParentRuntimeAsync(
|
|
DeviceState device, string parentTagName, CancellationToken ct)
|
|
{
|
|
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
|
|
|
|
var runtime = _tagFactory.Create(device.BuildCreateParams(parentTagName, _options.Timeout));
|
|
try
|
|
{
|
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
runtime.Dispose();
|
|
throw;
|
|
}
|
|
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
|
|
// first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
|
|
if (device.ParentRuntimes.TryAdd(parentTagName, runtime))
|
|
return runtime;
|
|
runtime.Dispose();
|
|
return device.ParentRuntimes[parentTagName];
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
private async Task<IAbCipTagRuntime> EnsureTagRuntimeAsync(
|
|
DeviceState device, AbCipTagDefinition def, CancellationToken ct)
|
|
{
|
|
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
|
|
|
var parsed = AbCipTagPath.TryParse(def.TagPath)
|
|
?? throw new InvalidOperationException(
|
|
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
|
|
|
|
// 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 bypasses this (its own runtime) and drives the failure/success recording.
|
|
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)));
|
|
try
|
|
{
|
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
runtime.Dispose();
|
|
device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window
|
|
throw;
|
|
}
|
|
device.Backoff.RecordSuccess(); // 05/STAB-8: Forward-Open succeeded — reset the window
|
|
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
|
|
// first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
|
|
if (device.Runtimes.TryAdd(def.Name, runtime))
|
|
return runtime;
|
|
runtime.Dispose();
|
|
return device.Runtimes[def.Name];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose
|
|
/// it so the next read/write call re-creates and re-initializes a fresh handle.
|
|
/// Called from <see cref="ReadSingleAsync"/>, <see cref="ReadGroupAsync"/>, and
|
|
/// <see cref="WriteAsync"/> after a non-zero libplctag status or transport exception —
|
|
/// mirroring the probe loop's recreate-on-failure behaviour.
|
|
/// </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;
|
|
|
|
/// <inheritdoc />
|
|
public long GetMemoryFootprint() => 0;
|
|
|
|
/// <inheritdoc />
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
|
{
|
|
_templateCache.Clear();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---- 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 />
|
|
/// <remarks>Pre-declared tags are emitted under their <c>Name</c>, which is the driver FullName the
|
|
/// controller-browse leaves also use.</remarks>
|
|
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
|
|
_declaredTags.Select(t => t.Name).ToArray();
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>True: <c>DiscoverAsync</c> emits browsed controller symbols AND re-emits every
|
|
/// pre-declared tag.</remarks>
|
|
public bool DiscoveryStreamIncludesAuthoredTags => 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));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
|
|
}
|
|
|
|
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
|
|
// walker (LibplctagTagEnumerator) is the factory default,
|
|
// so leaving the flag off keeps the strict-config path for deployments where only
|
|
// declared tags should appear.
|
|
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
|
{
|
|
using var enumerator = _enumeratorFactory.Create();
|
|
var deviceParams = state.BuildCreateParams("@tags", _options.Timeout);
|
|
|
|
IAddressSpaceBuilder? discoveredFolder = null;
|
|
// Dedup discovered member full-names within this device's Discovered folder.
|
|
var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken)
|
|
.ConfigureAwait(false))
|
|
{
|
|
if (discovered.IsSystemTag) continue;
|
|
if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue;
|
|
|
|
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
|
|
var fullName = discovered.ProgramScope is null
|
|
? discovered.Name
|
|
: $"Program:{discovered.ProgramScope}.{discovered.Name}";
|
|
|
|
// A discovered UDT/struct is expanded into a member sub-folder with one Variable
|
|
// per ATOMIC leaf (mirroring the pre-declared fan-out above), rather than the
|
|
// bogus single Structure → String placeholder Variable. When the shape can't be
|
|
// resolved (or yields no emittable leaf) we fall through to the legacy single
|
|
// Variable so nothing regresses.
|
|
if (discovered.DataType == AbCipDataType.Structure)
|
|
{
|
|
// Pass the discovered tag's CIP template instance id (surfaced by the Symbol
|
|
// Object decoder) so ResolveDiscoveredUdtShapeAsync can fetch the Template Object
|
|
// off the controller via FetchUdtShapeAsync — this is what makes the production
|
|
// top-level UDT fan-out functional (rather than degrading to a placeholder).
|
|
var shape = await ResolveDiscoveredUdtShapeAsync(
|
|
device.HostAddress, discovered.Name, discovered.TemplateInstanceId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (shape is not null)
|
|
{
|
|
// Materialise the member sub-folder LAZILY — only once a leaf is actually
|
|
// about to be emitted into it. A shape that yields no usable atomic leaf
|
|
// then leaves no empty sub-folder behind, and we fall through to the legacy
|
|
// single Variable below.
|
|
var capturedDiscoveredFolder = discoveredFolder;
|
|
var udtFolder = new Lazy<IAddressSpaceBuilder>(
|
|
() => capturedDiscoveredFolder.Folder(fullName, discovered.Name));
|
|
var anyEmitted = await FanOutDiscoveredUdtMembersAsync(
|
|
udtFolder, device.HostAddress, fullName, shape, discovered.ReadOnly,
|
|
depth: 1, emitted, visited: [discovered.Name], cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (anyEmitted) continue; // expanded — don't emit the placeholder Variable
|
|
// Empty shape (no usable atomic leaf): degrade to a single Variable below.
|
|
}
|
|
}
|
|
|
|
if (!emitted.Add(fullName)) continue;
|
|
// Review I-1 — a discovered array of length 1 is still an array; honour the
|
|
// explicit IsArray flag (legacy ElementCount > 1 still surfaces as an array).
|
|
var discoveredIsArray = discovered.IsArray || discovered.ElementCount > 1;
|
|
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
|
|
FullName: fullName,
|
|
DriverDataType: discovered.DataType.ToDriverDataType(),
|
|
IsArray: discoveredIsArray,
|
|
ArrayDim: discoveredIsArray ? (uint)Math.Max(1, discovered.ElementCount) : null,
|
|
SecurityClass: discovered.ReadOnly
|
|
? SecurityClassification.ViewOnly
|
|
: SecurityClassification.Operate,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
|
|
/// ATOMIC leaf — under the folder produced by <paramref name="folder"/>. Mirrors the
|
|
/// pre-declared UDT fan-out (member full-name = <c>Parent.Member</c>, dot-joined for nested
|
|
/// structs; atomic <c>DriverDataType</c>; SecurityClass from writability;
|
|
/// <c>IsHistorized:false</c>, <c>IsAlarm:false</c>). Atomic members emit directly; a
|
|
/// nested-struct member recurses into ITS members. Bounded by <see cref="MaxUdtDepth"/>
|
|
/// (deeper members are dropped) and a per-path <paramref name="visited"/> set (cyclic struct
|
|
/// references can't loop). A nested struct whose sub-shape can't be resolved is dropped —
|
|
/// never mis-emitted as a placeholder.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The <paramref name="folder"/> is a <see cref="Lazy{T}"/> so the sub-folder node is
|
|
/// materialised only once a leaf is actually about to be emitted into it. A shape (or nested
|
|
/// sub-shape) that yields no emittable leaf therefore never leaves an empty sub-folder in the
|
|
/// browse tree.
|
|
/// </remarks>
|
|
/// <param name="folder">Lazy producer of the address-space sub-folder the member Variables are
|
|
/// emitted under; only forced when the first leaf is emitted.</param>
|
|
/// <param name="deviceHostAddress">The device the UDT lives on (for nested sub-shape lookup).</param>
|
|
/// <param name="parentFullName">Dotted full-name prefix of the parent (e.g. <c>Motor1</c> or
|
|
/// <c>Motor1.Status</c>).</param>
|
|
/// <param name="shape">The resolved shape whose members are fanned out.</param>
|
|
/// <param name="parentReadOnly">Whether the discovered parent tag was read-only (propagated to
|
|
/// members — the Symbol Object carries no per-member access bits).</param>
|
|
/// <param name="depth">Current recursion depth (the parent's direct members are depth 1).</param>
|
|
/// <param name="emitted">Dedup set of member full-names already emitted in this Discovered folder.</param>
|
|
/// <param name="visited">Struct names on the current recursion path (cycle guard).</param>
|
|
/// <param name="cancellationToken">Cancellation token for any nested sub-shape read.</param>
|
|
/// <returns><c>true</c> when at least one atomic leaf was emitted.</returns>
|
|
private async Task<bool> FanOutDiscoveredUdtMembersAsync(
|
|
Lazy<IAddressSpaceBuilder> folder,
|
|
string deviceHostAddress,
|
|
string parentFullName,
|
|
AbCipUdtShape shape,
|
|
bool parentReadOnly,
|
|
int depth,
|
|
HashSet<string> emitted,
|
|
HashSet<string> visited,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Defensive: the only caller already gates each descent on depth + 1 <= MaxUdtDepth, so a
|
|
// depth past the cap can't be reached here — kept as a belt-and-suspenders guard.
|
|
if (depth > MaxUdtDepth) return false;
|
|
|
|
var anyEmitted = false;
|
|
foreach (var member in shape.Members)
|
|
{
|
|
var memberFullName = $"{parentFullName}.{member.Name}";
|
|
|
|
if (member.DataType == AbCipDataType.Structure)
|
|
{
|
|
// Nested struct: recurse into its sub-shape if it resolves, the depth cap allows it,
|
|
// and we're not re-entering a struct already on this path (cycle guard). The nested
|
|
// members live a level deeper, so the cap is checked against depth + 1.
|
|
if (depth + 1 > MaxUdtDepth) continue;
|
|
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
|
|
// The decoded parent shape carries the nested UDT's template instance id (low 12 bits of the
|
|
// member info, captured by CipTemplateObjectDecoder) — thread it so the nested shape is fetched
|
|
// off the controller via FetchUdtShapeAsync, making nested-struct members addressable in
|
|
// production (name-keyed seeded shapes still win first, for the test seam).
|
|
var nested = await ResolveDiscoveredUdtShapeAsync(
|
|
deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (nested is not null)
|
|
{
|
|
// Materialise the nested sub-folder lazily under the parent folder (itself lazy),
|
|
// so a nested struct that yields no leaf leaves no empty sub-folder behind.
|
|
var parentFolder = folder;
|
|
var nestedFolder = new Lazy<IAddressSpaceBuilder>(
|
|
() => parentFolder.Value.Folder(memberFullName, member.Name));
|
|
var nestedEmitted = await FanOutDiscoveredUdtMembersAsync(
|
|
nestedFolder, deviceHostAddress, memberFullName, nested, parentReadOnly,
|
|
depth + 1, emitted, visited, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
anyEmitted |= nestedEmitted;
|
|
}
|
|
visited.Remove(member.Name);
|
|
continue;
|
|
}
|
|
|
|
// Atomic leaf — emit one Variable. Dedup by full-name within the Discovered folder.
|
|
// Forcing folder.Value here is what materialises the sub-folder on the first real leaf.
|
|
if (!emitted.Add(memberFullName)) continue;
|
|
var memberIsArray = member.ArrayLength > 1;
|
|
folder.Value.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
|
FullName: memberFullName,
|
|
DriverDataType: member.DataType.ToDriverDataType(),
|
|
IsArray: memberIsArray,
|
|
ArrayDim: memberIsArray ? (uint)Math.Max(1, member.ArrayLength) : null,
|
|
// The Symbol Object carries no per-member ExternalAccess bits, so members inherit the
|
|
// parent tag's read-only state (a safety/read-only UDT keeps all its members ViewOnly).
|
|
SecurityClass: parentReadOnly
|
|
? SecurityClassification.ViewOnly
|
|
: SecurityClassification.Operate,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
anyEmitted = true;
|
|
}
|
|
|
|
return anyEmitted;
|
|
}
|
|
|
|
// Review I-1 — array-ness is the EXPLICIT IsArray flag (a 1-element array is still an array);
|
|
// a legacy definition carrying only ElementCount > 1 stays an array for back-compat.
|
|
private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1;
|
|
|
|
// 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. TODO(v3 WaveC):
|
|
// collapses to a DeviceName-only match once the connection host lives on the Device row's DeviceConfig.
|
|
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);
|
|
|
|
/// <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>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>
|
|
/// <returns>The computed tag creation parameters.</returns>
|
|
public AbCipTagCreateParams BuildCreateParams(
|
|
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false) => new(
|
|
Gateway: ParsedAddress.Gateway,
|
|
Port: ParsedAddress.Port,
|
|
CipPath: ParsedAddress.CipPath,
|
|
LibplctagPlcAttribute: Profile.LibplctagPlcAttribute,
|
|
TagName: tagName,
|
|
Timeout: timeout,
|
|
AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
|
|
ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize,
|
|
ElementCount: elementCount < 1 ? 1 : elementCount,
|
|
IsArray: isArray);
|
|
|
|
/// <summary>Disposes all runtime tag handles and clears the caches.</summary>
|
|
public void DisposeHandles()
|
|
{
|
|
foreach (var r in Runtimes.Values) r.Dispose();
|
|
Runtimes.Clear();
|
|
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
|
ParentRuntimes.Clear();
|
|
// 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();
|
|
}
|
|
}
|
|
}
|