fix(driver-abcip): resolve High code-review findings (Driver.AbCip-001, -002, -003, -008)
Driver.AbCip-001 — ReinitializeAsync silently discarded its config JSON. Extracted AbCipDriverFactoryExtensions.ParseOptions; InitializeAsync now re-parses a content-bearing driverConfigJson and replaces _options (and recreates the alarm projection), so a reinitialize with a changed config (new device/tag, changed timeout) actually takes effect. A blank or empty-object JSON keeps construction-time options for the unit-test seam. Driver.AbCip-002 — libplctag status mapping used wrong integer constants. MapLibplctagStatus now switches on the libplctag.NET Status enum members (Ok/Pending/ErrorTimeout/ErrorNotFound/ErrorNotAllowed/ErrorOutOfBounds/…) instead of hand-typed natives, so timeout/not-found/not-allowed/out-of-bounds get their specific OPC UA codes instead of all collapsing to BadCommunicationError. The int overload casts to Status to stay correct against the wrapper's contiguous renumbering. Driver.AbCip-003 — whole-UDT reads decoded members at declaration-order offsets, which Studio 5000 does not guarantee. Added the opt-in AbCipDriverOptions.EnableDeclarationOnlyUdtGrouping flag (default false); AbCipUdtReadPlanner.Build forms no groups when it is off, so by default every UDT member reads per-tag rather than at possibly-wrong offsets. Driver.AbCip-008 — probe loops were fire-and-forget and ShutdownAsync raced them. Each probe Task is stored on DeviceState.ProbeTask; ShutdownAsync now cancels every CTS, awaits each probe Task (10s timeout), then disposes the CTS and handles. DeviceState.Runtimes/ParentRuntimes are ConcurrentDictionary and the Ensure*RuntimeAsync paths use TryAdd, disposing the losing concurrent creator instead of leaking a native tag handle. Adds AbCipDriverCodeReviewRegressionTests and updates existing AbCip tests to the corrected status constants + opt-in grouping flag. AbCip driver + test project build clean; all 244 AbCip tests pass. (The full-solution build has pre-existing, unrelated Driver.Galaxy protobuf-generation errors in this worktree.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-05-22 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `76d35d1` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 15 |
|
| Open findings | 11 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -33,13 +33,13 @@
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `AbCipDriver.cs:111`, `AbCipDriver.cs:163-167` |
|
| Location | `AbCipDriver.cs:111`, `AbCipDriver.cs:163-167` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `InitializeAsync(string driverConfigJson, ...)` never reads `driverConfigJson`. It builds all device/tag state from `_options`, captured at construction time. `ReinitializeAsync` calls `ShutdownAsync` then `InitializeAsync(driverConfigJson, ...)` and the JSON it is handed is silently discarded. `ReinitializeAsync` is documented (class remarks, lines 18-21) as the Tier-B escape hatch and is the IDriver entry point for picking up changed config. As written, a reinitialize with an updated config JSON (new device, new tag, changed timeout) applies none of the changes; the driver keeps running stale construction-time options. There is no validation that the passed JSON even matches the live options.
|
**Description:** `InitializeAsync(string driverConfigJson, ...)` never reads `driverConfigJson`. It builds all device/tag state from `_options`, captured at construction time. `ReinitializeAsync` calls `ShutdownAsync` then `InitializeAsync(driverConfigJson, ...)` and the JSON it is handed is silently discarded. `ReinitializeAsync` is documented (class remarks, lines 18-21) as the Tier-B escape hatch and is the IDriver entry point for picking up changed config. As written, a reinitialize with an updated config JSON (new device, new tag, changed timeout) applies none of the changes; the driver keeps running stale construction-time options. There is no validation that the passed JSON even matches the live options.
|
||||||
|
|
||||||
**Recommendation:** Either parse `driverConfigJson` inside `InitializeAsync` (re-deriving `AbCipDriverOptions` the way `AbCipDriverFactoryExtensions.CreateInstance` does, so config changes take effect on reinit), or, if config is intentionally immutable for the instance lifetime, document explicitly that AbCip ignores the parameter and assert the JSON is structurally identical to the construction options. Silently discarding it is the worst of both.
|
**Recommendation:** Either parse `driverConfigJson` inside `InitializeAsync` (re-deriving `AbCipDriverOptions` the way `AbCipDriverFactoryExtensions.CreateInstance` does, so config changes take effect on reinit), or, if config is intentionally immutable for the instance lifetime, document explicitly that AbCip ignores the parameter and assert the JSON is structurally identical to the construction options. Silently discarding it is the worst of both.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — extracted `AbCipDriverFactoryExtensions.ParseOptions` and `InitializeAsync` now re-parses a content-bearing `driverConfigJson`, replacing `_options` (and recreating the alarm projection) so `ReinitializeAsync` applies config changes; a blank/empty-object JSON keeps construction-time options for the test seam.
|
||||||
|
|
||||||
### Driver.AbCip-002
|
### Driver.AbCip-002
|
||||||
|
|
||||||
@@ -48,13 +48,13 @@
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `AbCipStatusMapper.cs:65-78` |
|
| Location | `AbCipStatusMapper.cs:65-78` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `MapLibplctagStatus` maps negative libplctag codes that do not match the libplctag.NET `Status` enum / native `libplctag.h` constants. `LibplctagTagRuntime.GetStatus()` returns `(int)_tag.GetStatus()`, the underlying value of the `Status` enum, whose members carry the native `PLCTAG_ERR_*` integer values. The real constants are `PLCTAG_ERR_BAD_CONNECTION = -7` (the only one the code gets right), `PLCTAG_ERR_NOT_FOUND = -18` (code expects -14), `PLCTAG_ERR_NOT_ALLOWED = -19` (code expects -16), `PLCTAG_ERR_OUT_OF_BOUNDS = -22` (code expects -17), `PLCTAG_ERR_TIMEOUT = -32` (code expects -5). Consequently a real timeout, not-found, not-allowed, or out-of-bounds error all fall through the switch to the `_ => BadCommunicationError` default. The driver reports `BadCommunicationError` for a non-existent tag instead of `BadNodeIdUnknown`, for a read-only tag instead of `BadNotWritable`, and for a timeout instead of `BadTimeout`. This defeats the transient-vs-permanent error classification the resilience pipeline relies on.
|
**Description:** `MapLibplctagStatus` maps negative libplctag codes that do not match the libplctag.NET `Status` enum / native `libplctag.h` constants. `LibplctagTagRuntime.GetStatus()` returns `(int)_tag.GetStatus()`, the underlying value of the `Status` enum, whose members carry the native `PLCTAG_ERR_*` integer values. The real constants are `PLCTAG_ERR_BAD_CONNECTION = -7` (the only one the code gets right), `PLCTAG_ERR_NOT_FOUND = -18` (code expects -14), `PLCTAG_ERR_NOT_ALLOWED = -19` (code expects -16), `PLCTAG_ERR_OUT_OF_BOUNDS = -22` (code expects -17), `PLCTAG_ERR_TIMEOUT = -32` (code expects -5). Consequently a real timeout, not-found, not-allowed, or out-of-bounds error all fall through the switch to the `_ => BadCommunicationError` default. The driver reports `BadCommunicationError` for a non-existent tag instead of `BadNodeIdUnknown`, for a read-only tag instead of `BadNotWritable`, and for a timeout instead of `BadTimeout`. This defeats the transient-vs-permanent error classification the resilience pipeline relies on.
|
||||||
|
|
||||||
**Recommendation:** Replace the hand-typed integer literals with the libplctag.NET `Status` enum members (Status.ErrorTimeout, Status.ErrorNotFound, Status.ErrorNotAllowed, Status.ErrorOutOfBounds, Status.ErrorBadConnection, etc.), or at minimum correct the integer values to -32 / -18 / -19 / -22. Map Status.Pending explicitly rather than treating "any positive value" as GoodMoreData.
|
**Recommendation:** Replace the hand-typed integer literals with the libplctag.NET `Status` enum members (Status.ErrorTimeout, Status.ErrorNotFound, Status.ErrorNotAllowed, Status.ErrorOutOfBounds, Status.ErrorBadConnection, etc.), or at minimum correct the integer values to -32 / -18 / -19 / -22. Map Status.Pending explicitly rather than treating "any positive value" as GoodMoreData.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — `MapLibplctagStatus` now switches on the libplctag.NET `Status` enum members (Ok/Pending/ErrorTimeout/ErrorNotFound/ErrorNotAllowed/ErrorOutOfBounds/…) instead of hand-typed integers; the `int` overload casts to `Status` so the `GetStatus()` seam stays correct against the wrapper's contiguous renumbering. Note: the live libplctag.NET 1.5.2 `Status` enum is renumbered contiguously, so the correct underlying integers are -32/-19/-18/-27, not the native -32/-18/-19/-22 the finding suggested; switching on the enum members sidesteps the hazard entirely.
|
||||||
|
|
||||||
### Driver.AbCip-003
|
### Driver.AbCip-003
|
||||||
|
|
||||||
@@ -63,13 +63,13 @@
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `AbCipUdtMemberLayout.cs:32-54`, `AbCipDriver.cs:426-430`, `AbCipUdtReadPlanner.cs:48` |
|
| Location | `AbCipUdtMemberLayout.cs:32-54`, `AbCipDriver.cs:426-430`, `AbCipUdtReadPlanner.cs:48` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The whole-UDT read path (`ReadGroupAsync`) decodes each grouped member at the byte offset produced by `AbCipUdtMemberLayout.TryBuild`, which computes offsets purely from declaration order of the configured `AbCipStructureMember` list under natural-alignment rules. Logix does not guarantee that the controller lays UDT members out in declaration order: the Studio 5000 compiler reorders members (largest-first packing, BOOL host bytes, nested-struct padding) and the on-wire offsets only come from the CIP Template Object. The class remarks on `AbCipUdtMemberLayout` and `driver-specs.md` both acknowledge this. The decoder for the real shape (`CipTemplateObjectDecoder` / `AbCipTemplateCache`) exists and is populated by `FetchUdtShapeAsync`, but `ReadGroupAsync` never consults it: it always uses the declaration-only layout. For any UDT whose member declaration order in config differs from the controller compiled layout, whole-UDT reads return values decoded from the wrong offsets, silently plausible wrong numbers.
|
**Description:** The whole-UDT read path (`ReadGroupAsync`) decodes each grouped member at the byte offset produced by `AbCipUdtMemberLayout.TryBuild`, which computes offsets purely from declaration order of the configured `AbCipStructureMember` list under natural-alignment rules. Logix does not guarantee that the controller lays UDT members out in declaration order: the Studio 5000 compiler reorders members (largest-first packing, BOOL host bytes, nested-struct padding) and the on-wire offsets only come from the CIP Template Object. The class remarks on `AbCipUdtMemberLayout` and `driver-specs.md` both acknowledge this. The decoder for the real shape (`CipTemplateObjectDecoder` / `AbCipTemplateCache`) exists and is populated by `FetchUdtShapeAsync`, but `ReadGroupAsync` never consults it: it always uses the declaration-only layout. For any UDT whose member declaration order in config differs from the controller compiled layout, whole-UDT reads return values decoded from the wrong offsets, silently plausible wrong numbers.
|
||||||
|
|
||||||
**Recommendation:** In the read planner / `ReadGroupAsync`, prefer the cached `AbCipUdtShape` offsets (from `AbCipTemplateCache` / `FetchUdtShapeAsync`) when available, and only fall back to `AbCipUdtMemberLayout` declaration-order offsets when no template shape can be read. Even then, consider gating the declaration-only fast path behind an explicit opt-in flag, since it is correct only when the operator has hand-verified declaration order matches the controller.
|
**Recommendation:** In the read planner / `ReadGroupAsync`, prefer the cached `AbCipUdtShape` offsets (from `AbCipTemplateCache` / `FetchUdtShapeAsync`) when available, and only fall back to `AbCipUdtMemberLayout` declaration-order offsets when no template shape can be read. Even then, consider gating the declaration-only fast path behind an explicit opt-in flag, since it is correct only when the operator has hand-verified declaration order matches the controller.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — the declaration-only whole-UDT grouping fast path is now gated behind the new opt-in `AbCipDriverOptions.EnableDeclarationOnlyUdtGrouping` flag (default `false`); `AbCipUdtReadPlanner.Build` forms no groups when it is off, so by default every UDT member reads per-tag instead of decoding at possibly-wrong declaration-order offsets. The richer CIP Template Object path remains the long-term fix.
|
||||||
|
|
||||||
### Driver.AbCip-004
|
### Driver.AbCip-004
|
||||||
|
|
||||||
@@ -138,13 +138,13 @@
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `AbCipDriver.cs:144-152`, `AbCipDriver.cs:169-183`, `AbCipDriver.cs:235-281` |
|
| Location | `AbCipDriver.cs:144-152`, `AbCipDriver.cs:169-183`, `AbCipDriver.cs:235-281` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Probe loops are started fire-and-forget (`_ = Task.Run(() => ProbeLoopAsync(state, ct), ct)`) and the resulting Task is never stored or awaited. `ShutdownAsync` cancels `state.ProbeCts`, then immediately disposes it, sets it null, and calls `state.DisposeHandles()` without waiting for `ProbeLoopAsync` to observe the cancellation and exit. Races: (1) the still-running probe loop may be mid-await against a `ProbeCts` that `ShutdownAsync` has already disposed, producing `ObjectDisposedException` on the loop thread; (2) `DisposeHandles` clears `Runtimes`/`ParentRuntimes` while a concurrent `ReadAsync`/`WriteAsync` from the alarm projection or a subscription poll could be iterating or adding to those plain `Dictionary` instances (not thread-safe), corrupting the dictionary or throwing; (3) the probe runtime created inside `ProbeLoopAsync` is never tracked by `DeviceState`, so `DisposeHandles` cannot dispose it; only the loop own finally does, which may run after `ShutdownAsync` returns.
|
**Description:** Probe loops are started fire-and-forget (`_ = Task.Run(() => ProbeLoopAsync(state, ct), ct)`) and the resulting Task is never stored or awaited. `ShutdownAsync` cancels `state.ProbeCts`, then immediately disposes it, sets it null, and calls `state.DisposeHandles()` without waiting for `ProbeLoopAsync` to observe the cancellation and exit. Races: (1) the still-running probe loop may be mid-await against a `ProbeCts` that `ShutdownAsync` has already disposed, producing `ObjectDisposedException` on the loop thread; (2) `DisposeHandles` clears `Runtimes`/`ParentRuntimes` while a concurrent `ReadAsync`/`WriteAsync` from the alarm projection or a subscription poll could be iterating or adding to those plain `Dictionary` instances (not thread-safe), corrupting the dictionary or throwing; (3) the probe runtime created inside `ProbeLoopAsync` is never tracked by `DeviceState`, so `DisposeHandles` cannot dispose it; only the loop own finally does, which may run after `ShutdownAsync` returns.
|
||||||
|
|
||||||
**Recommendation:** Store each probe Task on `DeviceState`; in `ShutdownAsync` cancel the CTS, then await Task.WhenAll (with a timeout) before disposing the CTS or the handles. Guard `Runtimes`/`ParentRuntimes` with a lock or switch to `ConcurrentDictionary`. Make `ShutdownAsync` idempotent and safe against in-flight `ReadAsync`/`WriteAsync`.
|
**Recommendation:** Store each probe Task on `DeviceState`; in `ShutdownAsync` cancel the CTS, then await Task.WhenAll (with a timeout) before disposing the CTS or the handles. Guard `Runtimes`/`ParentRuntimes` with a lock or switch to `ConcurrentDictionary`. Make `ShutdownAsync` idempotent and safe against in-flight `ReadAsync`/`WriteAsync`.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — each probe loop's `Task` is stored on `DeviceState.ProbeTask`; `ShutdownAsync` now runs three phases (cancel every CTS, then await each probe Task with a 10s timeout, then dispose the CTS + handles) so the loop never touches a disposed CTS or cleared dictionary. `DeviceState.Runtimes` / `ParentRuntimes` are now `ConcurrentDictionary`, and `EnsureTagRuntimeAsync` / `EnsureParentRuntimeAsync` use `TryAdd` and dispose the losing concurrent creator instead of leaking it. `ShutdownAsync` stays idempotent (a second call sees the cleared `_devices`).
|
||||||
|
|
||||||
### Driver.AbCip-009
|
### Driver.AbCip-009
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly AbCipDriverOptions _options;
|
private AbCipDriverOptions _options;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
private readonly IAbCipTagFactory _tagFactory;
|
private readonly IAbCipTagFactory _tagFactory;
|
||||||
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
||||||
@@ -32,7 +32,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly AbCipAlarmProjection _alarmProjection;
|
private AbCipAlarmProjection _alarmProjection;
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
@@ -108,11 +108,32 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public string DriverInstanceId => _driverInstanceId;
|
public string DriverInstanceId => _driverInstanceId;
|
||||||
public string DriverType => "AbCip";
|
public string DriverType => "AbCip";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize the driver from its <c>DriverConfig</c> JSON. When
|
||||||
|
/// <paramref name="driverConfigJson"/> carries a real configuration (any device or tag),
|
||||||
|
/// it is parsed via <see cref="AbCipDriverFactoryExtensions.ParseOptions"/> and the
|
||||||
|
/// parsed options REPLACE the construction-time options — this is what makes
|
||||||
|
/// <see cref="ReinitializeAsync"/> pick up a changed config (new device, new tag,
|
||||||
|
/// changed timeout). A blank or empty-object JSON (<c>"{}"</c>) is treated as "no
|
||||||
|
/// override" so callers that constructed the driver with explicit options — chiefly
|
||||||
|
/// unit tests — keep those options. The driver's address-space + runtime state is then
|
||||||
|
/// built from the effective <see cref="_options"/>.
|
||||||
|
/// </summary>
|
||||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
||||||
|
{
|
||||||
|
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||||
|
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
|
||||||
|
{
|
||||||
|
_options = parsed;
|
||||||
|
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var device in _options.Devices)
|
foreach (var device in _options.Devices)
|
||||||
{
|
{
|
||||||
var addr = AbCipHostAddress.TryParse(device.HostAddress)
|
var addr = AbCipHostAddress.TryParse(device.HostAddress)
|
||||||
@@ -147,7 +168,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
{
|
{
|
||||||
state.ProbeCts = new CancellationTokenSource();
|
state.ProbeCts = new CancellationTokenSource();
|
||||||
var ct = state.ProbeCts.Token;
|
var ct = state.ProbeCts.Token;
|
||||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
// Keep the loop Task so ShutdownAsync can await its clean exit before
|
||||||
|
// disposing the CTS / handles the loop is still using (Driver.AbCip-008).
|
||||||
|
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
@@ -166,15 +189,46 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tear the driver down: stop the alarm projection + poll engine, then for each device
|
||||||
|
/// cancel its probe loop, <em>await the loop's clean exit</em>, and only then dispose
|
||||||
|
/// the probe CTS + runtime handles. Awaiting the probe Task before disposing closes the
|
||||||
|
/// race where a still-running loop touches a disposed CTS or a cleared runtime
|
||||||
|
/// dictionary (Driver.AbCip-008). Idempotent — safe to call twice (e.g. ShutdownAsync
|
||||||
|
/// from ReinitializeAsync followed by DisposeAsync).
|
||||||
|
/// </summary>
|
||||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
|
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
|
||||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Phase 1: signal every probe loop to stop.
|
||||||
|
foreach (var state in _devices.Values)
|
||||||
|
{
|
||||||
|
try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: wait for each probe loop to observe cancellation and exit. The loop never
|
||||||
|
// throws on cancellation (it catches OperationCanceledException internally), but guard
|
||||||
|
// anyway so one slow device can't wedge the whole shutdown.
|
||||||
|
foreach (var state in _devices.Values)
|
||||||
|
{
|
||||||
|
var probeTask = state.ProbeTask;
|
||||||
|
if (probeTask is null) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await probeTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (TimeoutException) { }
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: now the loops are gone, dispose the CTS + native handles with no live reader.
|
||||||
foreach (var state in _devices.Values)
|
foreach (var state in _devices.Values)
|
||||||
{
|
{
|
||||||
try { state.ProbeCts?.Cancel(); } catch { }
|
|
||||||
state.ProbeCts?.Dispose();
|
state.ProbeCts?.Dispose();
|
||||||
state.ProbeCts = null;
|
state.ProbeCts = null;
|
||||||
|
state.ProbeTask = null;
|
||||||
state.DisposeHandles();
|
state.DisposeHandles();
|
||||||
}
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
@@ -316,7 +370,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read each <c>fullReference</c> in order. Unknown tags surface as
|
/// Read each <c>fullReference</c> in order. Unknown tags surface as
|
||||||
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
|
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
|
||||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
|
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>; any other exception becomes
|
||||||
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
|
/// <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.
|
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -331,8 +385,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// whole-UDT read + in-memory member decode; every other reference falls back to the
|
// 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
|
// 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
|
// current tag map; BOOL/String/Structure members stay on the fallback path because
|
||||||
// declaration-only offsets can't place them under Logix alignment rules.
|
// declaration-only offsets can't place them under Logix alignment rules. Whole-UDT
|
||||||
var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
|
// grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
|
||||||
|
// reorder UDT members vs declaration order, so the fast path is opt-in only (see
|
||||||
|
// Driver.AbCip-003 / AbCipUdtMemberLayout remarks).
|
||||||
|
var plan = AbCipUdtReadPlanner.Build(
|
||||||
|
fullReferences, _tagsByName, _options.EnableDeclarationOnlyUdtGrouping);
|
||||||
|
|
||||||
foreach (var group in plan.Groups)
|
foreach (var group in plan.Groups)
|
||||||
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
|
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -609,8 +667,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
runtime.Dispose();
|
runtime.Dispose();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
device.ParentRuntimes[parentTagName] = runtime;
|
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
|
||||||
return runtime;
|
// first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
|
||||||
|
if (device.ParentRuntimes.TryAdd(parentTagName, runtime))
|
||||||
|
return runtime;
|
||||||
|
runtime.Dispose();
|
||||||
|
return device.ParentRuntimes[parentTagName];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -643,8 +705,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
runtime.Dispose();
|
runtime.Dispose();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
device.Runtimes[def.Name] = runtime;
|
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
|
||||||
return runtime;
|
// first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
|
||||||
|
if (device.Runtimes.TryAdd(def.Name, runtime))
|
||||||
|
return runtime;
|
||||||
|
runtime.Dispose();
|
||||||
|
return device.Runtimes[def.Name];
|
||||||
}
|
}
|
||||||
|
|
||||||
public DriverHealth GetHealth() => _health;
|
public DriverHealth GetHealth() => _health;
|
||||||
@@ -803,14 +869,26 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public CancellationTokenSource? ProbeCts { get; set; }
|
public CancellationTokenSource? ProbeCts { get; set; }
|
||||||
public bool ProbeInitialized { get; set; }
|
public bool ProbeInitialized { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The fire-and-forget probe loop's <see cref="Task"/>. Stored so
|
||||||
|
/// <see cref="AbCipDriver.ShutdownAsync"/> can await the loop's clean exit after
|
||||||
|
/// cancelling <see cref="ProbeCts"/> and BEFORE disposing the CTS or the runtime
|
||||||
|
/// handles — otherwise the still-running loop can touch a disposed CTS or a cleared
|
||||||
|
/// runtime dictionary (Driver.AbCip-008).
|
||||||
|
/// </summary>
|
||||||
|
public Task? ProbeTask { get; set; }
|
||||||
|
|
||||||
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
|
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-tag runtime handles owned by this device. One entry per configured tag is
|
/// Per-tag runtime handles owned by this device. One entry per configured tag is
|
||||||
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
|
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
|
||||||
|
/// <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
|
||||||
|
/// because <c>ReadAsync</c> is invoked concurrently by the server read path, every
|
||||||
|
/// polled subscription loop, and the alarm projection loop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -819,7 +897,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
|
/// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
|
||||||
/// parent ("Motor.Flags") used to do the read + write.
|
/// parent ("Motor.Flags") used to do the read + write.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
|
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ public static class AbCipDriverFactoryExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
var options = ParseOptions(driverInstanceId, driverConfigJson);
|
||||||
|
return new AbCipDriver(options, driverInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserialise an AB CIP driver-config JSON document into <see cref="AbCipDriverOptions"/>.
|
||||||
|
/// Shared by <see cref="CreateInstance"/> (first construction) and
|
||||||
|
/// <see cref="AbCipDriver.InitializeAsync"/> / <see cref="AbCipDriver.ReinitializeAsync"/>
|
||||||
|
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
|
||||||
|
/// actually takes effect rather than being silently discarded.
|
||||||
|
/// </summary>
|
||||||
|
internal static AbCipDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
@@ -29,7 +43,7 @@ public static class AbCipDriverFactoryExtensions
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"AB CIP driver config for '{driverInstanceId}' deserialised to null");
|
$"AB CIP driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
var options = new AbCipDriverOptions
|
return new AbCipDriverOptions
|
||||||
{
|
{
|
||||||
Devices = dto.Devices is { Count: > 0 }
|
Devices = dto.Devices is { Count: > 0 }
|
||||||
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
|
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
|
||||||
@@ -53,9 +67,8 @@ public static class AbCipDriverFactoryExtensions
|
|||||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||||
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
|
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
|
||||||
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
|
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
|
||||||
|
EnableDeclarationOnlyUdtGrouping = dto.EnableDeclarationOnlyUdtGrouping ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new AbCipDriver(options, driverInstanceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
|
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
|
||||||
@@ -108,6 +121,7 @@ public static class AbCipDriverFactoryExtensions
|
|||||||
public int? TimeoutMs { get; init; }
|
public int? TimeoutMs { get; init; }
|
||||||
public bool? EnableControllerBrowse { get; init; }
|
public bool? EnableControllerBrowse { get; init; }
|
||||||
public bool? EnableAlarmProjection { get; init; }
|
public bool? EnableAlarmProjection { get; init; }
|
||||||
|
public bool? EnableDeclarationOnlyUdtGrouping { get; init; }
|
||||||
public int? AlarmPollIntervalMs { get; init; }
|
public int? AlarmPollIntervalMs { get; init; }
|
||||||
public List<AbCipDeviceDto>? Devices { get; init; }
|
public List<AbCipDeviceDto>? Devices { get; init; }
|
||||||
public List<AbCipTagDto>? Tags { get; init; }
|
public List<AbCipTagDto>? Tags { get; init; }
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ public sealed class AbCipDriverOptions
|
|||||||
/// 1 second — matches typical SCADA alarm-refresh conventions.
|
/// 1 second — matches typical SCADA alarm-refresh conventions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opt-in for the declaration-only whole-UDT read fast path. When <c>false</c> (the
|
||||||
|
/// default) a batch of UDT members is always read per-member, because the byte offsets
|
||||||
|
/// computed by <see cref="AbCipUdtMemberLayout"/> assume the controller lays members
|
||||||
|
/// out in declaration order — and the Studio 5000 compiler does NOT guarantee that
|
||||||
|
/// (it reorders for largest-first packing, BOOL host bytes, nested-struct padding).
|
||||||
|
/// Decoding at declaration-order offsets against a reordered controller layout yields
|
||||||
|
/// silently-plausible wrong numbers. Set <c>true</c> only when the operator has
|
||||||
|
/// hand-verified that every configured UDT's member declaration order matches the
|
||||||
|
/// controller's compiled layout; in that case whole-UDT grouping collapses N member
|
||||||
|
/// reads into one. The richer CIP Template Object path remains the long-term fix.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableDeclarationOnlyUdtGrouping { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using libplctag;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -24,8 +26,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
/// writes during download / test-mode transitions).</item>
|
/// writes during download / test-mode transitions).</item>
|
||||||
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
|
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
|
||||||
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
|
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
|
||||||
/// <item>any libplctag <c>PLCTAG_STATUS_*</c> below zero — wrapped as
|
/// <item>libplctag.NET <see cref="Status"/> errors — mapped per-member by
|
||||||
/// <c>BadCommunicationError</c> until fine-grained mapping lands (PR 3).</item>
|
/// <see cref="MapLibplctagStatus(Status)"/>: timeout, not-found, not-allowed, and
|
||||||
|
/// out-of-bounds get their specific OPC UA codes; the remaining transport errors
|
||||||
|
/// fold into <c>BadCommunicationError</c>.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class AbCipStatusMapper
|
public static class AbCipStatusMapper
|
||||||
@@ -58,22 +62,34 @@ public static class AbCipStatusMapper
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Map a libplctag return/status code (<c>PLCTAG_STATUS_*</c>) to an OPC UA StatusCode.
|
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
|
||||||
/// libplctag uses <c>0 = PLCTAG_STATUS_OK</c>, positive values for pending, negative
|
/// is <c>(int)Tag.GetStatus()</c> — i.e. the underlying value of the libplctag.NET
|
||||||
/// values for errors.
|
/// <see cref="Status"/> enum, NOT a raw native <c>PLCTAG_ERR_*</c> constant. The wrapper
|
||||||
|
/// renumbers the native codes into a contiguous enum, so this method switches on the
|
||||||
|
/// <see cref="Status"/> members directly to stay correct if the wrapper renumbers again.
|
||||||
|
/// <see cref="Status.Ok"/> is success; <see cref="Status.Pending"/> is an in-flight
|
||||||
|
/// operation; every other (negative) member is an error.
|
||||||
/// </summary>
|
/// </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 strongly-typed core of the mapper; the <c>int</c> overload exists only for the
|
||||||
|
/// <see cref="IAbCipTagRuntime.GetStatus"/> seam, which returns the boxed-as-int value.
|
||||||
|
/// </summary>
|
||||||
|
public static uint MapLibplctagStatus(Status status) => status switch
|
||||||
{
|
{
|
||||||
if (status == 0) return Good;
|
Status.Ok => Good,
|
||||||
if (status > 0) return GoodMoreData; // PLCTAG_STATUS_PENDING
|
Status.Pending => GoodMoreData,
|
||||||
return status switch
|
Status.ErrorTimeout => BadTimeout,
|
||||||
{
|
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
|
||||||
-5 => BadTimeout, // PLCTAG_ERR_TIMEOUT
|
Status.ErrorNotAllowed => BadNotWritable,
|
||||||
-7 => BadCommunicationError, // PLCTAG_ERR_BAD_CONNECTION
|
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
|
||||||
-14 => BadNodeIdUnknown, // PLCTAG_ERR_NOT_FOUND
|
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
|
||||||
-16 => BadNotWritable, // PLCTAG_ERR_NOT_ALLOWED / read-only tag
|
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
|
||||||
-17 => BadOutOfRange, // PLCTAG_ERR_OUT_OF_BOUNDS
|
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
|
||||||
_ => BadCommunicationError,
|
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
|
||||||
};
|
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
|
||||||
}
|
_ => BadCommunicationError,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,27 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
/// list that <see cref="AbCipDriver.ReadAsync"/> runs through its existing read path.
|
/// list that <see cref="AbCipDriver.ReadAsync"/> runs through its existing read path.
|
||||||
/// Pure function — the planner never touches the runtime + never reads the PLC.
|
/// Pure function — the planner never touches the runtime + never reads the PLC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The grouped offsets come from <see cref="AbCipUdtMemberLayout"/>, which assumes the
|
||||||
|
/// controller lays members out in declaration order. Studio 5000 does not guarantee that,
|
||||||
|
/// so grouping is gated behind <see cref="AbCipDriverOptions.EnableDeclarationOnlyUdtGrouping"/>:
|
||||||
|
/// when grouping is disabled every member falls back to its own per-tag read.
|
||||||
|
/// </remarks>
|
||||||
public static class AbCipUdtReadPlanner
|
public static class AbCipUdtReadPlanner
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
|
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
|
||||||
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
|
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
|
||||||
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
|
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
|
||||||
/// to match the driver's dictionary semantics.
|
/// to match the driver's dictionary semantics. When
|
||||||
|
/// <paramref name="enableDeclarationOnlyGrouping"/> is <c>false</c> no groups are
|
||||||
|
/// formed — every reference goes to the per-tag fallback path so member decoding never
|
||||||
|
/// relies on declaration-order offsets that may not match the controller layout.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static AbCipUdtReadPlan Build(
|
public static AbCipUdtReadPlan Build(
|
||||||
IReadOnlyList<string> requests,
|
IReadOnlyList<string> requests,
|
||||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
|
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
|
||||||
|
bool enableDeclarationOnlyGrouping = false)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(requests);
|
ArgumentNullException.ThrowIfNull(requests);
|
||||||
ArgumentNullException.ThrowIfNull(tagsByName);
|
ArgumentNullException.ThrowIfNull(tagsByName);
|
||||||
@@ -26,6 +36,13 @@ public static class AbCipUdtReadPlanner
|
|||||||
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
|
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
|
||||||
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
|
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!enableDeclarationOnlyGrouping)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < requests.Count; i++)
|
||||||
|
fallback.Add(new AbCipUdtReadFallback(i, requests[i]));
|
||||||
|
return new AbCipUdtReadPlan([], fallback);
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < requests.Count; i++)
|
for (var i = 0; i < requests.Count; i++)
|
||||||
{
|
{
|
||||||
var name = requests[i];
|
var name = requests[i];
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public interface IAbCipTagRuntime : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
|
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
|
||||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>. Zero on success, negative on error.
|
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>. Zero on success, negative on error.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
int GetStatus();
|
int GetStatus();
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ public sealed class AbCipAlarmProjectionTests
|
|||||||
Tags = [AlmdTag("HighTemp")],
|
Tags = [AlmdTag("HighTemp")],
|
||||||
EnableAlarmProjection = true,
|
EnableAlarmProjection = true,
|
||||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||||
|
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
|
||||||
|
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
|
||||||
|
EnableDeclarationOnlyUdtGrouping = true,
|
||||||
};
|
};
|
||||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
@@ -122,6 +125,9 @@ public sealed class AbCipAlarmProjectionTests
|
|||||||
Tags = [AlmdTag("HighTemp")],
|
Tags = [AlmdTag("HighTemp")],
|
||||||
EnableAlarmProjection = true,
|
EnableAlarmProjection = true,
|
||||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||||
|
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
|
||||||
|
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
|
||||||
|
EnableDeclarationOnlyUdtGrouping = true,
|
||||||
};
|
};
|
||||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
@@ -159,6 +165,9 @@ public sealed class AbCipAlarmProjectionTests
|
|||||||
Tags = [AlmdTag("HighTemp")],
|
Tags = [AlmdTag("HighTemp")],
|
||||||
EnableAlarmProjection = true,
|
EnableAlarmProjection = true,
|
||||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||||
|
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
|
||||||
|
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
|
||||||
|
EnableDeclarationOnlyUdtGrouping = true,
|
||||||
};
|
};
|
||||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for the High code-review findings Driver.AbCip-001 / -003 / -008.
|
||||||
|
/// (Driver.AbCip-002 is covered by <see cref="AbCipStatusMapperTests"/>.)
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipDriverCodeReviewRegressionTests
|
||||||
|
{
|
||||||
|
private const string Device = "ab://10.0.0.5/1,0";
|
||||||
|
|
||||||
|
// ---- Driver.AbCip-001 — ReinitializeAsync must apply a changed config JSON ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InitializeAsync_applies_devices_and_tags_from_the_config_json()
|
||||||
|
{
|
||||||
|
// Constructed with NO devices/tags — the JSON is the only source of config.
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"Devices": [ { "HostAddress": "ab://10.0.0.9/1,0", "PlcFamily": "ControlLogix" } ],
|
||||||
|
"Tags": [ { "Name": "Speed", "DeviceHostAddress": "ab://10.0.0.9/1,0",
|
||||||
|
"TagPath": "Speed", "DataType": "DInt" } ]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await drv.InitializeAsync(json, CancellationToken.None);
|
||||||
|
|
||||||
|
drv.DeviceCount.ShouldBe(1);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.9/1,0").ShouldNotBeNull();
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReinitializeAsync_with_a_changed_config_json_picks_up_the_new_device()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device)],
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
drv.GetDeviceState(Device).ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Reinitialize with a JSON that names a DIFFERENT device — the change must take effect
|
||||||
|
// instead of being silently discarded (Driver.AbCip-001).
|
||||||
|
const string changed = """
|
||||||
|
{ "Devices": [ { "HostAddress": "ab://10.0.0.99/1,0" } ] }
|
||||||
|
""";
|
||||||
|
await drv.ReinitializeAsync(changed, CancellationToken.None);
|
||||||
|
|
||||||
|
drv.DeviceCount.ShouldBe(1);
|
||||||
|
drv.GetDeviceState("ab://10.0.0.99/1,0").ShouldNotBeNull();
|
||||||
|
drv.GetDeviceState(Device).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InitializeAsync_with_blank_json_keeps_construction_time_options()
|
||||||
|
{
|
||||||
|
// The test seam: a driver constructed with explicit options + handed "{}" must keep
|
||||||
|
// those options (otherwise every fake-backed unit test would lose its config).
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device)],
|
||||||
|
}, "drv-1");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.DeviceCount.ShouldBe(1);
|
||||||
|
drv.GetDeviceState(Device).ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.AbCip-003 — declaration-only whole-UDT grouping is opt-in ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Whole_udt_grouping_is_off_by_default_so_members_read_per_tag()
|
||||||
|
{
|
||||||
|
// Default options — EnableDeclarationOnlyUdtGrouping is false. Reading two members of
|
||||||
|
// a UDT must NOT collapse into a single declaration-order whole-UDT read, because the
|
||||||
|
// controller may not lay members out in declaration order.
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members:
|
||||||
|
[
|
||||||
|
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||||
|
new AbCipStructureMember("Torque", AbCipDataType.Real),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||||
|
|
||||||
|
// Each member got its own per-tag runtime; the parent "Motor" runtime was never created.
|
||||||
|
factory.Tags.ShouldContainKey("Motor.Speed");
|
||||||
|
factory.Tags.ShouldContainKey("Motor.Torque");
|
||||||
|
factory.Tags.ShouldNotContainKey("Motor");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Planner_forms_no_groups_when_declaration_only_grouping_is_disabled()
|
||||||
|
{
|
||||||
|
var members = new[]
|
||||||
|
{
|
||||||
|
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||||
|
new AbCipStructureMember("Torque", AbCipDataType.Real),
|
||||||
|
};
|
||||||
|
var tags = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Motor"] = new("Motor", Device, "Motor", AbCipDataType.Structure, Members: members),
|
||||||
|
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
||||||
|
["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
|
||||||
|
};
|
||||||
|
|
||||||
|
var plan = AbCipUdtReadPlanner.Build(
|
||||||
|
["Motor.Speed", "Motor.Torque"], tags, enableDeclarationOnlyGrouping: false);
|
||||||
|
|
||||||
|
plan.Groups.ShouldBeEmpty();
|
||||||
|
plan.Fallbacks.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.AbCip-008 — ShutdownAsync awaits probe loops; reads are concurrency-safe ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownAsync_awaits_the_probe_loop_before_returning()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device)],
|
||||||
|
Probe = new AbCipProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
ProbeTagPath = "ProbeTag",
|
||||||
|
Interval = TimeSpan.FromMilliseconds(20),
|
||||||
|
},
|
||||||
|
}, "drv-1", factory);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
// Give the probe loop a moment to actually start spinning.
|
||||||
|
await Task.Delay(60);
|
||||||
|
|
||||||
|
// Must complete cleanly — no ObjectDisposedException from a loop racing a disposed CTS.
|
||||||
|
await Should.NotThrowAsync(() => drv.ShutdownAsync(CancellationToken.None));
|
||||||
|
drv.DeviceCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownAsync_is_idempotent()
|
||||||
|
{
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device)],
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
await Should.NotThrowAsync(() => drv.ShutdownAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrent_first_reads_of_the_same_tag_do_not_corrupt_the_runtime_cache()
|
||||||
|
{
|
||||||
|
// Two concurrent ReadAsync calls that both miss the runtime cache must not throw and
|
||||||
|
// must not leave the device with two un-disposed runtimes for one tag (Driver.AbCip-008
|
||||||
|
// ConcurrentDictionary + TryAdd loser-disposal).
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device)],
|
||||||
|
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var reads = Enumerable.Range(0, 16)
|
||||||
|
.Select(_ => drv.ReadAsync(["Speed"], CancellationToken.None))
|
||||||
|
.ToArray();
|
||||||
|
var allResults = await Task.WhenAll(reads);
|
||||||
|
|
||||||
|
foreach (var result in allResults)
|
||||||
|
result.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using libplctag;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
@@ -88,7 +89,7 @@ public sealed class AbCipDriverReadTests
|
|||||||
var (drv, factory) = NewDriver(
|
var (drv, factory) = NewDriver(
|
||||||
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
|
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
factory.Customise = p => new FakeAbCipTag(p) { Status = -14 /* PLCTAG_ERR_NOT_FOUND */ };
|
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorNotFound };
|
||||||
|
|
||||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public sealed class AbCipDriverWholeUdtReadTests
|
|||||||
{
|
{
|
||||||
Devices = [new AbCipDeviceOptions(Device)],
|
Devices = [new AbCipDeviceOptions(Device)],
|
||||||
Tags = tags,
|
Tags = tags,
|
||||||
|
// Whole-UDT grouping is opt-in (Driver.AbCip-003) — these tests exercise the
|
||||||
|
// grouping fast path, so they switch it on explicitly.
|
||||||
|
EnableDeclarationOnlyUdtGrouping = true,
|
||||||
};
|
};
|
||||||
return (new AbCipDriver(opts, "drv-1", factory), factory);
|
return (new AbCipDriver(opts, "drv-1", factory), factory);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using libplctag;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
@@ -85,7 +86,7 @@ public sealed class AbCipDriverWriteTests
|
|||||||
var (drv, factory) = NewDriver(
|
var (drv, factory) = NewDriver(
|
||||||
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
|
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
factory.Customise = p => new FakeAbCipTag(p) { Status = -5 /* timeout */ };
|
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorTimeout };
|
||||||
|
|
||||||
var results = await drv.WriteAsync(
|
var results = await drv.WriteAsync(
|
||||||
[new WriteRequest("Broken", 1)], CancellationToken.None);
|
[new WriteRequest("Broken", 1)], CancellationToken.None);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using libplctag;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
@@ -25,17 +26,37 @@ public sealed class AbCipStatusMapperTests
|
|||||||
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
|
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Driver.AbCip-002 — the integers here are the underlying values of the libplctag.NET
|
||||||
|
// Status enum (what (int)Tag.GetStatus() actually returns), NOT raw native PLCTAG_ERR_*
|
||||||
|
// constants. The libplctag.NET wrapper renumbers the native codes into a contiguous enum.
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(0, AbCipStatusMapper.Good)]
|
[InlineData(Status.Ok, AbCipStatusMapper.Good)]
|
||||||
[InlineData(1, AbCipStatusMapper.GoodMoreData)] // PLCTAG_STATUS_PENDING
|
[InlineData(Status.Pending, AbCipStatusMapper.GoodMoreData)]
|
||||||
[InlineData(-5, AbCipStatusMapper.BadTimeout)]
|
[InlineData(Status.ErrorTimeout, AbCipStatusMapper.BadTimeout)]
|
||||||
[InlineData(-7, AbCipStatusMapper.BadCommunicationError)]
|
[InlineData(Status.ErrorNotFound, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||||
[InlineData(-14, AbCipStatusMapper.BadNodeIdUnknown)]
|
[InlineData(Status.ErrorNotAllowed, AbCipStatusMapper.BadNotWritable)]
|
||||||
[InlineData(-16, AbCipStatusMapper.BadNotWritable)]
|
[InlineData(Status.ErrorOutOfBounds, AbCipStatusMapper.BadOutOfRange)]
|
||||||
[InlineData(-17, AbCipStatusMapper.BadOutOfRange)]
|
[InlineData(Status.ErrorBadConnection, AbCipStatusMapper.BadCommunicationError)]
|
||||||
[InlineData(-99, AbCipStatusMapper.BadCommunicationError)] // unknown negative → generic comms failure
|
[InlineData(Status.ErrorBadGateway, AbCipStatusMapper.BadCommunicationError)]
|
||||||
public void MapLibplctagStatus_maps_known_codes(int status, uint expected)
|
[InlineData(Status.ErrorUnsupported, AbCipStatusMapper.BadNotSupported)]
|
||||||
|
[InlineData(Status.ErrorNoMem, AbCipStatusMapper.BadCommunicationError)] // unmapped → generic comms
|
||||||
|
public void MapLibplctagStatus_maps_real_enum_members(Status status, uint expected)
|
||||||
{
|
{
|
||||||
AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
||||||
|
// The int overload must agree — it is the seam IAbCipTagRuntime.GetStatus() drives.
|
||||||
|
AbCipStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error()
|
||||||
|
{
|
||||||
|
// Regression for Driver.AbCip-002: a real timeout used to fall through to
|
||||||
|
// BadCommunicationError because the code matched the wrong integer (-5).
|
||||||
|
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorTimeout)
|
||||||
|
.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||||
|
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound)
|
||||||
|
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
|
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound)
|
||||||
|
.ShouldNotBe(AbCipStatusMapper.BadCommunicationError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed class AbCipUdtReadPlannerTests
|
|||||||
public void Groups_Two_Members_Of_The_Same_Udt_Parent()
|
public void Groups_Two_Members_Of_The_Same_Udt_Parent()
|
||||||
{
|
{
|
||||||
var tags = BuildUdtTagMap(out var _);
|
var tags = BuildUdtTagMap(out var _);
|
||||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags);
|
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags, enableDeclarationOnlyGrouping: true);
|
||||||
|
|
||||||
plan.Groups.Count.ShouldBe(1);
|
plan.Groups.Count.ShouldBe(1);
|
||||||
plan.Groups[0].ParentName.ShouldBe("Motor");
|
plan.Groups[0].ParentName.ShouldBe("Motor");
|
||||||
@@ -26,7 +26,7 @@ public sealed class AbCipUdtReadPlannerTests
|
|||||||
// Reading just one member of a UDT gains nothing from grouping — one whole-UDT read
|
// Reading just one member of a UDT gains nothing from grouping — one whole-UDT read
|
||||||
// vs one member read is equivalent cost but more client-side work. Planner demotes.
|
// vs one member read is equivalent cost but more client-side work. Planner demotes.
|
||||||
var tags = BuildUdtTagMap(out var _);
|
var tags = BuildUdtTagMap(out var _);
|
||||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags);
|
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags, enableDeclarationOnlyGrouping: true);
|
||||||
|
|
||||||
plan.Groups.ShouldBeEmpty();
|
plan.Groups.ShouldBeEmpty();
|
||||||
plan.Fallbacks.Count.ShouldBe(1);
|
plan.Fallbacks.Count.ShouldBe(1);
|
||||||
@@ -38,7 +38,7 @@ public sealed class AbCipUdtReadPlannerTests
|
|||||||
{
|
{
|
||||||
var tags = BuildUdtTagMap(out var _);
|
var tags = BuildUdtTagMap(out var _);
|
||||||
var plan = AbCipUdtReadPlanner.Build(
|
var plan = AbCipUdtReadPlanner.Build(
|
||||||
new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags);
|
new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags, enableDeclarationOnlyGrouping: true);
|
||||||
|
|
||||||
plan.Groups.Count.ShouldBe(1);
|
plan.Groups.Count.ShouldBe(1);
|
||||||
plan.Groups[0].Members.Count.ShouldBe(2);
|
plan.Groups[0].Members.Count.ShouldBe(2);
|
||||||
@@ -55,7 +55,7 @@ public sealed class AbCipUdtReadPlannerTests
|
|||||||
{
|
{
|
||||||
["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
|
["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
|
||||||
};
|
};
|
||||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags);
|
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags, enableDeclarationOnlyGrouping: true);
|
||||||
|
|
||||||
plan.Groups.Count.ShouldBe(1);
|
plan.Groups.Count.ShouldBe(1);
|
||||||
plan.Fallbacks.Count.ShouldBe(1);
|
plan.Fallbacks.Count.ShouldBe(1);
|
||||||
@@ -82,7 +82,7 @@ public sealed class AbCipUdtReadPlannerTests
|
|||||||
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
||||||
};
|
};
|
||||||
|
|
||||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags);
|
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags, enableDeclarationOnlyGrouping: true);
|
||||||
|
|
||||||
plan.Groups.ShouldBeEmpty();
|
plan.Groups.ShouldBeEmpty();
|
||||||
plan.Fallbacks.Count.ShouldBe(2);
|
plan.Fallbacks.Count.ShouldBe(2);
|
||||||
@@ -93,7 +93,7 @@ public sealed class AbCipUdtReadPlannerTests
|
|||||||
{
|
{
|
||||||
var tags = BuildUdtTagMap(out var _);
|
var tags = BuildUdtTagMap(out var _);
|
||||||
var plan = AbCipUdtReadPlanner.Build(
|
var plan = AbCipUdtReadPlanner.Build(
|
||||||
new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags);
|
new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags, enableDeclarationOnlyGrouping: true);
|
||||||
|
|
||||||
// Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so
|
// Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so
|
||||||
// ReadAsync can write decoded values back at the right output slot.
|
// ReadAsync can write decoded values back at the right output slot.
|
||||||
|
|||||||
Reference in New Issue
Block a user