Pure-text parser for Studio 5000 L5K controller exports. Recognises TAG/END_TAG, DATATYPE/END_DATATYPE, and PROGRAM/END_PROGRAM blocks, strips (* ... *) comments, and tolerates multi-line entries + unknown sections (CONFIG, MOTION_GROUP, etc.). Output records — L5kTag, L5kDataType, L5kMember — feed L5kIngest which converts to AbCipTagDefinition + AbCipStructureMember. Alias tags and ExternalAccess=None tags are skipped per Kepware precedent. AbCipDriverOptions gains an L5kImports collection (AbCipL5kImportOptions records — file path or inline text + per-import device + name prefix). InitializeAsync merges the imports into the declared Tags map, with declared tags winning on Name conflicts so operators can override import results without editing the L5K source. Tests cover controller-scope TAG, program-scope TAG, alias-tag flag, DATATYPE with member array dims, comment stripping, unknown-section skipping, multi-line entries, and the full ingest path including ExternalAccess=None / ReadOnly / UDT-typed tag fanout. Closes #229 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1053 lines
48 KiB
C#
1053 lines
48 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||
|
||
/// <summary>
|
||
/// Allen-Bradley CIP / EtherNet-IP driver for ControlLogix / CompactLogix / Micro800 /
|
||
/// GuardLogix families. Implements <see cref="IDriver"/> only for now — read/write/
|
||
/// subscribe/discover capabilities ship in subsequent PRs (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, ISubscribable,
|
||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||
{
|
||
private readonly AbCipDriverOptions _options;
|
||
private readonly string _driverInstanceId;
|
||
private readonly IAbCipTagFactory _tagFactory;
|
||
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
||
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
|
||
private readonly AbCipTemplateCache _templateCache = new();
|
||
private readonly PollGroupEngine _poll;
|
||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly AbCipAlarmProjection _alarmProjection;
|
||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||
|
||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||
|
||
/// <summary>Internal seam for the alarm projection to raise events through the driver.</summary>
|
||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||
|
||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||
IAbCipTagFactory? tagFactory = null,
|
||
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
|
||
IAbCipTemplateReaderFactory? templateReaderFactory = null)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(options);
|
||
_options = options;
|
||
_driverInstanceId = driverInstanceId;
|
||
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
||
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
|
||
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
|
||
_poll = new PollGroupEngine(
|
||
reader: ReadAsync,
|
||
onChange: (handle, tagRef, snapshot) =>
|
||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fetch + cache the shape of a Logix UDT by template instance id. First call reads
|
||
/// the Template Object off the controller; subsequent calls for the same
|
||
/// <c>(deviceHostAddress, templateInstanceId)</c> return the cached shape without
|
||
/// additional network traffic. <c>null</c> on template-not-found / decode failure so
|
||
/// callers can fall back to declaration-driven UDT fan-out.
|
||
/// </summary>
|
||
internal async Task<AbCipUdtShape?> FetchUdtShapeAsync(
|
||
string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken)
|
||
{
|
||
var cached = _templateCache.TryGet(deviceHostAddress, templateInstanceId);
|
||
if (cached is not null) return cached;
|
||
|
||
if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null;
|
||
|
||
var deviceParams = new AbCipTagCreateParams(
|
||
Gateway: device.ParsedAddress.Gateway,
|
||
Port: device.ParsedAddress.Port,
|
||
CipPath: device.ParsedAddress.CipPath,
|
||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||
TagName: $"@udt/{templateInstanceId}",
|
||
Timeout: _options.Timeout);
|
||
|
||
try
|
||
{
|
||
using var reader = _templateReaderFactory.Create();
|
||
var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
|
||
var shape = CipTemplateObjectDecoder.Decode(buffer);
|
||
if (shape is not null)
|
||
_templateCache.Put(deviceHostAddress, templateInstanceId, shape);
|
||
return shape;
|
||
}
|
||
catch (OperationCanceledException) { throw; }
|
||
catch
|
||
{
|
||
// Template read failure — log via the driver's health surface so operators see it,
|
||
// but don't propagate since callers should fall back to declaration-driven UDT
|
||
// semantics rather than failing the whole discovery run.
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
|
||
internal AbCipTemplateCache TemplateCache => _templateCache;
|
||
|
||
public string DriverInstanceId => _driverInstanceId;
|
||
public string DriverType => "AbCip";
|
||
|
||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||
{
|
||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||
try
|
||
{
|
||
foreach (var device in _options.Devices)
|
||
{
|
||
var addr = AbCipHostAddress.TryParse(device.HostAddress)
|
||
?? throw new InvalidOperationException(
|
||
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
|
||
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||
}
|
||
// Pre-declared tags first; L5K imports fill in only the names not already covered
|
||
// (operators can override an imported entry by re-declaring it under Tags).
|
||
var declaredNames = new HashSet<string>(
|
||
_options.Tags.Select(t => t.Name),
|
||
StringComparer.OrdinalIgnoreCase);
|
||
var allTags = new List<AbCipTagDefinition>(_options.Tags);
|
||
foreach (var import in _options.L5kImports)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress))
|
||
throw new InvalidOperationException(
|
||
"AbCip L5K import is missing DeviceHostAddress — every imported tag needs a target device.");
|
||
IL5kSource? src = null;
|
||
if (!string.IsNullOrEmpty(import.FilePath))
|
||
src = new FileL5kSource(import.FilePath);
|
||
else if (!string.IsNullOrEmpty(import.InlineText))
|
||
src = new StringL5kSource(import.InlineText);
|
||
if (src is null) continue;
|
||
var doc = L5kParser.Parse(src);
|
||
var ingest = new L5kIngest
|
||
{
|
||
DefaultDeviceHostAddress = import.DeviceHostAddress,
|
||
NamePrefix = import.NamePrefix,
|
||
};
|
||
var result = ingest.Ingest(doc);
|
||
foreach (var importedTag in result.Tags)
|
||
{
|
||
if (declaredNames.Contains(importedTag.Name)) continue;
|
||
allTags.Add(importedTag);
|
||
declaredNames.Add(importedTag.Name);
|
||
}
|
||
}
|
||
|
||
foreach (var tag in allTags)
|
||
{
|
||
_tagsByName[tag.Name] = tag;
|
||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||
{
|
||
foreach (var member in tag.Members)
|
||
{
|
||
var memberTag = new AbCipTagDefinition(
|
||
Name: $"{tag.Name}.{member.Name}",
|
||
DeviceHostAddress: tag.DeviceHostAddress,
|
||
TagPath: $"{tag.TagPath}.{member.Name}",
|
||
DataType: member.DataType,
|
||
Writable: member.Writable,
|
||
WriteIdempotent: member.WriteIdempotent,
|
||
StringLength: member.StringLength);
|
||
_tagsByName[memberTag.Name] = memberTag;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Probe loops — one per device when enabled + a ProbeTagPath is configured.
|
||
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
|
||
{
|
||
foreach (var state in _devices.Values)
|
||
{
|
||
state.ProbeCts = new CancellationTokenSource();
|
||
var ct = state.ProbeCts.Token;
|
||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||
}
|
||
}
|
||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||
throw;
|
||
}
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||
{
|
||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||
{
|
||
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
|
||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||
foreach (var state in _devices.Values)
|
||
{
|
||
try { state.ProbeCts?.Cancel(); } catch { }
|
||
state.ProbeCts?.Dispose();
|
||
state.ProbeCts = null;
|
||
state.DisposeHandles();
|
||
}
|
||
_devices.Clear();
|
||
_tagsByName.Clear();
|
||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||
}
|
||
|
||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||
|
||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||
|
||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||
{
|
||
_poll.Unsubscribe(handle);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
// ---- IAlarmSource (ALMD projection, #177) ----
|
||
|
||
/// <summary>
|
||
/// Subscribe to ALMD alarm transitions on <paramref name="sourceNodeIds"/>. Each id
|
||
/// names a declared ALMD UDT tag; the projection polls the tag's <c>InFaulted</c> +
|
||
/// <c>Severity</c> members at <see cref="AbCipDriverOptions.AlarmPollInterval"/> and
|
||
/// fires <see cref="OnAlarmEvent"/> on 0→1 (raise) + 1→0 (clear) transitions.
|
||
/// Feature-gated — when <see cref="AbCipDriverOptions.EnableAlarmProjection"/> is
|
||
/// <c>false</c> (the default), returns a handle wrapping a no-op subscription so
|
||
/// capability negotiation still works; <see cref="OnAlarmEvent"/> never fires.
|
||
/// </summary>
|
||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||
{
|
||
if (!_options.EnableAlarmProjection)
|
||
{
|
||
var disabled = new AbCipAlarmSubscriptionHandle(0);
|
||
return Task.FromResult<IAlarmSubscriptionHandle>(disabled);
|
||
}
|
||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||
}
|
||
|
||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||
_options.EnableAlarmProjection
|
||
? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
|
||
: Task.CompletedTask;
|
||
|
||
public Task AcknowledgeAsync(
|
||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||
_options.EnableAlarmProjection
|
||
? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken)
|
||
: Task.CompletedTask;
|
||
|
||
// ---- IHostConnectivityProbe ----
|
||
|
||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||
|
||
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||
{
|
||
var probeParams = new AbCipTagCreateParams(
|
||
Gateway: state.ParsedAddress.Gateway,
|
||
Port: state.ParsedAddress.Port,
|
||
CipPath: state.ParsedAddress.CipPath,
|
||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||
TagName: _options.Probe.ProbeTagPath!,
|
||
Timeout: _options.Probe.Timeout);
|
||
|
||
IAbCipTagRuntime? probeRuntime = null;
|
||
while (!ct.IsCancellationRequested)
|
||
{
|
||
var success = false;
|
||
try
|
||
{
|
||
probeRuntime ??= _tagFactory.Create(probeParams);
|
||
// Lazy-init on first attempt; re-init after a transport failure has caused the
|
||
// native handle to be destroyed.
|
||
if (!state.ProbeInitialized)
|
||
{
|
||
await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
|
||
state.ProbeInitialized = true;
|
||
}
|
||
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||
success = probeRuntime.GetStatus() == 0;
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||
{
|
||
break;
|
||
}
|
||
catch
|
||
{
|
||
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
|
||
try { probeRuntime?.Dispose(); } catch { }
|
||
probeRuntime = null;
|
||
state.ProbeInitialized = false;
|
||
}
|
||
|
||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||
|
||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||
catch (OperationCanceledException) { break; }
|
||
}
|
||
|
||
try { probeRuntime?.Dispose(); } catch { }
|
||
}
|
||
|
||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||
{
|
||
HostState old;
|
||
lock (state.ProbeLock)
|
||
{
|
||
old = state.HostState;
|
||
if (old == newState) return;
|
||
state.HostState = newState;
|
||
state.HostStateChangedUtc = DateTime.UtcNow;
|
||
}
|
||
OnHostStatusChanged?.Invoke(this,
|
||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||
}
|
||
|
||
// ---- IPerCallHostResolver ----
|
||
|
||
/// <summary>
|
||
/// Resolve the device host address for a given tag full-reference. Per plan decision #144
|
||
/// the Phase 6.1 resilience pipeline keys its bulkhead + breaker on
|
||
/// <c>(DriverInstanceId, hostName)</c> so multi-PLC drivers get per-device isolation —
|
||
/// one dead PLC trips only its own breaker. Unknown references fall back to the
|
||
/// first configured device's host address rather than throwing — the invoker handles the
|
||
/// mislookup at the capability level when the actual read returns BadNodeIdUnknown.
|
||
/// </summary>
|
||
public string ResolveHost(string fullReference)
|
||
{
|
||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||
return def.DeviceHostAddress;
|
||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||
}
|
||
|
||
// ---- IReadable ----
|
||
|
||
/// <summary>
|
||
/// Read each <c>fullReference</c> in order. Unknown tags surface as
|
||
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
|
||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
|
||
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
|
||
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
|
||
/// </summary>
|
||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||
var now = DateTime.UtcNow;
|
||
var results = new DataValueSnapshot[fullReferences.Count];
|
||
|
||
// Task #194 — plan the batch: members of the same parent UDT get collapsed into one
|
||
// whole-UDT read + in-memory member decode; every other reference falls back to the
|
||
// per-tag path that's been here since PR 3. Planner is a pure function over the
|
||
// current tag map; BOOL/String/Structure members stay on the fallback path because
|
||
// declaration-only offsets can't place them under Logix alignment rules.
|
||
var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
|
||
|
||
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 (!_tagsByName.TryGetValue(reference, out var def))
|
||
{
|
||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||
return;
|
||
}
|
||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||
{
|
||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||
return;
|
||
}
|
||
|
||
// PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to
|
||
// AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one
|
||
// Rockwell array read; the contiguous buffer is decoded at element stride into a
|
||
// single snapshot whose Value is an object[] of the N elements.
|
||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||
if (parsedPath?.Slice is not null)
|
||
{
|
||
await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||
|
||
var status = runtime.GetStatus();
|
||
if (status != 0)
|
||
{
|
||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||
$"libplctag status {status} reading {reference}");
|
||
return;
|
||
}
|
||
|
||
var bitIndex = parsedPath?.BitIndex;
|
||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// PR abcip-1.3 — slice read path. Builds an <see cref="AbCipArrayReadPlan"/> from the
|
||
/// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so
|
||
/// repeat reads reuse the same libplctag handle), issues one PLC array read, and
|
||
/// decodes the contiguous buffer into <c>object?[]</c> at element stride. Unsupported
|
||
/// element types fall back to <see cref="AbCipStatusMapper.BadNotSupported"/>.
|
||
/// </summary>
|
||
private async Task ReadSliceAsync(
|
||
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
|
||
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||
{
|
||
var baseParams = new AbCipTagCreateParams(
|
||
Gateway: device.ParsedAddress.Gateway,
|
||
Port: device.ParsedAddress.Port,
|
||
CipPath: device.ParsedAddress.CipPath,
|
||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||
TagName: parsedPath.ToLibplctagName(),
|
||
Timeout: _options.Timeout);
|
||
|
||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
|
||
if (plan is null)
|
||
{
|
||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||
AbCipStatusMapper.BadNotSupported, null, now);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, ct)
|
||
.ConfigureAwait(false);
|
||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||
|
||
var status = runtime.GetStatus();
|
||
if (status != 0)
|
||
{
|
||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||
$"libplctag status {status} reading slice {def.Name}");
|
||
return;
|
||
}
|
||
|
||
var values = AbCipArrayReadPlanner.Decode(plan, runtime);
|
||
results[fb.OriginalIndex] = new DataValueSnapshot(values, AbCipStatusMapper.Good, now, now);
|
||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Idempotently materialise a slice-read runtime. Slice runtimes share the device's
|
||
/// <see cref="DeviceState.Runtimes"/> dict keyed by the tag's full name so repeated
|
||
/// reads reuse the same libplctag handle without re-creating the native tag every poll.
|
||
/// </summary>
|
||
private async Task<IAbCipTagRuntime> EnsureSliceRuntimeAsync(
|
||
DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct)
|
||
{
|
||
if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing;
|
||
|
||
var runtime = _tagFactory.Create(createParams);
|
||
try
|
||
{
|
||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
runtime.Dispose();
|
||
throw;
|
||
}
|
||
device.Runtimes[tagName] = runtime;
|
||
return runtime;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
|
||
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
||
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
|
||
/// the mapped fault across every grouped member only — sibling groups + the
|
||
/// per-tag fallback list are unaffected.
|
||
/// </summary>
|
||
private async Task ReadGroupAsync(
|
||
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||
{
|
||
var parent = group.ParentDefinition;
|
||
|
||
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
|
||
{
|
||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);
|
||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||
|
||
var status = runtime.GetStatus();
|
||
if (status != 0)
|
||
{
|
||
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
|
||
StampGroupStatus(group, results, now, mapped);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||
$"libplctag status {status} reading UDT {group.ParentName}");
|
||
return;
|
||
}
|
||
|
||
foreach (var member in group.Members)
|
||
{
|
||
var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
|
||
results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||
}
|
||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||
}
|
||
}
|
||
|
||
private static void StampGroupStatus(
|
||
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, uint statusCode)
|
||
{
|
||
foreach (var member in group.Members)
|
||
results[member.OriginalIndex] = new DataValueSnapshot(null, statusCode, null, now);
|
||
}
|
||
|
||
// ---- IWritable ----
|
||
|
||
/// <summary>
|
||
/// Write each request in the batch. Writes are NOT auto-retried by the driver — per
|
||
/// plan decisions #44, #45, #143 the caller opts in via
|
||
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> and the resilience pipeline (layered
|
||
/// above the driver) decides whether to replay. Non-writable configurations surface as
|
||
/// <c>BadNotWritable</c>; type-conversion failures as <c>BadTypeMismatch</c>; transport
|
||
/// errors as <c>BadCommunicationError</c>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// PR abcip-1.4 — multi-tag write packing. Writes are grouped by device via
|
||
/// <see cref="AbCipMultiWritePlanner"/>. Devices whose family
|
||
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> is <c>true</c> dispatch
|
||
/// their packable writes concurrently so libplctag's native scheduler can coalesce them
|
||
/// onto one CIP Multi-Service Packet (0x0A) per round-trip; Micro800 (no packing) still
|
||
/// issues writes one-at-a-time. BOOL-within-DINT writes always go through the RMW path
|
||
/// under a per-parent semaphore, regardless of the family flag, because two concurrent
|
||
/// RMWs on the same DINT could lose one another's update. Per-tag StatusCodes are
|
||
/// preserved in the caller's input order on partial failures.
|
||
/// </remarks>
|
||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(writes);
|
||
var results = new WriteResult[writes.Count];
|
||
|
||
var plans = AbCipMultiWritePlanner.Build(
|
||
writes, _tagsByName, _devices,
|
||
reportPreflight: (idx, code) => results[idx] = new WriteResult(code));
|
||
|
||
foreach (var plan in plans)
|
||
{
|
||
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
|
||
{
|
||
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||
continue;
|
||
}
|
||
|
||
// Bit-RMW writes always serialise per-parent — never packed.
|
||
foreach (var entry in plan.BitRmw)
|
||
results[entry.OriginalIndex] = new WriteResult(
|
||
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
|
||
|
||
if (plan.Packable.Count == 0) continue;
|
||
|
||
if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1)
|
||
{
|
||
// Concurrent dispatch — libplctag's native scheduler packs same-connection writes
|
||
// into one Multi-Service Packet when the family supports it.
|
||
var tasks = new Task<(int idx, uint code)>[plan.Packable.Count];
|
||
for (var i = 0; i < plan.Packable.Count; i++)
|
||
{
|
||
var entry = plan.Packable[i];
|
||
tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken);
|
||
}
|
||
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||
foreach (var (idx, code) in outcomes)
|
||
results[idx] = new WriteResult(code);
|
||
}
|
||
else
|
||
{
|
||
// Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential.
|
||
foreach (var entry in plan.Packable)
|
||
{
|
||
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
results[entry.OriginalIndex] = new WriteResult(code.code);
|
||
}
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Execute one packable write — encode the value into the per-tag runtime, flush, and
|
||
/// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the
|
||
/// pre-1.4 per-tag loop so callers see no behaviour change for individual writes.
|
||
/// </summary>
|
||
private async Task<(int idx, uint code)> ExecutePackableWriteAsync(
|
||
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
||
{
|
||
var def = entry.Definition;
|
||
var w = entry.Request;
|
||
var now = DateTime.UtcNow;
|
||
try
|
||
{
|
||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||
runtime.EncodeValue(def.DataType, entry.ParsedPath?.BitIndex, w.Value);
|
||
await runtime.WriteAsync(ct).ConfigureAwait(false);
|
||
|
||
var status = runtime.GetStatus();
|
||
if (status == 0)
|
||
{
|
||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||
return (entry.OriginalIndex, AbCipStatusMapper.Good);
|
||
}
|
||
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (NotSupportedException nse)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||
return (entry.OriginalIndex, AbCipStatusMapper.BadNotSupported);
|
||
}
|
||
catch (FormatException fe)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||
}
|
||
catch (InvalidCastException ice)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||
}
|
||
catch (OverflowException oe)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||
return (entry.OriginalIndex, AbCipStatusMapper.BadOutOfRange);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||
return (entry.OriginalIndex, AbCipStatusMapper.BadCommunicationError);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Execute one BOOL-within-DINT write through <see cref="WriteBitInDIntAsync"/>, with
|
||
/// the same exception-mapping fan-out as the pre-1.4 per-tag loop. Bit RMWs cannot be
|
||
/// packed because two concurrent writes against the same parent DINT would race their
|
||
/// read-modify-write windows.
|
||
/// </summary>
|
||
private async Task<uint> ExecuteBitRmwWriteAsync(
|
||
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
var bit = entry.ParsedPath!.BitIndex!.Value;
|
||
var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct)
|
||
.ConfigureAwait(false);
|
||
if (code == AbCipStatusMapper.Good)
|
||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||
return code;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (NotSupportedException nse)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||
return AbCipStatusMapper.BadNotSupported;
|
||
}
|
||
catch (FormatException fe)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||
return AbCipStatusMapper.BadTypeMismatch;
|
||
}
|
||
catch (InvalidCastException ice)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||
return AbCipStatusMapper.BadTypeMismatch;
|
||
}
|
||
catch (OverflowException oe)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||
return AbCipStatusMapper.BadOutOfRange;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||
return AbCipStatusMapper.BadCommunicationError;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
||
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
|
||
/// writers against the same parent via a per-parent <see cref="SemaphoreSlim"/>.
|
||
/// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern shipped in pass 1 of task #181.
|
||
/// </summary>
|
||
private async Task<uint> WriteBitInDIntAsync(
|
||
DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
|
||
{
|
||
var parentPath = bitPath with { BitIndex = null };
|
||
var parentName = parentPath.ToLibplctagName();
|
||
|
||
var rmwLock = device.GetRmwLock(parentName);
|
||
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
||
try
|
||
{
|
||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||
var readStatus = parentRuntime.GetStatus();
|
||
if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);
|
||
|
||
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
|
||
var updated = Convert.ToBoolean(value)
|
||
? current | (1 << bit)
|
||
: current & ~(1 << bit);
|
||
|
||
parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
|
||
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
|
||
var writeStatus = parentRuntime.GetStatus();
|
||
return writeStatus == 0
|
||
? AbCipStatusMapper.Good
|
||
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
|
||
}
|
||
finally
|
||
{
|
||
rmwLock.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
|
||
/// so repeated bit writes against the same DINT share one handle.
|
||
/// </summary>
|
||
private async Task<IAbCipTagRuntime> EnsureParentRuntimeAsync(
|
||
DeviceState device, string parentTagName, CancellationToken ct)
|
||
{
|
||
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
|
||
|
||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||
Gateway: device.ParsedAddress.Gateway,
|
||
Port: device.ParsedAddress.Port,
|
||
CipPath: device.ParsedAddress.CipPath,
|
||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||
TagName: parentTagName,
|
||
Timeout: _options.Timeout));
|
||
try
|
||
{
|
||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
runtime.Dispose();
|
||
throw;
|
||
}
|
||
device.ParentRuntimes[parentTagName] = runtime;
|
||
return runtime;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Idempotently materialise the runtime handle for a tag definition. First call creates
|
||
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
|
||
/// lifetime of the device.
|
||
/// </summary>
|
||
private async Task<IAbCipTagRuntime> EnsureTagRuntimeAsync(
|
||
DeviceState device, AbCipTagDefinition def, CancellationToken ct)
|
||
{
|
||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||
|
||
var parsed = AbCipTagPath.TryParse(def.TagPath)
|
||
?? throw new InvalidOperationException(
|
||
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
|
||
|
||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||
Gateway: device.ParsedAddress.Gateway,
|
||
Port: device.ParsedAddress.Port,
|
||
CipPath: device.ParsedAddress.CipPath,
|
||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||
TagName: parsed.ToLibplctagName(),
|
||
Timeout: _options.Timeout,
|
||
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
|
||
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. UDT tags with declared
|
||
// Members fan out into a sub-folder + one Variable per member instead of a single
|
||
// Structure Variable (Structure has no useful scalar value + member-addressable paths
|
||
// are what downstream consumers actually want).
|
||
var preDeclared = _options.Tags.Where(t =>
|
||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||
foreach (var tag in preDeclared)
|
||
{
|
||
if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;
|
||
|
||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||
{
|
||
var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
|
||
foreach (var member in tag.Members)
|
||
{
|
||
var memberFullName = $"{tag.Name}.{member.Name}";
|
||
udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||
FullName: memberFullName,
|
||
DriverDataType: member.DataType.ToDriverDataType(),
|
||
IsArray: false,
|
||
ArrayDim: null,
|
||
SecurityClass: member.Writable
|
||
? SecurityClassification.Operate
|
||
: SecurityClassification.ViewOnly,
|
||
IsHistorized: false,
|
||
IsAlarm: false,
|
||
WriteIdempotent: member.WriteIdempotent));
|
||
}
|
||
continue;
|
||
}
|
||
|
||
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
|
||
}
|
||
|
||
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
|
||
// walker (LibplctagTagEnumerator) is the factory default since task #178 shipped,
|
||
// so leaving the flag off keeps the strict-config path for deployments where only
|
||
// declared tags should appear.
|
||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||
{
|
||
using var enumerator = _enumeratorFactory.Create();
|
||
var deviceParams = 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 && !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>
|
||
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 object ProbeLock { get; } = new();
|
||
public HostState HostState { get; set; } = HostState.Unknown;
|
||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||
public CancellationTokenSource? ProbeCts { get; set; }
|
||
public bool ProbeInitialized { get; set; }
|
||
|
||
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
|
||
new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// Per-tag runtime handles owned by this device. One entry per configured tag is
|
||
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
|
||
/// </summary>
|
||
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||
new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// Parent-DINT runtimes created on-demand by <see cref="AbCipDriver.EnsureParentRuntimeAsync"/>
|
||
/// for BOOL-within-DINT RMW writes. Separate from <see cref="Runtimes"/> because a
|
||
/// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
|
||
/// parent ("Motor.Flags") used to do the read + write.
|
||
/// </summary>
|
||
public Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
|
||
new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||
|
||
public SemaphoreSlim GetRmwLock(string parentTagName) =>
|
||
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
|
||
|
||
public void DisposeHandles()
|
||
{
|
||
foreach (var h in TagHandles.Values) h.Dispose();
|
||
TagHandles.Clear();
|
||
foreach (var r in Runtimes.Values) r.Dispose();
|
||
Runtimes.Clear();
|
||
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||
ParentRuntimes.Clear();
|
||
}
|
||
}
|
||
}
|