InitializeAsync catch block now mirrors ShutdownAsync teardown: cancels and disposes probe CancellationTokenSources, calls DisposeRuntimes, and clears _devices/_tagsByName before rethrowing. A caller that catches and abandons (rather than retrying via ReinitializeAsync) no longer leaves orphaned probe tasks or libplctag handles alive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
610 lines
27 KiB
C#
610 lines
27 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
|
|
|
/// <summary>
|
|
/// AB Legacy / PCCC driver — SLC 500, MicroLogix, PLC-5, LogixPccc. Implements
|
|
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
|
|
/// host-resolver capabilities ship in PRs 2 and 3.
|
|
/// </summary>
|
|
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
|
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
|
{
|
|
private readonly AbLegacyDriverOptions _options;
|
|
private readonly string _driverInstanceId;
|
|
private readonly IAbLegacyTagFactory _tagFactory;
|
|
private readonly PollGroupEngine _poll;
|
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// volatile: _health is read by GetHealth() on any thread while ReadAsync / WriteAsync /
|
|
// InitializeAsync write it from worker / poll threads. The record-reference assignment is
|
|
// atomic on all .NET platforms, but without a memory barrier a reader can see a stale
|
|
// snapshot indefinitely. volatile enforces acquire/release ordering so GetHealth() always
|
|
// observes the most recently written value.
|
|
private volatile DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
|
|
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
|
|
IAbLegacyTagFactory? tagFactory = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_options = options;
|
|
_driverInstanceId = driverInstanceId;
|
|
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
|
_poll = new PollGroupEngine(
|
|
reader: ReadAsync,
|
|
onChange: (handle, tagRef, snapshot) =>
|
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
|
}
|
|
|
|
public string DriverInstanceId => _driverInstanceId;
|
|
public string DriverType => "AbLegacy";
|
|
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
|
try
|
|
{
|
|
foreach (var device in _options.Devices)
|
|
{
|
|
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
|
|
?? throw new InvalidOperationException(
|
|
$"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
|
|
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
|
|
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
|
}
|
|
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
|
|
|
// Probe loops — one per device when enabled + probe address configured.
|
|
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
|
|
{
|
|
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);
|
|
// Tear down any probe loops and cached state that were created before the failure so
|
|
// that a caller who catches and abandons (rather than retrying via ReinitializeAsync)
|
|
// doesn't leave orphaned background tasks, CancellationTokenSources, and libplctag
|
|
// handles alive. Mirrors the body of ShutdownAsync without awaiting the poll engine
|
|
// (nothing has been subscribed yet at init time).
|
|
foreach (var state in _devices.Values)
|
|
{
|
|
try { state.ProbeCts?.Cancel(); } catch { }
|
|
state.ProbeCts?.Dispose();
|
|
state.ProbeCts = null;
|
|
state.DisposeRuntimes();
|
|
}
|
|
_devices.Clear();
|
|
_tagsByName.Clear();
|
|
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 _poll.DisposeAsync().ConfigureAwait(false);
|
|
foreach (var state in _devices.Values)
|
|
{
|
|
try { state.ProbeCts?.Cancel(); } catch { }
|
|
state.ProbeCts?.Dispose();
|
|
state.ProbeCts = null;
|
|
state.DisposeRuntimes();
|
|
}
|
|
_devices.Clear();
|
|
_tagsByName.Clear();
|
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
|
}
|
|
|
|
public DriverHealth GetHealth() => _health;
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
internal int DeviceCount => _devices.Count;
|
|
internal DeviceState? GetDeviceState(string hostAddress) =>
|
|
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
|
|
|
// ---- IReadable ----
|
|
|
|
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, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
|
continue;
|
|
}
|
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
|
{
|
|
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
|
|
|
int status;
|
|
object? value;
|
|
// Serialise the Read → GetStatus → DecodeValue sequence against the shared
|
|
// runtime — the server read path and the poll loop both call ReadAsync against
|
|
// the same cached libplctag Tag handle, which is not concurrency-safe.
|
|
var opLock = device.GetRuntimeLock(def.Name);
|
|
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
|
status = runtime.GetStatus();
|
|
var parsed = AbLegacyAddress.TryParse(def.Address);
|
|
value = status == 0 ? runtime.DecodeValue(def.DataType, parsed?.BitIndex) : null;
|
|
}
|
|
finally
|
|
{
|
|
opLock.Release();
|
|
}
|
|
|
|
if (status != 0)
|
|
{
|
|
results[i] = new DataValueSnapshot(null,
|
|
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
|
$"libplctag status {status} reading {reference}");
|
|
continue;
|
|
}
|
|
|
|
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (Exception ex)
|
|
{
|
|
results[i] = new DataValueSnapshot(null,
|
|
AbLegacyStatusMapper.BadCommunicationError, null, now);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ---- IWritable ----
|
|
|
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(writes);
|
|
var results = new WriteResult[writes.Count];
|
|
|
|
for (var i = 0; i < writes.Count; i++)
|
|
{
|
|
var w = writes[i];
|
|
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
|
{
|
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
|
|
continue;
|
|
}
|
|
if (!def.Writable)
|
|
{
|
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
|
|
continue;
|
|
}
|
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
|
{
|
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var parsed = AbLegacyAddress.TryParse(def.Address);
|
|
|
|
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
|
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
|
// concurrent bit writers. Applies to N-file bit-in-word (N7:0/3) + B-file bits
|
|
// (B3:0/0). T/C/R sub-elements don't hit this path because they're not Bit typed.
|
|
if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit
|
|
&& parsed.FileLetter is not "B" and not "I" and not "O")
|
|
{
|
|
results[i] = new WriteResult(
|
|
await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
|
|
continue;
|
|
}
|
|
|
|
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
|
|
|
int status;
|
|
// Serialise Encode → Write → GetStatus against the shared runtime — the same
|
|
// cached Tag handle may be in use by a concurrent ReadAsync or poll loop.
|
|
var opLock = device.GetRuntimeLock(def.Name);
|
|
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
|
|
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
|
status = runtime.GetStatus();
|
|
}
|
|
finally
|
|
{
|
|
opLock.Release();
|
|
}
|
|
|
|
results[i] = new WriteResult(status == 0
|
|
? AbLegacyStatusMapper.Good
|
|
: AbLegacyStatusMapper.MapLibplctagStatus(status));
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (NotSupportedException nse)
|
|
{
|
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
|
}
|
|
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
|
{
|
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch);
|
|
}
|
|
catch (OverflowException)
|
|
{
|
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ---- ITagDiscovery ----
|
|
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
var root = builder.Folder("AbLegacy", "AbLegacy");
|
|
foreach (var device in _options.Devices)
|
|
{
|
|
var label = device.DeviceName ?? device.HostAddress;
|
|
var deviceFolder = root.Folder(device.HostAddress, label);
|
|
var tagsForDevice = _options.Tags.Where(t =>
|
|
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
|
foreach (var tag in tagsForDevice)
|
|
{
|
|
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
|
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));
|
|
}
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---- 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;
|
|
}
|
|
|
|
// ---- 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 AbLegacyTagCreateParams(
|
|
Gateway: state.ParsedAddress.Gateway,
|
|
Port: state.ParsedAddress.Port,
|
|
CipPath: state.ParsedAddress.CipPath,
|
|
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
|
TagName: _options.Probe.ProbeAddress!,
|
|
Timeout: _options.Probe.Timeout);
|
|
|
|
IAbLegacyTagRuntime? probeRuntime = null;
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
var success = false;
|
|
try
|
|
{
|
|
probeRuntime ??= _tagFactory.Create(probeParams);
|
|
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
|
|
{
|
|
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 ----
|
|
|
|
public string ResolveHost(string fullReference)
|
|
{
|
|
if (_tagsByName.TryGetValue(fullReference, out var def))
|
|
return def.DeviceHostAddress;
|
|
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read-modify-write one bit within a PCCC word. Strips the /N bit suffix to form the
|
|
/// parent-word address (N7:0/3 → N7:0), creates / reuses a parent-word runtime typed at
|
|
/// the parent's native width (Int16 for N/I/O/S/A files, Int32 for the 32-bit L file),
|
|
/// and serialises concurrent bit writers against the same parent via a per-parent
|
|
/// <see cref="SemaphoreSlim"/>. The parent word is masked to its native width before the
|
|
/// bit operation so a sign-extended decode never corrupts the high bits.
|
|
/// </summary>
|
|
private async Task<uint> WriteBitInWordAsync(
|
|
AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
|
|
{
|
|
// The parent word width follows the file letter: an L-file element is 32-bit, every
|
|
// other bit-addressable PCCC file (N/I/O/S/A) is a 16-bit word. bit is already
|
|
// range-checked by AbLegacyAddress.TryParse (0..15 for 16-bit files, 0..31 for L), so
|
|
// 1 << bit can never overflow the chosen width here.
|
|
var isLong = bitAddress.FileLetter == "L";
|
|
var parentType = isLong ? AbLegacyDataType.Long : AbLegacyDataType.Int;
|
|
var widthMask = isLong ? unchecked((int)0xFFFFFFFF) : 0xFFFF;
|
|
|
|
var parentAddress = bitAddress with { BitIndex = null };
|
|
var parentName = parentAddress.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 AbLegacyStatusMapper.MapLibplctagStatus(readStatus);
|
|
|
|
// Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a
|
|
// sign-extended int, so a word with bit 15 set decodes negative. Masking pins the
|
|
// RMW arithmetic to exactly the parent's bits.
|
|
var current = Convert.ToInt32(parentRuntime.DecodeValue(parentType, bitIndex: null) ?? 0) & widthMask;
|
|
var updated = Convert.ToBoolean(value)
|
|
? current | (1 << bit)
|
|
: current & ~(1 << bit);
|
|
updated &= widthMask;
|
|
|
|
if (isLong)
|
|
parentRuntime.EncodeValue(AbLegacyDataType.Long, bitIndex: null, updated);
|
|
else
|
|
parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
|
|
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
|
|
var writeStatus = parentRuntime.GetStatus();
|
|
return writeStatus == 0
|
|
? AbLegacyStatusMapper.Good
|
|
: AbLegacyStatusMapper.MapLibplctagStatus(writeStatus);
|
|
}
|
|
finally
|
|
{
|
|
rmwLock.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
|
|
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
|
|
{
|
|
// Fast path: runtime already cached.
|
|
if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
|
|
|
|
// Slow path: serialise creation per key so concurrent callers don't each create a
|
|
// runtime and one of them gets overwritten + leaked. Only one caller initialises; the
|
|
// others find the entry on the second TryGetValue inside the lock.
|
|
var creationLock = device.GetCreationLock($"parent:{parentName}");
|
|
await creationLock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing;
|
|
|
|
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
|
Gateway: device.ParsedAddress.Gateway,
|
|
Port: device.ParsedAddress.Port,
|
|
CipPath: device.ParsedAddress.CipPath,
|
|
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
|
TagName: parentName,
|
|
Timeout: _options.Timeout));
|
|
try
|
|
{
|
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
runtime.Dispose();
|
|
throw;
|
|
}
|
|
device.ParentRuntimes[parentName] = runtime;
|
|
return runtime;
|
|
}
|
|
finally
|
|
{
|
|
creationLock.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
|
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
|
{
|
|
// Fast path: runtime already cached.
|
|
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
|
|
|
// Slow path: serialise creation per tag name so concurrent callers for the same tag
|
|
// (server read path + poll loop) don't both create a runtime and one gets leaked.
|
|
var creationLock = device.GetCreationLock($"tag:{def.Name}");
|
|
await creationLock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing;
|
|
|
|
var parsed = AbLegacyAddress.TryParse(def.Address)
|
|
?? throw new InvalidOperationException(
|
|
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
|
|
|
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
|
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;
|
|
}
|
|
finally
|
|
{
|
|
creationLock.Release();
|
|
}
|
|
}
|
|
|
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
|
|
|
internal sealed class DeviceState(
|
|
AbLegacyHostAddress parsedAddress,
|
|
AbLegacyDeviceOptions options,
|
|
AbLegacyPlcFamilyProfile profile)
|
|
{
|
|
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
|
|
public AbLegacyDeviceOptions Options { get; } = options;
|
|
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
|
|
|
|
/// <summary>
|
|
/// Per-tag cached runtimes. <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
|
|
/// avoids the check-then-act race present on a plain <c>Dictionary</c>: two concurrent
|
|
/// <c>EnsureTagRuntimeAsync</c> callers for the same key both miss the lookup on a
|
|
/// plain dict and both create + store a runtime, leaking the loser. Access is guarded
|
|
/// by a per-key creation semaphore (<see cref="GetCreationLock"/>) so exactly one
|
|
/// runtime is created per tag name.
|
|
/// </summary>
|
|
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// Parent-word runtimes for bit-within-word RMW writes (task #181). Keyed by the
|
|
/// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
|
|
/// single parent runtime for N7:0.
|
|
/// </summary>
|
|
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// Per-key creation locks for <see cref="Runtimes"/> and <see cref="ParentRuntimes"/>.
|
|
/// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd
|
|
/// sequence so that a concurrent caller waits rather than creating a duplicate runtime
|
|
/// that would be leaked on <see cref="DisposeRuntimes"/>.
|
|
/// </summary>
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _creationLocks = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public SemaphoreSlim GetCreationLock(string key) =>
|
|
_creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
|
|
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
|
|
|
public SemaphoreSlim GetRmwLock(string parentName) =>
|
|
_rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(1, 1));
|
|
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _runtimeLocks = new();
|
|
|
|
/// <summary>
|
|
/// Per-runtime operation lock. A libplctag <c>Tag</c> handle is not safe for
|
|
/// concurrent Read/GetStatus/DecodeValue from multiple threads — the server read
|
|
/// path and the poll loop both call <see cref="AbLegacyDriver.ReadAsync"/> against
|
|
/// the same cached runtime. Callers hold this lock around the whole
|
|
/// Read → GetStatus → Decode (or Encode → Write → GetStatus) sequence so a status
|
|
/// or value is never observed mid-update by another thread. Keyed by tag name, which
|
|
/// is also the <see cref="Runtimes"/> dictionary key.
|
|
/// </summary>
|
|
public SemaphoreSlim GetRuntimeLock(string tagName) =>
|
|
_runtimeLocks.GetOrAdd(tagName, _ => new SemaphoreSlim(1, 1));
|
|
|
|
public object ProbeLock { get; } = new();
|
|
public HostState HostState { get; set; } = HostState.Unknown;
|
|
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
|
public CancellationTokenSource? ProbeCts { get; set; }
|
|
public bool ProbeInitialized { get; set; }
|
|
|
|
public void DisposeRuntimes()
|
|
{
|
|
foreach (var r in Runtimes.Values) r.Dispose();
|
|
Runtimes.Clear();
|
|
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
|
ParentRuntimes.Clear();
|
|
}
|
|
}
|
|
}
|