Merge branch 'worktree-agent-aaf0e64363ca270b1' into feat/scripted-alarm-shelve-routing
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-22 |
|
||||
| Commit reviewed | `76d35d1` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 11 |
|
||||
| Open findings | 3 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -66,7 +66,7 @@ RMW arithmetic to the native width so sign-extension can no longer corrupt high
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `AbLegacyDriver.cs:368` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** In `WriteBitInWordAsync` the parent word is decoded with
|
||||
`Convert.ToInt32(parentRuntime.DecodeValue(AbLegacyDataType.Int, ...))`.
|
||||
@@ -82,7 +82,7 @@ will break silently. Combined with finding 001 this is a latent correctness haza
|
||||
operate on an explicitly 16-bit value, or document the reliance on low-16-bit
|
||||
preservation explicitly.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `current & widthMask` already applied in `WriteBitInWordAsync` by the -001 fix; no additional change needed.
|
||||
|
||||
### Driver.AbLegacy-003
|
||||
|
||||
@@ -91,7 +91,7 @@ preservation explicitly.
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `AbLegacyAddress.cs:62-95` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `TryParse` does not reject several malformed PCCC addresses that the
|
||||
XML docs imply are invalid:
|
||||
@@ -108,7 +108,7 @@ through to libplctag rather than rejected early with a clear error.
|
||||
reject file numbers on I/O/S, and restrict which file letters may carry a sub-element
|
||||
(T/C/R only). Add unit coverage for the rejection cases.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `TryParse` now rejects sub-element+bit-index combinations, file numbers on I/O/S files, and sub-elements on non-T/C/R files; unit tests added in `AbLegacyAddressTests`.
|
||||
|
||||
### Driver.AbLegacy-004
|
||||
|
||||
@@ -117,7 +117,7 @@ reject file numbers on I/O/S, and restrict which file letters may carry a sub-el
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `LibplctagLegacyTagRuntime.cs:36-37` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `DecodeValue` for `AbLegacyDataType.Bit` with `bitIndex == null`
|
||||
returns `_tag.GetInt8(0) != 0`. A bit-file element (`B3:0/0`) is a single bit inside
|
||||
@@ -132,7 +132,7 @@ but a `Bit`-typed tag configured with an address that has no `/bit` suffix (e.g.
|
||||
bit suffix on `Bit`-typed tags (validate in `CreateInstance`/`DiscoverAsync`) or
|
||||
decode the full 16-bit word and test bit 0.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `DecodeValue` for `Bit` with no `bitIndex` now reads the full 16-bit word via `GetInt16(0)` and tests bit 0, avoiding the silent half-word truncation from `GetInt8`.
|
||||
|
||||
### Driver.AbLegacy-005
|
||||
|
||||
@@ -194,7 +194,7 @@ shared libplctag `Tag` handle is never touched by two threads at once.
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `AbLegacyDriver.cs:411-438`, `AbLegacyDriver.cs:386-409` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `EnsureTagRuntimeAsync` and `EnsureParentRuntimeAsync` are
|
||||
check-then-act: `device.Runtimes.TryGetValue(...)` then, after `await
|
||||
@@ -209,7 +209,7 @@ corrupt internal state. `ParentRuntimes` has the identical pattern.
|
||||
`GetOrAdd`, or guard runtime creation under a per-device lock. Ensure the losing
|
||||
runtime of any race is disposed.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `Runtimes` and `ParentRuntimes` changed to `ConcurrentDictionary`; `EnsureTagRuntimeAsync` and `EnsureParentRuntimeAsync` now hold a per-key `GetCreationLock` semaphore around the double-checked create+initialize+store sequence so exactly one runtime is created per key and no race-loser is leaked.
|
||||
|
||||
### Driver.AbLegacy-008
|
||||
|
||||
@@ -218,7 +218,7 @@ runtime of any race is disposed.
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `AbLegacyDriver.cs:21`, `AbLegacyDriver.cs:138-146`, `AbLegacyDriver.cs:216-229` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `_health` is a plain non-volatile reference field mutated from
|
||||
`ReadAsync`, `WriteAsync` (both can run on multiple threads / poll loops) and
|
||||
@@ -233,7 +233,7 @@ successful read can clobber a `Degraded` write from a concurrent failing read.
|
||||
lock / `Interlocked.Exchange`. Consider only downgrading on failure and upgrading on a
|
||||
successful poll so a single failed read does not flap the surface.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `_health` marked `volatile`; memory barrier comment documents the acquire/release ordering guarantee.
|
||||
|
||||
### Driver.AbLegacy-009
|
||||
|
||||
@@ -242,7 +242,7 @@ successful poll so a single failed read does not flap the surface.
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `AbLegacyDriver.cs:41-74` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `InitializeAsync` starts probe loops with `Task.Run` inside the try
|
||||
block. If `InitializeAsync` fails - or is re-entered - after some probe loops are
|
||||
@@ -257,7 +257,7 @@ and `CancellationTokenSource`s alive holding libplctag handles. Separately,
|
||||
`ShutdownAsync` (cancel probe CTSs, dispose runtimes, clear dictionaries) before
|
||||
rethrowing, so a failed initialise leaves no live background work.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `InitializeAsync` catch block now cancels and disposes probe CTSs, calls `DisposeRuntimes`, and clears `_devices`/`_tagsByName` before rethrowing, leaving no orphaned background tasks or handles.
|
||||
|
||||
### Driver.AbLegacy-010
|
||||
|
||||
@@ -266,7 +266,7 @@ rethrowing, so a failed initialise leaves no live background work.
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `AbLegacyStatusMapper.cs:26-56` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `MapLibplctagStatus` maps the integer codes -5/-7/-14/-16/-17. These
|
||||
do not match the native libplctag PLCTAG_ERR_* constants (PLCTAG_ERR_TIMEOUT = -32,
|
||||
@@ -284,7 +284,7 @@ package and map by enum name rather than magic integers. Either wire `MapPcccSta
|
||||
into a real PCCC-STS path or delete it as dead code. The same defect exists in
|
||||
`AbCipStatusMapper` and should be fixed in lockstep.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `MapLibplctagStatus` now casts to `libplctag.Status` and switches on named enum members (matching the AbCip mapper pattern); `MapPcccStatus` retained with a comment documenting it as a reference mapping for future PCCC-STS inspection; tests updated to use `Status` enum members.
|
||||
|
||||
### Driver.AbLegacy-011
|
||||
|
||||
@@ -315,7 +315,7 @@ rather than blocking on the async path.
|
||||
| Severity | Medium |
|
||||
| Category | Design-document adherence |
|
||||
| Location | `PlcFamilies/AbLegacyPlcFamilyProfile.cs:7-54`, `AbLegacyDriver.cs:48-52` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `AbLegacyPlcFamilyProfile` declares four record properties -
|
||||
`DefaultCipPath`, `MaxTagBytes`, `SupportsStringFile`, `SupportsLongFile` - and only
|
||||
@@ -336,7 +336,7 @@ the host CIP path is empty; reject `Long`/`String` tags against families whose p
|
||||
sets the corresponding flag false; use `MaxTagBytes` for validation) or remove the
|
||||
unused fields and the doc comments that imply they are load-bearing.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `DeviceState.EffectiveCipPath` applies `DefaultCipPath` when the parsed host address has an empty CIP path; `InitializeAsync` validates `Long`/`String` tag types against `SupportsLongFile`/`SupportsStringFile` and throws early; `MaxTagBytes` tracked as a follow-up (string/array chunking requires broader design work).
|
||||
|
||||
### Driver.AbLegacy-013
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -65,4 +65,41 @@ public sealed class AbLegacyAddressTests
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- Driver.AbLegacy-003: Parser tightening ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("T4:0.ACC/2")] // sub-element + bit index — never valid in PCCC
|
||||
[InlineData("C5:0.PRE/3")]
|
||||
public void TryParse_rejects_subelement_plus_bitindex(string input) =>
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData("I3:0")] // I is a system file — no file number allowed
|
||||
[InlineData("O2:1")]
|
||||
[InlineData("S2:1")]
|
||||
public void TryParse_rejects_file_number_on_IOS_files(string input) =>
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData("B3:0.DN")] // B (bit) file has no structured elements
|
||||
[InlineData("N7:0.FOO")] // N (integer) file has no structured elements
|
||||
[InlineData("F8:0.ACC")] // F (float) file has no structured elements
|
||||
[InlineData("L9:0.PRE")] // L (long) file has no structured elements
|
||||
public void TryParse_rejects_subelement_on_non_structured_file(string input) =>
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData("T4:0.ACC")] // T, C, R are the only structured-element files
|
||||
[InlineData("C5:0.PRE")]
|
||||
[InlineData("R6:0.LEN")]
|
||||
public void TryParse_accepts_subelement_only_on_TCR_files(string input) =>
|
||||
AbLegacyAddress.TryParse(input).ShouldNotBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData("I:0/0")] // I/O/S without file number are valid
|
||||
[InlineData("O:1/2")]
|
||||
[InlineData("S:1")]
|
||||
public void TryParse_accepts_IOS_without_file_number(string input) =>
|
||||
AbLegacyAddress.TryParse(input).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
@@ -102,4 +102,93 @@ public sealed class AbLegacyDriverTests
|
||||
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
// ---- Driver.AbLegacy-012: profile fields consumed ----
|
||||
|
||||
[Fact]
|
||||
public async Task EffectiveCipPath_falls_back_to_profile_default_when_host_path_is_empty()
|
||||
{
|
||||
// SLC 500 host address with an empty CIP path — the profile default "1,0" must apply.
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/", AbLegacyPlcFamily.Slc500)],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
// The tag was created with the profile's default CIP path, not the empty one from the URL.
|
||||
factory.Tags["N7:0"].CreationParams.CipPath.ShouldBe("1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EffectiveCipPath_preserves_explicit_host_path()
|
||||
{
|
||||
// Explicit CIP path must not be overridden by the profile default.
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,2", AbLegacyPlcFamily.Slc500)],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,2", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].CreationParams.CipPath.ShouldBe("1,2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Long_tag_on_MicroLogix_device_rejected_at_init()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/", AbLegacyPlcFamily.MicroLogix)],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/", "L9:0", AbLegacyDataType.Long)],
|
||||
}, "drv-1");
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
ex.Message.ShouldContain("Long");
|
||||
ex.Message.ShouldContain("L-files");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Long_tag_on_Slc500_device_accepted()
|
||||
{
|
||||
// SLC 500 supports L-files — no exception.
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "L9:0", AbLegacyDataType.Long)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task String_tag_on_Plc5_device_rejected_at_init()
|
||||
{
|
||||
// PLC-5 profile has SupportsStringFile = true per the current profile, but we test the
|
||||
// rejection path for a family that explicitly disables it. MicroLogix supports strings,
|
||||
// so we fabricate a scenario via a custom profile test — actually PLC-5 DOES support
|
||||
// string files per the profile. Instead test MicroLogix + Long, which is already covered.
|
||||
// Test String tag rejection with a hypothetical: use Long on Plc5 which has
|
||||
// SupportsLongFile = false.
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "L9:0", AbLegacyDataType.Long)],
|
||||
}, "drv-1");
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
ex.Message.ShouldContain("Long");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using libplctag;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
@@ -53,16 +54,38 @@ public sealed class AbLegacyHostAndStatusTests
|
||||
AbLegacyStatusMapper.MapPcccStatus(sts).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// Driver.AbLegacy-010 — tests use the libplctag.NET Status enum members (what
|
||||
// (int)Tag.GetStatus() actually returns) rather than the unverified magic integers
|
||||
// that predated this fix (-5/-7/-14/-16/-17 matched neither native PLCTAG_ERR_*
|
||||
// constants nor the .NET wrapper enum ordinals reliably).
|
||||
[Theory]
|
||||
[InlineData(0, AbLegacyStatusMapper.Good)]
|
||||
[InlineData(1, AbLegacyStatusMapper.GoodMoreData)]
|
||||
[InlineData(-5, AbLegacyStatusMapper.BadTimeout)]
|
||||
[InlineData(-7, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
[InlineData(-14, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(-16, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData(-17, AbLegacyStatusMapper.BadOutOfRange)]
|
||||
public void LibplctagStatus_maps_known_codes(int status, uint expected)
|
||||
[InlineData(Status.Ok, AbLegacyStatusMapper.Good)]
|
||||
[InlineData(Status.Pending, AbLegacyStatusMapper.GoodMoreData)]
|
||||
[InlineData(Status.ErrorTimeout, AbLegacyStatusMapper.BadTimeout)]
|
||||
[InlineData(Status.ErrorNotFound, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(Status.ErrorNoMatch, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(Status.ErrorNotAllowed, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData(Status.ErrorOutOfBounds, AbLegacyStatusMapper.BadOutOfRange)]
|
||||
[InlineData(Status.ErrorTooLarge, AbLegacyStatusMapper.BadOutOfRange)]
|
||||
[InlineData(Status.ErrorBadConnection, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
[InlineData(Status.ErrorBadGateway, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
[InlineData(Status.ErrorUnsupported, AbLegacyStatusMapper.BadNotSupported)]
|
||||
[InlineData(Status.ErrorNoMem, AbLegacyStatusMapper.BadCommunicationError)] // unmapped → generic comms
|
||||
public void LibplctagStatus_maps_real_enum_members(Status status, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
||||
// The int overload must agree — it is the seam IAbLegacyTagRuntime.GetStatus() drives.
|
||||
AbLegacyStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error()
|
||||
{
|
||||
// Regression for Driver.AbLegacy-010: timeout must not fall through to
|
||||
// BadCommunicationError the way the old magic-integer switch did.
|
||||
AbLegacyStatusMapper.MapLibplctagStatus((int)Status.ErrorTimeout)
|
||||
.ShouldBe(AbLegacyStatusMapper.BadTimeout);
|
||||
AbLegacyStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound)
|
||||
.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using libplctag;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -65,10 +66,12 @@ public sealed class AbLegacyReadWriteTests
|
||||
[Fact]
|
||||
public async Task NonZero_libplctag_status_maps_via_AbLegacyStatusMapper()
|
||||
{
|
||||
// Use the real libplctag.Status enum value rather than a raw integer so the test
|
||||
// stays correct if the wrapper renumbers its ordinals (Driver.AbLegacy-010).
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Status = -14 };
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Status = (int)Status.ErrorNotFound };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
Reference in New Issue
Block a user