fix(abcip): review fix round 1 - BOOL-array fallback, local refusals, minors
I1: the U11 Bits[n] -> Bits[n/32] rewrite was unconditional. The native runtime now keeps the original element path until the first reply settles the layout: 0xD3 confirms DWORD addressing (bit n%32); any other reply type (e.g. a 0xC1 per-element BOOL array) or a refusal of the DWORD index falls back to Bits[n] and re-reads, and a write settles the layout by a read first, so it can never decode or write element n/32 of an element-addressed array. I2: libplctag ErrorNoMatch/ErrorBadDevice no longer fold into NotFound (they now read as BadConnection: comms, Degraded, LastSuccessfulRead preserved). ErrorNotImplemented - and the native runtime's own pre-send refusals - raise AbCipLocalRefusalException: BadNotSupported, no health, LastSuccessfulRead NOT advanced, no backoff record, not reachable for the probe or Test Connect. M1: the bit RMW's parent read/write status words follow the health contract (refusal: per-tag Bad + Warning; comms: evict the parent + Degraded). M2: device Unsupported read refusals return Refused (advance LastSuccessfulRead); device write refusals call RecordWriteAnswered (not the pre-send native type gate). M3: a good write clears a Degraded a write comms fault recorded (a read-side Degraded is still left for the read path). M4: a superseded init throws AbCipInitializationSupersededException (not an OCE); ShutdownAsync stamps Unknown only if no newer init published meanwhile. M5: the sync-Dispose timing assert is 2 s against a 4 s probe read; the unencodable-array test asserts health. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -515,7 +515,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
if (generation != _lifecycleGeneration)
|
||||
throw new OperationCanceledException(
|
||||
throw new AbCipInitializationSupersededException(
|
||||
$"AbCip driver {_driverInstanceId} initialization was superseded by a shutdown or a newer initialization.");
|
||||
|
||||
_options = options;
|
||||
@@ -562,7 +562,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Never-published states hold no probe loop and no runtime yet; dispose for symmetry.
|
||||
if (builtDevices is not null)
|
||||
foreach (var state in builtDevices.Values) state.Retire();
|
||||
if (ex is OperationCanceledException)
|
||||
if (ex is AbCipInitializationSupersededException)
|
||||
{
|
||||
_logger.LogInformation("AbCip driver {DriverInstanceId} initialization abandoned: {Reason}",
|
||||
_driverInstanceId, ex.Message);
|
||||
@@ -652,9 +652,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
var generation = await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
UpdateHealth(h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null));
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
// M4: a newer InitializeAsync that published while this shutdown was still joining its probe loops owns the
|
||||
// health now — stamping Unknown over its Healthy would misreport a live driver.
|
||||
if (generation == _lifecycleGeneration)
|
||||
UpdateHealth(h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
@@ -764,10 +770,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
probeRuntime = null;
|
||||
}
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
catch (Exception ace) when (ace is AbCipTagConfigurationException or AbCipLocalRefusalException)
|
||||
{
|
||||
// The probe tag's own name cannot become a request — it never reached the device, so it is no evidence
|
||||
// either way: no backoff signal, no host transition, one Warning.
|
||||
// The probe tag's own name cannot become a request (or the library refused it locally) — it never
|
||||
// reached the device, so it is no evidence either way: no backoff signal, no host transition, one Warning.
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
@@ -1145,13 +1151,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (AbCipLocalRefusalException local)
|
||||
{
|
||||
// #737 I2: refused by the library / driver itself — no I/O, so no proof the device answered: BadNotSupported,
|
||||
// keep the handle, no health signal, LastSuccessfulRead untouched.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, local.Message);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
|
||||
// health signal.
|
||||
// The DEVICE refused (CIP 0x08, or a decode refusal of data it returned) — not a comms fault:
|
||||
// BadNotSupported, keep the handle, no state change; it answered, so LastSuccessfulRead advances (M2).
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.None;
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
@@ -1169,7 +1183,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
reference, def.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status))
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate))
|
||||
{
|
||||
// H1/L4: the controller answered "no" (renamed tag, Arr[999], read-protected …) — a per-tag config
|
||||
// error, not a comms fault. Keep the handle (no evict → no lease / Forward-Open churn), no Degraded,
|
||||
@@ -1284,10 +1298,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (AbCipLocalRefusalException local)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
|
||||
WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, local.Message);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
|
||||
return ReadOutcome.None;
|
||||
WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
@@ -1485,14 +1506,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
RecordWriteAnswered(now);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, def.DataType);
|
||||
}
|
||||
else if (AbCipRefusals.IsDeviceRefusal(status))
|
||||
{
|
||||
// The controller refused this write (read-only, out of bounds …): keep the handle, no health.
|
||||
// The controller refused this write (read-only, out of bounds …): keep the handle, no state change;
|
||||
// it answered, so LastSuccessfulRead advances (M2).
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
|
||||
RecordWriteAnswered(now);
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ((AbStatus)status).ToString());
|
||||
}
|
||||
else if (status != 0)
|
||||
@@ -1550,14 +1574,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"AbCip write to {Tag} on device {Device}: the value is out of range for {DataType}: {Reason} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, def.DataType, oe.Message);
|
||||
}
|
||||
catch (AbCipLocalRefusalException local)
|
||||
{
|
||||
// Refused locally (e.g. the native runtime cannot type the write; libplctag NotImplemented) — nothing
|
||||
// sent: BadNotSupported, keep the handle, leave health alone.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, local.Message);
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
// A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms
|
||||
// fault: BadNotSupported, keep the handle, leave health alone.
|
||||
// The device refused the write as unsupported — BadNotSupported, keep the handle, no state change;
|
||||
// it answered, so LastSuccessfulRead advances (M2).
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
_logger.LogWarning(ate,
|
||||
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
|
||||
w.FullReference, def.DeviceHostAddress);
|
||||
RecordWriteAnswered(now);
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
@@ -1570,15 +1600,18 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
|
||||
// CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave
|
||||
// health alone, warn once per tag. Retrying cannot succeed until the config is corrected.
|
||||
// No LastSuccessfulRead advance: the native type gate throws this BEFORE sending.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status))
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate))
|
||||
{
|
||||
// H1: the controller answered "no" to this write — keep the handle, leave health alone, warn once.
|
||||
// H1: the controller answered "no" to this write — keep the handle, no state change, warn once; it
|
||||
// answered, so LastSuccessfulRead advances (M2).
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(ate.Status));
|
||||
RecordWriteAnswered(now);
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (Exception) when (device.Retired)
|
||||
@@ -1607,14 +1640,29 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// A good write proves the device answered: advance <c>LastSuccessfulRead</c> but leave the STATE alone. Writing
|
||||
/// <c>Healthy</c> here (H19) overwrote a <c>Degraded</c> the poll loop had just recorded for other tags.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// M3: a <c>Degraded</c> that a WRITE comms fault recorded (still the current health — same error, no read has
|
||||
/// replaced it since) is cleared back to <c>Healthy</c>: the device just accepted a write. A Degraded the read
|
||||
/// path recorded is left for the read path to clear.
|
||||
/// </remarks>
|
||||
private void RecordWriteAnswered(DateTime now) =>
|
||||
UpdateHealth(h => h with { LastSuccessfulRead = now });
|
||||
UpdateHealth(h => h.State == DriverState.Degraded && h.LastError is { } err
|
||||
&& ReferenceEquals(err, Volatile.Read(ref _writeFaultError))
|
||||
? new DriverHealth(DriverState.Healthy, now, null)
|
||||
: h with { LastSuccessfulRead = now });
|
||||
|
||||
// The LastError string the most recent write-side comms fault wrote (reference identity marks "write-owned").
|
||||
private string? _writeFaultError;
|
||||
|
||||
/// <summary>A write-side comms fault: <c>Degraded</c> (never downgrading <c>Faulted</c>), <c>LastSuccessfulRead</c> preserved.</summary>
|
||||
private void RecordWriteCommsFault(string error) =>
|
||||
private void RecordWriteCommsFault(string error)
|
||||
{
|
||||
var owned = new string(error.AsSpan()); // a distinct instance, so reference identity cannot alias a read error
|
||||
Volatile.Write(ref _writeFaultError, owned);
|
||||
UpdateHealth(h => h.State == DriverState.Faulted
|
||||
? h
|
||||
: new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, error));
|
||||
: new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within an integer parent (SINT / INT / DINT / LINT — Logix allows <c>.n</c> on
|
||||
@@ -1636,7 +1684,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus);
|
||||
if (readStatus != 0) return BitWriteStatusWord(device, parentName, readStatus, "read");
|
||||
|
||||
// Unsigned at the narrow widths so a set top bit (SINT bit 7, INT bit 15) encodes without overflowing the
|
||||
// signed type; DINT keeps its signed int arithmetic (1 << 31 is int.MinValue, which a DINT encodes as is).
|
||||
@@ -1685,7 +1733,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapStatus(writeStatus);
|
||||
: BitWriteStatusWord(device, parentName, writeStatus, "write");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1693,6 +1741,28 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M1: a non-zero status word on the bit write's parent read / write follows the health contract — a device
|
||||
/// refusal is per-tag Bad with one Warning and no state change (it answered: LastSuccessfulRead advances); any
|
||||
/// other status is a comms fault: evict the PARENT runtime and write Degraded (LastSuccessfulRead preserved).
|
||||
/// </summary>
|
||||
private uint BitWriteStatusWord(DeviceState device, string parentName, int status, string phase)
|
||||
{
|
||||
var mapped = AbCipStatusMapper.MapStatus(status);
|
||||
if (AbCipRefusals.IsDeviceRefusal(status))
|
||||
{
|
||||
RecordWriteAnswered(DateTime.UtcNow);
|
||||
WarnRefusalOnce(parentName, $"bit-write parent {phase}", parentName, device.Options.HostAddress, ((AbStatus)status).ToString());
|
||||
return mapped;
|
||||
}
|
||||
EvictParentRuntime(device, parentName);
|
||||
RecordWriteCommsFault($"transport status {status} on the bit-write parent {phase} of {parentName}");
|
||||
_logger.LogWarning(
|
||||
"AbCip bit-write parent {Phase} of {Parent} on device {Device} returned transport status {Status}; evicting the parent runtime",
|
||||
phase, parentName, device.Options.HostAddress, status);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
|
||||
/// so repeated bit writes against the same DINT share one handle.
|
||||
@@ -1802,10 +1872,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (!device.Runtimes.TryAdd(def.Name, runtime)) runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (AbCipTagConfigurationException)
|
||||
catch (Exception ex) when (ex is AbCipTagConfigurationException or AbCipLocalRefusalException)
|
||||
{
|
||||
// The tag's own name cannot become a request — no I/O happened, so it says nothing
|
||||
// about the device: leave the backoff window exactly as it was.
|
||||
// The tag's own name cannot become a request, or the library refused the tag locally — no I/O happened,
|
||||
// so it says nothing about the device: leave the backoff window exactly as it was.
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -206,7 +206,8 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
internal static bool IsReachableException(AbTransportException ex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
return IsReachableStatus(ex.Status);
|
||||
// A local refusal (the library / driver refused without any I/O) proves nothing about the controller.
|
||||
return ex is not AbCipLocalRefusalException && IsReachableStatus(ex.Status);
|
||||
}
|
||||
|
||||
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="AbCipDriver.InitializeAsync"/> when a shutdown, dispose or newer initialization retired the
|
||||
/// driver's tables while this initialization was building, so it published nothing. Deliberately NOT an
|
||||
/// <see cref="OperationCanceledException"/>: nobody cancelled this call, and a caller that treats cancellation as
|
||||
/// "my own token fired" must not mistake a superseded attempt for one. The superseding operation owns the driver's
|
||||
/// state; the caller (normally the runtime, which has already abandoned this attempt) can ignore the result.
|
||||
/// </summary>
|
||||
/// <param name="message">The detail.</param>
|
||||
public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message);
|
||||
@@ -0,0 +1,15 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// A refusal raised LOCALLY — by the driver's own runtime or by libplctag (<c>ErrorNotImplemented</c>) — before or
|
||||
/// without any reply from the controller. It carries <see cref="AbStatus.Unsupported"/> (so it maps to
|
||||
/// <c>BadNotSupported</c>) but, unlike a device refusal, proves nothing about the device: it keeps the runtime and
|
||||
/// writes no health, does NOT advance <c>LastSuccessfulRead</c>, records nothing in the connect backoff, and is not
|
||||
/// "reachable" for the probe or Test Connect (review 07 / Gitea #737 I2).
|
||||
/// </summary>
|
||||
/// <param name="message">The refusal detail.</param>
|
||||
/// <param name="inner">The underlying exception, if any.</param>
|
||||
public sealed class AbCipLocalRefusalException(string message, Exception? inner = null)
|
||||
: AbTransportException(AbStatus.Unsupported, message, inner);
|
||||
@@ -36,5 +36,7 @@ internal static class AbCipRefusals
|
||||
/// <summary>True when <paramref name="ex"/> is an <see cref="AbTransportException"/> carrying a device refusal.</summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
|
||||
public static bool IsDeviceRefusal(Exception ex) => ex is AbTransportException ate && IsDeviceRefusal(ate.Status);
|
||||
/// <remarks>An <see cref="AbCipLocalRefusalException"/> is never a device refusal: nothing reached the device.</remarks>
|
||||
public static bool IsDeviceRefusal(Exception ex) =>
|
||||
ex is AbTransportException ate and not AbCipLocalRefusalException && IsDeviceRefusal(ate.Status);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ internal static class LibplctagStatusTranslation
|
||||
Status.Ok => AbStatus.Ok,
|
||||
Status.Pending => AbStatus.Pending,
|
||||
Status.ErrorTimeout => AbStatus.Timeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
|
||||
// #737 I2: only ErrorNotFound is the controller saying "no such tag". ErrorNoMatch / ErrorBadDevice describe a
|
||||
// session or device-level failure (no proof the controller answered this tag), so they are comms-class: with
|
||||
// the widened refusal classification, folding them into NotFound made a broken session read as "reachable".
|
||||
Status.ErrorNotFound => AbStatus.NotFound,
|
||||
Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.BadConnection,
|
||||
Status.ErrorNotAllowed => AbStatus.NotAllowed,
|
||||
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
|
||||
Status.ErrorTooLarge => AbStatus.TooLarge,
|
||||
Status.ErrorTooSmall => AbStatus.TooSmall,
|
||||
// ErrorNotImplemented is the LIBRARY refusing (no I/O); the exception paths raise it as an
|
||||
// AbCipLocalRefusalException so it never counts as "the device answered" (see FromException).
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
|
||||
Status.ErrorPartial => AbStatus.Partial,
|
||||
Status.ErrorAbort => AbStatus.Abort,
|
||||
@@ -70,9 +76,21 @@ internal static class LibplctagStatusTranslation
|
||||
public static async Task Wrap(Func<Task> op)
|
||||
{
|
||||
try { await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transport exception for a libplctag failure: <c>ErrorNotImplemented</c> becomes an
|
||||
/// <see cref="AbCipLocalRefusalException"/> (a library refusal, no I/O); everything else an
|
||||
/// <see cref="AbTransportException"/> carrying the translated status.
|
||||
/// </summary>
|
||||
/// <param name="ex">The libplctag exception.</param>
|
||||
/// <returns>The exception to throw.</returns>
|
||||
public static AbTransportException FromException(LibPlcTagException ex) =>
|
||||
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorNotImplemented
|
||||
? new AbCipLocalRefusalException($"libplctag does not implement this operation: {ex.Message}", ex)
|
||||
: new AbTransportException(ToAbStatus(ex), ex.Message ?? string.Empty, ex);
|
||||
|
||||
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
/// <typeparam name="T">The result type.</typeparam>
|
||||
/// <param name="op">The libplctag operation.</param>
|
||||
@@ -80,7 +98,7 @@ internal static class LibplctagStatusTranslation
|
||||
public static async Task<T> Wrap<T>(Func<Task<T>> op)
|
||||
{
|
||||
try { return await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
|
||||
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
@@ -90,7 +108,7 @@ internal static class LibplctagStatusTranslation
|
||||
public static T WrapSync<T>(Func<T> op)
|
||||
{
|
||||
try { return op(); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
|
||||
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
@@ -98,6 +116,6 @@ internal static class LibplctagStatusTranslation
|
||||
public static void WrapSync(Action op)
|
||||
{
|
||||
try { op(); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
private byte[]? _path;
|
||||
|
||||
// U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of the DWORD the request
|
||||
// addresses (n / 32). Null for every other tag.
|
||||
// addresses (n / 32). Null for every other tag, and cleared (with _path restored to _elementPath) the moment the
|
||||
// controller shows its BOOL array is element-addressed (#737 I1: a 0xC1 reply, or a refusal, to the DWORD request).
|
||||
private int? _boolElementBit;
|
||||
|
||||
// The ORIGINAL element-addressed path ("Bits[n]") while a DWORD-addressed _path is still unconfirmed; null once
|
||||
// the layout is settled either way.
|
||||
private byte[]? _elementPath;
|
||||
|
||||
private ConnectionLease? _lease;
|
||||
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
|
||||
private LogixTagClient? _client;
|
||||
@@ -124,6 +129,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
if (split.Bit is null && !_p.IsArray && _p.AuthoredType == AbCipDataType.Bool
|
||||
&& TrySplitBoolArrayElement(name, out var dwordName, out var bit))
|
||||
{
|
||||
_elementPath = CipPath.EncodeSymbolic(name);
|
||||
name = dwordName;
|
||||
_boolElementBit = bit;
|
||||
}
|
||||
@@ -148,7 +154,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex.Status))
|
||||
catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex))
|
||||
{
|
||||
// The controller answered. _status already holds the refusal.
|
||||
}
|
||||
@@ -162,11 +168,25 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
try
|
||||
{
|
||||
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
|
||||
if (_elementPath is not null)
|
||||
{
|
||||
// First answer to the DWORD-addressed BOOL-array request settles the layout: 0xD3 confirms it; anything
|
||||
// else means this family addresses BOOL-array ELEMENTS (e.g. a 0xC1 per-element array) — decoding or
|
||||
// writing element n / 32 there would hit the wrong element, so go back to "Bits[n]" and read again.
|
||||
if (type != LogixTypeCode.BoolArray) { FallBackToElementPath(); await ReadAsync(cancellationToken).ConfigureAwait(false); return; }
|
||||
_elementPath = null;
|
||||
}
|
||||
_data = data;
|
||||
_typeCode = type;
|
||||
_structHandle = handle;
|
||||
_status = IsTypeMismatch(type) ? AbStatus.TypeMismatch : AbStatus.Ok;
|
||||
}
|
||||
catch (AbTransportException ex) when (_elementPath is not null && AbCipRefusals.IsDeviceRefusal(ex))
|
||||
{
|
||||
// The DWORD index was refused (e.g. out of bounds on an element-addressed array): retry as "Bits[n]".
|
||||
FallBackToElementPath();
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
// libplctag reports a failed operation by exception AND leaves the status readable.
|
||||
@@ -175,6 +195,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Abandons the DWORD addressing of a BOOL-array element: the original "Bits[n]" path, no bit extraction.</summary>
|
||||
private void FallBackToElementPath()
|
||||
{
|
||||
_path = _elementPath;
|
||||
_elementPath = null;
|
||||
_boolElementBit = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on one element's wire bytes for the scheduler's reply budget; 0 when the
|
||||
/// authored type is unknown or a structure, which sends the read alone rather than guess.
|
||||
@@ -207,6 +235,13 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
|
||||
try
|
||||
{
|
||||
// An unsettled BOOL-array element layout is settled by a read first, so a write never goes to the
|
||||
// DWORD-addressed element of an element-addressed array (#737 I1).
|
||||
if (_elementPath is not null)
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
path = Path();
|
||||
}
|
||||
if (_pendingString is { } text)
|
||||
{
|
||||
// A STRING writes as the type the controller reported — a Logix STRING is a structure
|
||||
@@ -215,7 +250,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
|
||||
throw new AbTransportException(AbStatus.Unsupported,
|
||||
throw new AbCipLocalRefusalException(
|
||||
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
|
||||
var payload = EncodeStringPayload(text);
|
||||
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
|
||||
@@ -224,7 +259,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
else if (_writeBuffer is { } buffer)
|
||||
{
|
||||
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
|
||||
?? throw new AbTransportException(AbStatus.Unsupported,
|
||||
?? throw new AbCipLocalRefusalException(
|
||||
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
|
||||
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
|
||||
// would otherwise send the int bit pattern as a REAL and report Good.
|
||||
|
||||
Reference in New Issue
Block a user