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:
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbCipDriverOptions _options;
|
||||
private AbCipDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbCipTagFactory _tagFactory;
|
||||
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
||||
@@ -32,7 +32,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly AbCipAlarmProjection _alarmProjection;
|
||||
private AbCipAlarmProjection _alarmProjection;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
@@ -108,11 +108,32 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
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)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
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)
|
||||
{
|
||||
var addr = AbCipHostAddress.TryParse(device.HostAddress)
|
||||
@@ -147,7 +168,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
state.ProbeCts = new CancellationTokenSource();
|
||||
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);
|
||||
@@ -166,15 +189,46 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
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)
|
||||
{
|
||||
await _alarmProjection.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)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.ProbeTask = null;
|
||||
state.DisposeHandles();
|
||||
}
|
||||
_devices.Clear();
|
||||
@@ -316,7 +370,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>
|
||||
/// Read each <c>fullReference</c> in order. Unknown tags surface as
|
||||
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>; any other exception becomes
|
||||
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
|
||||
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
|
||||
/// </summary>
|
||||
@@ -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
|
||||
// per-tag path that's been here since PR 3. Planner is a pure function over the
|
||||
// current tag map; BOOL/String/Structure members stay on the fallback path because
|
||||
// declaration-only offsets can't place them under Logix alignment rules.
|
||||
var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
|
||||
// declaration-only offsets can't place them under Logix alignment rules. Whole-UDT
|
||||
// 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)
|
||||
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
|
||||
@@ -609,8 +667,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.ParentRuntimes[parentTagName] = runtime;
|
||||
return runtime;
|
||||
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
|
||||
// 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>
|
||||
@@ -643,8 +705,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[def.Name] = runtime;
|
||||
return runtime;
|
||||
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
|
||||
// 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;
|
||||
@@ -803,14 +869,26 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public CancellationTokenSource? ProbeCts { 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; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Per-tag runtime handles owned by this device. One entry per configured tag is
|
||||
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
|
||||
/// <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>
|
||||
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <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
|
||||
/// parent ("Motor.Flags") used to do the read + write.
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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)
|
||||
{
|
||||
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(driverConfigJson);
|
||||
@@ -29,7 +43,7 @@ public static class AbCipDriverFactoryExtensions
|
||||
?? throw new InvalidOperationException(
|
||||
$"AB CIP driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
var options = new AbCipDriverOptions
|
||||
return new AbCipDriverOptions
|
||||
{
|
||||
Devices = dto.Devices is { Count: > 0 }
|
||||
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
|
||||
@@ -53,9 +67,8 @@ public static class AbCipDriverFactoryExtensions
|
||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
|
||||
EnableDeclarationOnlyUdtGrouping = dto.EnableDeclarationOnlyUdtGrouping ?? false,
|
||||
};
|
||||
|
||||
return new AbCipDriver(options, driverInstanceId);
|
||||
}
|
||||
|
||||
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
|
||||
@@ -108,6 +121,7 @@ public static class AbCipDriverFactoryExtensions
|
||||
public int? TimeoutMs { get; init; }
|
||||
public bool? EnableControllerBrowse { get; init; }
|
||||
public bool? EnableAlarmProjection { get; init; }
|
||||
public bool? EnableDeclarationOnlyUdtGrouping { get; init; }
|
||||
public int? AlarmPollIntervalMs { get; init; }
|
||||
public List<AbCipDeviceDto>? Devices { get; init; }
|
||||
public List<AbCipTagDto>? Tags { get; init; }
|
||||
|
||||
@@ -56,6 +56,20 @@ public sealed class AbCipDriverOptions
|
||||
/// 1 second — matches typical SCADA alarm-refresh conventions.
|
||||
/// </summary>
|
||||
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>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
@@ -24,8 +26,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// writes during download / test-mode transitions).</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>any libplctag <c>PLCTAG_STATUS_*</c> below zero — wrapped as
|
||||
/// <c>BadCommunicationError</c> until fine-grained mapping lands (PR 3).</item>
|
||||
/// <item>libplctag.NET <see cref="Status"/> errors — mapped per-member by
|
||||
/// <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>
|
||||
/// </remarks>
|
||||
public static class AbCipStatusMapper
|
||||
@@ -58,22 +62,34 @@ public static class AbCipStatusMapper
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag return/status code (<c>PLCTAG_STATUS_*</c>) to an OPC UA StatusCode.
|
||||
/// libplctag uses <c>0 = PLCTAG_STATUS_OK</c>, positive values for pending, negative
|
||||
/// values for errors.
|
||||
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
|
||||
/// is <c>(int)Tag.GetStatus()</c> — i.e. the underlying value of the libplctag.NET
|
||||
/// <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>
|
||||
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;
|
||||
if (status > 0) return GoodMoreData; // PLCTAG_STATUS_PENDING
|
||||
return status switch
|
||||
{
|
||||
-5 => BadTimeout, // PLCTAG_ERR_TIMEOUT
|
||||
-7 => BadCommunicationError, // PLCTAG_ERR_BAD_CONNECTION
|
||||
-14 => BadNodeIdUnknown, // PLCTAG_ERR_NOT_FOUND
|
||||
-16 => BadNotWritable, // PLCTAG_ERR_NOT_ALLOWED / read-only tag
|
||||
-17 => BadOutOfRange, // PLCTAG_ERR_OUT_OF_BOUNDS
|
||||
_ => 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,17 +8,27 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// list that <see cref="AbCipDriver.ReadAsync"/> runs through its existing read path.
|
||||
/// Pure function — the planner never touches the runtime + never reads the PLC.
|
||||
/// </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
|
||||
{
|
||||
/// <summary>
|
||||
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
|
||||
/// <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
|
||||
/// 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>
|
||||
public static AbCipUdtReadPlan Build(
|
||||
IReadOnlyList<string> requests,
|
||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
|
||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
|
||||
bool enableDeclarationOnlyGrouping = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
ArgumentNullException.ThrowIfNull(tagsByName);
|
||||
@@ -26,6 +36,13 @@ public static class AbCipUdtReadPlanner
|
||||
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
|
||||
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++)
|
||||
{
|
||||
var name = requests[i];
|
||||
|
||||
@@ -20,7 +20,7 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
int GetStatus();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user