415 lines
18 KiB
C#
415 lines
18 KiB
C#
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 <see cref="IDriver"/> only for now — read/write/
|
||
/// subscribe/discover capabilities ship in subsequent PRs (3–8) and family-specific quirk
|
||
/// profiles ship in PRs 9–12.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>Wire layer is libplctag 1.6.x (plan decision #11). Per-device host addresses use
|
||
/// the <c>ab://gateway[:port]/cip-path</c> canonical form parsed via
|
||
/// <see cref="AbCipHostAddress.TryParse"/>; those strings become the <c>hostName</c> key
|
||
/// for Polly bulkhead + circuit-breaker isolation per plan decision #144.</para>
|
||
///
|
||
/// <para>Tier A per plan decisions #143–145 — in-process, shares server lifetime, no
|
||
/// sidecar. <see cref="ReinitializeAsync"/> is the Tier-B escape hatch for recovering
|
||
/// from native-heap growth that the CLR allocator can't see; it tears down every
|
||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||
/// </remarks>
|
||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IDisposable, IAsyncDisposable
|
||
{
|
||
private readonly AbCipDriverOptions _options;
|
||
private readonly string _driverInstanceId;
|
||
private readonly IAbCipTagFactory _tagFactory;
|
||
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
||
private readonly AbCipTemplateCache _templateCache = new();
|
||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||
|
||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||
IAbCipTagFactory? tagFactory = null,
|
||
IAbCipTagEnumeratorFactory? enumeratorFactory = null)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(options);
|
||
_options = options;
|
||
_driverInstanceId = driverInstanceId;
|
||
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
||
_enumeratorFactory = enumeratorFactory ?? new EmptyAbCipTagEnumeratorFactory();
|
||
}
|
||
|
||
/// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
|
||
internal AbCipTemplateCache TemplateCache => _templateCache;
|
||
|
||
public string DriverInstanceId => _driverInstanceId;
|
||
public string DriverType => "AbCip";
|
||
|
||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||
{
|
||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||
try
|
||
{
|
||
foreach (var device in _options.Devices)
|
||
{
|
||
var addr = AbCipHostAddress.TryParse(device.HostAddress)
|
||
?? throw new InvalidOperationException(
|
||
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
|
||
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||
}
|
||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||
throw;
|
||
}
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||
{
|
||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||
{
|
||
foreach (var state in _devices.Values)
|
||
state.DisposeHandles();
|
||
_devices.Clear();
|
||
_tagsByName.Clear();
|
||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
// ---- IReadable ----
|
||
|
||
/// <summary>
|
||
/// Read each <c>fullReference</c> in order. Unknown tags surface as
|
||
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
|
||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
|
||
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
|
||
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
|
||
/// </summary>
|
||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||
var now = DateTime.UtcNow;
|
||
var results = new DataValueSnapshot[fullReferences.Count];
|
||
|
||
for (var i = 0; i < fullReferences.Count; i++)
|
||
{
|
||
var reference = fullReferences[i];
|
||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||
{
|
||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||
continue;
|
||
}
|
||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||
{
|
||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||
continue;
|
||
}
|
||
|
||
try
|
||
{
|
||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||
|
||
var status = runtime.GetStatus();
|
||
if (status != 0)
|
||
{
|
||
results[i] = new DataValueSnapshot(null,
|
||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||
$"libplctag status {status} reading {reference}");
|
||
continue;
|
||
}
|
||
|
||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||
var bitIndex = tagPath?.BitIndex;
|
||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||
results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
results[i] = new DataValueSnapshot(null,
|
||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
// ---- IWritable ----
|
||
|
||
/// <summary>
|
||
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
|
||
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
||
/// and the resilience pipeline (layered above the driver) decides whether to replay.
|
||
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
|
||
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
|
||
/// </summary>
|
||
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 (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||
{
|
||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||
continue;
|
||
}
|
||
if (!def.Writable)
|
||
{
|
||
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
||
continue;
|
||
}
|
||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||
{
|
||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||
continue;
|
||
}
|
||
|
||
try
|
||
{
|
||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||
runtime.EncodeValue(def.DataType, tagPath?.BitIndex, w.Value);
|
||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||
|
||
var status = runtime.GetStatus();
|
||
results[i] = new WriteResult(status == 0
|
||
? AbCipStatusMapper.Good
|
||
: AbCipStatusMapper.MapLibplctagStatus(status));
|
||
if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (NotSupportedException nse)
|
||
{
|
||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||
}
|
||
catch (FormatException fe)
|
||
{
|
||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||
}
|
||
catch (InvalidCastException ice)
|
||
{
|
||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||
}
|
||
catch (OverflowException oe)
|
||
{
|
||
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/// <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}'.");
|
||
|
||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||
Gateway: device.ParsedAddress.Gateway,
|
||
Port: device.ParsedAddress.Port,
|
||
CipPath: device.ParsedAddress.CipPath,
|
||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||
TagName: parsed.ToLibplctagName(),
|
||
Timeout: _options.Timeout));
|
||
try
|
||
{
|
||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
runtime.Dispose();
|
||
throw;
|
||
}
|
||
device.Runtimes[def.Name] = runtime;
|
||
return runtime;
|
||
}
|
||
|
||
public DriverHealth GetHealth() => _health;
|
||
|
||
/// <summary>
|
||
/// CLR-visible allocation footprint only — libplctag's native heap is invisible to the
|
||
/// GC. driver-specs.md §3 flags this: operators must watch whole-process RSS for the
|
||
/// full picture, and <see cref="ReinitializeAsync"/> is the Tier-B remediation.
|
||
/// </summary>
|
||
public long GetMemoryFootprint() => 0;
|
||
|
||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
||
{
|
||
_templateCache.Clear();
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
// ---- ITagDiscovery ----
|
||
|
||
/// <summary>
|
||
/// Stream the driver's tag set into the builder. Pre-declared tags from
|
||
/// <see cref="AbCipDriverOptions.Tags"/> emit first; optionally, the
|
||
/// <see cref="IAbCipTagEnumerator"/> walks each device's symbol table and adds
|
||
/// controller-discovered tags under a <c>Discovered/</c> sub-folder. System / module /
|
||
/// routine / task tags are hidden via <see cref="AbCipSystemTagFilter"/>.
|
||
/// </summary>
|
||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(builder);
|
||
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.
|
||
var preDeclared = _options.Tags.Where(t =>
|
||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||
foreach (var tag in preDeclared)
|
||
{
|
||
if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;
|
||
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
|
||
}
|
||
|
||
// Controller-discovered tags — optional. Default enumerator returns an empty sequence;
|
||
// tests + the follow-up real @tags walker plug in via the ctor parameter.
|
||
if (_devices.TryGetValue(device.HostAddress, out var state))
|
||
{
|
||
using var enumerator = _enumeratorFactory.Create();
|
||
var deviceParams = new AbCipTagCreateParams(
|
||
Gateway: state.ParsedAddress.Gateway,
|
||
Port: state.ParsedAddress.Port,
|
||
CipPath: state.ParsedAddress.CipPath,
|
||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||
TagName: "@tags",
|
||
Timeout: _options.Timeout);
|
||
|
||
IAddressSpaceBuilder? discoveredFolder = null;
|
||
await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken)
|
||
.ConfigureAwait(false))
|
||
{
|
||
if (discovered.IsSystemTag) continue;
|
||
if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue;
|
||
|
||
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
|
||
var fullName = discovered.ProgramScope is null
|
||
? discovered.Name
|
||
: $"Program:{discovered.ProgramScope}.{discovered.Name}";
|
||
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
|
||
FullName: fullName,
|
||
DriverDataType: discovered.DataType.ToDriverDataType(),
|
||
IsArray: false,
|
||
ArrayDim: null,
|
||
SecurityClass: discovered.ReadOnly
|
||
? SecurityClassification.ViewOnly
|
||
: SecurityClassification.Operate,
|
||
IsHistorized: false,
|
||
IsAlarm: false,
|
||
WriteIdempotent: false));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
|
||
FullName: tag.Name,
|
||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||
IsArray: false,
|
||
ArrayDim: null,
|
||
SecurityClass: tag.Writable
|
||
? 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>
|
||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||
|
||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Per-device runtime state. Holds the parsed host address, family profile, and the
|
||
/// live <see cref="PlcTagHandle"/> cache keyed by tag path. PRs 3–8 populate + consume
|
||
/// this dict via libplctag.
|
||
/// </summary>
|
||
internal sealed class DeviceState(
|
||
AbCipHostAddress parsedAddress,
|
||
AbCipDeviceOptions options,
|
||
AbCipPlcFamilyProfile profile)
|
||
{
|
||
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
|
||
public AbCipDeviceOptions Options { get; } = options;
|
||
public AbCipPlcFamilyProfile Profile { get; } = profile;
|
||
|
||
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
|
||
new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// Per-tag runtime handles owned by this device. One entry per configured tag is
|
||
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
|
||
/// </summary>
|
||
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||
new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
public void DisposeHandles()
|
||
{
|
||
foreach (var h in TagHandles.Values) h.Dispose();
|
||
TagHandles.Clear();
|
||
foreach (var r in Runtimes.Values) r.Dispose();
|
||
Runtimes.Clear();
|
||
}
|
||
}
|
||
}
|