Merge branch 'worktree-agent-aaf0e64363ca270b1' into feat/scripted-alarm-shelve-routing

This commit is contained in:
Joseph Doherty
2026-05-22 09:40:45 -04:00
9 changed files with 372 additions and 76 deletions

View File

@@ -102,6 +102,19 @@ public sealed record AbLegacyAddress(
if (maxBit < 0 || b > maxBit) return null;
}
// I/O/S are single-letter system files — they carry no file number in the PCCC spec.
// Accepting I3:0 or S2:1 would pass a malformed address straight to libplctag; reject early.
if (fileNumber is not null && IsNoFileNumberLetter(letter)) return null;
// A PCCC address cannot have both a sub-element and a bit index: the word is either
// structured (T4:0.ACC) or bit-addressed (N7:0/3), never both.
if (subElement is not null && bitIndex is not null) return null;
// Sub-elements are only meaningful on Timer (T), Counter (C), and Control (R) files —
// those are the only structured-element file types in the PCCC spec. Accepting B3:0.DN
// or N7:0.FOO would produce an address libplctag silently misinterprets.
if (subElement is not null && !IsSubElementFileLetter(letter)) return null;
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
}
@@ -122,4 +135,18 @@ public sealed record AbLegacyAddress(
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
_ => false,
};
/// <summary>
/// Returns <see langword="true"/> for file letters that carry no explicit file number in the
/// PCCC spec. <c>I</c> (input), <c>O</c> (output), and <c>S</c> (status) are single-letter
/// system files; a digit after the letter (e.g. <c>I3</c>) is a malformed address.
/// </summary>
private static bool IsNoFileNumberLetter(string letter) => letter is "I" or "O" or "S";
/// <summary>
/// Returns <see langword="true"/> for file letters that may carry a sub-element suffix
/// (<c>.ACC</c>, <c>.PRE</c>, etc.). Only Timer (<c>T</c>), Counter (<c>C</c>), and
/// Control (<c>R</c>) files have structured elements in the PCCC spec.
/// </summary>
private static bool IsSubElementFileLetter(string letter) => letter is "T" or "C" or "R";
}

View File

@@ -17,7 +17,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
// 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;
@@ -53,6 +59,24 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
// Validate tag types against their device's family profile. Long (32-bit integer)
// and String (ST-file) are not supported by all PCCC families; reject them early
// so a misconfigured tag fails at init time with a clear message rather than
// surfacing an opaque comms error at runtime.
foreach (var tag in _options.Tags)
{
if (!_devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue;
var profile = deviceForTag.Profile;
if (tag.DataType == AbLegacyDataType.Long && !profile.SupportsLongFile)
throw new InvalidOperationException(
$"Tag '{tag.Name}' is typed as Long but device '{tag.DeviceHostAddress}' " +
$"(family {deviceForTag.Options.PlcFamily}) does not support L-files.");
if (tag.DataType == AbLegacyDataType.String && !profile.SupportsStringFile)
throw new InvalidOperationException(
$"Tag '{tag.Name}' is typed as String but device '{tag.DeviceHostAddress}' " +
$"(family {deviceForTag.Options.PlcFamily}) does not support ST-files.");
}
// Probe loops — one per device when enabled + probe address configured.
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
{
@@ -68,6 +92,20 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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;
@@ -313,7 +351,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var probeParams = new AbLegacyTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
CipPath: state.ParsedAddress.CipPath,
CipPath: state.EffectiveCipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: _options.Probe.ProbeAddress!,
Timeout: _options.Probe.Timeout);
@@ -431,55 +469,84 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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;
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));
// 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
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
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.EffectiveCipPath,
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;
}
catch
finally
{
runtime.Dispose();
throw;
creationLock.Release();
}
device.ParentRuntimes[parentName] = runtime;
return runtime;
}
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;
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));
// 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
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
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.EffectiveCipPath,
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;
}
catch
finally
{
runtime.Dispose();
throw;
creationLock.Release();
}
device.Runtimes[def.Name] = runtime;
return runtime;
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
@@ -493,7 +560,26 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
public AbLegacyDeviceOptions Options { get; } = options;
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
/// <summary>
/// The CIP path to pass to libplctag. When the parsed host address has an empty CIP
/// path (e.g. <c>ab://10.0.0.5/</c>), the profile-supplied default is used instead so
/// that a SLC 500 misconfigured without an explicit path still gets the required
/// <c>1,0</c> backplane route. MicroLogix has an empty default by design (direct EIP).
/// </summary>
public string EffectiveCipPath => ParsedAddress.CipPath.Length > 0
? ParsedAddress.CipPath
: Profile.DefaultCipPath;
/// <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>
@@ -501,9 +587,20 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// 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 Dictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
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) =>

View File

@@ -1,3 +1,5 @@
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
@@ -20,28 +22,42 @@ public static class AbLegacyStatusMapper
public const uint BadTypeMismatch = 0x80730000u;
/// <summary>
/// Map libplctag return/status codes. Same polarity as the AbCip mapper — 0 success,
/// positive pending, negative error families.
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
/// is <c>(int)Tag.GetStatus()</c> — the underlying value of the libplctag.NET
/// <see cref="Status"/> enum. Delegates to the strongly-typed overload so the mapping
/// stays correct regardless of how the wrapper renumbers native PLCTAG_ERR_* constants
/// in future releases.
/// </summary>
public static uint MapLibplctagStatus(int status)
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
/// <summary>
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
/// the canonical core; the <c>int</c> overload exists only for the
/// <see cref="IAbLegacyTagRuntime.GetStatus"/> seam which boxes the enum as an int.
/// </summary>
public static uint MapLibplctagStatus(Status status) => status switch
{
if (status == 0) return Good;
if (status > 0) return GoodMoreData;
return status switch
{
-5 => BadTimeout,
-7 => BadCommunicationError,
-14 => BadNodeIdUnknown,
-16 => BadNotWritable,
-17 => BadOutOfRange,
_ => BadCommunicationError,
};
}
Status.Ok => Good,
Status.Pending => GoodMoreData,
Status.ErrorTimeout => BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
Status.ErrorNotAllowed => BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
_ => BadCommunicationError,
};
/// <summary>
/// Map a PCCC STS (status) byte. Common codes per AB PCCC reference:
/// 0x00 = success, 0x10 = illegal command, 0x20 = bad address, 0x30 = protected,
/// 0x40 = programmer busy, 0x50 = file locked, 0xF0 = extended status follows.
/// libplctag surfaces only its own <see cref="Status"/> enum rather than exposing
/// the raw STS byte, so this method is not wired into the current read/write path.
/// It is retained as the reference mapping for future PCCC-STS inspection.
/// </summary>
public static uint MapPcccStatus(byte sts) => sts switch
{

View File

@@ -33,9 +33,13 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
{
// When a bit suffix is present (e.g. B3:0/5) libplctag resolves the individual bit and
// GetBit returns it directly. When there is no suffix the caller addressed a Bit-typed
// tag without an explicit bit index; read the full 16-bit word and test bit 0 — GetInt8
// only covers the low byte and silently misses any bit set in bits 8..15.
AbLegacyDataType.Bit => bitIndex is int bit
? _tag.GetBit(bit)
: _tag.GetInt8(0) != 0,
: (_tag.GetInt16(0) & 1) != 0,
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)_tag.GetInt16(0),
AbLegacyDataType.Long => _tag.GetInt32(0),
AbLegacyDataType.Float => _tag.GetFloat32(0),